@staix/agent-hub 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +12 -8
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +6 -3
  5. package/docs/agent-notes/budget.md +4 -2
  6. package/docs/agent-notes/daemon.md +9 -3
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +13 -1
  9. package/docs/agent-notes/tests.md +3 -0
  10. package/docs/dogfood.md +13 -4
  11. package/docs/events.md +1 -1
  12. package/docs/memory.md +78 -10
  13. package/docs/operations.md +116 -30
  14. package/docs/quickstart.md +8 -4
  15. package/docs/security.md +80 -18
  16. package/docs/smoke.md +7 -3
  17. package/docs/specs/2026-09-19-agent-hub-design.md +84 -8
  18. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  19. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  20. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  21. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  22. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  23. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  24. package/docs/verified.json +26 -17
  25. package/package.json +2 -2
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +2085 -1702
  28. package/src/adapters/acp.ts +5 -2
  29. package/src/adapters/codex-appserver.ts +69 -1
  30. package/src/adapters/compat.ts +2 -2
  31. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  32. package/src/adapters/local-worker.ts +5 -4
  33. package/src/adapters/registry-types.ts +1 -1
  34. package/src/cli/console-state.ts +20 -2
  35. package/src/cli/console-symbols.ts +3 -2
  36. package/src/cli/console-usage.ts +5 -2
  37. package/src/cli/console.ts +5 -1
  38. package/src/cli/help.ts +7 -5
  39. package/src/cli/identity.ts +3 -1
  40. package/src/cli/main.ts +44 -10
  41. package/src/cli/memory.ts +119 -7
  42. package/src/cli/output.ts +12 -4
  43. package/src/hub/action-class.ts +211 -17
  44. package/src/hub/action-enforce.ts +13 -5
  45. package/src/hub/action-policy.ts +19 -8
  46. package/src/hub/ask.ts +16 -4
  47. package/src/hub/board.ts +86 -31
  48. package/src/hub/budget-notes.ts +65 -0
  49. package/src/hub/budget.ts +42 -10
  50. package/src/hub/bus.ts +1 -1
  51. package/src/hub/code-facts.ts +56 -16
  52. package/src/hub/conductor.ts +5 -2
  53. package/src/hub/config-apply.ts +7 -0
  54. package/src/hub/control-client.ts +1 -1
  55. package/src/hub/daemon.ts +250 -49
  56. package/src/hub/envelope.ts +1 -1
  57. package/src/hub/events.ts +2 -2
  58. package/src/hub/hub-tools.ts +3 -3
  59. package/src/hub/kimi-usage.ts +12 -0
  60. package/src/hub/lifecycle.ts +10 -5
  61. package/src/hub/memory-code-head.ts +44 -0
  62. package/src/hub/memory-code-overlay.ts +119 -0
  63. package/src/hub/memory-plan-guard.ts +10 -1
  64. package/src/hub/memory-stale-anchors.ts +159 -0
  65. package/src/hub/memory-symbols.ts +74 -0
  66. package/src/hub/planning.ts +10 -4
  67. package/src/hub/read-tools.ts +21 -3
  68. package/src/hub/sdlc.ts +34 -0
  69. package/src/hub/settings.ts +17 -2
  70. package/src/hub/settled-roles.ts +15 -3
  71. package/src/hub/task-sweep.ts +25 -3
  72. package/src/hub/tasks.ts +353 -64
  73. package/src/hub/token-window.ts +15 -2
  74. package/src/hub/window-kind.ts +13 -0
  75. package/src/local/tools.ts +5 -5
  76. package/src/memory/brief.ts +22 -12
  77. package/src/memory/capture.ts +26 -40
  78. package/src/memory/client.ts +137 -65
  79. package/src/memory/recall.ts +13 -22
  80. package/src/ui/index.html +100 -18
@@ -8,8 +8,21 @@ export class TokenWindow {
8
8
 
9
9
  add(at: number, n: number): void {
10
10
  if (!Number.isFinite(at) || !Number.isSafeInteger(n) || n <= 0) return;
11
- const index = this.rows.findIndex(row => row.at > at);
12
- if (index < 0) this.rows.push({ at, n }); else this.rows.splice(index, 0, { at, n });
11
+ // The common case is a row at or after the newest one: no search. An older row (a late read) is placed by a scan from the end, which stops at once for a near-current one.
12
+ let index = this.rows.length;
13
+ while (index > 0 && this.rows[index - 1]!.at > at) index--;
14
+ if (index === this.rows.length) this.rows.push({ at, n }); else this.rows.splice(index, 0, { at, n });
15
+ }
16
+
17
+ /**
18
+ * Rebuild from a history in one pass, for a start-up replay of a long event log: rows already out of the window at `now` are skipped before
19
+ * anything is inserted, so the cost follows the window and not the log. `add`-ing every historical row instead keeps the expired ones until a read prunes them.
20
+ */
21
+ seed(rows: Iterable<{ at: number; n: number }>, now: number): void {
22
+ const live: { at: number; n: number }[] = [];
23
+ for (const row of rows) if (Number.isFinite(row.at) && Number.isSafeInteger(row.n) && row.n > 0 && now - row.at <= this.spanMs) live.push({ at: row.at, n: row.n });
24
+ live.sort((a, b) => a.at - b.at);
25
+ this.rows = this.rows.length ? [...this.rows, ...live].sort((a, b) => a.at - b.at) : live;
13
26
  }
14
27
 
15
28
  private prune(now: number): void {
@@ -0,0 +1,13 @@
1
+ /** What a number is (#285 AC6): the provider's own ratio, tokens the hub counted against a ceiling a person set, or a value a person fed. */
2
+ export type WindowKind = "native" | "count" | "manual";
3
+ export interface KindedWindow { id: string; source?: string; kind?: WindowKind }
4
+ /**
5
+ * The one place a window is classified. A reader that names its kind wins; an untagged window is the hub count when it is the `tokens` window,
6
+ * a person's when its source is `ahub budget set`, and the provider's otherwise.
7
+ */
8
+ export function windowKind(w: KindedWindow): WindowKind {
9
+ if (w.kind) return w.kind;
10
+ if (w.id === "tokens") return "count";
11
+ if (w.source === "ahub budget set") return "manual";
12
+ return "native";
13
+ }
@@ -29,7 +29,7 @@ export interface ToolContext {
29
29
  /** Turn cancellation signal, including the execution-budget wall cap. */
30
30
  signal?: AbortSignal;
31
31
  /** Only true permits an effect; expiry and abort remain distinct from a person's denial. */
32
- permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver, originalPath?: string) => Promise<ToolApproval>;
32
+ permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver, originalPath?: string, input?: unknown) => Promise<ToolApproval>;
33
33
  /** Trusted process status, independent of what its stdout says. */
34
34
  onProcessExit?: (code: number | null) => void;
35
35
  /** Publish a message to other peers mid-turn. Returns a one-line receipt. */
@@ -147,7 +147,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
147
147
  let file = guardPath(ctx, String(a.path), "write");
148
148
  const approvedTarget = file;
149
149
  const content = String(a.content ?? "");
150
- const refused = approvalError(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`, "write", ctx.signal, file, undefined, String(a.path)), "error: the user did not approve this write");
150
+ const refused = approvalError(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`, "write", ctx.signal, file, undefined, String(a.path), a), "error: the user did not approve this write");
151
151
  if (refused) return refused;
152
152
  if (ctx.signal?.aborted) return "error: turn cancelled before write";
153
153
  file = guardPath(ctx, String(a.path), "write");
@@ -162,7 +162,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
162
162
  let text = readFileSync(file, "utf8");
163
163
  let count = text.split(String(a.old)).length - 1;
164
164
  if (!a.old || count !== 1) return `error: \`old\` must match exactly once, it matched ${count} times`;
165
- const refused = approvalError(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`, "edit", ctx.signal, file, undefined, String(a.path)), "error: the user did not approve this edit");
165
+ const refused = approvalError(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`, "edit", ctx.signal, file, undefined, String(a.path), a), "error: the user did not approve this edit");
166
166
  if (refused) return refused;
167
167
  if (ctx.signal?.aborted) return "error: turn cancelled before edit";
168
168
  // Approval can outlive another peer's edit or a path change. Apply only the approved fragment to current bytes.
@@ -179,7 +179,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
179
179
  if (!command.trim()) return "error: empty command";
180
180
  // The approver sees the whole command, not a prefix: what is hidden cannot be approved.
181
181
  if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
182
- const refused = approvalError(await ctx.permit(`bash: ${command}`, "bash", ctx.signal), "error: the user did not approve this command");
182
+ const refused = approvalError(await ctx.permit(`bash: ${command}`, "bash", ctx.signal, undefined, undefined, undefined, a), "error: the user did not approve this command");
183
183
  if (refused) return refused;
184
184
  const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
185
185
  try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
@@ -192,7 +192,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
192
192
  const problem = gitArgsProblem(args, ctx.deny);
193
193
  if (problem) return `error: git ${sub}: ${problem}`;
194
194
  if (GIT_WRITE.has(sub)) {
195
- const refused = approvalError(await ctx.permit(`git ${args.join(" ")}`, "git", ctx.signal), "error: the user did not approve this git command");
195
+ const refused = approvalError(await ctx.permit(`git ${args.join(" ")}`, "git", ctx.signal, undefined, undefined, undefined, a), "error: the user did not approve this git command");
196
196
  if (refused) return refused;
197
197
  }
198
198
  // Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
@@ -2,17 +2,17 @@ import type { PeerId } from "../hub/envelope.ts";
2
2
  import type { MemoryClient } from "./client.ts";
3
3
 
4
4
  export interface BriefItem {
5
- id: number;
5
+ id: number | string;
6
6
  time: string;
7
7
  type: string;
8
8
  title: string;
9
9
  }
10
10
 
11
- /** Rows of claude-mem's index tables: `| #65499 | 12:30 PM | ◆ | Title | ~372 |`. A row that does not match is skipped, never an error. */
11
+ /** Amem entry tables (plus legacy numeric fixture compatibility): `| #65499 | 12:30 PM | ◆ | Title | ~372 |`. A row that does not match is skipped, never an error. */
12
12
  export function parseRows(markdown: string | undefined): BriefItem[] {
13
13
  const out: BriefItem[] = [];
14
- for (const m of (markdown ?? "").matchAll(/^\|\s*#(\d+)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*(.*?)\s*\|/gm)) {
15
- out.push({ id: Number(m[1]), time: m[2]!, type: m[3]!, title: m[4]!.replace(/\s*<-\s*\*\*ANCHOR\*\*/, "") });
14
+ for (const m of (markdown ?? "").matchAll(/^\|\s*#(mem:[a-f0-9]{32}|\d+)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*(.*?)\s*\|/gm)) {
15
+ out.push({ id: m[1]!.startsWith("mem:") ? m[1]! : Number(m[1]), time: m[2]!, type: m[3]!, title: m[4]!.replace(/\s*<-\s*\*\*ANCHOR\*\*/, "") });
16
16
  }
17
17
  return out;
18
18
  }
@@ -28,14 +28,14 @@ export async function related(client: MemoryClient, project: string, query: stri
28
28
  }
29
29
 
30
30
  /** A native memory line for a brief (#258): `mem:<id8> <kind> <tier>: <title or text>`, fails first. */
31
- export interface NativeItem { id: string; line: string; kind?: string; about?: readonly string[] }
32
- export type NativeBrief = (task: BriefTask) => NativeItem[];
31
+ export interface NativeItem { id: string; line: string; kind?: string; about?: readonly string[]; tier?: string; peer?: string; machine?: string; at?: string }
32
+ export type NativeBrief = (task: BriefTask) => NativeItem[] | Promise<NativeItem[]>;
33
33
  export interface BriefTask { id?: number; created?: number; title: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }
34
34
 
35
35
  /**
36
36
  * Task briefs: what memory already knows about a piece of work, handed over with it. The hub's own store first (#258:
37
37
  * entries one or two hops from the task's paths, symbols, issue and task, a linked fail first, superseded entries
38
- * dropped), then claude-mem: index first (search), then the neighbourhood of the top hit (timeline); details stay in
38
+ * dropped), then amem: index first (search), then the neighbourhood of the top hit (timeline); details stay in
39
39
  * the store for the receiver to fetch by id. `brief_items` caps both together. A peer is never shown the same entry
40
40
  * twice in one hub run, except a directly matching plan fail that must accompany a new task.
41
41
  */
@@ -53,25 +53,35 @@ export class Briefs {
53
53
  ) {}
54
54
 
55
55
  async forTask(peer: PeerId, task: BriefTask, priority: readonly NativeItem[] = []): Promise<string | undefined> {
56
+ // A brief snapshots its configured cap once, before either optional source is selected.
56
57
  const maximum = typeof this.maxItems === "function" ? this.maxItems() : this.maxItems;
57
58
  const seen = this.seen.get(peer) ?? new Set<string>();
58
59
  this.seen.set(peer, seen);
60
+ let native: NativeItem[] = [];
61
+ try { native = await (this.native?.(task) ?? []); } catch { /* fail-open: the store and derived code context are optional */ }
59
62
  const own: NativeItem[] = [];
60
63
  // A plan fail is task-specific guidance even when a previous task already carried it.
64
+ const priorityIds = new Set(priority.map(item => item.id));
61
65
  for (const item of priority) if (own.length < maximum && !own.some(existing => existing.id === item.id)) own.push(item);
62
- try { for (const item of this.native?.(task) ?? []) if (!seen.has(item.id) && !own.some(existing => existing.id === item.id) && own.length < maximum) own.push(item); } catch { /* fail-open: the store is optional */ }
66
+ // Native order is host-supplied (linked fails, then nearer/newer); preserve it for synchronous callers too.
67
+ try { for (const item of native) if (!seen.has(item.id) && !own.some(existing => existing.id === item.id) && own.length < maximum) own.push(item); } catch { /* optional native iteration stays fail-open */ }
63
68
  const found = this.client && own.length < maximum ? await related(this.client, this.project, [task.title, ...(task.refs.paths ?? [])].join(" ")) : [];
69
+ // Another brief for this peer may finish while either optional source is awaited.
70
+ const currentOwn = own.filter(item => priorityIds.has(item.id) || !seen.has(item.id)).slice(0, maximum);
71
+ own.splice(0, own.length, ...currentOwn);
64
72
  const items: BriefItem[] = [];
65
73
  for (const item of found) {
66
- if (seen.has(`#${item.id}`)) continue;
74
+ if (own.length + items.length >= maximum) break;
75
+ if (seen.has(`#${item.id}`) || seen.has(String(item.id)) || own.some(v => v.id === item.id)) continue;
67
76
  items.push(item);
68
77
  if (own.length + items.length === maximum) break;
69
78
  }
70
- if (!own.length && !items.length) return undefined;
79
+ const notice = this.client?.readNotice();
80
+ if (!own.length && !items.length && !notice) return undefined;
71
81
  for (const item of own) seen.add(item.id);
72
82
  for (const item of items) seen.add(`#${item.id}`);
73
83
  if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: own.length, fails: own.filter((i) => i.kind === "fail").length, claudeMem: items.length }); } catch { /* telemetry never fails a brief */ }
74
- const header = own.length ? "Memory brief (hub memory first, fails first: ahub memory show <mem id>; claude-mem by #id):" : "Memory brief (claude-mem; fetch details by id if you need them):";
75
- return [header, ...(this.displayNative?.(own) ?? own.map((i) => i.line)), ...items.map((i) => `#${i.id} ${i.time} ${i.type} ${i.title}`)].join("\n");
84
+ const header = own.length ? "Memory brief (hub memory first, fails first: ahub memory show <mem id>; amem by mem:id):" : "Memory brief (amem curated notes; fetch details by mem:id):";
85
+ return [header, ...(notice ? [notice] : []), ...(this.displayNative?.(own) ?? own.map((i) => i.line)), ...items.map((i) => `${i.id.toString().startsWith("mem:") ? i.id : `#${i.id}`} ${i.time} ${i.type} ${i.title}`)].join("\n");
76
86
  }
77
87
  }
@@ -1,23 +1,10 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
1
2
  import { defaultCapturePeer } from "../adapters/definitions/native-identity-defaults.ts";
2
3
  import { BOUNDED_TASK_TOOLS } from "../hub/hub-tools.ts";
3
4
  import { MEMORY_READ_TOOLS } from "../hub/read-tools.ts";
4
- import { readFileSync } from "node:fs";
5
- import { homedir } from "node:os";
6
- import { join } from "node:path";
7
5
  import { isDenied } from "../local/deny.ts";
8
6
  import type { MemoryClient } from "./client.ts";
9
7
 
10
- const OUTPUT_CAP = 4000;
11
-
12
- /** claude-mem's own skip list, so the hub captures what the native hooks would. */
13
- export function skipTools(settingsPath = join(homedir(), ".claude-mem", "settings.json")): string[] {
14
- try {
15
- return String(JSON.parse(readFileSync(settingsPath, "utf8")).CLAUDE_MEM_SKIP_TOOLS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
16
- } catch {
17
- return [];
18
- }
19
- }
20
-
21
8
  export interface CaptureOptions {
22
9
  project: string;
23
10
  cwd: string;
@@ -27,12 +14,11 @@ export interface CaptureOptions {
27
14
  deny?: string[];
28
15
  }
29
16
 
30
- /**
31
- * The hub acting as claude-mem's hook client for the local worker (payload shapes read from the 13.25.1 hook code).
32
- * Fire-and-forget on top of the fail-open client: a down worker costs one log line per call and never a turn.
33
- */
17
+ /** Deterministic local capture. No observer/model runs; private-turn gating stays in the host. */
34
18
  export class Capture {
35
19
  private sessionId = "";
20
+ private readonly toolTimes = new Map<string, string>();
21
+ private sequence = 0;
36
22
  private readonly platformSource: string;
37
23
  private readonly agentId: string;
38
24
 
@@ -46,41 +32,41 @@ export class Capture {
46
32
 
47
33
  init(sessionId: string, prompt: string): void {
48
34
  this.sessionId = sessionId;
49
- void this.client.request("POST", "/api/sessions/init", { contentSessionId: sessionId, project: this.opts.project, prompt, platformSource: this.platformSource });
35
+ this.toolTimes.clear();
36
+ this.sequence = 0;
37
+ void this.client.appendCapture({ project: this.opts.project, cwd: this.opts.cwd, at: new Date().toISOString(), sessionId, agent: this.agentId, event: "SessionStart", excerpt: prompt, platformSource: this.platformSource });
50
38
  }
51
39
 
52
- observe(call: { tool: string; args: string; output: string; id: string; paths: string[] }): void {
40
+ observe(call: { tool: string; args: string; output: string; id: string; paths: string[]; sourceId?: string }): void {
53
41
  if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || this.opts.skip?.includes(call.tool)) return;
54
42
  // Anything that names a denylisted path is kept out of memory entirely, input and output alike.
55
- if (call.paths.some((p) => p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
56
- void this.client.request("POST", "/api/sessions/observations", {
57
- contentSessionId: this.sessionId,
58
- platformSource: this.platformSource,
59
- tool_name: call.tool,
60
- tool_input: call.args,
61
- tool_response: call.output.slice(0, OUTPUT_CAP),
62
- cwd: this.opts.cwd,
63
- agentId: this.agentId,
64
- agentType: "local-worker",
65
- tool_use_id: call.id,
66
- });
43
+ if (call.paths.some((p) => isDenied(p, this.opts.deny) || p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
44
+ // ponytail: legacy callers without a host sourceId dedupe native ids within a summary interval; all live LocalPeer calls supply invocation identity. Require that port for other hosts.
45
+ const sourceId = call.sourceId ?? `${this.sequence}:${call.id}`;
46
+ let at = this.toolTimes.get(sourceId);
47
+ if (!at) {
48
+ at = new Date().toISOString(); this.toolTimes.set(sourceId, at);
49
+ // ponytail: duplicate-call timestamps retain the newest 1024 source ids; upgrade to a host-supplied source-event timestamp.
50
+ if (this.toolTimes.size > 1024) this.toolTimes.delete(this.toolTimes.keys().next().value!);
51
+ }
52
+ // Commands/argv are policy inputs, never path metadata. Persist only in-project file-tool paths.
53
+ const paths = ["read", "write", "edit"].includes(call.tool) ? call.paths.flatMap(p => {
54
+ const name = relative(resolve(this.opts.cwd), resolve(this.opts.cwd, p));
55
+ return !name || isAbsolute(name) || name.split(sep).includes("..") ? [] : [name.split(sep).join("/")];
56
+ }) : [];
57
+ void this.client.appendCapture({ project: this.opts.project, cwd: this.opts.cwd, at, sessionId: this.sessionId, agent: this.agentId, event: "PostToolUse", tool: call.tool, paths, excerpt: `${call.args}\n${call.output}`, input: call.args, output: call.output, toolUseId: call.id, sourceId, platformSource: this.platformSource });
67
58
  }
68
59
 
69
60
  summarize(lastAssistantMessage: string): void {
70
61
  if (!this.sessionId) return;
71
- // No agentId here: the worker answers a summarize that carries one with {status: "skipped", reason: "subagent_context"} (verified live).
72
- void this.client.request("POST", "/api/sessions/summarize", {
73
- contentSessionId: this.sessionId,
74
- last_assistant_message: lastAssistantMessage,
75
- platformSource: this.platformSource,
76
- });
62
+ void this.client.appendCapture({ project: this.opts.project, cwd: this.opts.cwd, at: new Date().toISOString(), sessionId: this.sessionId, toolUseId: `summary:${++this.sequence}`, agent: this.agentId, event: "Stop", excerpt: lastAssistantMessage, platformSource: this.platformSource });
77
63
  }
78
64
 
79
- /** Awaited by the caller: the hub process exits right after stop(), and an unawaited request would die with it. Bounded by the client timeout. */
65
+ /** Awaited by the caller: the hub process exits right after stop(), and an unawaited request would die with it. The synchronous published store operation has completed before this promise settles. */
80
66
  async end(): Promise<void> {
81
67
  if (!this.sessionId) return;
82
68
  const contentSessionId = this.sessionId;
83
69
  this.sessionId = "";
84
- await this.client.request("POST", "/api/sessions/session-end", { contentSessionId, platformSource: this.platformSource, reason: "hub stop", cwd: this.opts.cwd });
70
+ await this.client.appendCapture({ project: this.opts.project, cwd: this.opts.cwd, at: new Date().toISOString(), sessionId: contentSessionId, agent: this.agentId, event: "SessionEnd", excerpt: "hub stop", platformSource: this.platformSource });
85
71
  }
86
72
  }
@@ -1,79 +1,151 @@
1
- import { readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
1
+ import packageJson from "../../package.json";
2
+ import { createHash } from "node:crypto";
3
+ import { CaptureStore, SearchIndex, looksSecret, memoryBase, readCaptureConfig, type CaptureOptions as StoreCaptureOptions, type MemoryStore, type CandidateStore, type View } from "@staix/agent-mem";
4
4
 
5
- const DEFAULT_PORT = 37701;
5
+ export interface CaptureEvent {
6
+ project: string; at?: string; agent: string; event: string; tool?: string; paths?: string[]; status?: string; excerpt: string;
7
+ cwd?: string; sourceId?: string; sessionId?: string; toolUseId?: string; input?: string; output?: string; platformSource?: string;
8
+ }
9
+ export interface MemoryClientOptions {
10
+ project: string;
11
+ store?: MemoryStore;
12
+ candidates?: CandidateStore;
13
+ check(): void;
14
+ /** The host screens complete rows before selection or rendering. */
15
+ screen(rows: View[]): View[];
16
+ publicText(text: string): boolean;
17
+ capture?: () => Omit<StoreCaptureOptions, "project">;
18
+ log?: (line: string) => void;
19
+ }
20
+ const safeCell = (text: string) => text.replace(/[|\r\n]/g, " ");
21
+ const table = (rows: View[]) => "| ID | Time | T | Title |\n|---|---|---|---|\n" + rows.map(v => `| #mem:${v.id} | ${v.at} | ${v.kind} | ${safeCell(v.title || v.text)} |`).join("\n");
6
22
 
7
- /** claude-mem worker URL: `memory.worker_url` override, else the port in ~/.claude-mem/settings.json. */
8
- export function workerUrl(settingsPath = join(homedir(), ".claude-mem", "settings.json")): string {
9
- let port = DEFAULT_PORT;
10
- try {
11
- port = Number(JSON.parse(readFileSync(settingsPath, "utf8")).CLAUDE_MEM_WORKER_PORT) || DEFAULT_PORT;
12
- } catch {
13
- // no settings file: default port
14
- }
15
- return `http://127.0.0.1:${port}`;
23
+ /** Policy sees literal field strings as well as JSON, before display/storage escaping can hide a private value. */
24
+ function policyText(value: unknown): string {
25
+ const raw: string[] = [];
26
+ const encoded = JSON.stringify(value, (key: string, item: unknown) => {
27
+ if (key && item !== undefined) raw.push(key);
28
+ if (typeof item === "number" && !Number.isFinite(item)) throw new Error("invalid memory scalar");
29
+ if (item === null || ["string", "number", "boolean"].includes(typeof item)) {
30
+ const text = String(item); raw.push(text); if (key) raw.push(`${key}: ${text}`);
31
+ }
32
+ return item;
33
+ });
34
+ return `${encoded ?? ""}\n${raw.join("\n")}`;
35
+ }
36
+ /** Only authoritative tool JSON is decoded, once; arbitrary embedded encodings are not interpreted. */
37
+ function toolJson(text: string | undefined): unknown {
38
+ if (text === undefined) return undefined;
39
+ try { return JSON.parse(text) as unknown; } catch { return undefined; }
16
40
  }
17
41
 
18
- /**
19
- * Client for the claude-mem worker HTTP API. The API is internal and unversioned, so every call is
20
- * fail-open: a down or changed worker yields `undefined`, never an exception, and never blocks a turn.
21
- * Typed endpoint wrappers are added by the milestone that first needs them.
22
- */
42
+ /** In-process amem host. No worker URL, HTTP, model, timer or resident helper. Errors are neutral and fail open. */
23
43
  export class MemoryClient {
24
- constructor(
25
- readonly url = workerUrl(),
26
- private readonly timeoutMs = 2000,
27
- private readonly log: (line: string) => void = () => {},
28
- ) {}
29
-
30
- async request<T = unknown>(method: "GET" | "POST", path: string, body?: unknown): Promise<T | undefined> {
31
- return this.call(method, path, body, (res) => res.json() as Promise<T>);
44
+ private captureStore?: CaptureStore;
45
+ private captureKey?: string;
46
+ private closed = false;
47
+ private viewStatus = { truncated: false, scanned: 0, limit: 4096 };
48
+ constructor(private readonly options?: MemoryClientOptions) {}
49
+ readStatus(): { truncated: boolean; scanned: number; limit: number } { return { ...this.viewStatus }; }
50
+ readNotice(): string | undefined { return this.viewStatus.truncated ? `memory view truncated: newest ${this.viewStatus.limit} project candidates considered` : undefined; }
51
+ private releaseCapture(): void {
52
+ const store = this.captureStore; this.captureStore = undefined; this.captureKey = undefined;
53
+ try { store?.close(); } catch { /* fail-open teardown */ }
32
54
  }
33
-
34
- /** Recent-context block (text/plain markdown). `chain`: project names, the primary one last. No `platformSource` = all platforms. */
35
- contextInject(chain: string[], platformSource?: string): Promise<string | undefined> {
36
- const query = `projects=${encodeURIComponent(chain.join(","))}${platformSource ? `&platformSource=${encodeURIComponent(platformSource)}` : ""}`;
37
- return this.call("GET", `/api/context/inject?${query}`, undefined, (res) => res.text());
55
+ close(): void { this.closed = true; this.releaseCapture(); }
56
+ private run<T>(work: (o: MemoryClientOptions) => T): T | undefined {
57
+ try { if (!this.options || this.closed) return undefined; this.options.check(); return work(this.options); }
58
+ catch { this.releaseCapture(); this.options?.log?.("amem operation unavailable"); return undefined; }
38
59
  }
39
-
40
- /** Index search. The worker answers in MCP shape, `{content: [{type: "text", text: <markdown table>}]}`; this returns the text. */
41
- async search(query: string, project: string, limit = 10): Promise<string | undefined> {
42
- const q = `query=${encodeURIComponent(query)}&project=${encodeURIComponent(project)}&limit=${limit}`;
43
- return mcpText(await this.request("GET", `/api/search?${q}`));
60
+ private rows(o: MemoryClientOptions): View[] {
61
+ const store = o.store;
62
+ this.viewStatus = { truncated: false, scanned: 0, limit: 4096 };
63
+ if (!store) return [];
64
+ const rows: View[] = [];
65
+ let after: { position: number; id: string } | undefined;
66
+ // ponytail: at most 4096 exact-project candidate bodies, in published keyset pages. The core still folds relation metadata; upgrade to core tier/scope pages and incremental metadata snapshots.
67
+ for (let page = 0; page < 16; page++) {
68
+ const result = store.readPage({ project: o.project, limit: 256, ...(after ? { after } : {}) });
69
+ this.viewStatus.scanned += result.rows.length;
70
+ if (result.rows.length) rows.push(...o.screen(result.rows.filter(v => !looksSecret(`${v.title ?? ""}\n${v.text}`) && !v.title?.startsWith("ahub ask (model answer)"))));
71
+ if (!result.next) return rows;
72
+ after = result.next;
73
+ }
74
+ this.viewStatus.truncated = !!store.readPage({ project: o.project, limit: 1, ...(after ? { after } : {}) }).next;
75
+ return rows;
44
76
  }
45
-
46
- async timeline(anchor: number, project: string, before = 3, after = 3): Promise<string | undefined> {
47
- return mcpText(await this.request("GET", `/api/timeline?anchor=${anchor}&project=${encodeURIComponent(project)}&depth_before=${before}&depth_after=${after}`));
77
+ async contextInject(_chain: string[], _platformSource?: string): Promise<string | undefined> {
78
+ return this.run(o => {
79
+ const rows = this.rows(o).filter(v => v.tier === "short").slice(0, 30);
80
+ const notice = this.readNotice();
81
+ return rows.length || notice ? `# [amem] recent curated context\n\n### Curated short-term notes\n${[...(notice ? [notice] : []), ...rows.map(v => `mem:${v.id} ${v.kind}: ${safeCell(v.title || v.text)}`)].join("\n")}` : undefined;
82
+ });
48
83
  }
49
-
50
- /** An explicit shared note. `metadata` carries who said it and about which task. */
51
- save(note: { text: string; title?: string; project: string; metadata: Record<string, unknown> }): Promise<unknown> {
52
- return this.request("POST", "/api/memory/save", note);
84
+ async search(query: string, _project: string, limit = 10): Promise<string | undefined> {
85
+ return this.run(o => {
86
+ if (!o.publicText(query)) return undefined;
87
+ const rows = this.rows(o), cap = Math.min(30, Math.max(0, limit));
88
+ const index = new SearchIndex(":memory:");
89
+ try {
90
+ const notice = this.readNotice();
91
+ const coverage = notice ? `${notice}\n` : "";
92
+ if (!index.available) {
93
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
94
+ return coverage + "Search engine: substring\n" + table(rows.filter(v => terms.every(term => `${v.title ?? ""} ${v.text} ${v.about.join(" ")}`.toLowerCase().includes(term))).slice(0, cap));
95
+ }
96
+ index.resync(() => rows.map(v => ({ id: v.id, at: v.at, project: o.project, tier: v.tier, scope: v.scope, body: `${v.title ?? ""}\n${v.text}\n${v.about.join(" ")}` })));
97
+ const byId = new Map(rows.map(v => [v.id, v]));
98
+ return coverage + table(index.query(query, cap, o.project).flatMap(hit => { const v = byId.get(hit.ref); return v ? [v] : []; }));
99
+ } finally { index.close(); }
100
+ });
53
101
  }
54
-
55
- private async call<T>(method: "GET" | "POST", path: string, body: unknown, read: (res: Response) => Promise<T>): Promise<T | undefined> {
56
- try {
57
- const res = await fetch(`${this.url}${path}`, {
58
- method,
59
- signal: AbortSignal.timeout(this.timeoutMs),
60
- ...(body === undefined ? {} : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }),
61
- });
62
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
63
- return await read(res);
64
- } catch (e) {
65
- this.log(`memory ${method} ${path} dropped: ${(e as Error).message}`);
66
- return undefined;
67
- }
102
+ async timeline(anchor: number | string, _project: string, before = 3, after = 3): Promise<string | undefined> {
103
+ return this.run(o => {
104
+ const rows = this.rows(o), at = rows.findIndex(v => `mem:${v.id}` === anchor);
105
+ return at < 0 ? this.readNotice() : `${this.readNotice() ? this.readNotice() + "\n" : ""}` + table(rows.slice(Math.max(0, at - Math.min(10, before)), at + Math.min(10, after) + 1));
106
+ });
107
+ }
108
+ /** Auxiliary model/checkpoint writes are candidates, never curated evidence. Tasks' native note remains authoritative. */
109
+ async save(note: { text: string; title?: string; project: string; metadata: Record<string, unknown> }): Promise<unknown> {
110
+ return this.run(o => {
111
+ if (!o.candidates) return undefined;
112
+ const complete = policyText({ ...note, project: o.project });
113
+ if (!o.publicText(complete) || looksSecret(complete)) return undefined;
114
+ o.check();
115
+ const text = `Submitted metadata: ${JSON.stringify(note.metadata)}\n\n${note.text}`;
116
+ if (!o.publicText(text) || looksSecret(text)) return undefined;
117
+ const noteKind = ["decision", "finding", "contract", "fail"].includes(String(note.metadata.kind)) ? note.metadata.kind as "decision" | "finding" | "contract" | "fail" : "finding";
118
+ const id = o.candidates.put({ kind: "memory", project: o.project, text, noteKind, ...(note.title ? { title: note.title } : {}) }, "public");
119
+ return { status: "candidate", id: `candidate:${id}` };
120
+ });
121
+ }
122
+ async appendCapture(event: CaptureEvent): Promise<unknown> {
123
+ return this.run(o => {
124
+ // Inspect persisted/user data, not host locators such as cwd (the parent of every private path).
125
+ const data = { project: o.project, agent: event.agent, event: event.event, tool: event.tool, paths: event.paths,
126
+ status: event.status, excerpt: event.excerpt, input: event.input, output: event.output };
127
+ const complete = policyText([data, toolJson(event.input), toolJson(event.output)]);
128
+ if (!o.capture || !o.publicText(complete) || looksSecret(complete)) return undefined;
129
+ o.check();
130
+ const options = o.capture();
131
+ const config = this.captureStore ? readCaptureConfig(this.captureStore.dir, options.memoryDir ?? memoryBase(options.home)) : undefined;
132
+ const key = JSON.stringify([o.project, options.home, options.memoryDir, options.capMb ?? config?.capMb, options.retentionDays ?? config?.retentionDays]);
133
+ if (this.captureKey !== key) {
134
+ this.releaseCapture();
135
+ this.captureStore = new CaptureStore({ ...options, project: o.project, screen: text => looksSecret(text) || !o.publicText(text) });
136
+ const limits = this.captureStore.stats();
137
+ this.captureKey = JSON.stringify([o.project, options.home, options.memoryDir, limits.capMb, limits.retentionDays]);
138
+ }
139
+ const store = this.captureStore!;
140
+ {
141
+ const at = event.at ?? new Date().toISOString();
142
+ const receipt = createHash("sha256").update(JSON.stringify([o.project,event.sessionId,event.sourceId,event.toolUseId,event.event,at])).digest("hex");
143
+ o.check();
144
+ return store.append({ project: o.project, at, agent: event.agent, event: event.event, ...(event.tool ? { tool: event.tool } : {}), paths: event.paths ?? [], ...(event.status ? { status: event.status } : {}), excerpt: event.excerpt }, { receipt });
145
+ }
146
+ });
68
147
  }
69
-
70
148
  async health(): Promise<{ ok: boolean; version?: string }> {
71
- const h = await this.request<{ status?: string; version?: string }>("GET", "/api/health");
72
- return h?.status === "ok" ? { ok: true, ...(h.version ? { version: h.version } : {}) } : { ok: false };
149
+ return this.run(() => ({ ok: true, version: packageJson.dependencies["@staix/agent-mem"] })) ?? { ok: false };
73
150
  }
74
151
  }
75
-
76
- function mcpText(res: unknown): string | undefined {
77
- const content = (res as { content?: { type: string; text?: string }[] } | undefined)?.content;
78
- return content?.find((c) => c.type === "text")?.text;
79
- }
@@ -1,11 +1,10 @@
1
- import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
2
1
  import type { PeerId } from "../hub/envelope.ts";
3
2
  import type { MemoryClient } from "./client.ts";
4
3
  import type { View as MemoryView } from "@staix/agent-mem";
5
4
 
6
5
 
7
6
  const HEADER =
8
- "Shared project memory (claude-mem): recent work by the agents in this project. Reference only, not a request. " +
7
+ "Shared project memory (amem): curated recent notes by the agents in this project. Reference only, not a request. " +
9
8
  "Fetch details by id with your memory tools if you need them.";
10
9
 
11
10
  /** Cut at a line boundary. */
@@ -17,29 +16,21 @@ export function trimToTokens(text: string, tokens: number): string {
17
16
  return `${cut >= 0 ? text.slice(0, cut) : ""}\n(trimmed)`;
18
17
  }
19
18
 
20
- /** The worker answers unknown projects and empty filters with a status page, not context. */
19
+ /** The project client supplies a screened context block; unavailable/empty views have no preface. */
21
20
  const hasContext = (text: string | undefined): text is string => !!text && text.startsWith("# [");
22
21
 
23
- /** Observation lines only: every block repeats the same legend, which would eat the token budget. */
24
- const entries = (text: string) => text.slice(Math.max(text.indexOf("\n### "), 0)).trim();
22
+ /** Drop only the redundant source heading. Keep coverage/truncation notices before section headings. */
23
+ const entries = (text: string) => {
24
+ const start = text.indexOf("\n");
25
+ return start < 0 ? "" : text.slice(start + 1).trim();
26
+ };
25
27
 
26
- export async function recallFor(peer: PeerId, client: MemoryClient, chain: string[], tokens: number): Promise<string | undefined> {
27
- const hooks = definitionOf(peer)?.hooks;
28
- const own = hooks?.recallInjected ? hooks.recallPlatform : undefined;
29
- // Everyone else gets all platforms in one call; claude and codex get the other platforms only.
30
- const blocks = own
31
- ? await Promise.all(
32
- [...ADAPTERS.flatMap(adapter => adapter.hooks?.recallSource ? [adapter.hooks.recallSource] : []), "agent-hub"].filter((p) => p !== own).map(async (p) => {
33
- const text = await client.contextInject(chain, p);
34
- return hasContext(text) ? `## from ${p} sessions\n${entries(text)}` : undefined;
35
- }),
36
- )
37
- : [await client.contextInject(chain).then((text) => (hasContext(text) ? entries(text) : undefined))];
38
- const found = blocks.filter((b): b is string => !!b);
39
- if (!found.length) return undefined;
40
- // The budget is split per block, or the first platform could crowd the others out of a cross-platform recall.
41
- const share = Math.floor(tokens / found.length);
42
- return `${HEADER}\n\n${found.map((b) => trimToTokens(b, share)).join("\n\n")}`;
28
+ export async function recallFor(_peer: PeerId, client: MemoryClient, chain: string[], tokens: number): Promise<string | undefined> {
29
+ // Curated amem notes are one project source, regardless of which native peer originally authored them.
30
+ const text = await client.contextInject(chain);
31
+ if (!hasContext(text)) return undefined;
32
+ const body = entries(text);
33
+ return body ? `${HEADER}\n\n${trimToTokens(body, tokens)}` : undefined;
43
34
  }
44
35
 
45
36
  /**