feat(workspace): default-aggregation for task/state/memory
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:
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user