@staix/agent-mem 0.6.0 → 0.7.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/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +12 -2
- package/README.md +135 -18
- package/package.json +1 -1
- package/src/cli.ts +476 -39
- package/src/commands-table.ts +53 -15
- package/src/hook-spool.ts +24 -2
- package/src/hooks.ts +9 -5
- package/src/index.ts +14 -0
- package/src/ingest.ts +3 -2
- package/src/mcp-memory.ts +141 -33
- package/src/mcp.ts +42 -7
- package/src/memory/aliases.ts +4 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +144 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +139 -11
- package/src/memory/capture-input.ts +8 -3
- package/src/memory/capture.ts +30 -5
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/cross-project.ts +75 -0
- package/src/memory/curated-read.ts +29 -0
- package/src/memory/decay-review.ts +136 -0
- package/src/memory/doctor.ts +412 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +137 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +150 -0
- package/src/memory/gc.ts +97 -0
- package/src/memory/generation-index.ts +22 -17
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/goal-promotion.ts +71 -0
- package/src/memory/home.ts +15 -4
- package/src/memory/intent-brief.ts +89 -0
- package/src/memory/lifecycle-log.ts +54 -0
- package/src/memory/lifecycle.ts +61 -0
- package/src/memory/metrics.ts +2 -2
- package/src/memory/note-add.ts +100 -0
- package/src/memory/path-glob.ts +94 -0
- package/src/memory/plan-guard.ts +47 -0
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +122 -0
- package/src/memory/recall-staleness.ts +56 -0
- package/src/memory/recall.ts +74 -0
- package/src/memory/repo-scope.ts +404 -0
- package/src/memory/shard-sync-format.ts +116 -0
- package/src/memory/shard-sync.ts +421 -0
- package/src/memory/store.ts +282 -27
- package/src/memory/sync-cadence.ts +139 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +79 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +10 -4
- package/src/session-brief-worker.ts +3 -2
- package/src/session-brief.ts +74 -15
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +291 -0
- package/src/setup.ts +327 -0
package/src/mcp-memory.ts
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { buildIntentBrief } from "./memory/intent-brief.ts";
|
|
4
|
+
import { createIntentBriefSource, recallViewIsLive } from "./memory/recall.ts";
|
|
5
|
+
import { createRecallStalenessProbe } from "./memory/recall-staleness.ts";
|
|
6
|
+
import { recordMemoryUse } from "./memory/use-telemetry.ts";
|
|
1
7
|
import { lstatSync } from "node:fs";
|
|
2
8
|
import { join } from "node:path";
|
|
3
|
-
import type { McpHandlers } from "./mcp.ts";
|
|
4
|
-
import { CandidateStore } from "./memory/candidates.ts";
|
|
9
|
+
import type { McpHandlers, McpOptions } from "./mcp.ts";
|
|
10
|
+
import { CandidateStore, acceptedCorrectionText, candidateTargetRevision, type CandidateInput } from "./memory/candidates.ts";
|
|
5
11
|
import { capacityReport } from "./memory/capacity.ts";
|
|
6
12
|
import { CaptureStore } from "./memory/capture.ts";
|
|
7
|
-
import { readSelector } from "./memory/generation.ts";
|
|
13
|
+
import { hashJSON, readSelector } from "./memory/generation.ts";
|
|
8
14
|
import { memoryHome } from "./memory/home.ts";
|
|
9
15
|
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
10
|
-
import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
|
|
16
|
+
import { createStandalonePolicy, type StandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
|
|
17
|
+
import { configuredPolicyOptions } from "./memory/config.ts";
|
|
11
18
|
import { memoryProjectKey } from "./memory/project.ts";
|
|
12
19
|
import { registerReaderLease } from "./memory/reader-lease.ts";
|
|
13
20
|
import { type ReadRef, type ReadWindow, SearchIndex } from "./memory/search-fts.ts";
|
|
14
21
|
import { aliasedKeys, readShardedSelection, resolveAlias } from "./memory/shard.ts";
|
|
22
|
+
import { projectState } from "./memory/lifecycle.ts";
|
|
23
|
+
import { boundRepoRoot, readRepoScope, repoReadPolicy, type RepoView } from "./memory/repo-scope.ts";
|
|
15
24
|
import { MemoryStore, openShardStore, type Graph, type View } from "./memory/store.ts";
|
|
16
25
|
import { vaultCandidates } from "./memory/vault.ts";
|
|
17
26
|
import { WorkListStore, type WorkListFields } from "./memory/work-lists.ts";
|
|
@@ -21,8 +30,8 @@ export const MCP_SCAN_LIMIT = 5000;
|
|
|
21
30
|
// ponytail: rank the newest 5000 permitted matching candidates per tier; use since to narrow or upgrade to a policy-bound persistent index for global ranking.
|
|
22
31
|
const SEARCH_CANDIDATES = 5000;
|
|
23
32
|
const READ_PAGE = 256;
|
|
24
|
-
export interface MemoryMcpOptions { cwd: string; base?: string; project?: string; policy?: StandalonePolicyOptions }
|
|
25
|
-
export interface MemoryMcp { tools: McpHandlers; close(): void }
|
|
33
|
+
export interface MemoryMcpOptions { cwd: string; base?: string; project?: string; policy?: StandalonePolicyOptions; allowed?: () => boolean; policyVersion?: () => string; assertCurrent?: () => void }
|
|
34
|
+
export interface MemoryMcp { tools: McpHandlers; close(): void; onEmitted: NonNullable<McpOptions["onEmitted"]> }
|
|
26
35
|
const unavailable = (): never => { throw new Error("memory operation unavailable"); };
|
|
27
36
|
const bound = (args: Record<string, unknown>, keys: string[]) => { if (Object.keys(args).some(key => !keys.includes(key))) unavailable(); };
|
|
28
37
|
const limitOf = (args: Record<string, unknown>): number => args.limit === undefined ? 20 : Number.isSafeInteger(args.limit) && Number(args.limit) >= 1 && Number(args.limit) <= 100 ? Number(args.limit) : unavailable();
|
|
@@ -34,9 +43,19 @@ const sinceOf = (args: Record<string, unknown>): string | undefined => { const s
|
|
|
34
43
|
|
|
35
44
|
/** All identities are resolved once. Per-call arguments cannot change projects, homes, profiles or policy. */
|
|
36
45
|
export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
46
|
+
const permitted = () => { try { return options.allowed?.() !== false; } catch { return false; } };
|
|
47
|
+
if (!permitted()) return unavailable();
|
|
37
48
|
const cwd = realPath(options.cwd), base = canonicalPath(options.base ?? memoryHome());
|
|
38
49
|
const requested = options.project ?? memoryProjectKey(cwd), project = resolveAlias(base, requested);
|
|
39
|
-
|
|
50
|
+
// #15 T5: an archived or forgotten project has no MCP server: the person took it out of recall.
|
|
51
|
+
if (projectState(base, project) !== "active") unavailable();
|
|
52
|
+
// F4: the person's configured policy (machine file, plus this repository's narrowing file) screens every MCP answer; a host passes a stricter one through `options.policy`.
|
|
53
|
+
const calls = new AsyncLocalStorage<{ policy: StandalonePolicy; revision: string; configured: StandalonePolicyOptions }>();
|
|
54
|
+
const policyOptions = () => configuredPolicyOptions(base, cwd, options.policy);
|
|
55
|
+
const policyKey = (value: StandalonePolicyOptions) => hashJSON({ denyPaths: value.denyPaths ?? [], piiPatterns: value.piiPatterns ?? [], hostRevision: options.policyVersion?.() ?? null });
|
|
56
|
+
const currentPolicy = () => calls.getStore()?.policy ?? createStandalonePolicy(policyOptions());
|
|
57
|
+
const policyRevisionForCall = () => { const revision = policyKey(policyOptions()), active = calls.getStore(); if (active && active.revision !== revision) return unavailable(); return revision; };
|
|
58
|
+
const policy = currentPolicy(), release = registerReaderLease(base);
|
|
40
59
|
const closers: (() => void)[] = [release]; let closed = false;
|
|
41
60
|
const close = () => { if (closed) return; closed = true; let failure: unknown; for (const closer of closers.reverse()) { try { closer(); } catch (error) { failure ??= error; } } if (failure) throw failure; };
|
|
42
61
|
try {
|
|
@@ -47,26 +66,47 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
47
66
|
const aliases = aliasedKeys(base, project), reconcile = aliases.length ? { canonical: project, aliases } : undefined;
|
|
48
67
|
const store = sharded ? openShardStore({ memoryDir: base, project, deferPromotions: true, ...(reconcile ? { reconcile } : {}) }) : new MemoryStore({ memoryDir: base, deferPromotions: true, ...(reconcile ? { reconcile } : {}) });
|
|
49
68
|
closers.push(() => store.close());
|
|
50
|
-
const capture = new CaptureStore({ memoryDir: base, project }); closers.push(() => capture.close());
|
|
69
|
+
const capture = new CaptureStore({ memoryDir: base, project, cwd }); closers.push(() => capture.close());
|
|
70
|
+
// #15 T4: the bound repository's `.amem/log.jsonl`, only when that repository's own key IS this project (AC3). The root is fixed at start like the project; the file is read per call.
|
|
71
|
+
const repoRoot = boundRepoRoot(cwd, base, project);
|
|
72
|
+
const repoRows = async (): Promise<RepoView[]> => {
|
|
73
|
+
const scoped = repoRoot ? repoReadPolicy(repoRoot, calls.getStore()?.configured ?? policyOptions()) : undefined;
|
|
74
|
+
if (!scoped || "reason" in scoped) return [];
|
|
75
|
+
const read = await readRepoScope(repoRoot, scoped.policy); guard();
|
|
76
|
+
return read.state === "ok" ? read.views : [];
|
|
77
|
+
};
|
|
51
78
|
const candidates = new CandidateStore({ memoryDir: base, ...(reconcile ? { reconcile } : {}) }); closers.push(() => candidates.close());
|
|
52
79
|
const work = new WorkListStore({ base, project, aliases, policy: async row => { const permitted = await policy.screen(row); guard(); return permitted; } }); closers.push(() => work.close());
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
80
|
+
const fullGuard = () => {
|
|
81
|
+
if (!permitted()) unavailable();
|
|
82
|
+
if (closed) unavailable(); options.assertCurrent?.();
|
|
55
83
|
release.verify();
|
|
56
|
-
if (resolveAlias(base, requested) !== project || aliasedKeys(base, project).join("\n") !== aliases.join("\n") || readSelector(base).revision !== selection) unavailable();
|
|
84
|
+
if (resolveAlias(base, requested) !== project || aliasedKeys(base, project).join("\n") !== aliases.join("\n") || readSelector(base).revision !== selection || projectState(base, project) !== "active") unavailable();
|
|
57
85
|
const split = !!lstatSync(join(base, "shards.json"), { throwIfNoEntry: false });
|
|
58
86
|
if (split !== sharded || split && JSON.stringify(readShardedSelection(base)) !== shardSelection) unavailable();
|
|
59
87
|
};
|
|
88
|
+
const guard = () => { if (!permitted() || closed) return unavailable(); if (!calls.getStore()) fullGuard(); };
|
|
89
|
+
const emitted = new WeakMap<object, { ids: string[]; deliveryId: string }>(), session = randomUUID(); let sequence = 0;
|
|
90
|
+
const observed = <T>(value: T, ids: readonly string[], _rows: readonly unknown[] = []): T => { guard(); if (value && typeof value === "object") emitted.set(value, { ids: [...new Set(ids)].filter(id => /^[a-f0-9]{32}$/.test(id)).slice(0, 256), deliveryId: `mcp:${session}:${++sequence}` }); return value; };
|
|
91
|
+
const onEmitted: MemoryMcp["onEmitted"] = (_tool, _requestId, value) => {
|
|
92
|
+
if (!value || typeof value !== "object") return;
|
|
93
|
+
const receipt = emitted.get(value); emitted.delete(value); if (!receipt?.ids.length) return;
|
|
94
|
+
const { ids, deliveryId } = receipt;
|
|
95
|
+
for (const entryId of ids) recordMemoryUse(candidates, { project, entryId, deliveryId, session }, { rendered: 1 }, { allowed: permitted, assertCurrent: guard });
|
|
96
|
+
};
|
|
97
|
+
const staleRoot = repoRoot ?? cwd, rootConfirmed = repoRoot !== undefined || project.startsWith("local:") && resolveAlias(base, memoryProjectKey(cwd)) === project;
|
|
98
|
+
const stale = () => { policyRevisionForCall(); return createRecallStalenessProbe({ cwd: staleRoot, rootConfirmed, policy: currentPolicy }); };
|
|
99
|
+
const decorate = (view: View, probe: ReturnType<typeof stale>) => ({ ...view, ...(probe(view) === true ? { stale: true } : {}) });
|
|
60
100
|
const screen = async <T>(rows: T[]): Promise<T[]> => {
|
|
61
101
|
if (rows.length > MCP_SCAN_LIMIT) unavailable();
|
|
62
|
-
const publicRows: T[] = []; for (const row of rows) if (await
|
|
102
|
+
const publicRows: T[] = []; for (const row of rows) { guard(); if (await currentPolicy().screen(row)) publicRows.push(row); guard(); } return publicRows;
|
|
63
103
|
};
|
|
64
104
|
const curated = async (superseded = false) => {
|
|
65
105
|
guard();
|
|
66
106
|
// list() includes shared rows: remove other projects before screening or selection.
|
|
67
107
|
const rows = store.list({ project, includeSuperseded: superseded, limit: MCP_SCAN_LIMIT + 1 });
|
|
68
108
|
if (rows.length > MCP_SCAN_LIMIT) unavailable();
|
|
69
|
-
return screen(rows.filter(row => row.project === project));
|
|
109
|
+
return screen(rows.filter(row => row.project === project && recallViewIsLive(row, store.readNow())));
|
|
70
110
|
};
|
|
71
111
|
const readPermitted = async <T>(read: (window: ReadWindow) => { rows: T[]; next?: ReadRef; engine?: "fts5" | "substring" },
|
|
72
112
|
options: Omit<ReadWindow, "project" | "limit" | "after">, cap: number, matches: (row: T, engine?: "fts5" | "substring") => boolean = () => true): Promise<T[]> => {
|
|
@@ -74,7 +114,7 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
74
114
|
while (result.length < cap) {
|
|
75
115
|
guard();
|
|
76
116
|
const page = read({ ...options, project, limit: READ_PAGE, after });
|
|
77
|
-
for (const row of page.rows) if (await
|
|
117
|
+
for (const row of page.rows) if (await currentPolicy().screen(row) && matches(row, page.engine)) {
|
|
78
118
|
result.push(row); if (result.length === cap) break;
|
|
79
119
|
}
|
|
80
120
|
guard();
|
|
@@ -87,14 +127,35 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
87
127
|
if (/^capture:[1-9][0-9]*$/.test(id)) {
|
|
88
128
|
return (await readPermitted(window => capture.readPage(window), { id: id.slice(8) }, 1))[0]?.at;
|
|
89
129
|
}
|
|
90
|
-
const rows = await readPermitted(window => store.readPage(
|
|
130
|
+
const rows = await readPermitted(window => store.readPage(window), { prefix: id.replace(/^mem:/, "") }, 2, view => recallViewIsLive(view, store.readNow()));
|
|
91
131
|
return rows.length === 1 ? rows[0]!.at : undefined;
|
|
92
132
|
};
|
|
133
|
+
const correctionTarget = (id: string): View => {
|
|
134
|
+
guard();
|
|
135
|
+
const current = store.get(id, project);
|
|
136
|
+
// Proposal visibility matches MCP reads; shared rows from another project are not evidence here.
|
|
137
|
+
if (!current || current.id !== id || current.supersededBy || current.project !== project) return unavailable();
|
|
138
|
+
return current;
|
|
139
|
+
};
|
|
140
|
+
const propose = async (input: CandidateInput, target?: View): Promise<{ id: string }> => {
|
|
141
|
+
if (input.kind === "correction" && acceptedCorrectionText(input).length > 4000) return unavailable();
|
|
142
|
+
const revision = target && candidateTargetRevision(target);
|
|
143
|
+
const currentPolicy = () => createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy));
|
|
144
|
+
const check = () => { guard(); if (target && candidateTargetRevision(correctionTarget(target.id)) !== revision) unavailable(); };
|
|
145
|
+
const complete = target ? { ...input, targetView: target } : input;
|
|
146
|
+
check();
|
|
147
|
+
if (!await currentPolicy().screen(complete)) unavailable();
|
|
148
|
+
check();
|
|
149
|
+
// Config can tighten while the host screen awaits. Its synchronous check fences the final append.
|
|
150
|
+
if (!currentPolicy().screenStandalone(complete)) unavailable();
|
|
151
|
+
check();
|
|
152
|
+
return { id: candidates.put(input, "public") };
|
|
153
|
+
};
|
|
93
154
|
const tools: McpHandlers = {
|
|
94
155
|
async amem_search(args) {
|
|
95
156
|
bound(args, ["query", "limit", "since", "includeCapture"]); const query = textOf(args, "query")!, limit = limitOf(args), since = sinceOf(args);
|
|
96
157
|
if (args.includeCapture !== undefined && typeof args.includeCapture !== "boolean") unavailable();
|
|
97
|
-
if (!await
|
|
158
|
+
if (!await currentPolicy().screen({ query })) unavailable();
|
|
98
159
|
const index = new SearchIndex(":memory:");
|
|
99
160
|
try {
|
|
100
161
|
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
@@ -107,9 +168,11 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
107
168
|
(row, engine) => index.available && engine === "fts5" || words.every(word => `${row.excerpt}\n${row.paths.join(" ")}\n${row.event}`.toLowerCase().includes(word)));
|
|
108
169
|
const useFts = index.available && captureState.engine !== "substring";
|
|
109
170
|
const notes = await readPermitted(window => store.readPage({ ...window, ...(useFts ? { query } : {}) }), { since }, SEARCH_CANDIDATES,
|
|
110
|
-
view => useFts || words.every(word => `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}`.toLowerCase().includes(word)));
|
|
171
|
+
view => recallViewIsLive(view, store.readNow()) && (useFts || words.every(word => `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}`.toLowerCase().includes(word))));
|
|
172
|
+
const repoNotes = (await repoRows()).filter(view => (!since || Date.parse(view.at) >= Date.parse(since)) && (useFts || words.every(word => `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}`.toLowerCase().includes(word))));
|
|
111
173
|
const hits = [...notes.map(view => ({ source: "curated" as const, evidence: true, view, ref: view.id, at: view.at, tier: view.tier, body: `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}` })),
|
|
112
|
-
...
|
|
174
|
+
...repoNotes.map(view => ({ source: "repo" as const, evidence: false, view, ref: `repo:${view.id}`, at: view.at, tier: "long", body: `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}` })),
|
|
175
|
+
...recalls.map(row => ({ source: "capture" as const, evidence: false, capture: row, ref: `capture:${row.id}`, at: row.at, tier: "capture", body: `${row.excerpt}\n${row.paths.join(" ")}\n${row.event}` }))].filter(hit => (!since || Date.parse(hit.at) >= Date.parse(since)) && (hit.source !== "curated" || recallViewIsLive(hit.view, store.readNow())));
|
|
113
176
|
if (useFts) {
|
|
114
177
|
index.resync(() => hits.map(hit => ({ id: hit.ref, at: hit.at, project, tier: hit.tier, scope: "project", body: hit.body })));
|
|
115
178
|
if (index.needsResync(hits.length)) unavailable();
|
|
@@ -120,19 +183,22 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
120
183
|
if (useFts ? rank === undefined : !words.every(word => hit.body.toLowerCase().includes(word))) return [];
|
|
121
184
|
const weight = hit.tier === "long" ? 1 : hit.tier === "capture" ? 0.5 : 0.9;
|
|
122
185
|
const score = -(rank ?? -1) * weight / (1 + Math.max(0, Date.now() - Date.parse(hit.at)) / (30 * 86400_000));
|
|
123
|
-
return [{ source: hit.source, evidence: hit.evidence, ...(hit.source === "
|
|
186
|
+
return [{ source: hit.source, evidence: hit.evidence, ...(hit.source === "capture" ? { capture: hit.capture } : { view: hit.view }), score }];
|
|
124
187
|
}).sort((a, b) => b.score - a.score).slice(0, limit);
|
|
125
|
-
|
|
188
|
+
const probe = stale(), result = { engine: useFts ? "fts5" : "substring", hits: selected.map(hit => hit.source === "curated" && "view" in hit && hit.view ? { ...hit, view: decorate(hit.view as View, probe) } : hit) };
|
|
189
|
+
guard(); return observed(result, selected.flatMap(hit => hit.source === "curated" && "view" in hit && hit.view ? [hit.view.id] : []), selected.map(hit => "view" in hit ? hit.view : hit.capture));
|
|
126
190
|
} finally { index.close(); }
|
|
127
191
|
},
|
|
128
192
|
async amem_show(args) {
|
|
129
193
|
bound(args, ["id"]); guard(); const id = textOf(args, "id")!;
|
|
194
|
+
if (/^repo:[a-f0-9]{32}$/.test(id)) { const row = (await repoRows()).find(view => `repo:${view.id}` === id); guard(); return row ? { source: "repo", evidence: false, view: row } : null; }
|
|
130
195
|
if (/^capture:[1-9][0-9]*$/.test(id)) {
|
|
131
196
|
const row = (await readPermitted(window => capture.readPage(window), { id: id.slice(8) }, 1))[0];
|
|
132
197
|
guard(); return row ? { source: "capture", evidence: false, capture: row } : null;
|
|
133
198
|
}
|
|
134
|
-
const rows = await readPermitted(window => store.readPage(
|
|
135
|
-
|
|
199
|
+
const rows = await readPermitted(window => store.readPage(window), { prefix: id.replace(/^mem:/, "") }, 2);
|
|
200
|
+
const view = rows.length === 1 && recallViewIsLive(rows[0]!, store.readNow()) ? rows[0]! : undefined;
|
|
201
|
+
guard(); return view ? observed(decorate(view, stale()), [view.id], [view]) : null;
|
|
136
202
|
},
|
|
137
203
|
async amem_timeline(args) {
|
|
138
204
|
bound(args, ["id", "since", "limit"]);
|
|
@@ -140,17 +206,30 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
140
206
|
guard();
|
|
141
207
|
const anchor = id ? await anchorAt(id) : undefined;
|
|
142
208
|
if (id && !anchor) { guard(); return []; }
|
|
143
|
-
const rows = await readPermitted(window => store.readPage(
|
|
209
|
+
const rows = await readPermitted(window => store.readPage(window), { since, anchor }, limit, view => recallViewIsLive(view, store.readNow()));
|
|
144
210
|
const recalls = await readPermitted(window => capture.readPage(window), { since, anchor }, limit);
|
|
145
|
-
const entries = [...rows.map(row => ({ at: row.at, ref: row.id, value: row as unknown })),
|
|
211
|
+
const entries: { at: string; ref: string; value: unknown; memoryId?: string }[] = [...rows.map(row => ({ at: row.at, ref: row.id, value: row as unknown, memoryId: row.id })),
|
|
146
212
|
...recalls.map(row => ({ at: row.at, ref: `capture:${row.id}`, value: { source: "capture", evidence: false, capture: row } as unknown }))];
|
|
147
|
-
|
|
148
|
-
|
|
213
|
+
const currentEntries = entries.filter(row => !row.memoryId || recallViewIsLive(row.value as View, store.readNow()));
|
|
214
|
+
currentEntries.sort((a, b) => (anchor ? Math.abs(Date.parse(a.at) - Date.parse(anchor)) - Math.abs(Date.parse(b.at) - Date.parse(anchor)) : Date.parse(b.at) - Date.parse(a.at)) || (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0));
|
|
215
|
+
const selected = currentEntries.slice(0, limit), probe = stale();
|
|
216
|
+
const values = selected.map(row => row.memoryId ? decorate(row.value as View, probe) : row.value);
|
|
217
|
+
guard(); return observed(values, selected.flatMap(row => row.memoryId ? [row.memoryId] : []), selected.map(row => row.value));
|
|
218
|
+
},
|
|
219
|
+
async amem_brief(args) {
|
|
220
|
+
bound(args, ["intent", "paths", "symbols", "maxBytes"]); guard();
|
|
221
|
+
const values = (key: string): string[] => { const value = args[key]; if (value === undefined) return []; if (!Array.isArray(value) || value.length > 150 || value.some(item => typeof item !== "string" || item.length > 512 || item.includes("\0"))) return unavailable(); return value as string[]; };
|
|
222
|
+
const intent = textOf(args, "intent")!, paths = values("paths"), symbols = values("symbols"), maxBytes = args.maxBytes ?? 2048;
|
|
223
|
+
if (intent.length > 1000 || !Number.isSafeInteger(maxBytes) || Number(maxBytes) < 1 || Number(maxBytes) > 8192) return unavailable();
|
|
224
|
+
const result = await buildIntentBrief(createIntentBriefSource(store, project),
|
|
225
|
+
{ project, cwd: staleRoot, repo: project, intent, paths, symbols, maxBytes: Number(maxBytes), policy: currentPolicy, policyVersion: policyRevisionForCall, allowed: permitted, assertCurrent: guard, now: () => store.readNow(), stale: stale() });
|
|
226
|
+
guard();
|
|
227
|
+
return observed({ brief: result.text, bytes: result.bytes, truncated: result.truncated, state: result.state }, result.items.map(view => view.id), result.items);
|
|
149
228
|
},
|
|
150
229
|
async amem_graph(args) {
|
|
151
230
|
bound(args, ["start", "hops", "limit"]); const start = textOf(args, "start")!, cap = limitOf(args), hops = args.hops ?? 1;
|
|
152
231
|
if (hops !== 1 && hops !== 2) unavailable();
|
|
153
|
-
const rows = await curated(
|
|
232
|
+
const rows = (await curated()).filter(row => recallViewIsLive(row, store.readNow())); const result = screenedGraph(rows, start, Number(hops), cap); guard(); return observed(result, result.nodes.filter(node => node.id.startsWith("mem:")).map(node => node.id.slice(4)), rows.filter(row => result.nodes.some(node => node.id === `mem:${row.id}`)));
|
|
154
233
|
},
|
|
155
234
|
async amem_work_state_read(args) {
|
|
156
235
|
bound(args, ["list", "includeClosed"]); guard();
|
|
@@ -164,15 +243,44 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
164
243
|
},
|
|
165
244
|
async amem_work_state_write(args) { bound(args, ["list", "fields"]); guard(); const result = await work.write(textOf(args, "list")!, args.fields as WorkListFields); guard(); return result; },
|
|
166
245
|
async amem_remember(args) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
246
|
+
const structured = ["approach", "symptom", "cause", "evidenceRefs", "retryWhen"];
|
|
247
|
+
bound(args, ["text", "title", "kind", ...structured]); guard();
|
|
248
|
+
const kind = args.kind ?? "finding";
|
|
249
|
+
if (typeof kind !== "string" || !["decision", "finding", "contract", "fail"].includes(kind)) unavailable();
|
|
250
|
+
if (structured.some(key => args[key] !== undefined)) {
|
|
251
|
+
if (kind !== "fail" || args.text !== undefined || args.title !== undefined || !Array.isArray(args.evidenceRefs) || args.evidenceRefs.length < 1 || args.evidenceRefs.length > 16) return unavailable();
|
|
252
|
+
const evidenceRefs = args.evidenceRefs.map(value => typeof value === "string" && value.trim() && value.length <= 400 && !value.includes("\0") ? value : unavailable());
|
|
253
|
+
return propose({ project, kind: "fail", approach: textOf(args, "approach")!, symptom: textOf(args, "symptom")!, cause: textOf(args, "cause")!, evidenceRefs, retryWhen: textOf(args, "retryWhen")! });
|
|
254
|
+
}
|
|
255
|
+
return propose({ project, kind: "memory", text: textOf(args, "text")!, ...(args.title === undefined ? {} : { title: textOf(args, "title")! }), noteKind: kind as "decision" | "finding" | "contract" | "fail" });
|
|
171
256
|
},
|
|
172
|
-
async
|
|
257
|
+
async amem_correct(args) {
|
|
258
|
+
bound(args, ["id", "proposedText", "reason", "kind"]); guard();
|
|
259
|
+
const id = textOf(args, "id")!.replace(/^mem:/, "");
|
|
260
|
+
if (!/^[a-f0-9]{32}$/.test(id) || args.kind !== undefined && (typeof args.kind !== "string" || !["decision", "contract", "fail"].includes(args.kind))) unavailable();
|
|
261
|
+
const target = correctionTarget(id);
|
|
262
|
+
const input: CandidateInput = { project, kind: "correction", target: { id, revision: candidateTargetRevision(target) }, proposedText: textOf(args, "proposedText")!, reason: textOf(args, "reason")!,
|
|
263
|
+
...(args.kind === undefined ? {} : { noteKind: args.kind as "decision" | "contract" | "fail" }) };
|
|
264
|
+
return propose(input, target);
|
|
265
|
+
},
|
|
266
|
+
async amem_vault_candidates(args) { bound(args, ["limit"]); guard(); const result = await vaultCandidates(store, { project, policy: currentPolicy(), limit: limitOf(args) }); guard(); return result; },
|
|
173
267
|
async amem_capacity(args) { bound(args, []); guard(); const report = capacityReport({ memoryDir: base }); if (!await policy.screen(report)) unavailable(); guard(); return report; },
|
|
174
268
|
};
|
|
175
|
-
|
|
269
|
+
const exposed = { ...tools };
|
|
270
|
+
const readTools = ["amem_search", "amem_show", "amem_timeline", "amem_graph", "amem_brief", "amem_vault_candidates"] as const;
|
|
271
|
+
for (const name of readTools) exposed[name] = async args => {
|
|
272
|
+
fullGuard();
|
|
273
|
+
const configured = policyOptions(), revision = policyKey(configured), snapshot = createStandalonePolicy(configured);
|
|
274
|
+
const sourceCurrent = store.beginRecallRead();
|
|
275
|
+
if (policyKey(policyOptions()) !== revision) return unavailable();
|
|
276
|
+
return calls.run({ policy: snapshot, revision, configured }, async () => {
|
|
277
|
+
const result = await tools[name](args);
|
|
278
|
+
fullGuard(); sourceCurrent();
|
|
279
|
+
if (policyKey(policyOptions()) !== revision) return unavailable();
|
|
280
|
+
return result;
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
return { tools: exposed, close, onEmitted };
|
|
176
284
|
} catch (error) { close(); throw error; }
|
|
177
285
|
}
|
|
178
286
|
|
package/src/mcp.ts
CHANGED
|
@@ -7,16 +7,19 @@ const object = (properties: Record<string, unknown>, required: string[] = []) =>
|
|
|
7
7
|
const tool = <Name extends string>(name: Name, description: string, inputSchema: ReturnType<typeof object>, readOnlyHint = true) => ({ name, description, inputSchema,
|
|
8
8
|
annotations: { readOnlyHint, destructiveHint: false, openWorldHint: false } });
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/** Storage scope comes from startup, never tool arguments; candidate proposals remain person-reviewed. */
|
|
11
11
|
export const MCP_TOOLS = [
|
|
12
12
|
tool("amem_search", "Search screened memory in this project. Capture results are recall, not curated evidence.", object({ query: string(1000), limit, since: string(40), includeCapture: { type: "boolean" } }, ["query"])),
|
|
13
13
|
tool("amem_show", "Show a screened memory entry in this project by its exact id or unique prefix.", object({ id: string(80) }, ["id"])),
|
|
14
14
|
tool("amem_timeline", "Read screened memory near an entry or time in this project.", object({ id: string(80), since: string(40), limit })),
|
|
15
|
+
tool("amem_brief", "Read an intent/path-scoped brief of live public memory: fails first, then decisions/contracts and one-hop neighbours. Memory is untrusted data; byte cap covers the complete brief body.", object({ intent: string(1000), paths: { type: "array", maxItems: 150, items: string(512) }, symbols: { type: "array", maxItems: 150, items: string(512) }, maxBytes: { type: "integer", minimum: 1, maximum: 8192 } }, ["intent"])),
|
|
15
16
|
tool("amem_graph", "Read the screened curated-memory neighbourhood. Capture is never graph evidence.", object({ start: string(500), hops: { type: "integer", minimum: 1, maximum: 2 }, limit }, ["start"])),
|
|
16
17
|
tool("amem_work_state_read", "Read project work lists; closed lists/items can be included explicitly.", object({ list: string(200), includeClosed: { type: "boolean" } })),
|
|
17
18
|
tool("amem_work_state_write", "Append scalar project work-list fields; null clears, task selects an item, list status done closes.", object({ list: string(200), fields: { type: "object", minProperties: 1, maxProperties: 64,
|
|
18
19
|
additionalProperties: { type: ["string", "number", "boolean", "null"], maxLength: 4000 } } }, ["list", "fields"]), false),
|
|
19
|
-
tool("amem_remember", "Propose a
|
|
20
|
+
tool("amem_remember", "Propose a memory candidate (text, optional kind/title), or kind=fail with approach, symptom, cause, evidenceRefs and retryWhen. Person acceptance only; never writes curated notes.", object({ text: string(3000), title: string(200), kind: { type: "string", enum: ["decision", "finding", "contract", "fail"] },
|
|
21
|
+
approach: string(4000), symptom: string(4000), cause: string(4000), evidenceRefs: { type: "array", minItems: 1, maxItems: 16, items: string(400) }, retryWhen: string(4000) }), false),
|
|
22
|
+
tool("amem_correct", "Propose a correction to an exact curated entry id visible in this project. The host binds its current revision; the full accepted body including reason and provenance headers must fit 4000 characters. A person must accept before any note or supersession is written.", object({ id: string(80), proposedText: string(4000), reason: string(4000), kind: { type: "string", enum: ["decision", "contract", "fail"] } }, ["id", "proposedText", "reason"]), false),
|
|
20
23
|
tool("amem_vault_candidates", "List screened durable curated notes eligible for a person to move to the vault. Read only.", object({ limit })),
|
|
21
24
|
tool("amem_capacity", "Read this machine's bounded capture capacity proposal; applying it is person-only.", object({})),
|
|
22
25
|
] as const;
|
|
@@ -28,12 +31,14 @@ export interface McpOptions {
|
|
|
28
31
|
tools: McpHandlers;
|
|
29
32
|
close?: () => void | Promise<void>;
|
|
30
33
|
version?: string;
|
|
34
|
+
/** Host-only observation after a successful tool response reaches the write callback. Never implies delivery or citation. */
|
|
35
|
+
onEmitted?: (tool: McpToolName, requestId: string | number, value: unknown) => void | Promise<void>;
|
|
31
36
|
}
|
|
32
37
|
const FRAME_BYTES = 65_536, RESULT_BYTES = 262_144;
|
|
33
38
|
const protocols = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
34
39
|
const plain = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
35
40
|
|
|
36
|
-
/** The deliberately small schema vocabulary of the
|
|
41
|
+
/** The deliberately small schema vocabulary of the tools, shared with runtime validation. */
|
|
37
42
|
function valid(value: unknown, raw: unknown): boolean {
|
|
38
43
|
const s = raw as Record<string, any>, type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
39
44
|
const types = Array.isArray(s.type) ? s.type : [s.type];
|
|
@@ -41,6 +46,10 @@ function valid(value: unknown, raw: unknown): boolean {
|
|
|
41
46
|
if (s.enum && !s.enum.includes(value)) return false;
|
|
42
47
|
if (typeof value === "string" && (value.length < (s.minLength ?? 0) || value.length > (s.maxLength ?? FRAME_BYTES) || value.includes("\0"))) return false;
|
|
43
48
|
if (typeof value === "number" && (!Number.isFinite(value) || value < (s.minimum ?? -Infinity) || value > (s.maximum ?? Infinity))) return false;
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
if (value.length < (s.minItems ?? 0) || value.length > (s.maxItems ?? 64) || !plain(s.items)) return false;
|
|
51
|
+
return value.every(item => valid(item, s.items));
|
|
52
|
+
}
|
|
44
53
|
if (plain(value)) {
|
|
45
54
|
const keys = Object.keys(value);
|
|
46
55
|
if (keys.length < (s.minProperties ?? 0) || keys.length > (s.maxProperties ?? 64)) return false;
|
|
@@ -54,6 +63,24 @@ function valid(value: unknown, raw: unknown): boolean {
|
|
|
54
63
|
return true;
|
|
55
64
|
}
|
|
56
65
|
|
|
66
|
+
/** Shared callback-confirmed emission; close/error are failures and never delivery evidence. */
|
|
67
|
+
export const writeConfirmed = (output: Writable, text: string): Promise<void> => new Promise((resolve, reject) => {
|
|
68
|
+
let settled = false, writing = true, callbackDone = false, callbackFailure: Error | null | undefined;
|
|
69
|
+
const finish = (failure?: Error | null) => {
|
|
70
|
+
if (settled) return; settled = true; output.removeListener("close", closed);
|
|
71
|
+
// A Writable callback failure precedes its error event; keep that one-shot listener until the event arrives.
|
|
72
|
+
if (!failure) output.removeListener("error", failed);
|
|
73
|
+
if (failure) reject(failure); else resolve();
|
|
74
|
+
};
|
|
75
|
+
const failed = (failure: Error) => { finish(failure); output.removeListener("error", failed); };
|
|
76
|
+
const closed = () => { finish(Error("output closed before emission")); output.removeListener("error", failed); };
|
|
77
|
+
output.once("error", failed); output.once("close", closed);
|
|
78
|
+
try {
|
|
79
|
+
output.write(text, failure => { callbackDone = true; callbackFailure = failure; if (!writing) finish(failure); });
|
|
80
|
+
writing = false; if (callbackDone) finish(callbackFailure);
|
|
81
|
+
} catch (failure) { writing = false; finish(failure as Error); output.removeListener("error", failed); }
|
|
82
|
+
});
|
|
83
|
+
|
|
57
84
|
/** MCP 2025-06-18 basic/transports: newline stdio only. Sequential, bounded, no listener or timer. */
|
|
58
85
|
export async function serveMcp(options: McpOptions): Promise<void> {
|
|
59
86
|
const input = options.input ?? process.stdin, output = options.output ?? process.stdout;
|
|
@@ -89,13 +116,21 @@ export async function serveMcp(options: McpOptions): Promise<void> {
|
|
|
89
116
|
if (!plain(params) || Object.keys(params).some(k => !["name", "arguments", "_meta"].includes(k)) || typeof params.name !== "string") { await error(id, -32602, "Invalid tool call"); return; }
|
|
90
117
|
const definition = MCP_TOOLS.find(t => t.name === params.name), args = params.arguments ?? {};
|
|
91
118
|
if (!definition || !valid(args, definition.inputSchema)) { await error(id, -32602, "Invalid tool name or arguments"); return; }
|
|
92
|
-
let result: unknown;
|
|
119
|
+
let result: unknown, observedValue: unknown, succeeded = false;
|
|
93
120
|
try {
|
|
94
121
|
const value = await options.tools[definition.name](args as Record<string, unknown>), text = JSON.stringify(value);
|
|
95
122
|
if (text === undefined || Buffer.byteLength(text) > RESULT_BYTES) throw new Error("result unavailable");
|
|
96
|
-
result = { content: [{ type: "text", text }] };
|
|
97
|
-
} catch
|
|
98
|
-
|
|
123
|
+
result = { content: [{ type: "text", text }] }; observedValue = value; succeeded = true;
|
|
124
|
+
} catch (failure) {
|
|
125
|
+
// Every failure is opaque to the model except one named, fixed, text-free condition: the person's review queue is full. It carries no stored content, so it is safe to say, and it
|
|
126
|
+
// tells the agent what to do (stop proposing, ask the person) instead of leaving it to retry blindly.
|
|
127
|
+
const full = typeof failure === "object" && failure !== null && (failure as { code?: unknown }).code === "candidate_queue_full";
|
|
128
|
+
result = { isError: true, content: [{ type: "text", text: full ? "The person's review queue is full: no more candidates are accepted until the person runs `amem review`. Do not retry; ask the person to review the pending candidates." : "Memory operation unavailable or refused; no partial result is shown." }] };
|
|
129
|
+
}
|
|
130
|
+
if (succeeded && options.onEmitted) {
|
|
131
|
+
await writeConfirmed(output, JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
|
|
132
|
+
try { await options.onEmitted(definition.name, id, observedValue); } catch { /* optional observation never changes the response or emits another */ }
|
|
133
|
+
} else await send({ jsonrpc: "2.0", id, result });
|
|
99
134
|
};
|
|
100
135
|
let pending = "", overflow = false;
|
|
101
136
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
package/src/memory/aliases.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { lifecycleStates } from "./lifecycle-log.ts";
|
|
3
4
|
|
|
4
5
|
export const SHARD_SCHEMA = "agent-hub.memory.shard/v1";
|
|
5
6
|
|
|
@@ -25,6 +26,9 @@ export function appendAlias(base: string, input: { alias: string; key: string; a
|
|
|
25
26
|
if (!alias || !key) throw new Error("an alias needs both keys");
|
|
26
27
|
if (alias === key) throw new Error("an alias cannot target itself");
|
|
27
28
|
if (resolveAlias(base, key) !== key) throw new Error("an alias cannot target another alias");
|
|
29
|
+
// A forget is terminal: an alias op could otherwise bring a forgotten project's notes back under an active key, or hide an active one behind it.
|
|
30
|
+
const lifecycle = lifecycleStates(base);
|
|
31
|
+
for (const forgotten of [alias, key]) if (lifecycle.get(forgotten) === "forgotten") throw new Error(`project ${forgotten} is forgotten: an alias to or from it would bring it back`);
|
|
28
32
|
// The other direction too, using only the latest op per alias: a target abandoned by a later retarget or a removal is free again.
|
|
29
33
|
const currentTargets = new Set(effectiveAliases(base).values());
|
|
30
34
|
if (currentTargets.has(alias)) throw new Error("an alias target cannot become an alias");
|
package/src/memory/bridge.ts
CHANGED
|
@@ -38,6 +38,10 @@ function directory(path: string, optional = false): boolean {
|
|
|
38
38
|
catch (error) { if (optional && (error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export class SegmentReadBudgetError extends Error {
|
|
42
|
+
readonly code = "retained_read_budget";
|
|
43
|
+
constructor() { super("retained history unavailable within the read byte budget"); this.name = "SegmentReadBudgetError"; }
|
|
44
|
+
}
|
|
41
45
|
export interface SegmentReadMetrics { bytesRead: number; bytesHashed: number; coldChanged: number }
|
|
42
46
|
interface PrefixState { offset: number; hash: string; hasher: Hash; bytes: Buffer }
|
|
43
47
|
type SegmentBytes = Pick<LegacySegment, "bytes" | "offset" | "hash" | "tailBytes" | "stamp" | "start">;
|
|
@@ -47,6 +51,7 @@ const statStamp = (stat: { dev: bigint | number; ino: bigint | number; size: big
|
|
|
47
51
|
export class SegmentReader {
|
|
48
52
|
private readonly prefixes = new Map<string, PrefixState>();
|
|
49
53
|
private measured: SegmentReadMetrics = { bytesRead: 0, bytesHashed: 0, coldChanged: 0 };
|
|
54
|
+
constructor(private remainingBytes = Infinity) { if (remainingBytes !== Infinity && (!Number.isSafeInteger(remainingBytes) || remainingBytes < 0)) failure(); }
|
|
50
55
|
metrics(): SegmentReadMetrics { return { ...this.measured }; }
|
|
51
56
|
resetMetrics(): void { this.measured = { bytesRead: 0, bytesHashed: 0, coldChanged: 0 }; }
|
|
52
57
|
read(path: string, prior?: LegacyCheckpoint, full = false, immutable = false): SegmentBytes {
|
|
@@ -60,8 +65,9 @@ export class SegmentReader {
|
|
|
60
65
|
const opened = fstatSync(fd, { bigint: true });
|
|
61
66
|
if (statStamp(opened) !== stamp) failure();
|
|
62
67
|
const read = (length: number): Buffer => {
|
|
68
|
+
if (length > this.remainingBytes) throw new SegmentReadBudgetError();
|
|
63
69
|
const bytes = Buffer.alloc(length); let at = 0;
|
|
64
|
-
while (at < length) { const n = readSync(fd, bytes, at, length - at, at); if (!n) failure(); at += n; this.measured.bytesRead += n; }
|
|
70
|
+
while (at < length) { const n = readSync(fd, bytes, at, length - at, at); if (!n) failure(); at += n; this.measured.bytesRead += n; this.remainingBytes -= n; }
|
|
65
71
|
return bytes;
|
|
66
72
|
};
|
|
67
73
|
const all = read(size), end = all.lastIndexOf(10) + 1, bytes = all.subarray(0, end);
|
|
@@ -273,7 +279,7 @@ function head(homeM: string, generation: string): string | undefined {
|
|
|
273
279
|
|
|
274
280
|
|
|
275
281
|
export function readLegacySnapshot(homeM: string, options: LegacySnapshotOptions = {}): LegacySegment[] {
|
|
276
|
-
try { return snapshot(homeM, options); } catch { return failure(); }
|
|
282
|
+
try { return snapshot(homeM, options); } catch (error) { if (error instanceof SegmentReadBudgetError) throw error; return failure(); }
|
|
277
283
|
}
|
|
278
284
|
export function legacyHead(homeM: string, generation: string): string | undefined {
|
|
279
285
|
try { return head(homeM, generation); } catch { return failure(); }
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* F7 (agent-mem#40): pending-review visibility. This leaf imports only `bun:sqlite` and node built-ins, so the SessionStart hook can read it without loading the
|
|
7
|
+
* candidate store (no create, no prune, no schema change on that path).
|
|
8
|
+
*
|
|
9
|
+
* AC4, "no candidate expires without having been shown": a candidate's age expiry (30 days from creation) only deletes it once it was SHOWN, and not before a grace period after
|
|
10
|
+
* it was first shown, or once the person decided it (accepted or rejected). Only `amem review walk` stamps `shown_at`, after it has written the candidate to the terminal. The
|
|
11
|
+
* brief line and `amem review list` print no candidate content, and the SessionStart hook cannot know the context was read, so none of them stamps: the brief line informs, it does
|
|
12
|
+
* not count as showing. An unshown pending candidate stays however old it is. The 500-per-project count cap keeps unshown pending rows first, then shown pending rows (owed their
|
|
13
|
+
* grace), then decided ones, so it only reaches an unshown candidate when 500 of them wait.
|
|
14
|
+
*/
|
|
15
|
+
export const CANDIDATE_AGE_MS = 30 * 86400_000;
|
|
16
|
+
/** After a candidate is first shown it stays at least this long, so the person has a real chance to act on what the brief said. */
|
|
17
|
+
export const CANDIDATE_SHOWN_GRACE_MS = 7 * 86400_000;
|
|
18
|
+
/** A candidate this close to expiry is flagged in `amem status`. */
|
|
19
|
+
export const CANDIDATE_DUE_MS = 7 * 86400_000;
|
|
20
|
+
const DAY_MS = 86400_000;
|
|
21
|
+
|
|
22
|
+
export interface PendingSummary {
|
|
23
|
+
/** Candidates waiting for the person (status pending or an interrupted accepting). */
|
|
24
|
+
count: number;
|
|
25
|
+
/** Whole days until the soonest of them can expire, counting an unshown one as shown now (so at least the grace period). 0 when it may go today. Null with nothing pending. */
|
|
26
|
+
oldestExpiresInDays: number | null;
|
|
27
|
+
/** How many are within CANDIDATE_DUE_MS of their 30-day age expiry (an unshown one is protected beyond it, shown or not it is flagged so the person acts). */
|
|
28
|
+
dueWithin7Days: number;
|
|
29
|
+
/** How many were never shown. */
|
|
30
|
+
unshown: number;
|
|
31
|
+
}
|
|
32
|
+
export const EMPTY_PENDING: PendingSummary = { count: 0, oldestExpiresInDays: null, dueWithin7Days: 0, unshown: 0 };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Open the sidecar read-only. A WAL database whose `-wal` and `-shm` files are gone (a truncating checkpoint, a copy) cannot be opened read-only, so the fallback is an immutable
|
|
36
|
+
* open: it writes nothing and creates nothing, at the price of not seeing a write another process has not checkpointed yet, which a count line can live with. Undefined when
|
|
37
|
+
* neither opens (a missing or unreadable file).
|
|
38
|
+
*/
|
|
39
|
+
function openReadOnly(file: string): Database | undefined {
|
|
40
|
+
// A construction that succeeds can still fail at its first read (no -wal or -shm to attach to), so each form is proven with a real query before it is returned.
|
|
41
|
+
const forms: (() => Database)[] = [
|
|
42
|
+
() => new Database(file, { readonly: true }),
|
|
43
|
+
() => new Database(`file:${encodeURI(file).replace(/\?/g, "%3F").replace(/#/g, "%23")}?immutable=1`, { readonly: true } as ConstructorParameters<typeof Database>[1]),
|
|
44
|
+
];
|
|
45
|
+
for (const open of forms) {
|
|
46
|
+
let db: Database | undefined;
|
|
47
|
+
try { db = open(); db.query("SELECT 1 FROM sqlite_master LIMIT 1").get(); return db; }
|
|
48
|
+
catch { try { db?.close(); } catch { /* nothing to close */ } }
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The first-shown column, added in place: an old database gets it when a `CandidateStore` opens, a reader treats its absence as "never shown". */
|
|
54
|
+
export function hasShownColumn(db: Database): boolean {
|
|
55
|
+
return (db.query("PRAGMA table_info(candidates)").all() as { name: string }[]).some(column => column.name === "shown_at");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The instant a pending candidate becomes deletable: the later of its age expiry and the end of its grace period (counted from `now` while it is unshown). */
|
|
59
|
+
export function expiresAt(createdAt: number, shownAt: number | null, now: number): number {
|
|
60
|
+
return Math.max(createdAt + CANDIDATE_AGE_MS, (shownAt ?? now) + CANDIDATE_SHOWN_GRACE_MS);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read-only summary of the pending candidates of one project (with its aliases), or of every project when `projects` is undefined. Never throws. No file means nothing is
|
|
65
|
+
* pending (`EMPTY_PENDING`); a file that exists but cannot be read gives `null`, so `amem status` can say "unavailable" instead of a false zero.
|
|
66
|
+
*/
|
|
67
|
+
export function pendingSummary(base: string, projects: readonly string[] | undefined, now: number): PendingSummary;
|
|
68
|
+
export function pendingSummary(base: string, projects: readonly string[] | undefined, now: number, strict: true): PendingSummary | null;
|
|
69
|
+
export function pendingSummary(base: string, projects: readonly string[] | undefined, now: number, strict?: true): PendingSummary | null {
|
|
70
|
+
const file = join(base, "candidates.db");
|
|
71
|
+
if (!existsSync(file)) return EMPTY_PENDING;
|
|
72
|
+
const unreadable = strict ? null : EMPTY_PENDING;
|
|
73
|
+
let db: Database | undefined;
|
|
74
|
+
try {
|
|
75
|
+
db = openReadOnly(file);
|
|
76
|
+
if (!db) return unreadable;
|
|
77
|
+
db.run("PRAGMA busy_timeout = 0");
|
|
78
|
+
const shown = hasShownColumn(db);
|
|
79
|
+
const where = projects === undefined ? "" : " AND project IN (SELECT value FROM json_each(?))";
|
|
80
|
+
const rows = db.query(`SELECT at, ${shown ? "shown_at" : "NULL AS shown_at"} FROM candidates WHERE status IN ('pending', 'accepting')${where}`)
|
|
81
|
+
.all(...(projects === undefined ? [] : [JSON.stringify(projects)])) as { at: number; shown_at: number | null }[];
|
|
82
|
+
if (!rows.length) return EMPTY_PENDING;
|
|
83
|
+
let soonest = Infinity, due = 0, unshown = 0;
|
|
84
|
+
for (const row of rows) {
|
|
85
|
+
const at = expiresAt(row.at, row.shown_at, now);
|
|
86
|
+
soonest = Math.min(soonest, at);
|
|
87
|
+
if (row.at + CANDIDATE_AGE_MS - now <= CANDIDATE_DUE_MS) due++; // by age: an unshown row is protected past it, but the person should hear it is old
|
|
88
|
+
if (row.shown_at === null) unshown++;
|
|
89
|
+
}
|
|
90
|
+
return { count: rows.length, oldestExpiresInDays: Math.max(0, Math.ceil((soonest - now) / DAY_MS)), dueWithin7Days: due, unshown };
|
|
91
|
+
} catch { return unreadable; } // fail open: a locked or unreadable sidecar costs the brief this line, never the brief
|
|
92
|
+
finally { try { db?.close(); } catch { /* nothing to close */ } }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The one brief line. Numbers only, so it can carry no private text. */
|
|
96
|
+
export function pendingLine(summary: PendingSummary): string | undefined {
|
|
97
|
+
if (!summary.count || summary.oldestExpiresInDays === null) return undefined;
|
|
98
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
99
|
+
return `${plural(summary.count, "candidate")} pending, oldest expires in ${plural(summary.oldestExpiresInDays, "day")}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Stamp the first time candidates were shown (`shown_at` stays at its first value). Best effort and zero-wait: on a locked, absent or pre-column database it stamps
|
|
104
|
+
* nothing, which only keeps candidates longer. Returns how many rows it stamped.
|
|
105
|
+
*/
|
|
106
|
+
export function markShown(base: string, projects: readonly string[], now: number, ids?: readonly string[]): number {
|
|
107
|
+
const file = join(base, "candidates.db");
|
|
108
|
+
if (!existsSync(file) || !projects.length) return 0;
|
|
109
|
+
let db: Database | undefined;
|
|
110
|
+
try {
|
|
111
|
+
db = new Database(file);
|
|
112
|
+
db.run("PRAGMA busy_timeout = 0");
|
|
113
|
+
if (!hasShownColumn(db)) return 0;
|
|
114
|
+
const filter = ids === undefined ? "" : " AND id IN (SELECT value FROM json_each(?))";
|
|
115
|
+
return db.query(`UPDATE candidates SET shown_at = ? WHERE shown_at IS NULL AND status IN ('pending', 'accepting') AND project IN (SELECT value FROM json_each(?))${filter}`)
|
|
116
|
+
.run(now, JSON.stringify(projects), ...(ids === undefined ? [] : [JSON.stringify(ids)])).changes;
|
|
117
|
+
} catch { return 0; }
|
|
118
|
+
finally { try { db?.close(); } catch { /* nothing to close */ } }
|
|
119
|
+
}
|