@staix/agent-hub 0.14.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 +42 -0
- package/README.md +23 -9
- package/docs/agent-notes/README.md +8 -0
- package/docs/agent-notes/adapters.md +21 -4
- package/docs/agent-notes/budget.md +6 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +20 -9
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +21 -4
- package/docs/agent-notes/tests.md +6 -3
- package/docs/dogfood.md +19 -5
- package/docs/events.md +21 -7
- package/docs/memory.md +78 -10
- package/docs/operations.md +197 -39
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +16 -6
- package/docs/sdlc.md +17 -0
- package/docs/security.md +110 -21
- package/docs/smoke.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +156 -16
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- 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 +40 -19
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3109 -2046
- package/src/adapters/acp.ts +46 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/compat.ts +6 -2
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +17 -5
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/registry-types.ts +8 -4
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +30 -53
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +48 -3
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +30 -2
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +10 -7
- package/src/cli/identity.ts +3 -1
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +91 -19
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +40 -4
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +90 -0
- package/src/hub/action-policy.ts +40 -17
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +110 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +44 -7
- package/src/hub/bus.ts +6 -6
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +12 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +614 -104
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +5 -3
- package/src/hub/hub-tools.ts +22 -6
- package/src/hub/instances.ts +7 -4
- package/src/hub/kimi-usage.ts +156 -0
- package/src/hub/lifecycle.ts +10 -5
- package/src/hub/manager.ts +1 -1
- 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/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +18 -3
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +551 -75
- package/src/hub/token-window.ts +46 -0
- package/src/hub/usage-rollup.ts +16 -6
- package/src/hub/window-kind.ts +13 -0
- package/src/hub/workflow.ts +16 -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 +215 -19
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A rolling window of counted tokens, by the time each was used (not the time it was read), so a row read late still ages out
|
|
3
|
+
* on schedule. The hub's own count against a ceiling a person configured, never the provider's quota (#285).
|
|
4
|
+
*/
|
|
5
|
+
export class TokenWindow {
|
|
6
|
+
private rows: { at: number; n: number }[] = [];
|
|
7
|
+
constructor(private readonly spanMs: number) {}
|
|
8
|
+
|
|
9
|
+
add(at: number, n: number): void {
|
|
10
|
+
if (!Number.isFinite(at) || !Number.isSafeInteger(n) || n <= 0) return;
|
|
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;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private prune(now: number): void {
|
|
29
|
+
let drop = 0;
|
|
30
|
+
while (drop < this.rows.length && now - this.rows[drop]!.at > this.spanMs) drop++;
|
|
31
|
+
if (drop) this.rows.splice(0, drop);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
sum(now: number): number {
|
|
35
|
+
this.prune(now);
|
|
36
|
+
return this.rows.reduce((total, row) => total + row.n, 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** When the oldest counted token was used: the window frees its first share at this time plus its span. */
|
|
40
|
+
oldest(now: number): number | undefined {
|
|
41
|
+
this.prune(now);
|
|
42
|
+
return this.rows[0]?.at;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
reset(): void { this.rows = []; }
|
|
46
|
+
}
|
package/src/hub/usage-rollup.ts
CHANGED
|
@@ -101,7 +101,8 @@ export class UsageRollup {
|
|
|
101
101
|
* Fold the next slice of the log. Whole lines only: a trailing partial line (a crash, or a write in flight) waits for the next pass.
|
|
102
102
|
* A log that is shorter than the stored offset was replaced or reset, so the roll-up starts over from byte 0.
|
|
103
103
|
*/
|
|
104
|
-
poll(): { bytes: number; done: boolean } {
|
|
104
|
+
poll(maxMs = 20): { bytes: number; done: boolean } {
|
|
105
|
+
const deadline = performance.now() + Math.max(0, Math.min(20, maxMs));
|
|
105
106
|
if (this.closed) return { bytes: 0, done: true };
|
|
106
107
|
if (!existsSync(this.eventsFile)) return { bytes: 0, done: true };
|
|
107
108
|
const fd = openSync(this.eventsFile, "r");
|
|
@@ -127,18 +128,27 @@ export class UsageRollup {
|
|
|
127
128
|
want = Math.min(size - offset, want * 2);
|
|
128
129
|
}
|
|
129
130
|
if (end < 0) return { bytes: 0, done: true }; // the last line is still being written: it waits for the next pass
|
|
130
|
-
|
|
131
|
+
let consumed = 0;
|
|
131
132
|
// The offset was read before the slice was; another roll-up on this hub.db (a restart that overlaps the old process) may have folded the same
|
|
132
133
|
// bytes since. The transaction takes the write lock first and checks the stored offset again: a pass whose start is stale is abandoned whole,
|
|
133
134
|
// so a byte range is folded exactly once however many writers there are (found by a forced interleave: the counts doubled).
|
|
134
135
|
const applied = this.db.transaction(() => {
|
|
135
136
|
if (this.state().offset !== offset) return false;
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
// Commit only complete processed records with their offset. Yield between records;
|
|
138
|
+
// ponytail: one whole record and the existing corrupt-line skip are indivisible;
|
|
139
|
+
// a streaming parser would be needed to preempt within either operation.
|
|
140
|
+
for (let start = 0; start <= end;) {
|
|
141
|
+
const newline = buffer.indexOf(0x0a, start);
|
|
142
|
+
if (newline < 0 || newline > end) break;
|
|
143
|
+
this.fold(buffer.subarray(start, newline).toString("utf8"));
|
|
144
|
+
consumed = newline + 1; start = consumed;
|
|
145
|
+
if (performance.now() >= deadline) break;
|
|
146
|
+
}
|
|
147
|
+
this.db.query("UPDATE rollup_state SET byte_offset = ? WHERE id = 1").run(offset + consumed);
|
|
138
148
|
return true;
|
|
139
149
|
}).immediate();
|
|
140
150
|
if (!applied) return { bytes: 0, done: false }; // stale: the next pass starts from the stored offset
|
|
141
|
-
return { bytes:
|
|
151
|
+
return { bytes: consumed, done: offset + consumed >= size };
|
|
142
152
|
} finally { closeSync(fd); }
|
|
143
153
|
}
|
|
144
154
|
|
|
@@ -160,7 +170,7 @@ export class UsageRollup {
|
|
|
160
170
|
*/
|
|
161
171
|
catchUp(maxMs?: number): boolean {
|
|
162
172
|
const until = maxMs === undefined ? Infinity : Date.now() + maxMs;
|
|
163
|
-
for (let i = 0; i < 100_000; i++) { if (this.poll().done) return true; if (Date.now() >= until) return false; }
|
|
173
|
+
for (let i = 0; i < 100_000; i++) { if (this.poll(Math.min(20, until - Date.now())).done) return true; if (Date.now() >= until) return false; }
|
|
164
174
|
return true;
|
|
165
175
|
}
|
|
166
176
|
|
|
@@ -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/hub/workflow.ts
CHANGED
|
@@ -46,4 +46,20 @@ export function criteriaView(config: SdlcConfig, task: Task, isPrivate: (t: Task
|
|
|
46
46
|
for (const criterion of checklist.criteria) if (criterion.required && criterion.result !== "verified") blockers.push(`criterion ${criterion.id}: ${criterion.result}`);
|
|
47
47
|
return { id: checklist.id, ready: blockers.length === 0, blockers, criteria: checklist.criteria.map(c => ({ ...c, refs: [...c.refs] })), ...("tasks" in checklist && (checklist as LegacyPhaseReceipt).tasks.length ? { note: "stored checklist task list ignored: task import was retired (#481)" } : {}) };
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* #507b: which of a task's recorded acceptance criteria (the #481 S8 checklist, the only criteria model) a reviewer's unmet items name.
|
|
51
|
+
* An item names a criterion when it STARTS with that criterion's id, exactly (case and all), followed by the end of the item or a delimiter:
|
|
52
|
+
* `AC3: the overwrite case has no test`, or just `AC3`. An id the checklist does not hold is plain text, never a criterion, and an id in the
|
|
53
|
+
* middle of a sentence is not a claim. Pure: items in, ids out (in the order first named, each once).
|
|
54
|
+
*/
|
|
55
|
+
export function unmetCriteriaIds(items: readonly string[], checklist: Pick<CriteriaReceipt, "criteria"> | undefined): string[] {
|
|
56
|
+
if (!checklist) return [];
|
|
57
|
+
const known = new Set(checklist.criteria.map(c => c.id));
|
|
58
|
+
const named: string[] = [];
|
|
59
|
+
for (const item of items) {
|
|
60
|
+
const id = /^\s*([A-Za-z][A-Za-z0-9-]{0,47})(?=$|[\s:.,;)\]])/.exec(item)?.[1];
|
|
61
|
+
if (id !== undefined && known.has(id) && !named.includes(id)) named.push(id);
|
|
62
|
+
}
|
|
63
|
+
return named;
|
|
64
|
+
}
|
|
49
65
|
export function workflowHash(value: unknown): string { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
|
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
|
-
}
|