@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.
- package/CHANGELOG.md +25 -0
- package/README.md +12 -8
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +6 -3
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/daemon.md +9 -3
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +13 -1
- package/docs/agent-notes/tests.md +3 -0
- package/docs/dogfood.md +13 -4
- package/docs/events.md +1 -1
- package/docs/memory.md +78 -10
- package/docs/operations.md +116 -30
- package/docs/quickstart.md +8 -4
- package/docs/security.md +80 -18
- package/docs/smoke.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -8
- package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
- package/docs/verification/2026-10-24-amem-runtime.md +69 -0
- package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
- package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
- package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
- package/docs/verification/2026-10-25-0.16.0.md +82 -0
- package/docs/verified.json +26 -17
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2085 -1702
- package/src/adapters/acp.ts +5 -2
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/compat.ts +2 -2
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/registry-types.ts +1 -1
- package/src/cli/console-state.ts +20 -2
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +5 -1
- package/src/cli/help.ts +7 -5
- package/src/cli/identity.ts +3 -1
- package/src/cli/main.ts +44 -10
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +12 -4
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +13 -5
- package/src/hub/action-policy.ts +19 -8
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +86 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +42 -10
- package/src/hub/bus.ts +1 -1
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +7 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +250 -49
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -2
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/kimi-usage.ts +12 -0
- package/src/hub/lifecycle.ts +10 -5
- package/src/hub/memory-code-head.ts +44 -0
- package/src/hub/memory-code-overlay.ts +119 -0
- package/src/hub/memory-plan-guard.ts +10 -1
- package/src/hub/memory-stale-anchors.ts +159 -0
- package/src/hub/memory-symbols.ts +74 -0
- package/src/hub/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +17 -2
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +353 -64
- package/src/hub/token-window.ts +15 -2
- package/src/hub/window-kind.ts +13 -0
- package/src/local/tools.ts +5 -5
- package/src/memory/brief.ts +22 -12
- package/src/memory/capture.ts +26 -40
- package/src/memory/client.ts +137 -65
- package/src/memory/recall.ts +13 -22
- package/src/ui/index.html +100 -18
package/src/hub/token-window.ts
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
+
}
|
package/src/local/tools.ts
CHANGED
|
@@ -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.
|
package/src/memory/brief.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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*#(
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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>;
|
|
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
|
}
|
package/src/memory/capture.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|
package/src/memory/client.ts
CHANGED
|
@@ -1,79 +1,151 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
) {}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/src/memory/recall.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
-
/**
|
|
24
|
-
const entries = (text: string) =>
|
|
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(
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
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
|
/**
|