From b7dc97e6dc3eef92ed55f2271b1384aa19f8c41e Mon Sep 17 00:00:00 2001 From: Amir Moradi Date: Mon, 28 Sep 2026 00:51:31 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(mcp,cli):=20deterministic=20skills=20?= =?UTF-8?q?=E2=80=94=20today/week/clashes/from=20(ODIAA-2332)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add zero-LLM-reasoning skills that compose existing read-only bridge endpoints (/calendar/events/list, /calendar/clashes, /stats, /messages/search) into pre-formatted, compact Markdown: skill_today, skill_week, skill_clashes, skill_from as MCP tools, and matching tb today/week/clashes/from CLI commands. Rendering lives in a new shared lib/skills.js so the CLI and MCP tools produce byte-identical Markdown from the same data — pure functions taking explicit Date/data inputs (no Date.now() defaults) for deterministic snapshot testing. No new extension routes or access-control classifications were needed: every dependency is an already-ungated, read-only endpoint. Co-Authored-By: Claude Sonnet 5 --- cli/src/cli.js | 94 ++++++++++++++++++++++ lib/skills.js | 188 +++++++++++++++++++++++++++++++++++++++++++ mcp/src/server.js | 6 ++ mcp/src/tools.js | 114 ++++++++++++++++++++++++++ package.json | 4 +- test/skills.test.mjs | 149 ++++++++++++++++++++++++++++++++++ 6 files changed, 554 insertions(+), 1 deletion(-) create mode 100644 lib/skills.js create mode 100644 test/skills.test.mjs diff --git a/cli/src/cli.js b/cli/src/cli.js index 11130ec..d06af0f 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/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 112703c..76d839c 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) ───────────── @@ -926,4 +941,103 @@ export const tools = [ return await api("POST", "/calendar/clashes", { start: args.start, end: args.end }); }, }, + + // ─── 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 }; + }, + }, ]; diff --git a/package.json b/package.json index c4228c3..c3d2a1c 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/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); From 0de690c2251f2579d9ef195f08916a562721724e Mon Sep 17 00:00:00 2001 From: Amir Moradi Date: Mon, 28 Sep 2026 00:51:35 +0200 Subject: [PATCH 2/3] test(mcp): cover skill_today/week/clashes/from in integration suite Bump the tools/list count assertions to 31 and pin TZ=UTC (picked up by the spawned server subprocess) so the new skills' date/time output is deterministic regardless of the machine running the suite. Co-Authored-By: Claude Sonnet 5 --- test/mcp-test.mjs | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/test/mcp-test.mjs b/test/mcp-test.mjs index d60b158..e5337a2 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; @@ -361,7 +365,7 @@ await client.initialize(); console.log("\x1b[1mProtocol\x1b[0m"); const toolList = await client.listTools(); -test("tools/list returns 27 tools", toolList, (r) => Array.isArray(r) && r.length === 27); +test("tools/list returns 31 tools", toolList, (r) => Array.isArray(r) && r.length === 31); test("each tool has name+description+inputSchema", toolList, (r) => r.every((t) => t.name && t.description && t.inputSchema) ); @@ -648,6 +652,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", @@ -726,7 +764,7 @@ const toolsB = await clientB.listTools(); test( "concurrent MCP server instances initialize and list tools", { toolsACount: toolsA.length, toolsBCount: toolsB.length }, - (r) => r.toolsACount === 27 && r.toolsBCount === 27 + (r) => r.toolsACount === 31 && r.toolsBCount === 31 ); clientA.close(); clientB.close(); From af4a794f2eec4c11d2e4336b1307313568b4b282 Mon Sep 17 00:00:00 2001 From: Amir Moradi Date: Mon, 28 Sep 2026 00:51:41 +0200 Subject: [PATCH 3/3] docs: document deterministic skills (ODIAA-2332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover tb today/week/clashes/from and the matching skill_* MCP tools in docs/COMMANDS.md, SPEC.md's command reference and MCP tool catalog, cli/README.md, root README.md, AGENTS.md, and the thunderbird-cli skill — bumping the command/tool counts these additions invalidate (43→47 commands, 27→31 MCP tools). Pre-existing, already-stale counts unrelated to this change (e.g. SKILL.md's version field, SPEC.md's "16 high-level tools" line) are left as-is. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 2 +- README.md | 2 +- SPEC.md | 39 +++++++++++++++++++++++++++++---- cli/README.md | 5 +++-- docs/COMMANDS.md | 15 ++++++++++++- skills/thunderbird-cli/SKILL.md | 12 +++++++--- 6 files changed, 63 insertions(+), 12 deletions(-) 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 90ed2aa..ab5fcea 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 7e84dde..ad74b0d 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,7 +40,7 @@ | **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 | +| **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 16 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 @@ -618,6 +618,33 @@ tb calendar clashes --start --end --- +### 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 @@ -1213,7 +1240,7 @@ The MCP server: ### Tool Catalog -The 27 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 31 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 | |----------|---------------------| @@ -1244,6 +1271,10 @@ The 27 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` | Notes are also exposed as MCP **resources** (`note://`, `text/markdown`) so clients that browse resources rather than call tools can list and read the @@ -1251,7 +1282,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/docs/COMMANDS.md b/docs/COMMANDS.md index fdfa15a..4e277c3 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 +``` + ## Bulk Operations ```bash diff --git a/skills/thunderbird-cli/SKILL.md b/skills/thunderbird-cli/SKILL.md index de16545..2bdb58b 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 27 MCP tools +## The 31 MCP tools -Use these; don't reach for the 43-command 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 43-command CLI unless the user explicitly asks fo | `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 | Notes live entirely on disk (`~/.config/thunderbird-cli/notes` by default) — no Thunderbird round-trip except `note_to_draft`, which reuses the same @@ -276,7 +282,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: