diff --git a/apps/cli/package.json b/apps/cli/package.json index 62a530f..a9c6234 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.15.0", + "version": "1.16.0", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/me.ts b/apps/cli/src/commands/me.ts index fc58232..3398501 100644 --- a/apps/cli/src/commands/me.ts +++ b/apps/cli/src/commands/me.ts @@ -510,6 +510,237 @@ function highlightMatch(text: string, query: string): string { return `${before}${yellow(match)}${after}`; } +interface WorkspaceTask { + id: string; + meshId: string; + meshSlug: string; + title: string; + assignee: string | null; + claimedByName: string | null; + priority: string; + status: string; + tags: string[]; + result: string | null; + createdByName: string | null; + createdAt: string; + claimedAt: string | null; + completedAt: string | null; +} + +interface WorkspaceTasksResponse { + tasks: WorkspaceTask[]; + totals: { open: number; claimed: number; completed: number }; +} + +export interface MeTasksFlags extends MeFlags { + status?: string; +} + +export async function runMeTasks(flags: MeTasksFlags): Promise { + return withRestKey( + { + meshSlug: resolveMeshForMint(flags.mesh), + purpose: "workspace-tasks", + capabilities: ["read"], + }, + async ({ secret }) => { + const params = new URLSearchParams(); + if (flags.status) params.set("status", flags.status); + const path = + "/api/v1/me/tasks" + + (params.toString() ? `?${params.toString()}` : ""); + const ws = await request({ + path, + token: secret, + }); + + if (flags.json) { + console.log(JSON.stringify(ws, null, 2)); + return EXIT.SUCCESS; + } + + render.section( + `${clay("tasks")} — ${dim( + `${ws.totals.open} open · ${ws.totals.claimed} in-flight · ${ws.totals.completed} done`, + )}`, + ); + + if (ws.tasks.length === 0) { + process.stdout.write(dim(" no tasks in window\n")); + return EXIT.SUCCESS; + } + + const slugWidth = Math.max(...ws.tasks.map((t) => t.meshSlug.length), 6); + for (const t of ws.tasks) { + const slug = dim(t.meshSlug.padEnd(slugWidth)); + const status = + t.status === "open" + ? yellow("open ") + : t.status === "claimed" + ? cyan("working ") + : green("done "); + const prio = + t.priority === "urgent" + ? yellow("!") + : t.priority === "low" + ? dim("·") + : " "; + const claimer = t.claimedByName ? dim(` ← ${t.claimedByName}`) : ""; + process.stdout.write( + ` ${slug} ${prio} ${status} ${t.title}${claimer}\n`, + ); + } + return EXIT.SUCCESS; + }, + ); +} + +interface WorkspaceStateEntry { + meshId: string; + meshSlug: string; + key: string; + value: unknown; + updatedByName: string | null; + updatedAt: string; +} + +interface WorkspaceStateResponse { + entries: WorkspaceStateEntry[]; + totals: { entries: number; meshes: number }; +} + +export interface MeStateFlags extends MeFlags { + key?: string; +} + +export async function runMeState(flags: MeStateFlags): Promise { + return withRestKey( + { + meshSlug: resolveMeshForMint(flags.mesh), + purpose: "workspace-state", + capabilities: ["read"], + }, + async ({ secret }) => { + const params = new URLSearchParams(); + if (flags.key) params.set("key", flags.key); + const path = + "/api/v1/me/state" + + (params.toString() ? `?${params.toString()}` : ""); + const ws = await request({ + path, + token: secret, + }); + + if (flags.json) { + console.log(JSON.stringify(ws, null, 2)); + return EXIT.SUCCESS; + } + + render.section( + `${clay("state")} — ${ws.totals.entries} entr${ws.totals.entries === 1 ? "y" : "ies"} ${dim( + `across ${ws.totals.meshes} mesh${ws.totals.meshes === 1 ? "" : "es"}`, + )}`, + ); + + if (ws.entries.length === 0) { + process.stdout.write(dim(" no state entries\n")); + return EXIT.SUCCESS; + } + + const slugWidth = Math.max(...ws.entries.map((e) => e.meshSlug.length), 6); + const keyWidth = Math.max(...ws.entries.map((e) => e.key.length), 8); + for (const e of ws.entries) { + const slug = dim(e.meshSlug.padEnd(slugWidth)); + const key = cyan(e.key.padEnd(keyWidth)); + const valueStr = + typeof e.value === "string" + ? e.value + : JSON.stringify(e.value); + const trimmed = + valueStr.length > 80 ? valueStr.slice(0, 80) + "…" : valueStr; + const ago = dim(formatRelativeTime(e.updatedAt)); + process.stdout.write(` ${slug} ${key} ${trimmed} ${ago}\n`); + } + return EXIT.SUCCESS; + }, + ); +} + +interface WorkspaceMemory { + id: string; + meshId: string; + meshSlug: string; + content: string; + tags: string[]; + rememberedByName: string | null; + rememberedAt: string; +} + +interface WorkspaceMemoryResponse { + query: string; + memories: WorkspaceMemory[]; + totals: { entries: number }; +} + +export interface MeMemoryFlags extends MeFlags { + query?: string; +} + +export async function runMeMemory(flags: MeMemoryFlags): Promise { + return withRestKey( + { + meshSlug: resolveMeshForMint(flags.mesh), + purpose: "workspace-memory", + capabilities: ["read"], + }, + async ({ secret }) => { + const params = new URLSearchParams(); + if (flags.query) params.set("q", flags.query); + const path = + "/api/v1/me/memory" + + (params.toString() ? `?${params.toString()}` : ""); + const ws = await request({ + path, + token: secret, + }); + + if (flags.json) { + console.log(JSON.stringify(ws, null, 2)); + return EXIT.SUCCESS; + } + + const headerLabel = flags.query + ? `recall — "${flags.query}"` + : "recall — last 30 days"; + render.section( + `${clay(headerLabel)} ${dim(`${ws.totals.entries} match${ws.totals.entries === 1 ? "" : "es"}`)}`, + ); + + if (ws.memories.length === 0) { + process.stdout.write(dim(" no memories\n")); + return EXIT.SUCCESS; + } + + const slugWidth = Math.max( + ...ws.memories.map((m) => m.meshSlug.length), + 6, + ); + for (const m of ws.memories) { + const slug = dim(m.meshSlug.padEnd(slugWidth)); + const ago = dim(formatRelativeTime(m.rememberedAt)); + const tags = + m.tags.length > 0 + ? " " + dim("[" + m.tags.join(", ") + "]") + : ""; + const content = + m.content.length > 240 ? m.content.slice(0, 240) + "…" : m.content; + process.stdout.write(` ${slug} ${ago}${tags}\n ${content}\n`); + } + return EXIT.SUCCESS; + }, + ); +} + function formatRelativeTime(iso: string): string { const then = new Date(iso).getTime(); const now = Date.now(); diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index b7f4858..6e4ee2d 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -323,13 +323,30 @@ async function main(): Promise { case "state": { const sub = positionals[0]; if (sub === "set") { const { runStateSet } = await import("~/commands/state.js"); await runStateSet({}, positionals[1] ?? "", positionals[2] ?? ""); } - else if (sub === "list") { const { runStateList } = await import("~/commands/state.js"); await runStateList({}); } + else if (sub === "list") { + // v0.5.0 phase 2: aggregate across every mesh when --mesh is omitted. + if (!flags.mesh) { + const { runMeState } = await import("~/commands/me.js"); + process.exit(await runMeState({ json: !!flags.json, key: flags.key as string | undefined })); + } + const { runStateList } = await import("~/commands/state.js"); + await runStateList({}); + } else { const { runStateGet } = await import("~/commands/state.js"); await runStateGet({}, positionals[0] ?? ""); } break; } case "info": { const { runInfo } = await import("~/commands/info.js"); await runInfo({}); break; } case "remember": { const { remember } = await import("~/commands/remember.js"); process.exit(await remember(positionals.join(" "), { mesh: flags.mesh as string, tags: flags.tags as string, json: !!flags.json })); break; } - case "recall": { const { recall } = await import("~/commands/recall.js"); process.exit(await recall(positionals.join(" "), { mesh: flags.mesh as string, json: !!flags.json })); break; } + case "recall": { + // v0.5.0 phase 2: aggregate across every mesh when --mesh is omitted. + if (!flags.mesh) { + const { runMeMemory } = await import("~/commands/me.js"); + process.exit(await runMeMemory({ json: !!flags.json, query: positionals.join(" ") })); + } + const { recall } = await import("~/commands/recall.js"); + process.exit(await recall(positionals.join(" "), { mesh: flags.mesh as string, json: !!flags.json })); + break; + } case "forget": { const { runForget } = await import("~/commands/broker-actions.js"); process.exit(await runForget(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; } case "remind": { const { runRemind } = await import("~/commands/remind.js"); await runRemind({ mesh: flags.mesh as string }, positionals); break; } // (profile case moved to resource-aliases block below for sub-command extensibility) @@ -774,7 +791,15 @@ async function main(): Promise { const f = { mesh: flags.mesh as string, json: !!flags.json }; if (sub === "claim") { const { runTaskClaim } = await import("~/commands/broker-actions.js"); process.exit(await runTaskClaim(positionals[1], f)); } else if (sub === "complete") { const { runTaskComplete } = await import("~/commands/broker-actions.js"); process.exit(await runTaskComplete(positionals[1], positionals.slice(2).join(" ") || undefined, f)); } - else if (sub === "list") { const { runTaskList } = await import("~/commands/platform-actions.js"); process.exit(await runTaskList({ ...f, status: flags.status as string, assignee: flags.assignee as string })); } + else if (sub === "list") { + // v0.5.0 phase 2: aggregate across every mesh when --mesh is omitted. + if (!f.mesh) { + const { runMeTasks } = await import("~/commands/me.js"); + process.exit(await runMeTasks({ json: f.json, status: flags.status as string | undefined })); + } + const { runTaskList } = await import("~/commands/platform-actions.js"); + process.exit(await runTaskList({ ...f, status: flags.status as string, assignee: flags.assignee as string })); + } else if (sub === "create") { const { runTaskCreate } = await import("~/commands/platform-actions.js"); process.exit(await runTaskCreate(positionals.slice(1).join(" "), { ...f, assignee: flags.assignee as string, priority: flags.priority as string, tags: flags.tags as string })); } else { console.error("Usage: claudemesh task "); process.exit(EXIT.INVALID_ARGS); } break; diff --git a/docs/roadmap.md b/docs/roadmap.md index e38e7e3..0217fc6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -289,13 +289,15 @@ level, or wire claudemesh to messaging surfaces beyond Claude Code. now route through `/v1/me/topics` and `/v1/me/notifications` instead of prompting. `--mesh foo` keeps the per-mesh behavior. *Shipped 2026-05-03 in CLI v1.15.0.* -- **v0.5.0 phase 2+ — default-aggregation for `task list`, - `state list`, `memory recall`** — needs `/v1/me/tasks`, - `/v1/me/state`, `/v1/me/memory` aggregator endpoints first. - Each subsystem's per-mesh keying scheme decides whether - aggregation is straight union (state) or needs ranking - (memory recall — vector similarity across meshes is non- - trivial). +- **v0.5.0 phase 2 — default-aggregation for `task list`, + `state list`, `memory recall`** — three new aggregator + endpoints land: `/v1/me/tasks` (open + claimed by default, + `?status=all|open|claimed|completed`), `/v1/me/state` + (every key/value across meshes, `?key=foo` filters), and + `/v1/me/memory?q=` (ILIKE on content + tags, no-query + default returns last 30d). CLI: omitting `--mesh` on each + verb routes through the matching aggregator. *Shipped + 2026-05-03 in CLI v1.16.0.* - **v0.3.2 — multi-session DM routing + broadcast self-loopback** — fixes two production bugs: (1) replies via `claudemesh send ` rejected with "no connected peer" when the sender's diff --git a/packages/api/src/modules/mesh/v1-router.ts b/packages/api/src/modules/mesh/v1-router.ts index 492bfa5..cd56f8b 100644 --- a/packages/api/src/modules/mesh/v1-router.ts +++ b/packages/api/src/modules/mesh/v1-router.ts @@ -31,7 +31,10 @@ import { mesh, meshApiKey, meshMember, + meshMemory, meshNotification, + meshState, + meshTask, meshTopic, meshTopicMember, meshTopicMemberKey, @@ -905,6 +908,278 @@ export const v1Router = new Hono() }); }) + // GET /v1/me/tasks — cross-mesh task list. + // + // Default: open + claimed (status != "completed"). ?status=all + // to include completed; ?status=open|claimed|completed to scope. + // 30-day window on completed tasks. Sorted: open first (priority + // desc), then claimed, then completed (most recent first). Cap 200. + .get("/me/tasks", async (c) => { + const key = c.var.apiKey; + requireCapability(key, "read"); + if (!key.issuedByMemberId) { + return c.json({ error: "api_key_has_no_issuer" }, 400); + } + const [issuer] = await db + .select({ userId: meshMember.userId }) + .from(meshMember) + .where(eq(meshMember.id, key.issuedByMemberId)); + if (!issuer?.userId) { + return c.json({ error: "issuer_member_has_no_user" }, 400); + } + + const memberships = await db + .select({ meshId: meshMember.meshId, meshSlug: mesh.slug }) + .from(meshMember) + .innerJoin(mesh, eq(mesh.id, meshMember.meshId)) + .where( + and( + eq(meshMember.userId, issuer.userId), + isNull(meshMember.revokedAt), + isNull(mesh.archivedAt), + ), + ); + if (memberships.length === 0) { + return c.json({ tasks: [], totals: { open: 0, claimed: 0, completed: 0 } }); + } + + const meshIds = memberships.map((m) => m.meshId); + const meshSlugBy = new Map(memberships.map((m) => [m.meshId, m.meshSlug])); + const statusFilter = c.req.query("status") ?? "active"; // active = open+claimed + const statusSet = + statusFilter === "all" + ? null + : statusFilter === "active" + ? new Set(["open", "claimed"]) + : new Set(statusFilter.split(",").map((s) => s.trim())); + + const completedWindow = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const rows = await db + .select({ + id: meshTask.id, + meshId: meshTask.meshId, + title: meshTask.title, + assignee: meshTask.assignee, + claimedByName: meshTask.claimedByName, + priority: meshTask.priority, + status: meshTask.status, + tags: meshTask.tags, + result: meshTask.result, + createdByName: meshTask.createdByName, + createdAt: meshTask.createdAt, + claimedAt: meshTask.claimedAt, + completedAt: meshTask.completedAt, + }) + .from(meshTask) + .where( + and( + inArray(meshTask.meshId, meshIds), + // Bound the completed-task scan; open/claimed have no time filter. + ...(statusSet === null || statusSet.has("completed") + ? [] + : [sql`${meshTask.status} != 'completed'`]), + ...(statusFilter === "active" + ? [sql`${meshTask.status} != 'completed'`] + : []), + // Hide stale completed tasks beyond the window unless explicitly all. + ...(statusFilter === "all" + ? [] + : [ + sql`(${meshTask.status} != 'completed' OR ${meshTask.completedAt} > ${completedWindow})`, + ]), + ), + ) + .orderBy( + sql`case ${meshTask.status} when 'open' then 0 when 'claimed' then 1 else 2 end`, + desc(meshTask.createdAt), + ) + .limit(200); + + const filtered = statusSet + ? rows.filter((r) => statusSet.has(r.status)) + : rows; + + const tasks = filtered.map((r) => ({ + id: r.id, + meshId: r.meshId, + meshSlug: meshSlugBy.get(r.meshId) ?? "?", + title: r.title, + assignee: r.assignee, + claimedByName: r.claimedByName, + priority: r.priority, + status: r.status, + tags: r.tags ?? [], + result: r.result, + createdByName: r.createdByName, + createdAt: r.createdAt.toISOString(), + claimedAt: r.claimedAt ? r.claimedAt.toISOString() : null, + completedAt: r.completedAt ? r.completedAt.toISOString() : null, + })); + + const totals = { + open: tasks.filter((t) => t.status === "open").length, + claimed: tasks.filter((t) => t.status === "claimed").length, + completed: tasks.filter((t) => t.status === "completed").length, + }; + return c.json({ tasks, totals }); + }) + + // GET /v1/me/state — cross-mesh shared-state map. + // + // Returns every (mesh, key, value) row across the user's meshes, + // sorted by most-recently-updated. ?key=foo filters to a specific + // key across meshes (useful for "where do I have a `release` flag?"). + // Cap 500 — state is meant to be small per mesh. + .get("/me/state", async (c) => { + const key = c.var.apiKey; + requireCapability(key, "read"); + if (!key.issuedByMemberId) { + return c.json({ error: "api_key_has_no_issuer" }, 400); + } + const [issuer] = await db + .select({ userId: meshMember.userId }) + .from(meshMember) + .where(eq(meshMember.id, key.issuedByMemberId)); + if (!issuer?.userId) { + return c.json({ error: "issuer_member_has_no_user" }, 400); + } + + const memberships = await db + .select({ meshId: meshMember.meshId, meshSlug: mesh.slug }) + .from(meshMember) + .innerJoin(mesh, eq(mesh.id, meshMember.meshId)) + .where( + and( + eq(meshMember.userId, issuer.userId), + isNull(meshMember.revokedAt), + isNull(mesh.archivedAt), + ), + ); + if (memberships.length === 0) { + return c.json({ entries: [], totals: { entries: 0, meshes: 0 } }); + } + + const meshIds = memberships.map((m) => m.meshId); + const meshSlugBy = new Map(memberships.map((m) => [m.meshId, m.meshSlug])); + const keyFilter = c.req.query("key"); + + const rows = await db + .select({ + meshId: meshState.meshId, + key: meshState.key, + value: meshState.value, + updatedByName: meshState.updatedByName, + updatedAt: meshState.updatedAt, + }) + .from(meshState) + .where( + and( + inArray(meshState.meshId, meshIds), + ...(keyFilter ? [eq(meshState.key, keyFilter)] : []), + ), + ) + .orderBy(desc(meshState.updatedAt)) + .limit(500); + + const entries = rows.map((r) => ({ + meshId: r.meshId, + meshSlug: meshSlugBy.get(r.meshId) ?? "?", + key: r.key, + value: r.value, + updatedByName: r.updatedByName, + updatedAt: r.updatedAt.toISOString(), + })); + + return c.json({ + entries, + totals: { + entries: entries.length, + meshes: new Set(entries.map((e) => e.meshId)).size, + }, + }); + }) + + // GET /v1/me/memory?q=... — cross-mesh memory recall. + // + // ILIKE search over content + tags across every joined mesh, + // excluding forgotten rows. Sorted by recency. Empty q returns + // recent (last 30d) memories across all meshes. Cap 200. + .get("/me/memory", async (c) => { + const key = c.var.apiKey; + requireCapability(key, "read"); + if (!key.issuedByMemberId) { + return c.json({ error: "api_key_has_no_issuer" }, 400); + } + const [issuer] = await db + .select({ userId: meshMember.userId }) + .from(meshMember) + .where(eq(meshMember.id, key.issuedByMemberId)); + if (!issuer?.userId) { + return c.json({ error: "issuer_member_has_no_user" }, 400); + } + + const memberships = await db + .select({ meshId: meshMember.meshId, meshSlug: mesh.slug }) + .from(meshMember) + .innerJoin(mesh, eq(mesh.id, meshMember.meshId)) + .where( + and( + eq(meshMember.userId, issuer.userId), + isNull(meshMember.revokedAt), + isNull(mesh.archivedAt), + ), + ); + if (memberships.length === 0) { + return c.json({ memories: [], totals: { entries: 0 } }); + } + + const meshIds = memberships.map((m) => m.meshId); + const meshSlugBy = new Map(memberships.map((m) => [m.meshId, m.meshSlug])); + const q = (c.req.query("q") ?? "").trim(); + const qPattern = q ? `%${q.toLowerCase()}%` : null; + const recencyWindow = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + + const rows = await db + .select({ + id: meshMemory.id, + meshId: meshMemory.meshId, + content: meshMemory.content, + tags: meshMemory.tags, + rememberedByName: meshMemory.rememberedByName, + rememberedAt: meshMemory.rememberedAt, + }) + .from(meshMemory) + .where( + and( + inArray(meshMemory.meshId, meshIds), + isNull(meshMemory.forgottenAt), + ...(qPattern + ? [ + sql`(lower(${meshMemory.content}) like ${qPattern} or exists (select 1 from unnest(${meshMemory.tags}) as t where lower(t) like ${qPattern}))`, + ] + : [gt(meshMemory.rememberedAt, recencyWindow)]), + ), + ) + .orderBy(desc(meshMemory.rememberedAt)) + .limit(200); + + const memories = rows.map((r) => ({ + id: r.id, + meshId: r.meshId, + meshSlug: meshSlugBy.get(r.meshId) ?? "?", + content: r.content, + tags: r.tags ?? [], + rememberedByName: r.rememberedByName, + rememberedAt: r.rememberedAt.toISOString(), + })); + + return c.json({ + query: q, + memories, + totals: { entries: memories.length }, + }); + }) + // GET /v1/me/topics — cross-mesh topic list for the caller's user. // // For each topic across every mesh the user belongs to, returns