Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ go run ./cmd/gws # or go run .

## Current Version

**v1.9.0** - Gmail pagination & slides documentation. Adds `--all` flag to `gmail list` for fetching >500 results via pagination. Enhanced slides SKILL.md with styling tips and workflow examples.
**v1.10.0** - Gmail label support & morning skill optimization. Adds `--include-labels` flag to `gmail list` for surfacing label IDs. Morning scripts use labels from inbox data instead of extra API calls (~2-3s savings).

## Roadmap

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ PKG := ./cmd/gws
BUILD_DIR := ./bin

# Version info
VERSION ?= 1.9.0
VERSION ?= 1.10.0
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
LDFLAGS := -ldflags "-X github.com/omriariav/workspace-cli/cmd.Version=$(VERSION) \
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Add `--format text` to any command for human-readable output.

| Command | Description |
|---------|-------------|
| `gws gmail list` | List threads with `thread_id` and `message_id` (`--max`, `--query`, `--all` for pagination) |
| `gws gmail list` | List threads with `thread_id` and `message_id` (`--max`, `--query`, `--all`, `--include-labels`) |
| `gws gmail read <id>` | Read message body and headers |
| `gws gmail thread <id>` | Read full thread conversation |
| `gws gmail send` | Send email (`--to`, `--subject`, `--body`, `--cc`, `--bcc`, `--thread-id`, `--reply-to-message-id`) |
Expand Down
18 changes: 18 additions & 0 deletions cmd/gmail.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/url"
"os"
"regexp"
"sort"
"strings"

"github.com/omriariav/workspace-cli/internal/client"
Expand Down Expand Up @@ -162,6 +163,7 @@ func init() {
gmailListCmd.Flags().Int64("max", 10, "Maximum number of results (use --all for unlimited)")
gmailListCmd.Flags().String("query", "", "Gmail search query (e.g., 'is:unread', 'from:someone@example.com')")
gmailListCmd.Flags().Bool("all", false, "Fetch all matching results (may take time for large result sets)")
gmailListCmd.Flags().Bool("include-labels", false, "Include Gmail label IDs in output")

// Send flags
gmailSendCmd.Flags().String("to", "", "Recipient email address (required)")
Expand Down Expand Up @@ -204,6 +206,7 @@ func runGmailList(cmd *cobra.Command, args []string) error {
maxResults, _ := cmd.Flags().GetInt64("max")
query, _ := cmd.Flags().GetString("query")
fetchAll, _ := cmd.Flags().GetBool("all")
includeLabels, _ := cmd.Flags().GetBool("include-labels")

// Gmail API has a hard limit of 500 results per request
const apiMaxPerPage int64 = 500
Expand Down Expand Up @@ -296,6 +299,21 @@ func runGmailList(cmd *cobra.Command, args []string) error {
threadInfo["date"] = header.Value
}
}

if includeLabels {
labelSet := make(map[string]bool)
for _, m := range threadDetail.Messages {
for _, lbl := range m.LabelIds {
labelSet[lbl] = true
}
}
labels := make([]string, 0, len(labelSet))
for lbl := range labelSet {
labels = append(labels, lbl)
}
sort.Strings(labels)
threadInfo["labels"] = labels
}
}

results = append(results, threadInfo)
Expand Down
128 changes: 128 additions & 0 deletions cmd/gmail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1414,3 +1414,131 @@ func TestGmailList_MaxRespected_MockServer(t *testing.T) {
t.Errorf("expected 3 threads, got %d", len(resp.Threads))
}
}

// TestGmailListCommand_IncludeLabelsFlag tests that the --include-labels flag exists
func TestGmailListCommand_IncludeLabelsFlag(t *testing.T) {
cmd := gmailListCmd

flag := cmd.Flags().Lookup("include-labels")
if flag == nil {
t.Error("expected --include-labels flag to exist")
}
if flag.DefValue != "false" {
t.Errorf("expected --include-labels default 'false', got '%s'", flag.DefValue)
}
}

// TestGmailList_IncludeLabels_MockServer tests that --include-labels returns union of all message labels
func TestGmailList_IncludeLabels_MockServer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

// Threads list
if r.URL.Path == "/gmail/v1/users/me/threads" && r.Method == "GET" {
resp := map[string]interface{}{
"threads": []map[string]interface{}{
{"id": "thread-lbl", "snippet": "Test labels"},
},
}
json.NewEncoder(w).Encode(resp)
return
}

// Thread get with metadata — two messages with different labels
if r.URL.Path == "/gmail/v1/users/me/threads/thread-lbl" && r.Method == "GET" {
resp := map[string]interface{}{
"id": "thread-lbl",
"messages": []map[string]interface{}{
{
"id": "msg-001",
"threadId": "thread-lbl",
"labelIds": []string{"INBOX", "UNREAD", "CATEGORY_PROMOTIONS"},
"payload": map[string]interface{}{
"headers": []map[string]string{
{"name": "Subject", "value": "Promo email"},
{"name": "From", "value": "promo@example.com"},
{"name": "Date", "value": "Mon, 6 Feb 2026 10:00:00 +0000"},
},
},
},
{
"id": "msg-002",
"threadId": "thread-lbl",
"labelIds": []string{"INBOX", "STARRED"},
"payload": map[string]interface{}{
"headers": []map[string]string{
{"name": "Subject", "value": "Re: Promo email"},
{"name": "From", "value": "reply@example.com"},
{"name": "Date", "value": "Mon, 6 Feb 2026 11:00:00 +0000"},
},
},
},
},
}
json.NewEncoder(w).Encode(resp)
return
}

t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()

svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL))
if err != nil {
t.Fatalf("failed to create gmail service: %v", err)
}

// Fetch thread list
listResp, err := svc.Users.Threads.List("me").MaxResults(10).Do()
if err != nil {
t.Fatalf("failed to list threads: %v", err)
}

if len(listResp.Threads) != 1 {
t.Fatalf("expected 1 thread, got %d", len(listResp.Threads))
}

// Get thread detail (same as runGmailList does)
threadDetail, err := svc.Users.Threads.Get("me", listResp.Threads[0].Id).Format("metadata").MetadataHeaders("Subject", "From", "Date").Do()
if err != nil {
t.Fatalf("failed to get thread detail: %v", err)
}

// Simulate includeLabels=true logic
labelSet := make(map[string]bool)
for _, m := range threadDetail.Messages {
for _, lbl := range m.LabelIds {
labelSet[lbl] = true
}
}
labels := make([]string, 0, len(labelSet))
for lbl := range labelSet {
labels = append(labels, lbl)
}

// Verify union of labels from both messages
expected := map[string]bool{
"INBOX": true,
"UNREAD": true,
"CATEGORY_PROMOTIONS": true,
"STARRED": true,
}
if len(labels) != len(expected) {
t.Errorf("expected %d labels, got %d: %v", len(expected), len(labels), labels)
}
for _, lbl := range labels {
if !expected[lbl] {
t.Errorf("unexpected label: %s", lbl)
}
}

// Simulate includeLabels=false — labels should NOT be in output
threadInfo := map[string]interface{}{
"thread_id": "thread-lbl",
}
// Without the flag, no "labels" key should be set
if _, exists := threadInfo["labels"]; exists {
t.Error("labels should not be present when include-labels is false")
}
}
60 changes: 42 additions & 18 deletions skills/morning/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: gws-morning
version: 0.5.0
version: 0.6.0
description: "AI-powered morning inbox briefing. Reads Gmail, Google Tasks, Calendar, and OKR sheets to produce a prioritized daily briefing with actionable recommendations. Triggers: /morning, morning briefing, inbox triage, email priorities, daily digest."
metadata:
short-description: AI inbox briefing with OKR/task matching
Expand Down Expand Up @@ -55,6 +55,16 @@ The config contains:
- `inbox_query` — Gmail search query (default: `"is:unread in:inbox"`)
- `daily_log_doc_id` — Google Doc ID for the daily log (empty = skip logging)

### Cache label list

Fetch the Gmail label list once at session start to avoid re-fetching on every label operation:

```bash
gws gmail labels > "$SCRATCHPAD_DIR/morning/labels.json"
```

This cached list is passed to the label-resolver sub-agent when the user picks "Label & archive" during triage. Saves ~4k tokens + 2-3s per label operation vs fetching each time.

## Step 2: Launch Background Data Gathering

Launch a **background agent** to run prefetch + pre-filter while the main agent shows the user immediate context.
Expand Down Expand Up @@ -118,7 +128,7 @@ Starting triage.
### What the scripts do internally

**Prefetch** (`morning-prefetch.sh`):
- `gws gmail list --max <max_unread> --query "is:unread in:inbox"` — **MUST use `in:inbox`**
- `gws gmail list --max <max_unread> --query "is:unread in:inbox" --include-labels` — **MUST use `in:inbox`**
- `gws calendar events --days 2` — today + tomorrow
- `gws tasks lists` → `gws tasks list <id>` for each list
- `gws sheets read <okr_sheet_id> "<sheet_name>!A1:Q100"` — OKR data (cached 24h)
Expand All @@ -129,9 +139,25 @@ Starting triage.
- Archives calendar invite emails (Invitation:, Updated Invitation:, Canceled: from calendar-notification@google.com)
- Writes `prefiltered.json` (remaining) and `auto_handled.json` (archived with reasons)

### Enrich emails (deterministic tags)

After pre-filtering, run enrichment to add deterministic tags:

```bash
scripts/morning-enrich.sh "$SCRATCHPAD_DIR/morning" ~/.config/gws/inbox-skill.yaml
```

This reads `prefiltered.json` + `calendar.json` + config, and writes `enriched.json` with tags:
- `noise_signal`: "promotions" if CATEGORY_PROMOTIONS label
- `vip_sender`: true if sender matches VIP list
- `starred`: true if STARRED label
- `is_thread`: true if multi-message thread
- `calendar_match`: matching meeting title

### Output files

- `prefiltered.json` — remaining emails for AI classification (OOO/invites removed)
- `prefiltered.json` — remaining emails after OOO/invite removal
- `enriched.json` — emails with deterministic tags for AI classification
- `auto_handled.json` — items archived by pre-filter with reasons
- `classified.json` — classification results from background agent
- `inbox.json` — original unread inbox emails
Expand All @@ -141,20 +167,18 @@ Starting triage.

## Step 3: Classify (Background Agent)

The background agent classifies all remaining emails using the rules from `skills/morning/prompts/triage-agent.md`. This happens as part of the background agent launched in Step 2 — the main agent does NOT classify.
The background agent classifies enriched emails using the rules from `skills/morning/prompts/triage-agent.md`. This happens as part of the background agent launched in Step 2 — the main agent does NOT classify.

For each email, the background agent determines:
- **Classification:** ACT_NOW / REVIEW / NOISE
- **Priority score:** 1-5 (highest signal, not additive)
- **Summary:** 1-2 lines
- **Matches:** OKR, task, or calendar match if any
- **Recommended action**
The background agent returns a grouped JSON result (lean format — see `triage-agent.md`):
- **`auto_handled`:** NOISE items — thread IDs + reason only (no subject/sender/matches)
- **`needs_input`:** ACT_NOW and REVIEW items with priority, summary, sender, subject, non-null matches, and `suggested_label`
- **`batch_stats`:** Total counts per category

The background agent writes results to `classified.json` and archives all NOISE items via `gws gmail archive-thread <thread_id> --quiet`.
The main agent uses `auto_handled` thread IDs for bulk archive via `scripts/bulk-gmail.sh archive-thread`, and `needs_input` for guided triage.

## Step 4: Collect Results

When the background agent completes, the main agent reads `classified.json` and presents the auto-action summary.
When the background agent completes, the main agent bulk-archives NOISE items and presents the auto-action summary.

### Classification Categories

Expand Down Expand Up @@ -282,15 +306,15 @@ Use AskUserQuestion with **4 options**. Pick the best 4 from the pool based on c

**Standard options (always include):**
- **Mark as read** — Mark read, keep in inbox: `gws gmail label <message_id> --remove UNREAD --quiet`
- **Archive** — Remove from inbox: `gws gmail archive-thread <thread_id> --quiet`
- **Archive** — If `suggested_label` exists, apply label first then archive: `gws gmail label <message_id> --add "<suggested_label>" --quiet` followed by `gws gmail archive-thread <thread_id> --quiet`. Show: "Archived with label: <label>". If no suggested label, just archive. User can override with "Other" to specify a different label.
- **Skip** — Move to next item (keeps email **unread**)

**Rotate the 4th slot based on context:**
- **Dig Deeper** — Spawn deep-dive sub-agent (for complex threads, action items)
- **Reply** — Compose a reply: fetch email via `gws gmail read <message_id>`, draft a response using conversation context (OKR/task matches, deep-dive insights if available), present draft for user approval, then `gws gmail reply <message_id> --body "<reply>"`. Thread ID, subject, and headers are auto-populated.
- **Reply All** — Same as Reply but to all recipients: `gws gmail reply <message_id> --body "<reply>" --all`
- **Forward** — Forward to a colleague: ask who, fetch email content (`read` for single message, `thread` for conversations), compose brief forwarding context (why forwarding + 1-2 key points, include OKR/task match if relevant), then `gws gmail send --to "<email>" --subject "Fwd: <subject>" --body "<your note>\n\n---\nOriginal message:\n<content>"`
- **Label & archive** — Spawn label-resolver sub-agent (`skills/morning/prompts/label-resolver.md`) with `action=archive`
- **Label & archive** — Spawn label-resolver sub-agent (`skills/morning/prompts/label-resolver.md`) with `action=archive` and `labels_file="$SCRATCHPAD_DIR/morning/labels.json"` (cached from Step 1)
- **Add task & archive** — Ask for title, run `gws tasks create`, then archive
- **Open in browser** — Run `open "https://mail.google.com/mail/u/0/#inbox/<thread_id>"`

Expand Down Expand Up @@ -329,10 +353,9 @@ skills/morning/scripts/bulk-gmail.sh mark-read <id1> <id2> ...
Spawn a sub-agent to fetch, summarize, and cross-reference the email.

**Prompt file:** `skills/morning/prompts/deep-dive.md`
**Model:** `sonnet` — **always use sonnet** (haiku is unreliable for email reading)
**Agent type:** `general-purpose`
**Model/agent_type:** defined in prompt frontmatter (`sonnet` / `general-purpose`)

Pass: email ID, message count (for `read` vs `thread`), OKR/task/calendar context.
Pass: **user context** (name, email, company, role/team), email ID, message count (for `read` vs `thread`), OKR/task/calendar context.

The sub-agent returns a structured brief. Present it and ask what to do next:
- **Open comment/doc** — open direct link (if available)
Expand Down Expand Up @@ -615,5 +638,6 @@ Common `gws` commands used during triage:

### Label Operations
- Gmail labels are resolved by **display name** (case-insensitive), not by internal ID.
- For label operations during triage, use the **label-resolver sub-agent** (`skills/morning/prompts/label-resolver.md`) to avoid loading the full label list (4000+ labels) into the main context.
- **Labels are cached at session start** (Step 1) in `$SCRATCHPAD_DIR/morning/labels.json`. Pass `labels_file` to the label-resolver sub-agent to avoid re-fetching on every label operation (~4k tokens + 2-3s saved per operation).
- For label operations during triage, use the **label-resolver sub-agent** (`skills/morning/prompts/label-resolver.md`) — keeps the full label list (4000+ labels) out of the main context.
- Common label patterns: `gws gmail label <id> --add "STARRED"`, `gws gmail label <id> --remove "UNREAD"`
8 changes: 6 additions & 2 deletions skills/morning/compact-prompt.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
---
name: compact-prompt
type: template
description: Session resumption template for /morning triage. Not a sub-agent.
---

# /morning Compact Prompt

Use this when context runs out during a `/morning` triage session. Copy the template below, fill in the state from the conversation, and paste it to resume.

---

## Template

```
Expand Down
21 changes: 16 additions & 5 deletions skills/morning/prompts/deep-dive.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
# Deep-Dive Email Summarizer Prompt

**Model:** `sonnet` — always use sonnet. Haiku is unreliable for email reading (fails to fetch content in practice).
---
name: deep-dive
model: sonnet
agent_type: general-purpose
description: Fetch and analyze a single email/thread with cross-references and suggested actions
notes: Always use sonnet - haiku is unreliable for email reading (fails to fetch content in practice)
---

**Agent type:** `general-purpose`
# Deep-Dive Email Summarizer Prompt

**Purpose:** When the user picks "Dig Deeper" on a specific email, fetch the full email/thread and return a structured brief with cross-references and suggested actions.
When the user picks "Dig Deeper" on a specific email, fetch the full email/thread and return a structured brief with cross-references and suggested actions.

## Prompt Template

```
You are a deep-dive email summarizer for an inbox triage skill. Fetch the email and return a structured brief.

## User Context

You will receive the user's name, email, company, and role/team. Use this to:
- Determine the user's relationship to the email (sender, recipient, CC'd, mentioned)
- Assess whether the user owns the next action
- Understand company-specific context (internal vs external communication)

## Task

Fetch this email:
Expand Down
Loading
Loading