feat(workspace): default-aggregation for task/state/memory
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

ships v0.5.0 phase 2.

api: three new aggregator endpoints for the per-mesh subsystems
that didn't have one yet.
- GET /v1/me/tasks — open + claimed by default; ?status=all
  surfaces completed (30d window). sorted open > claimed > done.
- GET /v1/me/state — every (key, value) row across the user's
  meshes, sorted by recency. ?key=foo filters to one key.
- GET /v1/me/memory?q=... — ilike on content + tags, no q
  returns the last 30 days. excludes forgotten rows.

cli (1.16.0): task list, state list, recall now route through
the matching aggregator when --mesh is omitted. --mesh foo
still scopes to one mesh (existing behavior preserved).

with this, every per-mesh read verb in the cli either has a
cross-mesh aggregator or doesn't need one. v0.5.0 substrate is
complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-03 10:17:41 +01:00
parent 5ceb311d74
commit f679b49b6c
5 changed files with 544 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "claudemesh-cli", "name": "claudemesh-cli",
"version": "1.15.0", "version": "1.16.0",
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
"keywords": [ "keywords": [
"claude-code", "claude-code",

View File

@@ -510,6 +510,237 @@ function highlightMatch(text: string, query: string): string {
return `${before}${yellow(match)}${after}`; 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<number> {
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<WorkspaceTasksResponse>({
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<number> {
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<WorkspaceStateResponse>({
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<number> {
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<WorkspaceMemoryResponse>({
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 { function formatRelativeTime(iso: string): string {
const then = new Date(iso).getTime(); const then = new Date(iso).getTime();
const now = Date.now(); const now = Date.now();

View File

@@ -323,13 +323,30 @@ async function main(): Promise<void> {
case "state": { case "state": {
const sub = positionals[0]; const sub = positionals[0];
if (sub === "set") { const { runStateSet } = await import("~/commands/state.js"); await runStateSet({}, positionals[1] ?? "", positionals[2] ?? ""); } 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] ?? ""); } else { const { runStateGet } = await import("~/commands/state.js"); await runStateGet({}, positionals[0] ?? ""); }
break; break;
} }
case "info": { const { runInfo } = await import("~/commands/info.js"); await runInfo({}); 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 "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 "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; } 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) // (profile case moved to resource-aliases block below for sub-command extensibility)
@@ -774,7 +791,15 @@ async function main(): Promise<void> {
const f = { mesh: flags.mesh as string, json: !!flags.json }; 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)); } 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 === "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 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 <create|list|claim|complete>"); process.exit(EXIT.INVALID_ARGS); } else { console.error("Usage: claudemesh task <create|list|claim|complete>"); process.exit(EXIT.INVALID_ARGS); }
break; break;

View File

@@ -289,13 +289,15 @@ level, or wire claudemesh to messaging surfaces beyond Claude Code.
now route through `/v1/me/topics` and `/v1/me/notifications` now route through `/v1/me/topics` and `/v1/me/notifications`
instead of prompting. `--mesh foo` keeps the per-mesh instead of prompting. `--mesh foo` keeps the per-mesh
behavior. *Shipped 2026-05-03 in CLI v1.15.0.* behavior. *Shipped 2026-05-03 in CLI v1.15.0.*
- **v0.5.0 phase 2+ — default-aggregation for `task list`, - **v0.5.0 phase 2 — default-aggregation for `task list`,
`state list`, `memory recall`** — needs `/v1/me/tasks`, `state list`, `memory recall`** — three new aggregator
`/v1/me/state`, `/v1/me/memory` aggregator endpoints first. endpoints land: `/v1/me/tasks` (open + claimed by default,
Each subsystem's per-mesh keying scheme decides whether `?status=all|open|claimed|completed`), `/v1/me/state`
aggregation is straight union (state) or needs ranking (every key/value across meshes, `?key=foo` filters), and
(memory recall — vector similarity across meshes is non- `/v1/me/memory?q=` (ILIKE on content + tags, no-query
trivial). 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** — - **v0.3.2 — multi-session DM routing + broadcast self-loopback** —
fixes two production bugs: (1) replies via `claudemesh send fixes two production bugs: (1) replies via `claudemesh send
<from_id>` rejected with "no connected peer" when the sender's <from_id>` rejected with "no connected peer" when the sender's

View File

@@ -31,7 +31,10 @@ import {
mesh, mesh,
meshApiKey, meshApiKey,
meshMember, meshMember,
meshMemory,
meshNotification, meshNotification,
meshState,
meshTask,
meshTopic, meshTopic,
meshTopicMember, meshTopicMember,
meshTopicMemberKey, meshTopicMemberKey,
@@ -905,6 +908,278 @@ export const v1Router = new Hono<Env>()
}); });
}) })
// 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. // GET /v1/me/topics — cross-mesh topic list for the caller's user.
// //
// For each topic across every mesh the user belongs to, returns // For each topic across every mesh the user belongs to, returns