diff --git a/CLAUDE.md b/CLAUDE.md index 4a2feab..a673dc2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Makefile b/Makefile index fe5a6a3..399caed 100644 --- a/Makefile +++ b/Makefile @@ -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) \ diff --git a/README.md b/README.md index d9bbb17..f769620 100644 --- a/README.md +++ b/README.md @@ -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 ` | Read message body and headers | | `gws gmail thread ` | Read full thread conversation | | `gws gmail send` | Send email (`--to`, `--subject`, `--body`, `--cc`, `--bcc`, `--thread-id`, `--reply-to-message-id`) | diff --git a/cmd/gmail.go b/cmd/gmail.go index 4eb7703..f06d1de 100644 --- a/cmd/gmail.go +++ b/cmd/gmail.go @@ -7,6 +7,7 @@ import ( "net/url" "os" "regexp" + "sort" "strings" "github.com/omriariav/workspace-cli/internal/client" @@ -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)") @@ -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 @@ -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) diff --git a/cmd/gmail_test.go b/cmd/gmail_test.go index 4744975..bb49e90 100644 --- a/cmd/gmail_test.go +++ b/cmd/gmail_test.go @@ -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") + } +} diff --git a/skills/morning/SKILL.md b/skills/morning/SKILL.md index 605c198..a3c71e6 100644 --- a/skills/morning/SKILL.md +++ b/skills/morning/SKILL.md @@ -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 @@ -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. @@ -118,7 +128,7 @@ Starting triage. ### What the scripts do internally **Prefetch** (`morning-prefetch.sh`): -- `gws gmail list --max --query "is:unread in:inbox"` — **MUST use `in:inbox`** +- `gws gmail list --max --query "is:unread in:inbox" --include-labels` — **MUST use `in:inbox`** - `gws calendar events --days 2` — today + tomorrow - `gws tasks lists` → `gws tasks list ` for each list - `gws sheets read "!A1:Q100"` — OKR data (cached 24h) @@ -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 @@ -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 --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 @@ -282,7 +306,7 @@ 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 --remove UNREAD --quiet` -- **Archive** — Remove from inbox: `gws gmail archive-thread --quiet` +- **Archive** — If `suggested_label` exists, apply label first then archive: `gws gmail label --add "" --quiet` followed by `gws gmail archive-thread --quiet`. Show: "Archived with label: