diff --git a/AGENTS.md b/AGENTS.md index 178896a..6a19512 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Four artifacts, one architecture. The three Node workspaces below are private an | Artifact | What | Where | |---|---|---| -| `thunderbird-cli` | `tb` CLI (43 commands) | `cli/` → bin `tb` in `@odience-network/thunderbird-cli-enhanced` | +| `thunderbird-cli` | `tb` CLI (47 commands) | `cli/` → bin `tb` in `@odience-network/thunderbird-cli-enhanced` | | `thunderbird-cli-bridge` | Stateless HTTP↔WS proxy daemon | `bridge/` → bin `tb-bridge` in `@odience-network/thunderbird-cli-enhanced` | | `thunderbird-cli-mcp` | MCP server (16 tools for Claude Desktop) | `mcp/` → bin `tb-mcp` in `@odience-network/thunderbird-cli-enhanced` | | Thunderbird WebExtension | WS client inside Thunderbird | `extension/` → Mozilla-signed (unlisted) XPI in `dist/releases/` | diff --git a/README.md b/README.md index bad938f..21f987c 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ Without the skill, the MCP still works. With it, Claude automatically uses the s |---|---| | **Extension** (`extension/`) | Thunderbird WebExtension. Calls `messenger.*` APIs; every route is classified by the access policy. | | **Bridge** (`bridge/`) | HTTP↔WebSocket proxy daemon on `127.0.0.1:7700`/`7701`. No business logic; buffers recent extension events for long-polling. | -| **CLI** (`cli/`) | `tb` command — 43 commands. Thin HTTP client. JSON output. | +| **CLI** (`cli/`) | `tb` command — 47 commands. Thin HTTP client. JSON output. | | **MCP** (`mcp/`) | `tb-mcp` server — 16 curated tools for Claude Desktop. | Thunderbird is the source of truth. The CLI never caches or stores email data. diff --git a/SPEC.md b/SPEC.md index 78427f2..511449c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -21,7 +21,7 @@ │ ┌────────────────────┬──────────────────────┐ │ │ ↕ ↕ ↕ │ │ tb CLI (Node) tb-mcp Server Direct HTTP │ -│ (43 commands) (27 MCP tools) (curl, scripts) │ +│ (47 commands) (31 MCP tools) (curl, scripts) │ │ ↕ ↕ │ │ AI Agent Claude Desktop │ │ (Claude Code) (stdio MCP transport) │ @@ -40,8 +40,8 @@ | **Thunderbird** | Host | Source of truth. Stores all emails, syncs IMAP, renders UI for human oversight | | **Extension** (background.js) | Inside Thunderbird | Pure WebExtension. Connects to bridge via WebSocket. Checks every request against the build-time access policy (`access-control.js`, see [docs/ACCESS-CONTROL.md](docs/ACCESS-CONTROL.md)), then translates it into `messenger.*` API calls | | **Bridge** (bridge.js) | Host (daemon) | Stateless HTTP↔WebSocket proxy. Receives HTTP from CLI/MCP, forwards to extension, returns response. No business logic | -| **CLI** (tb) | Host or Docker | Thin HTTP client. Parses args, calls bridge, outputs JSON to stdout. 43 commands. Auto-starts bridge daemon if not running. Zero state | -| **MCP Server** (tb-mcp) | Host (alongside Claude Desktop) | Stdio-based MCP server. Exposes 26 curated tools to Claude Desktop and other MCP clients. Auto-starts bridge daemon if not running. Reuses CLI's HTTP client to call bridge | +| **CLI** (tb) | Host or Docker | Thin HTTP client. Parses args, calls bridge, outputs JSON to stdout. 47 commands. Auto-starts bridge daemon if not running. Zero state | +| **MCP Server** (tb-mcp) | Host (alongside Claude Desktop) | Stdio-based MCP server. Exposes 41 curated tools to Claude Desktop and other MCP clients. Auto-starts bridge daemon if not running. Reuses CLI's HTTP client to call bridge | ### Request flow @@ -694,6 +694,33 @@ right after the click, within the listener's timeout) to catch the event. --- +### 20. Deterministic Skills + +Zero-LLM-reasoning shortcuts (ODIAA-2332): each composes existing read-only +endpoints (`/calendar/events/list`, `/calendar/clashes`, `/stats`, +`/messages/search`) and hands back pre-formatted, compact Markdown — no JSON +parsing or summarizing required by the caller. The same rendering functions +(`lib/skills.js`) back both the CLI and the matching `skill_*` MCP tools, so +output is byte-identical across surfaces. `--format json|compact|table` +opts back into the standard JSON envelope; otherwise Markdown is printed +regardless of `--output-version`. + +```bash +# Today's events plus unread/flagged mail counts +tb today + +# This week's events, grouped by day +tb week + +# Overlapping events across all calendars in the next N days (default 7) +tb clashes [--days ] + +# Recent mail from a sender address or domain, grouped into threads +tb from
[--limit ] +``` + +--- + ## Implementation Details ### Token Optimization Strategy @@ -1285,12 +1312,12 @@ Claude Desktop ──stdio JSON-RPC──> tb-mcp ──HTTP──> Bridge ─ The MCP server: - Has **no state** — every tool call is independent - **Reuses** `cli/src/client.js` for HTTP calls (no code duplication) -- Exposes **31 high-level tools** rather than all CLI commands +- Exposes **41 high-level tools** rather than all CLI commands - Defaults to **safe behavior** (compose/reply/forward/edit → draft, not send) ### Tool Catalog -The 37 MCP tools are **curated** for AI agent use cases. Bulk admin operations (folder CRUD, identity management, bulk delete, etc.) are intentionally excluded — they belong in the CLI for explicit human control. +The 41 MCP tools are **curated** for AI agent use cases. Bulk admin operations (folder CRUD, identity management, bulk delete, etc.) are intentionally excluded — they belong in the CLI for explicit human control. | MCP Tool | Maps to CLI commands | |----------|---------------------| @@ -1322,6 +1349,10 @@ The 37 MCP tools are **curated** for AI agent use cases. Bulk admin operations ( | `calendar_event_update` | `tb calendar update` (requires `calendarWrite`) | | `calendar_event_delete` | `tb calendar delete` (requires `calendarWrite`) | | `calendar_clashes` | `tb calendar clashes` | +| `skill_today` | `tb today` | +| `skill_week` | `tb week` | +| `skill_clashes` | `tb clashes` | +| `skill_from` | `tb from` | | `task_list` | `tb tasks list` | | `task_create` | `tb tasks create` (requires `tasksWrite`) | | `task_update` | `tb tasks update` (requires `tasksWrite`) | @@ -1339,7 +1370,7 @@ local notes workspace directly. ### Why fewer MCP tools than CLI commands? -| | CLI (43 commands) | MCP (27 tools) | +| | CLI (47 commands) | MCP (31 tools) | |---|---|---| | Audience | Humans + scripts | AI agents | | Discovery | `tb --help` | Tool descriptions in LLM context | diff --git a/cli/README.md b/cli/README.md index d2acf9c..b455e97 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,6 +1,6 @@ # thunderbird-cli -> Low-level CLI to manage Mozilla Thunderbird email from the shell. 43 commands designed for AI agents. +> Low-level CLI to manage Mozilla Thunderbird email from the shell. 47 commands designed for AI agents. [![tests](https://github.com/odience-network/thunderbird-cli-enhanced/actions/workflows/test.yml/badge.svg)](https://github.com/odience-network/thunderbird-cli-enhanced/actions/workflows/test.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -9,13 +9,14 @@ Part of the [Thunderbird CLI Enhanced](https://github.com/odience-network/thunde ## What it does -`tb` is a thin HTTP client that talks to a local Thunderbird WebExtension via a bridge daemon. It exposes 43 commands across all `messenger.*` APIs: +`tb` is a thin HTTP client that talks to a local Thunderbird WebExtension via a bridge daemon. It exposes 47 commands across all `messenger.*` APIs: - **Search & read** — full-text search across accounts, batch reads, threads - **Compose** — draft, open, or send (defaults to draft for safety) - **Folders** — list, create, rename, delete, info, sync - **Attachments** — list and download (base64 → file) - **Bulk ops** — mark-read, move, delete, tag, fetch with filters +- **Deterministic skills** — `today`, `week`, `clashes`, `from` render ready-to-show Markdown, no model reasoning required - **Token-optimized** — `--fields`, `--compact`, `--max-body` for AI use All output is JSON wrapped in `{ok, data}` / `{ok, error, code}`. diff --git a/cli/src/cli.js b/cli/src/cli.js index 9a8d6f0..d83b80c 100755 --- a/cli/src/cli.js +++ b/cli/src/cli.js @@ -20,6 +20,15 @@ import { searchNotes, renderNoteHtml, } from "./notes.js"; +import { + startOfDay, + addDays, + renderToday, + renderWeek, + renderClashes, + groupMessagesIntoThreads, + renderFrom, +} from "../../lib/skills.js"; const MIME_TO_EXT = { "application/pdf": ".pdf", @@ -155,6 +164,23 @@ function parseIds(str) { return str.split(",").map(id => parseInt(id.trim())); } +// /calendar/events/list and /calendar/clashes return { error } (not a thrown error) when the +// calendar Experiment API hasn't loaded — see docs/decisions/calendar-backend.md. +function calendarErrorOf(result) { + return !Array.isArray(result) && result?.error ? result.error : null; +} + +// Deterministic skill commands (ODIAA-2332) print compact Markdown by default — that's the +// point of a zero-parsing "skill" — but still honor an explicit --format for scripting, going +// through the normal v1/v2 JSON envelope machinery in that case. +function outputSkill(globalOpts, structuredData, markdown) { + if (globalOpts.format) { + output(structuredData, getFormat(globalOpts), getOutputOpts(globalOpts)); + } else { + process.stdout.write(markdown); + } +} + // ─── Health ─────────────────────────────────────────────────────────── program @@ -278,6 +304,74 @@ calendar output(data, getFormat(g), getOutputOpts(g)); })); +// ─── Deterministic skills (ODIAA-2332) ───────────────────────────────── +// Zero-LLM-reasoning shortcuts: compose existing read-only endpoints, print compact Markdown. + +program + .command("today") + .description("Today's calendar events plus unread/flagged mail counts (compact Markdown)") + .action(run(async () => { + const g = program.opts(); + const date = startOfDay(new Date()); + const end = addDays(date, 1); + const [eventsResult, stats, flaggedResult] = await Promise.all([ + api("POST", "/calendar/events/list", { start: date.toISOString(), end: end.toISOString() }, getTimeout(g)), + api("GET", "/stats", null, getTimeout(g)), + api("POST", "/messages/search", { flagged: true, limit: 200 }, getTimeout(g)), + ]); + const calendarError = calendarErrorOf(eventsResult); + const flagged = flaggedResult?.messages + ? { count: flaggedResult.messages.length, hasMore: !!flaggedResult.hasMore } + : null; + const markdown = renderToday({ + date, calendarError, flagged, + events: calendarError ? [] : eventsResult, + unreadTotal: stats?.totalUnread, + }); + outputSkill(g, { date: date.toISOString(), events: eventsResult, unreadTotal: stats?.totalUnread, flagged }, markdown); + })); + +program + .command("week") + .description("Calendar events for the next 7 days, grouped by day (compact Markdown)") + .action(run(async () => { + const g = program.opts(); + const start = startOfDay(new Date()); + const end = addDays(start, 7); + const eventsResult = await api("POST", "/calendar/events/list", { start: start.toISOString(), end: end.toISOString() }, getTimeout(g)); + const calendarError = calendarErrorOf(eventsResult); + const markdown = renderWeek({ start, numDays: 7, calendarError, events: calendarError ? [] : eventsResult }); + outputSkill(g, { start: start.toISOString(), end: end.toISOString(), events: eventsResult }, markdown); + })); + +program + .command("clashes") + .description("Overlapping events across all calendars in the next N days (compact Markdown)") + .option("--days ", "look ahead this many days", "7") + .action(run(async (opts) => { + const g = program.opts(); + const days = parseInt(opts.days) || 7; + const start = startOfDay(new Date()); + const end = addDays(start, days); + const result = await api("POST", "/calendar/clashes", { start: start.toISOString(), end: end.toISOString() }, getTimeout(g)); + const calendarError = calendarErrorOf(result); + const markdown = renderClashes({ start, end, calendarError, clashes: result?.clashes }); + outputSkill(g, result, markdown); + })); + +program + .command("from
") + .description("Recent mail from a sender address or domain, grouped into threads (compact Markdown)") + .option("-l, --limit ", "max messages to consider", "50") + .action(run(async (address, opts) => { + const g = program.opts(); + const limit = parseInt(opts.limit) || 50; + const result = await api("POST", "/messages/search", { fromAddress: address, limit }, getTimeout(g)); + const threads = groupMessagesIntoThreads(result?.messages); + const markdown = renderFrom({ address, threads, hasMore: !!result?.hasMore }); + outputSkill(g, result, markdown); + })); + // ─── Bridge Status ──────────────────────────────────────────────────── program diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 6c6cd44..66e9d7d 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1,6 +1,6 @@ # CLI Command Reference -All 43 commands in the `tb` CLI. For the quick tour, see the [main README](../README.md). For AI-agent-focused usage, see [CLAUDE.md](CLAUDE.md). +All 47 commands in the `tb` CLI. For the quick tour, see the [main README](../README.md). For AI-agent-focused usage, see [CLAUDE.md](CLAUDE.md). ## Global Options @@ -297,6 +297,19 @@ tb calendar clashes --start --end # find overlapping events across all calendars ``` +## Deterministic Skills + +Zero-LLM-reasoning shortcuts (ODIAA-2332): each composes existing read-only endpoints above and +prints compact, pre-formatted Markdown by default — pass `--format json|compact|table` to get the +underlying structured data instead (e.g. for scripting). All read-only, ungated. + +```bash +tb today # today's calendar events + unread/flagged mail counts +tb week # calendar events for the next 7 days, grouped by day +tb clashes [--days ] # overlapping events across all calendars (default: next 7 days) +tb from
[-l, --limit ] # recent mail from a sender/domain, grouped into threads +``` + ## Tasks Calendar tasks (VTODO), through the vendored `calendar.tasks` Experiment API (see diff --git a/lib/skills.js b/lib/skills.js new file mode 100644 index 0000000..8b94eea --- /dev/null +++ b/lib/skills.js @@ -0,0 +1,188 @@ +/** + * Deterministic "skill" formatters (ODIAA-2332): today / week / clashes / from. + * + * Pure functions only — no bridge/network calls, no Date.now() defaults — so the CLI + * (cli/src/cli.js) and MCP server (mcp/src/tools.js) render byte-identical Markdown from + * the same data, and so tests can pass fixed dates for deterministic snapshots. Callers do + * the data fetching and pass plain data in; this module never throws on missing/odd fields. + * + * Dates/times are formatted from the JS Date object's local-timezone getters (matching + * cli/src/client.js's non-UTC formatDate()), not locale-dependent Intl formatting — so + * output shape doesn't vary with the OS locale, only with TZ (tests pin TZ=UTC). + */ + +const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function pad(n) { + return String(n).padStart(2, "0"); +} + +export function formatDate(d) { + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +export function formatTime(d) { + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +export function startOfDay(date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +export function addDays(date, days) { + const d = new Date(date); + d.setDate(d.getDate() + days); + return d; +} + +/** + * Mirrors extension/src/thread-utils.js's normalizeSubject exactly, so grouping-by-subject + * agrees with the extension's own thread matching. Duplicated rather than imported: this + * module runs in Node (CLI/MCP); thread-utils.js is a WebExtension background script loaded + * into the browser's global scope (see extension/manifest.json), not an importable module. + */ +export function normalizeSubjectForThreading(subject) { + return (subject || "") + .trim() + .replace(/^((Re|WG|AW|Fwd?|FW|Sv|Vs|Ref):\s*|\[[^\]]*\]\s*)*/gi, "") + .trim(); +} + +// All-day events first (so a day's timed schedule reads top-to-bottom in start order), +// then by start instant. +export function sortEvents(events) { + return [...(events || [])].sort((a, b) => { + if (!!a.allDay !== !!b.allDay) return a.allDay ? -1 : 1; + return new Date(a.start) - new Date(b.start); + }); +} + +export function formatEventLine(event) { + const cal = event.calendarName ? ` (${event.calendarName})` : ""; + const loc = event.location ? ` — ${event.location}` : ""; + if (event.allDay) { + return `All day: ${event.title}${cal}${loc}`; + } + const start = new Date(event.start); + const end = new Date(event.end); + return `${formatTime(start)}–${formatTime(end)} ${event.title}${cal}${loc}`; +} + +export function renderToday({ date, events, calendarError, unreadTotal, flagged }) { + const lines = [`# Today — ${formatDate(date)}`, ""]; + + if (calendarError) { + lines.push("## Calendar", `- unavailable: ${calendarError}`, ""); + } else { + const sorted = sortEvents(events); + lines.push(`## Calendar (${sorted.length})`); + if (sorted.length === 0) lines.push("- no events"); + else for (const e of sorted) lines.push(`- ${formatEventLine(e)}`); + lines.push(""); + } + + lines.push("## Mail"); + if (unreadTotal != null) lines.push(`- ${unreadTotal} unread`); + if (flagged) lines.push(`- ${flagged.count}${flagged.hasMore ? "+" : ""} flagged`); + + return lines.join("\n").trimEnd() + "\n"; +} + +export function renderWeek({ start, numDays = 7, events, calendarError }) { + const lines = [`# Week of ${formatDate(start)}`, ""]; + + if (calendarError) { + lines.push(`unavailable: ${calendarError}`); + return lines.join("\n").trimEnd() + "\n"; + } + + const byDay = new Map(); + for (const e of events || []) { + const key = e.allDay ? String(e.start).slice(0, 10) : formatDate(new Date(e.start)); + if (!byDay.has(key)) byDay.set(key, []); + byDay.get(key).push(e); + } + + let freeCount = 0; + for (let i = 0; i < numDays; i++) { + const day = addDays(start, i); + const key = formatDate(day); + const dayEvents = sortEvents(byDay.get(key)); + if (dayEvents.length === 0) { + freeCount++; + continue; + } + lines.push(`## ${DOW[day.getDay()]} ${key}`); + for (const e of dayEvents) lines.push(`- ${formatEventLine(e)}`); + lines.push(""); + } + if (freeCount) lines.push(`${freeCount} of ${numDays} days free`); + + return lines.join("\n").trimEnd() + "\n"; +} + +export function renderClashes({ start, end, clashes, calendarError }) { + const lines = [`# Clashes — ${formatDate(start)} to ${formatDate(end)}`, ""]; + + if (calendarError) { + lines.push(`unavailable: ${calendarError}`); + return lines.join("\n").trimEnd() + "\n"; + } + + if (!clashes || clashes.length === 0) { + lines.push("No clashes found."); + return lines.join("\n").trimEnd() + "\n"; + } + + clashes.forEach((group, i) => { + const gStart = new Date(group.start); + const gEnd = new Date(group.end); + lines.push(`## Clash ${i + 1}: ${formatDate(gStart)} ${formatTime(gStart)}–${formatTime(gEnd)}`); + for (const e of sortEvents(group.events)) lines.push(`- ${formatEventLine(e)}`); + lines.push(""); + }); + + return lines.join("\n").trimEnd() + "\n"; +} + +// Groups by normalized subject (case-insensitive); most-recently-active thread first, +// newest message first within a thread. +export function groupMessagesIntoThreads(messages) { + const byKey = new Map(); + for (const m of messages || []) { + const normalized = normalizeSubjectForThreading(m.subject) || "(no subject)"; + const key = normalized.toLowerCase(); + if (!byKey.has(key)) byKey.set(key, { subject: normalized, messages: [] }); + byKey.get(key).messages.push(m); + } + const threads = [...byKey.values()]; + for (const t of threads) t.messages.sort((a, b) => new Date(b.date) - new Date(a.date)); + threads.sort((a, b) => new Date(b.messages[0].date) - new Date(a.messages[0].date)); + return threads; +} + +export function renderFrom({ address, threads, hasMore }) { + const msgCount = threads.reduce((sum, t) => sum + t.messages.length, 0); + const plusMore = hasMore ? "+" : ""; + const lines = [ + `# Mail from ${address} (${msgCount}${plusMore} message${msgCount === 1 ? "" : "s"}, ` + + `${threads.length} thread${threads.length === 1 ? "" : "s"})`, + "", + ]; + + if (threads.length === 0) { + lines.push("No messages found."); + return lines.join("\n").trimEnd() + "\n"; + } + + for (const t of threads) { + lines.push(`## ${t.subject} (${t.messages.length})`); + for (const m of t.messages) { + const d = new Date(m.date); + lines.push(`- ${formatDate(d)} ${formatTime(d)} — ${m.author || "unknown"}`); + } + lines.push(""); + } + + return lines.join("\n").trimEnd() + "\n"; +} diff --git a/mcp/src/server.js b/mcp/src/server.js index b37028e..db4ca59 100755 --- a/mcp/src/server.js +++ b/mcp/src/server.js @@ -76,6 +76,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const result = await tool.handler(args || {}, api); + // Deterministic "skill" tools (skill_today, skill_week, ...) hand back pre-formatted + // Markdown meant to be read as-is, not re-serialized as a JSON string — that's the whole + // point of a zero-LLM-reasoning tool (ODIAA-2332). + if (result && typeof result.markdown === "string" && Object.keys(result).length === 1) { + return { content: [{ type: "text", text: result.markdown }] }; + } return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; diff --git a/mcp/src/tools.js b/mcp/src/tools.js index b4e5840..74a842f 100644 --- a/mcp/src/tools.js +++ b/mcp/src/tools.js @@ -12,6 +12,21 @@ import { parseRelativeDate } from "./client.js"; import { listNotes, readNote, saveNote, appendNote, renderNoteHtml } from "./notes.js"; +import { + startOfDay, + addDays, + renderToday, + renderWeek, + renderClashes, + groupMessagesIntoThreads, + renderFrom, +} from "../../lib/skills.js"; + +// /calendar/events/list and /calendar/clashes return { error } (not a thrown error) when the +// calendar Experiment API hasn't loaded — see docs/decisions/calendar-backend.md. +function calendarErrorOf(result) { + return !Array.isArray(result) && result?.error ? result.error : null; +} export const tools = [ // ─── 0. Calendar list (ODIAA-2327 proof, read-only) ───────────── @@ -1035,6 +1050,105 @@ export const tools = [ }, }, + // ─── Deterministic skills (ODIAA-2332) ─────────────────────────── + // Zero-LLM-reasoning tools: they compose existing read-only endpoints and hand back + // pre-formatted, compact Markdown — no JSON for the model to parse or summarize. + { + name: "skill_today", + description: + "Today at a glance: today's calendar events (all calendars) plus unread and flagged mail counts. Returns ready-to-show Markdown, not JSON — do not reformat or re-summarize it.", + inputSchema: { type: "object", properties: {} }, + handler: async (_args, api) => { + const date = startOfDay(new Date()); + const end = addDays(date, 1); + const [eventsResult, stats, flaggedResult] = await Promise.all([ + api("POST", "/calendar/events/list", { start: date.toISOString(), end: end.toISOString() }), + api("GET", "/stats"), + api("POST", "/messages/search", { flagged: true, limit: 200 }), + ]); + const calendarError = calendarErrorOf(eventsResult); + const markdown = renderToday({ + date, + events: calendarError ? [] : eventsResult, + calendarError, + unreadTotal: stats?.totalUnread, + flagged: flaggedResult?.messages + ? { count: flaggedResult.messages.length, hasMore: !!flaggedResult.hasMore } + : null, + }); + return { markdown }; + }, + }, + + { + name: "skill_week", + description: + "This week at a glance: calendar events (all calendars) for the next 7 days, grouped by day. Returns ready-to-show Markdown, not JSON — do not reformat or re-summarize it.", + inputSchema: { type: "object", properties: {} }, + handler: async (_args, api) => { + const start = startOfDay(new Date()); + const end = addDays(start, 7); + const eventsResult = await api("POST", "/calendar/events/list", { + start: start.toISOString(), + end: end.toISOString(), + }); + const calendarError = calendarErrorOf(eventsResult); + const markdown = renderWeek({ + start, + numDays: 7, + events: calendarError ? [] : eventsResult, + calendarError, + }); + return { markdown }; + }, + }, + + { + name: "skill_clashes", + description: + "Find overlapping ('clashing') events across all calendars in the next N days (default 7). Ignores cancelled and free/transparent events. Returns ready-to-show Markdown, not JSON — do not reformat or re-summarize it.", + inputSchema: { + type: "object", + properties: { + days: { type: "number", description: "Look ahead this many days (default 7)" }, + }, + }, + handler: async (args, api) => { + const days = args.days > 0 ? args.days : 7; + const start = startOfDay(new Date()); + const end = addDays(start, days); + const result = await api("POST", "/calendar/clashes", { + start: start.toISOString(), + end: end.toISOString(), + }); + const calendarError = calendarErrorOf(result); + const markdown = renderClashes({ start, end, clashes: result?.clashes, calendarError }); + return { markdown }; + }, + }, + + { + name: "skill_from", + description: + "Recent mail from a sender address or domain, grouped into threads by subject. Returns ready-to-show Markdown, not JSON — do not reformat or re-summarize it.", + inputSchema: { + type: "object", + properties: { + address: { type: "string", description: "Sender email address or domain (e.g. '@example.com')" }, + limit: { type: "number", description: "Max messages to consider (default 50)" }, + }, + required: ["address"], + }, + handler: async (args, api) => { + if (!args.address) return { error: "address required" }; + const limit = args.limit > 0 ? args.limit : 50; + const result = await api("POST", "/messages/search", { fromAddress: args.address, limit }); + const threads = groupMessagesIntoThreads(result?.messages); + const markdown = renderFrom({ address: args.address, threads, hasMore: !!result?.hasMore }); + return { markdown }; + }, + }, + // ─── 31. Address books (ODIAA-2333) ──────────────────────────────── { name: "address_book_list", diff --git a/package.json b/package.json index 3c8931b..5822a18 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "bridge/package.json", "mcp/src/", "mcp/package.json", + "lib/", "skills/thunderbird-cli/", "server.json", "CHANGELOG.md" @@ -44,7 +45,8 @@ "test:sign": "node test/sign-xpi.test.mjs", "test:docs": "node test/docs.test.mjs", "test:notes": "node test/notes-test.mjs", - "test:all": "npm test && npm run test:bridge-auth && npm run test:bridge-security && npm run test:extension && npm run test:sign && npm run test:docs && npm run test:notes && npm run test:mcp && npm run test:mcp-concurrency && npm run test:pack", + "test:skills": "node test/skills.test.mjs", + "test:all": "npm test && npm run test:bridge-auth && npm run test:bridge-security && npm run test:extension && npm run test:sign && npm run test:docs && npm run test:notes && npm run test:skills && npm run test:mcp && npm run test:mcp-concurrency && npm run test:pack", "test:live-smoke": "node test/live-smoke.mjs", "test:live-attachment": "node test/live-attachment-smoke.mjs", "build:diagrams": "node scripts/build-diagrams.mjs", diff --git a/skills/thunderbird-cli/SKILL.md b/skills/thunderbird-cli/SKILL.md index 899e21d..2dca74a 100644 --- a/skills/thunderbird-cli/SKILL.md +++ b/skills/thunderbird-cli/SKILL.md @@ -38,9 +38,11 @@ Equivalent to `tb health` — returns account count and bridge status. If it err - **EXTENSION_DISCONNECTED** — open Thunderbird. The WebExtension auto-connects within 3s of Thunderbird being open. - **NOT_FOUND** on account/folder — the user hasn't added that account to Thunderbird yet. -## The 37 MCP tools +## The 41 MCP tools -Use these; don't reach for the CLI unless the user explicitly asks for a bulk operation not covered here. +Use these; don't reach for the 47-command CLI unless the user explicitly asks for a bulk operation not covered here. + +For "what's on my plate" style asks, prefer `skill_today`/`skill_week`/`skill_clashes`/`skill_from` over composing `email_search` + `calendar_events` + `email_stats` yourself — they return ready-to-show Markdown built deterministically (no model reasoning), so pass their output straight through to the user instead of re-summarizing it. | Tool | Purpose | Safe by default? | |---|---|---| @@ -71,6 +73,10 @@ Use these; don't reach for the CLI unless the user explicitly asks for a bulk op | `calendar_event_update` | Update a calendar event's properties by id | ⚠️ requires `calendarWrite` access switch (default off) | | `calendar_event_delete` | Delete a calendar event by id | ⚠️ requires `calendarWrite` access switch (default off) | | `calendar_clashes` | Detect overlapping events across all calendars in a date range | ✅ read-only | +| `skill_today` | Today's calendar events plus unread/flagged mail counts — pre-formatted Markdown | ✅ read-only | +| `skill_week` | This week's calendar events, grouped by day — pre-formatted Markdown | ✅ read-only | +| `skill_clashes` | Overlapping events across all calendars in the next N days — pre-formatted Markdown | ✅ read-only | +| `skill_from` | Recent mail from a sender/domain, grouped into threads — pre-formatted Markdown | ✅ read-only | | `task_list` | List calendar tasks (VTODO), optionally filtered by calendar or completion state | ✅ read-only | | `task_create` | Create a calendar task | ⚠️ requires `tasksWrite` access switch (default off) | | `task_update` | Update a task's fields (title, due, priority, description, completed) by id | ⚠️ requires `tasksWrite` access switch (default off) | @@ -321,7 +327,7 @@ Some IMAP servers don't preload attachments. Call `email_read id= mode="chec ## CLI fallback (for power users) -If the user says "from the terminal" or asks about scripting, the same capabilities are available via the `tb` CLI (43 commands, JSON output). Full reference: `tb --help` or https://github.com/odience-network/thunderbird-cli-enhanced/blob/main/docs/COMMANDS.md. +If the user says "from the terminal" or asks about scripting, the same capabilities are available via the `tb` CLI (47 commands, JSON output). Full reference: `tb --help` or https://github.com/odience-network/thunderbird-cli-enhanced/blob/main/docs/COMMANDS.md. MCP tool → CLI command mapping: diff --git a/test/mcp-test.mjs b/test/mcp-test.mjs index 498f0a0..874a8c5 100644 --- a/test/mcp-test.mjs +++ b/test/mcp-test.mjs @@ -16,6 +16,10 @@ import { dirname, join } from "path"; import { mkdtempSync, rmSync } from "fs"; import { tmpdir } from "os"; +// skill_today/skill_week render dates/times using the local Date getters (see lib/skills.js), +// so pin TZ for deterministic assertions regardless of the machine running the suite. +process.env.TZ = "UTC"; + const __dirname = dirname(fileURLToPath(import.meta.url)); const MCP_SERVER = join(__dirname, "../mcp/src/server.js"); const PORT = 19800; @@ -400,7 +404,7 @@ await client.initialize(); console.log("\x1b[1mProtocol\x1b[0m"); const toolList = await client.listTools(); -test("tools/list returns 37 tools", toolList, (r) => Array.isArray(r) && r.length === 37); +test("tools/list returns 41 tools", toolList, (r) => Array.isArray(r) && r.length === 41); test("each tool has name+description+inputSchema", toolList, (r) => r.every((t) => t.name && t.description && t.inputSchema) ); @@ -687,6 +691,40 @@ test( (r) => Array.isArray(r.clashes) && r.clashes[0]?.events?.length === 2 ); +console.log("\n\x1b[1mDeterministic skills\x1b[0m"); +// The MCP server subprocess picks up TZ=UTC from its env at spawn, but mutating +// process.env.TZ mid-process here isn't reliably honored by this process's own Date +// getters — so derive today's date from toISOString(), which is always UTC regardless. +const todayStr = new Date().toISOString().slice(0, 10); +test( + "skill_today returns pre-formatted markdown", + await client.callTool("skill_today", {}), + (r) => typeof r === "string" && r.startsWith(`# Today — ${todayStr}\n`) && r.includes("Standup") && r.includes("5 unread") && r.includes("1 flagged") +); +test( + // The mock's fixed event (2026-01-15) falls outside any 7-day window from "today", so this + // exercises the "no events this week" path rather than the event-rendering path (already + // covered exhaustively by test/skills.test.mjs's fixed-date snapshots). + "skill_week returns pre-formatted markdown", + await client.callTool("skill_week", {}), + (r) => typeof r === "string" && r.startsWith(`# Week of ${todayStr}\n`) && r.includes("7 of 7 days free") +); +test( + "skill_clashes returns pre-formatted markdown", + await client.callTool("skill_clashes", {}), + (r) => typeof r === "string" && r.startsWith("# Clashes — ") && r.includes("Standup") && r.includes("Overlap") +); +test( + "skill_from returns pre-formatted markdown", + await client.callTool("skill_from", { address: "a@b.com" }), + (r) => typeof r === "string" && r.startsWith("# Mail from a@b.com (1 message, 1 thread)\n") && r.includes("Test") +); +test( + "skill_from requires address", + await client.callTool("skill_from", {}), + (r) => r.error === "address required" +); + console.log("\n\x1b[1mNotes\x1b[0m"); test( "note_list starts empty", @@ -802,7 +840,7 @@ const toolsB = await clientB.listTools(); test( "concurrent MCP server instances initialize and list tools", { toolsACount: toolsA.length, toolsBCount: toolsB.length }, - (r) => r.toolsACount === 37 && r.toolsBCount === 37 + (r) => r.toolsACount === 41 && r.toolsBCount === 41 ); clientA.close(); clientB.close(); diff --git a/test/skills.test.mjs b/test/skills.test.mjs new file mode 100644 index 0000000..9cd2f44 --- /dev/null +++ b/test/skills.test.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/** + * Deterministic snapshot tests for lib/skills.js (ODIAA-2332). + * + * TZ is pinned to UTC so event/message times render the same regardless of the machine + * running the suite — these formatters use local-Date getters (see lib/skills.js), not UTC + * formatting, on purpose (matches cli/src/client.js's non-UTC formatDate()). + */ +process.env.TZ = "UTC"; + +import { + formatDate, + formatTime, + startOfDay, + addDays, + normalizeSubjectForThreading, + sortEvents, + formatEventLine, + renderToday, + renderWeek, + renderClashes, + groupMessagesIntoThreads, + renderFrom, +} from "../lib/skills.js"; + +let passed = 0, failed = 0; +function test(name, ok, detail = "") { + if (ok) { passed++; console.log(` \x1b[32m✓\x1b[0m ${name}`); } + else { failed++; console.log(` \x1b[31m✗\x1b[0m ${name}${detail ? `\n ${detail}` : ""}`); } +} + +function event(title, start, end, extra = {}) { + return { id: title, calendarId: "cal1", calendarName: "Work", title, start, end, allDay: false, ...extra }; +} + +const DATE = new Date("2026-09-28T00:00:00.000Z"); // a Monday + +console.log("\ndate/time helpers"); +test("formatDate pads month/day", formatDate(new Date(2026, 0, 5)) === "2026-01-05"); +test("formatTime pads hour/minute", formatTime(new Date(2026, 0, 5, 9, 5)) === "09:05"); +test("startOfDay strips time", startOfDay(new Date("2026-09-28T15:30:00Z")).getHours() === 0); +test("addDays advances calendar day", formatDate(addDays(DATE, 3)) === "2026-10-01"); +test("normalizeSubjectForThreading strips Re/Fwd/brackets", normalizeSubjectForThreading("Re: WG: [All-ipp] Call for Participants") === "Call for Participants"); + +console.log("\nsortEvents"); +{ + const events = [ + event("Standup", "2026-09-28T09:00:00Z", "2026-09-28T09:30:00Z"), + { ...event("Holiday", "2026-09-28", "2026-09-29"), allDay: true }, + event("Dentist", "2026-09-28T14:00:00Z", "2026-09-28T15:00:00Z"), + ]; + const sorted = sortEvents(events); + test("all-day events sort first", sorted[0].title === "Holiday"); + test("timed events sort by start ascending", sorted[1].title === "Standup" && sorted[2].title === "Dentist"); +} + +console.log("\nformatEventLine"); +test("timed event line", formatEventLine(event("Standup", "2026-09-28T09:00:00Z", "2026-09-28T09:30:00Z")) === "09:00–09:30 Standup (Work)"); +test("all-day event line", formatEventLine({ ...event("Holiday", "2026-09-28", "2026-09-29"), allDay: true, calendarName: "Family" }) === "All day: Holiday (Family)"); +test("event line includes location", formatEventLine(event("Dentist", "2026-09-28T14:00:00Z", "2026-09-28T15:00:00Z", { location: "Downtown Clinic" })) === "14:00–15:00 Dentist (Work) — Downtown Clinic"); + +console.log("\nrenderToday"); +{ + const md = renderToday({ + date: DATE, + events: [event("Standup", "2026-09-28T09:00:00Z", "2026-09-28T09:30:00Z")], + calendarError: null, + unreadTotal: 12, + flagged: { count: 3, hasMore: false }, + }); + test("today header uses date", md.startsWith("# Today — 2026-09-28\n")); + test("today lists event", md.includes("- 09:00–09:30 Standup (Work)")); + test("today lists unread/flagged", md.includes("- 12 unread") && md.includes("- 3 flagged")); + test("today output ends with single trailing newline", md.endsWith("\n") && !md.endsWith("\n\n")); +} +{ + const md = renderToday({ date: DATE, events: [], calendarError: "calendar experiment not loaded", unreadTotal: 0, flagged: null }); + test("today surfaces calendar error instead of crashing", md.includes("unavailable: calendar experiment not loaded")); +} +{ + const md = renderToday({ date: DATE, events: [], calendarError: null, unreadTotal: 0, flagged: { count: 5, hasMore: true } }); + test("today marks flagged count as a floor when capped", md.includes("- 5+ flagged")); +} + +console.log("\nrenderWeek"); +{ + const md = renderWeek({ + start: DATE, + numDays: 7, + events: [ + event("Standup", "2026-09-28T09:00:00Z", "2026-09-28T09:30:00Z"), + { ...event("Holiday", "2026-10-01", "2026-10-02"), allDay: true }, + ], + }); + test("week header", md.startsWith("# Week of 2026-09-28\n")); + test("week groups events under their day", md.includes("## Mon 2026-09-28") && md.includes("## Thu 2026-10-01")); + test("week reports free-day count", md.includes("5 of 7 days free")); +} +{ + const md = renderWeek({ start: DATE, numDays: 7, events: [], calendarError: "calendar experiment not loaded" }); + test("week surfaces calendar error", md.includes("unavailable: calendar experiment not loaded")); +} + +console.log("\nrenderClashes"); +{ + const md = renderClashes({ + start: DATE, + end: addDays(DATE, 7), + clashes: [{ + start: "2026-09-29T10:00:00Z", + end: "2026-09-29T11:00:00Z", + events: [ + event("Team Sync", "2026-09-29T10:00:00Z", "2026-09-29T11:00:00Z"), + event("Dentist", "2026-09-29T10:30:00Z", "2026-09-29T11:00:00Z"), + ], + }], + }); + test("clashes header covers the range", md.startsWith("# Clashes — 2026-09-28 to 2026-10-05\n")); + test("clashes group lists both events", md.includes("Team Sync") && md.includes("Dentist")); +} +{ + const md = renderClashes({ start: DATE, end: addDays(DATE, 7), clashes: [] }); + test("no clashes found message", md.includes("No clashes found.")); +} + +console.log("\ngroupMessagesIntoThreads / renderFrom"); +{ + const messages = [ + { subject: "Project Kickoff", author: "jane@example.com", date: "2026-09-18T14:22:00Z" }, + { subject: "Re: Project Kickoff", author: "jane@example.com", date: "2026-09-27T10:15:00Z" }, + { subject: "Invoice #4821", author: "billing@example.com", date: "2026-09-25T08:00:00Z" }, + ]; + const threads = groupMessagesIntoThreads(messages); + test("groups by normalized subject", threads.length === 2); + test("most-recently-active thread first", threads[0].subject === "Project Kickoff"); + test("newest message first within a thread", threads[0].messages[0].date === "2026-09-27T10:15:00Z"); + + const md = renderFrom({ address: "jane@example.com", threads, hasMore: false }); + test("from header includes counts", md.startsWith("# Mail from jane@example.com (3 messages, 2 threads)\n")); + test("from lists thread with message count", md.includes("## Project Kickoff (2)")); +} +{ + const md = renderFrom({ address: "nobody@example.com", threads: [], hasMore: false }); + test("no messages found message", md.includes("No messages found.")); +} + +console.log(`\n\x1b[1m${"─".repeat(40)}\x1b[0m`); +console.log(`\x1b[1m${passed} passed, ${failed} failed, ${passed + failed} total\x1b[0m\n`); +process.exit(failed > 0 ? 1 : 0);