@staix/agent-mem 0.12.0 → 0.14.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/plugin.json +1 -1
- package/README.md +70 -44
- package/package.json +5 -2
- package/src/cli-agent-reads.ts +105 -0
- package/src/cli.ts +219 -56
- package/src/commands-table.ts +10 -9
- package/src/index.ts +1 -0
- package/src/ingest.ts +21 -4
- package/src/mcp.ts +8 -8
- package/src/memory/candidates.ts +15 -13
- package/src/memory/capacity-split.ts +4 -1
- package/src/memory/capacity.ts +48 -4
- package/src/memory/catalog.ts +4 -4
- package/src/memory/cli-attribution.ts +23 -0
- package/src/memory/commands.ts +63 -11
- package/src/memory/consolidate.ts +3 -2
- package/src/memory/decay-review.ts +4 -4
- package/src/memory/doctor.ts +36 -7
- package/src/memory/generation-index.ts +56 -11
- package/src/memory/generation.ts +73 -7
- package/src/memory/metrics.ts +20 -8
- package/src/memory/migrate.ts +31 -0
- package/src/memory/mutation.ts +24 -4
- package/src/memory/note-add.ts +4 -4
- package/src/memory/prune-publisher-lineage.ts +139 -0
- package/src/memory/prune.ts +28 -1
- package/src/memory/public-prune-activation.ts +260 -0
- package/src/memory/public-prune-advance.ts +149 -0
- package/src/memory/public-prune-input-chain.ts +73 -0
- package/src/memory/public-prune-inputs.ts +455 -0
- package/src/memory/public-prune-view.ts +139 -0
- package/src/memory/public-prune-witness.ts +168 -0
- package/src/memory/reader-lease.ts +46 -34
- package/src/memory/shard-migration.ts +42 -3
- package/src/memory/shard-sync-format.ts +40 -2
- package/src/memory/shard-sync-graph.ts +87 -0
- package/src/memory/shard-sync.ts +341 -53
- package/src/memory/shard.ts +50 -9
- package/src/memory/store.ts +51 -12
- package/src/memory/style.ts +3 -0
- package/src/memory/sync.ts +141 -5
- package/src/memory/vault.ts +31 -0
- package/src/setup.ts +59 -31
package/src/memory/commands.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { cliAttribution, noteDisplayActor } from "./cli-attribution.ts";
|
|
1
2
|
import { extractNodes, GOAL_STATES, KINDS, MemoryStore, setMachineLabel, type GoalState, type GoalView, type Kind, type Tier, type View } from "./store.ts";
|
|
2
3
|
import { syncMemory, type SyncOptions } from "./sync.ts";
|
|
3
|
-
import { importClaudeMem } from "./import.ts";
|
|
4
|
+
import { importClaudeMem, type ImportResult, type WorkStateImportResult, type CaptureImportResult } from "./import.ts";
|
|
5
|
+
import { facts, label, PLAIN, type Style } from "./style.ts";
|
|
4
6
|
import { memoryProjectKey, projectChain } from "./project.ts";
|
|
5
7
|
import { recallForViews, recallGraph, recallRows, recallShow } from "./recall.ts";
|
|
6
8
|
import type { GoalPromotionReport } from "./goal-promotion.ts";
|
|
@@ -47,8 +49,8 @@ export interface GoalCommandOptions {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
/**
|
|
50
|
-
* #258: the memory commands, straight on the machine's store (no host needed; agent-hub exposes the same as `ahub memory`).
|
|
51
|
-
*
|
|
52
|
+
* #258: the memory commands, straight on the machine's store (no host needed; agent-hub exposes the same as `ahub memory`). Callers provide read visibility, retained-history intent and write policy;
|
|
53
|
+
* ordinary CLI calls carry scoped attribution while library calls keep their existing defaults.
|
|
52
54
|
*/
|
|
53
55
|
/** The usage line of the memory commands under `name` (`amem` by default; agent-hub passes `ahub memory`). */
|
|
54
56
|
export const memoryUsage = (name: string = AMEM_NAMES.memory): string => `usage: ${name} [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> [--history] [--as-of <UTC>] | show <id> [--history] [--as-of <UTC>] | graph <id|node> [--hops 1|2] [--history] [--as-of <UTC>] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] [--restart] [--capture-days N] | dedupe [--apply] | suggestions | confirm|reject <suggestion id> [--json]`;
|
|
@@ -58,10 +60,39 @@ export const goalUsage = (name: string = AMEM_NAMES.goal): string => `usage: ${n
|
|
|
58
60
|
export const GOAL_USAGE = goalUsage();
|
|
59
61
|
|
|
60
62
|
const fail = (message: string): never => { throw new Error(message); };
|
|
63
|
+
|
|
64
|
+
/** A person-facing aggregate receipt; import results and their JSON representation stay untouched. */
|
|
65
|
+
export function claudeMemImportText(notes: ImportResult, work: WorkStateImportResult, capture?: CaptureImportResult,
|
|
66
|
+
options: { captureDays?: number; restart?: boolean } = {}, s: Style = PLAIN): string {
|
|
67
|
+
const more = notes.more || work.more || capture?.more === true;
|
|
68
|
+
const row = (name: string, result: { read: number; added: number; more: boolean }, skipped: number, spooled?: number) =>
|
|
69
|
+
`${label(s, result.more || (spooled ?? 0) > 0 ? "attention" : "ok")} ${name}: ${facts({ read: String(result.read), added: String(result.added), skipped: String(skipped), spooled: spooled === undefined ? "n/a" : String(spooled) })}${result.more ? "; more remain" : ""}`;
|
|
70
|
+
const noteSkipped = notes.read - notes.added, workSkipped = work.read - work.added;
|
|
71
|
+
const otherNotes = Math.max(0, noteSkipped - notes.skipped.model - notes.skipped.pii - notes.skipped.secret);
|
|
72
|
+
const duplicateWork = Math.max(0, workSkipped - work.skipped.invalid - work.skipped.policy);
|
|
73
|
+
const lines = [`amem import claude-mem: ${more ? s.attention("run again") : s.ok("complete")}`,
|
|
74
|
+
row("notes", notes, noteSkipped),
|
|
75
|
+
` skipped reasons: ${facts({ model: notes.skipped.model, PII: notes.skipped.pii, secret: notes.skipped.secret, "empty/malformed/already imported": otherNotes }) || "none"}`,
|
|
76
|
+
row("work lists", work, workSkipped),
|
|
77
|
+
` skipped reasons: ${facts({ invalid: work.skipped.invalid, policy: work.skipped.policy, "already imported": duplicateWork }) || "none"}`];
|
|
78
|
+
if (capture) {
|
|
79
|
+
lines.push(row("capture", capture, capture.skipped, capture.spooled));
|
|
80
|
+
if (capture.skipped) lines.push(` skipped reasons: combined ${capture.skipped} (empty/invalid, policy or already imported; no breakdown reported)`);
|
|
81
|
+
} else lines.push(`${label(s, "skipped")} capture: not requested (use --capture-days N for recent capture)`);
|
|
82
|
+
lines.push(more ? options.restart
|
|
83
|
+
? "Next action: run the same import command again without --restart, keeping all other arguments, until all receipts are complete."
|
|
84
|
+
: "Next action: run the same import command again until all receipts are complete."
|
|
85
|
+
: capture?.spooled ? `Next action: ${capture.spooled} capture ${capture.spooled === 1 ? "row was" : "rows were"} spooled while busy; the next capture write (hook event or import adding capture rows) replays them.`
|
|
86
|
+
: "Next action: none; all requested import receipts are complete.");
|
|
87
|
+
if (capture && options.captureDays !== undefined) lines.push(`Capture window: last ${options.captureDays} days. Widening --capture-days later requires --restart.`);
|
|
88
|
+
if (options.restart) lines.push("Restart: selected cursors replayed from the start; existing import receipts prevent duplicates.");
|
|
89
|
+
return lines.join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
61
92
|
const one = (v: View) => `${v.id.slice(0, 8)} ${v.at.slice(0, 16).replace("T", " ")} ${v.tier.padEnd(5)} ${v.kind.padEnd(8)} ${v.peer.padEnd(8)} ${v.scope === "shared" ? "shared " : ""}${`${v.title ? `${v.title}: ` : ""}${v.text}`.replace(/\s+/g, " ").slice(0, 100)}${v.supersededBy ? ` (superseded by ${v.supersededBy.slice(0, 8)})` : ""}`;
|
|
62
93
|
function detail(v: View): string[] {
|
|
63
94
|
return [`${v.id} ${v.kind}, ${v.tier}-term${v.expiresAt ? ` until ${v.expiresAt}` : ""}, ${v.scope}`, `by ${v.peer} on machine ${v.machine}, ${v.at}, project ${v.project}${v.task ? `, task ${v.task}` : ""}`,
|
|
64
|
-
...(v.promotedFrom ? [`promoted from ${v.promotedFrom}`] : []), ...(v.supersededBy ? [`superseded by ${v.supersededBy}`] : []), ...(v.about.length ? [`about: ${v.about.join(", ")}`] : []), "", ...(v.title ? [v.title] : []), v.text];
|
|
95
|
+
...(cliAttribution() ? [`actor: ${noteDisplayActor(v.peer)}, peer: ${v.peer}`] : []), ...(v.promotedFrom ? [`promoted from ${v.promotedFrom}`] : []), ...(v.supersededBy ? [`superseded by ${v.supersededBy}`] : []), ...(v.about.length ? [`about: ${v.about.join(", ")}`] : []), "", ...(v.title ? [v.title] : []), v.text];
|
|
65
96
|
}
|
|
66
97
|
|
|
67
98
|
export async function rawMemoryCommand(args: string[], cwd: string, opts: MemoryCommandOptions): Promise<string> {
|
|
@@ -77,7 +108,11 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
77
108
|
if (args.includes("--explain") && (opts.person !== true || opts.rankedSearch !== true || opts.explainSearch !== true)) fail("score explanation requires ranked search and person intent");
|
|
78
109
|
for (let index = rest.indexOf("--explain"); index >= 0; index = rest.indexOf("--explain")) rest.splice(index, 1);
|
|
79
110
|
const store = opts.store ?? opts.open();
|
|
80
|
-
const actor = "user";
|
|
111
|
+
const actor = cliAttribution()?.actor ?? "user";
|
|
112
|
+
const visibleTarget = (view: View | undefined, id: string): View => {
|
|
113
|
+
if (cliAttribution()?.actor === "agent" && (!view || !opts.readable([view]).some(row => row.id === view.id))) fail("Memory operation unavailable or refused; no partial result is shown.");
|
|
114
|
+
return need(view, id);
|
|
115
|
+
};
|
|
81
116
|
const project = opts.project ?? memoryProjectKey(cwd);
|
|
82
117
|
const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (${names.memory} list shows the ids)`);
|
|
83
118
|
// #15 D4: this command is bound to one project. Crossing projects is the person's `amem search --all-projects`; here the flags would otherwise read as query words.
|
|
@@ -146,11 +181,14 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
146
181
|
const node = !start.includes(":") || start.startsWith("mem:") ? g.nodes[0]!.id : start;
|
|
147
182
|
return json ? JSON.stringify(g, null, 2) : [`${g.nodes.length} nodes, ${g.edges.length} edges around ${node}`, ...g.edges.map((e) => `${e.from} -${e.rel}-> ${e.to}`)].join("\n");
|
|
148
183
|
}
|
|
149
|
-
case "promote": { const v =
|
|
150
|
-
case "demote": { const v =
|
|
151
|
-
case "forget": { const
|
|
184
|
+
case "promote": { const v = visibleTarget(store.get(rest[0] ?? fail(usage), project), rest[0]!); if (v.supersededBy) fail(`${v.id.slice(0, 8)} is replaced by ${v.supersededBy.slice(0, 8)}: promote that one instead`); const id = store.promote(v.id, actor, project) ?? fail(v.project !== project ? `${v.id.slice(0, 8)} is another project's note: promote it from that project` : `${v.id.slice(0, 8)} stays short-term: it holds a secret, matches a PII pattern or is about a private path`); return id === v.id ? `${v.id.slice(0, 8)} is already long-term` : `promoted to long-term as ${id.slice(0, 8)}`; }
|
|
185
|
+
case "demote": { const v = visibleTarget(store.get(rest[0] ?? fail(usage), project), rest[0]!); if (v.tier !== "long") return `${v.id.slice(0, 8)} is already short-term`; store.demote(v.id, actor, project); return `demoted ${v.id.slice(0, 8)}: it expires ${store.get(v.id, project)?.expiresAt ?? "with its short-term lifetime"}`; }
|
|
186
|
+
case "forget": { const target = visibleTarget(store.get(rest[0] ?? fail(usage), project), rest[0]!);
|
|
187
|
+
if (cliAttribution()?.actor === "agent") for (const member of store.forgetTargets(target.id, project)) visibleTarget(member, member.id);
|
|
188
|
+
const v = need(store.forget(target.id, actor, project), rest[0]!); return `forgot ${v.id.slice(0, 8)}${v.local ? "" : "; it stays in the sync remote's git history until a person rewrites it (docs/memory.md)"}`; }
|
|
152
189
|
case "supersede": {
|
|
153
190
|
if (rest.length !== 2) fail(`usage: ${names.memory} supersede <old> <new>`);
|
|
191
|
+
if (cliAttribution()?.actor === "agent") for (const id of rest) visibleTarget(store.get(id, project), id);
|
|
154
192
|
return store.supersede(rest[0]!, rest[1]!, actor, project) ? `${rest[0]} is superseded by ${rest[1]}` : fail(`both ids must name different entries, and the newer one must reach as far: shared for a shared entry, long-term for a long-term one (${names.memory} promote it first)`);
|
|
155
193
|
}
|
|
156
194
|
case "sync": {
|
|
@@ -159,11 +197,19 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
159
197
|
const r = await syncMemory(store, { ...opts.sync, ...(remote ? { remote } : {}) });
|
|
160
198
|
if (json) return JSON.stringify(r, null, 2);
|
|
161
199
|
if (!r.ok) fail(`${names.memory} sync: ${r.error}`);
|
|
200
|
+
if (r.pruneApplication) {
|
|
201
|
+
const a = r.pruneApplication;
|
|
202
|
+
return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries transported; ${a.proofComplete} prune proofs complete, ${a.projectionActive ?? "pending"} foreign projections active, ${a.nativeApplied ?? "pending"} native prunes applied, ${a.held} held${r.warning ? `\nnote: ${r.warning}` : ""}`;
|
|
203
|
+
}
|
|
162
204
|
return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
|
|
163
205
|
}
|
|
164
206
|
case "dedupe": {
|
|
165
207
|
const apply = rest.includes("--apply");
|
|
166
208
|
if (rest.some(arg => arg !== "--apply")) fail(`usage: ${names.memory} dedupe [--apply] [--json]`);
|
|
209
|
+
if (cliAttribution()?.actor === "agent") {
|
|
210
|
+
const preview = store.dedupe(project, false);
|
|
211
|
+
for (const group of preview.groups) { for (const view of group.members) visibleTarget(view, view.id); visibleTarget(store.get(group.target, project), group.target); }
|
|
212
|
+
}
|
|
167
213
|
const result = store.dedupe(project, apply);
|
|
168
214
|
if (json) return JSON.stringify(result, null, 2);
|
|
169
215
|
return [result.groups.length ? `${result.groups.length} duplicate import group(s)${apply ? `; appended ${result.applied} supersede operation(s)` : "; use --apply to reconcile"}` : "no duplicate import groups to reconcile",
|
|
@@ -188,6 +234,11 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
188
234
|
return pending.length ? pending.map((x) => `${x.id.slice(0, 8)} ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)} (suggested by a model ${x.at}; not evidence until confirmed)`).join("\n") : "no edge suggestions waiting";
|
|
189
235
|
}
|
|
190
236
|
case "confirm": case "reject": {
|
|
237
|
+
if (cliAttribution()?.actor === "agent") {
|
|
238
|
+
const matches = store.suggestions(project).filter(row => row.id === rest[0] || row.id.startsWith(rest[0] ?? ""));
|
|
239
|
+
if (matches.length !== 1) fail("Memory operation unavailable or refused; no partial result is shown.");
|
|
240
|
+
for (const id of [matches[0]!.from, matches[0]!.to]) visibleTarget(store.get(id, project), id);
|
|
241
|
+
}
|
|
191
242
|
const x = store.judge(rest[0] ?? fail(`usage: ${names.memory} ${sub} <suggestion id>`), sub, actor, project) ?? fail(`no suggestion ${rest[0]} (${names.memory} suggestions lists them)`);
|
|
192
243
|
return `${sub === "confirm" ? "confirmed" : "rejected"}: ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)}`;
|
|
193
244
|
}
|
|
@@ -217,6 +268,7 @@ function leaseText(g: GoalView, me: string, goal: string): string {
|
|
|
217
268
|
* person's. A peer links its own board task with hub_task_propose's `goal`.
|
|
218
269
|
*/
|
|
219
270
|
export async function rawGoalCommand(args: string[], cwd: string, opts: GoalCommandOptions): Promise<string> {
|
|
271
|
+
const actor = cliAttribution()?.actor ?? "user";
|
|
220
272
|
const json = args.includes("--json");
|
|
221
273
|
const rest = args.filter((a) => a !== "--json");
|
|
222
274
|
const take = (name: string) => { const out: string[] = []; for (let i = rest.indexOf(name); i >= 0; i = rest.indexOf(name)) { out.push(rest[i + 1] ?? fail(`${name} needs a value`)); rest.splice(i, 2); } return out; };
|
|
@@ -251,7 +303,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
|
|
|
251
303
|
const shared = rest.includes("--shared");
|
|
252
304
|
const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(usage);
|
|
253
305
|
if (!clean(title, milestones)) fail("a long-term task syncs to every machine: this one holds a secret, a PII pattern or a private path and is not saved");
|
|
254
|
-
const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor
|
|
306
|
+
const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor });
|
|
255
307
|
return `long-term task ${id.slice(0, 8)} created: ${title}`;
|
|
256
308
|
}
|
|
257
309
|
case "link": {
|
|
@@ -264,7 +316,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
|
|
|
264
316
|
const g = need(rest[0]);
|
|
265
317
|
const state = (sub === "done" ? "done" : rest[1]) as GoalState;
|
|
266
318
|
if (!GOAL_STATES.includes(state)) fail(`the state is one of ${GOAL_STATES.join(", ")}`);
|
|
267
|
-
store.goalState(g.id, state,
|
|
319
|
+
store.goalState(g.id, state, actor);
|
|
268
320
|
if (state === "done" && opts.onDone) {
|
|
269
321
|
const current = store.goalOf(g.id, project);
|
|
270
322
|
if (!current || current.id !== g.id || current.state !== "done") return fail("goal state changed before promotion offering");
|
|
@@ -276,7 +328,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
|
|
|
276
328
|
}
|
|
277
329
|
case "take": {
|
|
278
330
|
const g = need(rest[0]);
|
|
279
|
-
const r = store.take(g.id,
|
|
331
|
+
const r = store.take(g.id, actor, { force: rest.includes("--force") });
|
|
280
332
|
if (!r.ok) fail(`held by machine ${r.holder.slice(0, 8)} until ${r.until}; wait for it to expire or take it over with --force`);
|
|
281
333
|
return `this machine holds long-term task ${g.id.slice(0, 8)} until ${r.until}${rest.includes("--force") ? " (earlier claims are void)" : ""}`;
|
|
282
334
|
}
|
|
@@ -28,14 +28,15 @@ export class ConsolidationProposalError extends Error {
|
|
|
28
28
|
const modelAnswer = (row: View) => row.peer === "amem-model" || row.text.startsWith("Model answer:");
|
|
29
29
|
/** On-demand candidate writes only. Every source is screened once under one policy and source revision. */
|
|
30
30
|
export async function proposeConsolidation(store: MemoryStore, candidates: CandidateStore, options: ConsolidationOptions): Promise<ConsolidationReport> {
|
|
31
|
-
|
|
31
|
+
const actor = options.actor;
|
|
32
|
+
if (actor !== "person" && actor !== "agent") throw Error("invalid consolidation actor");
|
|
32
33
|
if (options.allowed?.() === false) throw Error("consolidation unavailable");
|
|
33
34
|
if (realPath(candidates.dir) !== realPath(store.profile.home)) throw Error("consolidation requires its own candidate home");
|
|
34
35
|
const project = options.project, version = options.policyVersion(), policy = options.policy();
|
|
35
36
|
if (!project.trim() || typeof version !== "string") throw Error("consolidation context unavailable");
|
|
36
37
|
const sourceCurrent = store.beginRecallRead();
|
|
37
38
|
const check = () => {
|
|
38
|
-
if (options.actor !==
|
|
39
|
+
if (options.actor !== actor || options.project !== project || options.allowed?.() === false || options.policyVersion() !== version) throw Error("consolidation context changed");
|
|
39
40
|
options.assertCurrent?.(); sourceCurrent();
|
|
40
41
|
};
|
|
41
42
|
check();
|
|
@@ -74,7 +74,7 @@ export function decayPreview(store: MemoryStore, project: string, options: Decay
|
|
|
74
74
|
return { project, profile: hashJSON(store.profile), days, at: new Date(now).toISOString(), cutoff: new Date(cutoff).toISOString(), demoteExpiresAt: store.shortExpiry(), entries: entries.slice(0, DECAY_PREVIEW_LIMIT), withheld, citedExemptions: counters === undefined ? null : citedExemptions, truncated: entries.length > DECAY_PREVIEW_LIMIT, citationHistory: "unknown" };
|
|
75
75
|
});
|
|
76
76
|
}
|
|
77
|
-
/** Duplicate-group forgetting requires a separate
|
|
77
|
+
/** Duplicate-group forgetting requires a separate group review, never an implicit decay expansion. */
|
|
78
78
|
export function assertExactDecayForget(store: MemoryStore, ids: readonly string[], project: string): void {
|
|
79
79
|
store.withDeferredPromotions(() => {
|
|
80
80
|
for (const id of ids) {
|
|
@@ -104,10 +104,10 @@ export class DecayApplyError extends Error {
|
|
|
104
104
|
}
|
|
105
105
|
/** The host shows this exact plan; every selected revision/eligibility is rechecked before any append. */
|
|
106
106
|
export function applyDecay(store: MemoryStore, plan: DecayPlan, options: DecayApplyOptions): { action: "demote" | "forget"; ids: string[]; expiresAt?: string } {
|
|
107
|
-
if (options.actor !== "person") throw Error("decay apply requires
|
|
107
|
+
if (options.actor !== "person" && options.actor !== "agent") throw Error("decay apply requires a valid actor");
|
|
108
108
|
options.assertCurrent?.();
|
|
109
109
|
const ids = options.ids === undefined ? plan.entries.map(entry => entry.target.id) : idsOf(options.ids);
|
|
110
|
-
if ((options.ids === undefined || options.forget) && options.confirmed !== true) throw Error("decay bulk/forget apply requires explicit
|
|
110
|
+
if ((options.ids === undefined || options.forget) && options.confirmed !== true) throw Error("decay bulk/forget apply requires explicit confirmation");
|
|
111
111
|
if (hashJSON(store.profile) !== plan.profile || ids.some(id => !plan.entries.some(entry => entry.target.id === id))) throw Error("decay preview binding changed or target was not previewed");
|
|
112
112
|
const action = options.forget ? "forget" : "demote";
|
|
113
113
|
if (!ids.length) return { action, ids };
|
|
@@ -126,7 +126,7 @@ export function applyDecay(store: MemoryStore, plan: DecayPlan, options: DecayAp
|
|
|
126
126
|
for (let index = 0; index < ids.length; index++) {
|
|
127
127
|
const id = ids[index]!;
|
|
128
128
|
try {
|
|
129
|
-
const result = options.forget ? store.forget(id,
|
|
129
|
+
const result = options.forget ? store.forget(id, options.actor, plan.project, true) : store.demote(id, options.actor, plan.project, plan.demoteExpiresAt);
|
|
130
130
|
if (!result) throw Error("decay target outcome unavailable");
|
|
131
131
|
applied.push(id);
|
|
132
132
|
} catch (error) { throw new DecayApplyError({ action, applied: [...applied], failed: id, remaining: ids.slice(index + 1), ...expiry }, error); }
|
package/src/memory/doctor.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
1
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
4
|
import { Database } from "bun:sqlite";
|
|
5
5
|
import { sessionStartBrief } from "../session-brief.ts";
|
|
6
6
|
import { pendingSummary } from "./candidate-pending.ts";
|
|
7
|
+
import { readCaptureDrops } from "./metrics.ts";
|
|
7
8
|
import { walkShards } from "./catalog.ts";
|
|
8
9
|
import { loadAmemConfig, repoConfigRoot } from "./config.ts";
|
|
9
10
|
import { readSelector } from "./generation.ts";
|
|
@@ -26,7 +27,7 @@ import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } fro
|
|
|
26
27
|
export type SectionStatus = "ok" | "attention" | "skipped" | "unavailable";
|
|
27
28
|
export type Section<T extends object = Record<string, unknown>> = { status: SectionStatus } & T;
|
|
28
29
|
export const DOCTOR_SCHEMA = "amem.doctor/v1";
|
|
29
|
-
export const CLIS = ["claude", "codex", "kimi", "pi"] as const;
|
|
30
|
+
export const CLIS = ["claude", "codex", "kimi", "pi", "qwen"] as const;
|
|
30
31
|
export type Cli = typeof CLIS[number];
|
|
31
32
|
/** A CLI whose last capture is older than this is called stale. */
|
|
32
33
|
export const CAPTURE_STALE_MS = 7 * 86400_000;
|
|
@@ -54,6 +55,8 @@ export interface DoctorReport {
|
|
|
54
55
|
project: string;
|
|
55
56
|
sections: {
|
|
56
57
|
duplicates: Section; stale: Section; candidates: Section; capture: Section; brief: Section; index: Section; readers: Section; policy: Section; cadence: Section;
|
|
58
|
+
/** Added after v1 shipped (#132); additive, so the schema stays amem.doctor/v1. */
|
|
59
|
+
drops: Section;
|
|
57
60
|
};
|
|
58
61
|
}
|
|
59
62
|
|
|
@@ -84,8 +87,10 @@ class TooBig extends Error {}
|
|
|
84
87
|
function copyTree(from: string, to: string, budget: { bytes: number }, skipNames: ReadonlySet<string> = new Set()): void {
|
|
85
88
|
mkdirSync(to, { recursive: true, mode: 0o700 });
|
|
86
89
|
for (const name of readdirSync(from)) {
|
|
87
|
-
|
|
90
|
+
const proofObject = basename(from) === "public-prune-objects" && /^[a-f0-9]{40}\.git$/.exec(name)?.[0] === name;
|
|
91
|
+
if (SKIP.test(name) && !proofObject || skipNames.has(name)) continue;
|
|
88
92
|
const source = join(from, name), stat = lstatSync(source);
|
|
93
|
+
if (proofObject && !stat.isFile()) throw new Error("a public prune object is not a plain file");
|
|
89
94
|
if (stat.isSymbolicLink()) throw new Error("the memory home holds a link where a plain file or directory is expected");
|
|
90
95
|
if (stat.isDirectory()) copyTree(source, join(to, name), budget, skipNames);
|
|
91
96
|
else if (stat.isFile()) { budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig(); copyFileSync(source, join(to, name)); }
|
|
@@ -100,6 +105,14 @@ function copyCurated(options: DoctorOptions, layout: Layout, scratch: string, bu
|
|
|
100
105
|
const stat = statSync(join(home, name)); budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
|
|
101
106
|
copyFileSync(join(home, name), join(scratch, name));
|
|
102
107
|
}
|
|
108
|
+
const selected = readSelector(home).selector;
|
|
109
|
+
if (selected?.schema === "agent-mem.profile/v2") {
|
|
110
|
+
const name = `${selected.pruneInputSetHash}.json`, source = join(home, "public-prune-input-sets", name), stat = lstatSync(source);
|
|
111
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("the committed public prune view is not a plain file");
|
|
112
|
+
budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
|
|
113
|
+
const directory = join(scratch, "public-prune-input-sets"); mkdirSync(directory, { mode: 0o700 });
|
|
114
|
+
copyFileSync(source, join(directory, name)); chmodSync(join(directory, name), 0o600);
|
|
115
|
+
}
|
|
103
116
|
if (layout === "split") {
|
|
104
117
|
for (const key of [options.project, ...options.aliases]) {
|
|
105
118
|
const dir = shardDir(home, key);
|
|
@@ -219,7 +232,7 @@ function captureSection(options: DoctorOptions): Section {
|
|
|
219
232
|
const needs = registered ? state !== "fresh" : state === "stale";
|
|
220
233
|
if (needs) attention.push(cli);
|
|
221
234
|
clis[cli] = { hook: registered ? "registered" : "manual", ...(registered ? { hookEvidence: "setup receipt" } : {}), ...(phase === "prepared" ? { setup: "prepared, not applied" } : {}), capture: state, lastCaptureAt: last, rows: entry?.rows ?? 0,
|
|
222
|
-
...(needs || (!registered && state === "never") ? { hint: registered ? `the ${cli} hook is registered but its capture is ${state}: open a session${cli === "claude" ? "" : `, or run amem ingest ${cli}`}` : cli === "claude" ? "run amem setup claude --apply to register the capture hooks" : `run amem setup ${cli} --apply, or amem ingest ${cli} by hand` } : {}) };
|
|
235
|
+
...(needs || (!registered && state === "never") ? { hint: registered ? cli === "codex" && state === "never" ? "the codex hook is registered but has never captured: approve it in Codex's /hooks trust review (setup never grants hook trust), then open a session" : `the ${cli} hook is registered but its capture is ${state}: open a session${cli === "claude" ? "" : `, or run amem ingest ${cli}`}` : cli === "claude" ? "run amem setup claude --apply to register the capture hooks" : `run amem setup ${cli} --apply, or amem ingest ${cli} by hand` } : {}) };
|
|
223
236
|
}
|
|
224
237
|
return { status: !readable ? "unavailable" : attention.length ? "attention" : "ok", ...(readable ? {} : { reason: "a capture database could not be read" }), clis };
|
|
225
238
|
}
|
|
@@ -271,6 +284,17 @@ function policySection(options: DoctorOptions): Section {
|
|
|
271
284
|
} catch (error) { return unavailable(message(error)); }
|
|
272
285
|
}
|
|
273
286
|
|
|
287
|
+
/** #132: capture rows hooks dropped, by reason, from the bounded rolling metrics tail (counts only; no row text is ever recorded there). */
|
|
288
|
+
function dropsSection(options: DoctorOptions): Section {
|
|
289
|
+
const read = readCaptureDrops(options.home);
|
|
290
|
+
if (!read.readable) return unavailable(read.reason); // an existing file doctor cannot read is never a healthy zero
|
|
291
|
+
// One torn line (an interrupted append) is reported, not fatal; a tail with lines of which none parses is a file doctor cannot decode.
|
|
292
|
+
if (read.lines > 0 && read.malformedLines === read.lines) return unavailable("metrics.jsonl has no decodable record in its recent tail");
|
|
293
|
+
const counts = read.counts, total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
|
|
294
|
+
return { status: total ? "attention" : "ok", window: "recent metrics tail", total, counts, ...(read.malformedLines ? { malformedLines: read.malformedLines } : {}),
|
|
295
|
+
...(total ? { hint: "capture rows were dropped recently (see counts by reason); amem doctor --json keeps the numbers" } : {}) };
|
|
296
|
+
}
|
|
297
|
+
|
|
274
298
|
/** F16: which CLIs ingest at SessionEnd (setup receipts), and the person-owned sync cadence. amem registers no sync hook (a hook may not do network work). */
|
|
275
299
|
function cadenceSection(options: DoctorOptions): Section {
|
|
276
300
|
const ingest: Record<string, string> = {};
|
|
@@ -398,7 +422,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
398
422
|
let sections: DoctorReport["sections"];
|
|
399
423
|
try {
|
|
400
424
|
await tick();
|
|
401
|
-
sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options) };
|
|
425
|
+
sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options) };
|
|
402
426
|
await tick(); // a signal that arrived during the last synchronous stage is handled while the guard is still installed
|
|
403
427
|
} finally { try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { unguard?.(); } } }
|
|
404
428
|
return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections };
|
|
@@ -407,7 +431,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
407
431
|
/** Section order and one-line descriptions for the person-facing report (#125). */
|
|
408
432
|
const SECTION_TITLE: Record<keyof DoctorReport["sections"], string> = {
|
|
409
433
|
duplicates: "duplicate notes", stale: "stale path references", candidates: "review candidates", capture: "capture per CLI", brief: "session brief",
|
|
410
|
-
index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence",
|
|
434
|
+
index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence", drops: "recent capture drops",
|
|
411
435
|
};
|
|
412
436
|
|
|
413
437
|
function sectionDetail(name: string, detail: Record<string, unknown>, s: Style): string[] {
|
|
@@ -422,6 +446,11 @@ function sectionDetail(name: string, detail: Record<string, unknown>, s: Style):
|
|
|
422
446
|
return [`ingest ${Object.entries(ingest).map(([cli, how]) => `${cli} ${how}`).join(", ")}`,
|
|
423
447
|
`sync remote ${sync.remote}${sync.remote === "configured" ? `, last ok ${sync.lastOkAt ?? "never"}, failures ${sync.failures}${sync.due ? ", due" : ""}` : ""}`];
|
|
424
448
|
}
|
|
449
|
+
if (name === "drops") {
|
|
450
|
+
if (detail.counts === undefined) return []; // unavailable: only its reason is shown
|
|
451
|
+
const counts = Object.entries((detail.counts ?? {}) as Record<string, number>);
|
|
452
|
+
return [counts.length ? `${counts.map(([reason, n]) => `${reason.replaceAll("_", " ")} ${n}`).join(", ")} (recent metrics tail)` : "none in the recent metrics tail"].map(line => detail.malformedLines ? `${line}; ${detail.malformedLines} malformed lines skipped` : line);
|
|
453
|
+
}
|
|
425
454
|
if (name === "brief" && typeof detail.tokens === "number") return [`${detail.tokens} of ${detail.budget} tokens${detail.coverage ? `, coverage ${facts(detail.coverage as Record<string, unknown>) || "complete"}` : ""}`];
|
|
426
455
|
const { examples, ...rest } = detail;
|
|
427
456
|
const line = facts(rest);
|
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createPublicPruneReplayCache, type PublicPruneProjection } from "./public-prune-inputs.ts";
|
|
2
|
+
import { readPublicPruneView, readPublicPruneViewForActivation } from "./public-prune-view.ts";
|
|
3
|
+
import { SHARDED_SCHEMA, SHARDED_V2_SCHEMA } from "./shard.ts";
|
|
2
4
|
import { readShardLegacySnapshot } from "./shard-bridge.ts";
|
|
3
5
|
import type { Database } from "bun:sqlite";
|
|
4
6
|
import { createHash } from "node:crypto";
|
|
5
7
|
import { closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeSync } from "node:fs";
|
|
6
8
|
import { join } from "node:path";
|
|
7
|
-
import { canonicalJSON, hashJSON, fsyncDirectory, readProfileJSON, selectorRevision, writeProfileJSON, type BoundProfile, validateVaultLinkRow, type VaultLink } from "./generation.ts";
|
|
9
|
+
import { canonicalJSON, hashJSON, fsyncDirectory, readProfileJSON, selectorRevision, writeProfileJSON, type BoundProfile, validateVaultLinkRow, type VaultLink, rebindPublicPruneProfile } from "./generation.ts";
|
|
8
10
|
import { legacyEntries, legacyHead, readLegacySnapshot, SegmentReader, type LegacyCheckpoint, type LegacySegment } from "./bridge.ts";
|
|
9
11
|
import { reducePruneRows, validatePruneRow, type PrunePlan, type PruneRow, type PruneSnapshot, type PruneSourceRow, type PruneStorePort } from "./prune.ts";
|
|
10
12
|
import { PROMOTED_KINDS, type Entry } from "./store.ts";
|
|
11
13
|
|
|
14
|
+
const FOREIGN_TABLES = ["generation_foreign_inputs", "generation_foreign_rows", "generation_foreign_targets"] as const;
|
|
12
15
|
const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
|
13
16
|
const fail = (): never => { throw new Error("generation input unavailable or changed; person inspection required"); };
|
|
17
|
+
class PublicPruneViewChanged extends Error { constructor() { super("public prune view changed repeatedly; retry the read"); } }
|
|
14
18
|
export const GENERATION_TABLES = ["generation_vault_links", "generation_rows", "generation_prunes", "generation_targets", "generation_segments", "generation_state"] as const;
|
|
15
19
|
export interface GenerationIndexOptions {
|
|
16
20
|
profile: BoundProfile; db: Database; now: () => number;
|
|
@@ -65,9 +69,17 @@ export class GenerationIndex {
|
|
|
65
69
|
private fullCapture = false;
|
|
66
70
|
private retainedTail = false;
|
|
67
71
|
private replayProvenance = false;
|
|
72
|
+
private foreignStamp?: string;
|
|
73
|
+
private foreignCache?: { set: string; load: () => PublicPruneProjection };
|
|
68
74
|
constructor(private readonly options: GenerationIndexOptions) {
|
|
69
75
|
this.profile = options.profile;
|
|
70
76
|
const db = options.db;
|
|
77
|
+
if (this.profile.foreignPruneFormat) {
|
|
78
|
+
db.run("CREATE TABLE IF NOT EXISTS generation_foreign_inputs (id TEXT PRIMARY KEY)");
|
|
79
|
+
db.run("CREATE TABLE IF NOT EXISTS generation_foreign_rows (id TEXT PRIMARY KEY, hash TEXT NOT NULL)");
|
|
80
|
+
db.run("CREATE TABLE IF NOT EXISTS generation_foreign_targets (id TEXT PRIMARY KEY)");
|
|
81
|
+
this.replayProvenance = true; // persistent SQLite never supplies foreign membership
|
|
82
|
+
}
|
|
71
83
|
db.run("CREATE TABLE IF NOT EXISTS generation_rows (id TEXT PRIMARY KEY, line TEXT NOT NULL, local INTEGER NOT NULL, public INTEGER NOT NULL, own INTEGER NOT NULL, legacy INTEGER NOT NULL)");
|
|
72
84
|
if (!(db.query("PRAGMA table_info(generation_rows)").all() as { name: string }[]).some(row => row.name === "legacy")) { db.run("ALTER TABLE generation_rows ADD COLUMN legacy INTEGER NOT NULL DEFAULT 0"); this.replayProvenance = true; }
|
|
73
85
|
db.run("CREATE TABLE IF NOT EXISTS generation_vault_links (id TEXT PRIMARY KEY, target TEXT NOT NULL, row_hash TEXT NOT NULL, project TEXT NOT NULL, path TEXT NOT NULL, at TEXT NOT NULL)");
|
|
@@ -93,9 +105,14 @@ export class GenerationIndex {
|
|
|
93
105
|
/** Authoring handles cannot read or append a partially resumed split; migration index bootstraps use captured limits. */
|
|
94
106
|
private shardFrontier(): string | undefined {
|
|
95
107
|
if (!this.profile.sharded || this.options.limits !== undefined) return undefined;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
109
|
+
rebindPublicPruneProfile(this.profile);
|
|
110
|
+
const value = readProfileJSON(join(this.profile.home, "shards.json")) as { schema?: unknown; phase?: unknown; generation?: unknown; selectorRevision?: unknown } | undefined;
|
|
111
|
+
if (selectorRevision(this.profile.home) !== this.profile.selectorRevision) { if (this.profile.foreignPruneFormat) continue; return fail(); }
|
|
112
|
+
if (!value || value.schema !== (this.profile.foreignPruneFormat ? SHARDED_V2_SCHEMA : SHARDED_SCHEMA) || value.phase !== "active" || value.generation !== this.profile.generation || value.selectorRevision !== this.profile.selectorRevision) return fail();
|
|
113
|
+
return hashJSON(value);
|
|
114
|
+
}
|
|
115
|
+
return fail();
|
|
99
116
|
}
|
|
100
117
|
private capture(reader: SegmentReader = this.reader): { segments: LegacySegment[]; commit?: string } {
|
|
101
118
|
const shardBefore = this.shardFrontier();
|
|
@@ -119,7 +136,7 @@ export class GenerationIndex {
|
|
|
119
136
|
const segment = own.find(s => s.path === old.path);
|
|
120
137
|
if (!segment || segment.offset < old.offset) fail();
|
|
121
138
|
}
|
|
122
|
-
if (this.shardFrontier() !== shardBefore) return fail();
|
|
139
|
+
if (this.shardFrontier() !== shardBefore) { if (this.profile.foreignPruneFormat) throw new PublicPruneViewChanged(); return fail(); }
|
|
123
140
|
return { segments: [...legacy, ...own], ...(commit ? { commit } : {}) };
|
|
124
141
|
}
|
|
125
142
|
private index(captured: { segments: LegacySegment[]; commit?: string }): void {
|
|
@@ -161,7 +178,35 @@ export class GenerationIndex {
|
|
|
161
178
|
const reduced = reducePruneRows(prunes, snapshot, this.profile.generation!);
|
|
162
179
|
db.run("DELETE FROM generation_targets");
|
|
163
180
|
for (const id of [...reduced.targets].sort()) db.query("INSERT INTO generation_targets VALUES (?)").run(id);
|
|
164
|
-
this.
|
|
181
|
+
this.applyForeign(true);
|
|
182
|
+
this.pruned = new Set((db.query("SELECT id FROM generation_targets").all() as {id:string}[]).map(row => row.id));
|
|
183
|
+
}
|
|
184
|
+
private applyForeign(validateRows = false): void {
|
|
185
|
+
if (!this.profile.foreignPruneFormat) return;
|
|
186
|
+
const profile = this.profile;
|
|
187
|
+
if (!profile.pruneInputSetHash || !profile.generation || !profile.sharded) return fail();
|
|
188
|
+
const view = (this.options.limits !== undefined ? readPublicPruneViewForActivation : readPublicPruneView)(profile.home, profile.pruneInputSetHash, profile.generation);
|
|
189
|
+
const key = profile.shardUser ? "user" : `project:${profile.shardKey}`, set = view.realms[key];
|
|
190
|
+
const db = this.options.db;
|
|
191
|
+
if (!set) {
|
|
192
|
+
if ((db.query("SELECT COUNT(*) AS n FROM generation_foreign_inputs").get() as {n:number}).n) return fail();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (!this.foreignCache || this.foreignCache.set !== set) this.foreignCache = { set, load: createPublicPruneReplayCache({ dir: profile.dir,
|
|
196
|
+
realm: profile.shardUser ? { scope: "user" } : { scope: "project", key: profile.shardKey! }, receivingGeneration: profile.generation, inputSetHash: set }) };
|
|
197
|
+
const proof = this.foreignCache.load();
|
|
198
|
+
if (validateRows || this.foreignStamp !== set) for (const item of db.query("SELECT id,line FROM generation_rows").all() as {id:string;line:string}[]) {
|
|
199
|
+
const expected = proof.rowHashes.get(item.id); if (expected && hashJSON(JSON.parse(item.line)) !== expected) return fail();
|
|
200
|
+
}
|
|
201
|
+
if (validateRows || this.foreignStamp !== set) {
|
|
202
|
+
for (const table of FOREIGN_TABLES) db.run(`DELETE FROM ${table}`);
|
|
203
|
+
for (const id of proof.inputs) db.query("INSERT INTO generation_foreign_inputs VALUES (?)").run(id);
|
|
204
|
+
for (const [id, hash] of proof.rowHashes) db.query("INSERT INTO generation_foreign_rows VALUES (?,?)").run(id, hash);
|
|
205
|
+
for (const id of proof.targets) db.query("INSERT INTO generation_foreign_targets VALUES (?)").run(id);
|
|
206
|
+
this.foreignStamp = set;
|
|
207
|
+
}
|
|
208
|
+
const current = new Set((db.query("SELECT id FROM generation_targets").all() as {id:string}[]).map(row => row.id));
|
|
209
|
+
for (const id of proof.targets) if (!current.has(id)) db.query("INSERT INTO generation_targets VALUES (?)").run(id);
|
|
165
210
|
}
|
|
166
211
|
private recordSkipped(segment: LegacySegment): void {
|
|
167
212
|
this.options.db.query("INSERT INTO generation_state VALUES (?,'1') ON CONFLICT(key) DO UPDATE SET value=CAST(CAST(value AS INTEGER)+1 AS TEXT)").run(`skipped:${segment.source}:${segment.path}`);
|
|
@@ -183,12 +228,12 @@ export class GenerationIndex {
|
|
|
183
228
|
const before = this.frontier(), db = this.options.db;
|
|
184
229
|
let input: ReturnType<GenerationIndex["capture"]>;
|
|
185
230
|
try { input = this.capture(reader); }
|
|
186
|
-
catch (error) { if (attempt < 5 && this.frontier() !== before) return this.refresh(attempt + 1, reader); throw error; }
|
|
231
|
+
catch (error) { if (attempt < 5 && (error instanceof PublicPruneViewChanged || this.frontier() !== before)) return this.refresh(attempt + 1, reader); throw error; }
|
|
187
232
|
if (this.frontier() !== before) { if (attempt < 5) return this.refresh(attempt + 1, reader); return fail(); }
|
|
188
233
|
const known = db.query("SELECT source,path,offset,hash,commit_id,stamp FROM generation_segments").all() as { source: string; path: string; offset: number; hash: string; commit_id: string | null; stamp: string | null }[];
|
|
189
234
|
const currentCommit = (db.query("SELECT value FROM generation_state WHERE key='legacyCommit'").get() as { value: string } | null)?.value;
|
|
190
235
|
const unchanged = known.length === input.segments.length && currentCommit === input.commit && input.segments.every(segment => known.some(row => row.source === segment.source && row.path === segment.path && row.offset === segment.offset && row.hash === segment.hash && row.commit_id === (segment.commit ?? null) && row.stamp === (segment.stamp ?? null)));
|
|
191
|
-
if (unchanged) { this.retainedTail = input.segments.some(segment => segment.tailBytes > 0); this.pruned = new Set((db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id)); return; }
|
|
236
|
+
if (unchanged) { if (this.profile.foreignPruneFormat) db.transaction(() => this.applyForeign()).deferred(); this.retainedTail = input.segments.some(segment => segment.tailBytes > 0); this.pruned = new Set((db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id)); return; }
|
|
192
237
|
const committed = db.transaction(() => { if (this.frontier() !== before) return false; this.index(input); return true; }).immediate();
|
|
193
238
|
if (!committed) { if (attempt < 5) return this.refresh(attempt + 1, reader); return fail(); }
|
|
194
239
|
this.retainedTail = input.segments.some(segment => segment.tailBytes > 0);
|
|
@@ -197,11 +242,11 @@ export class GenerationIndex {
|
|
|
197
242
|
const before = this.frontier();
|
|
198
243
|
let input: ReturnType<GenerationIndex["capture"]>;
|
|
199
244
|
try { this.fullCapture = true; input = this.capture(reader); }
|
|
200
|
-
catch (error) { this.fullCapture = false; if (attempt < 5 && this.frontier() !== before) return this.rebuild(attempt + 1, reader); throw error; }
|
|
245
|
+
catch (error) { this.fullCapture = false; if (attempt < 5 && (error instanceof PublicPruneViewChanged || this.frontier() !== before)) return this.rebuild(attempt + 1, reader); throw error; }
|
|
201
246
|
finally { this.fullCapture = false; }
|
|
202
247
|
const committed = this.options.db.transaction(() => {
|
|
203
248
|
if (this.frontier() !== before) return false;
|
|
204
|
-
for (const table of ["entries", "ops", "edges", "goals", "segments", "heads", ...GENERATION_TABLES]) this.options.db.run(`DELETE FROM ${table}`);
|
|
249
|
+
for (const table of ["entries", "ops", "edges", "goals", "segments", "heads", ...GENERATION_TABLES, ...(this.profile.foreignPruneFormat ? FOREIGN_TABLES : [])]) this.options.db.run(`DELETE FROM ${table}`);
|
|
205
250
|
this.index(input); return true;
|
|
206
251
|
}).immediate();
|
|
207
252
|
if (!committed) { if (attempt < 5) return this.rebuild(attempt + 1, reader); return fail(); }
|
package/src/memory/generation.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { assertNoPublicPruneIntent, readPublicPruneView, waitForPublicPruneWriter, PUBLIC_PRUNE_INTENT } from "./public-prune-view.ts";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import { types } from "node:util";
|
|
3
4
|
import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { readerAdmissionDatabase } from "./mutation.ts";
|
|
5
6
|
import { dirname, join } from "node:path";
|
|
6
7
|
import { canonicalPath, processLiveness, processSignature, realPath } from "./platform.ts";
|
|
8
|
+
import { shardDir, userShardDir } from "./shard.ts";
|
|
9
|
+
import { readPublicPruneInputSetChain } from "./public-prune-input-chain.ts";
|
|
7
10
|
|
|
8
11
|
export interface BoundProfile {
|
|
9
12
|
kind: "shared-v1" | "generation";
|
|
@@ -12,6 +15,9 @@ export interface BoundProfile {
|
|
|
12
15
|
dir: string;
|
|
13
16
|
generation?: string;
|
|
14
17
|
selectorRevision: string;
|
|
18
|
+
/** Explicit v2 projection only, never inferred from an archive or caller flag. */
|
|
19
|
+
foreignPruneFormat?: "agent-mem.public-prune-witness/v1";
|
|
20
|
+
pruneInputSetHash?: string;
|
|
15
21
|
/** Sharded profiles read only their own legacy input; the root home remains the machine/selector identity. */
|
|
16
22
|
legacyHome?: string;
|
|
17
23
|
/** Public legacy witnesses stay distinct from same-machine private bridge input. */
|
|
@@ -21,7 +27,8 @@ export interface BoundProfile {
|
|
|
21
27
|
shardUser?: boolean;
|
|
22
28
|
}
|
|
23
29
|
export type ProfileInput = "shared-v1" | { generation: string };
|
|
24
|
-
export
|
|
30
|
+
export type ProfileSelector = { schema: "agent-mem.profile/v1"; kind: "shared-v1" | "generation"; generation?: string }
|
|
31
|
+
| { schema: "agent-mem.profile/v2"; kind: "generation"; generation: string; foreignPruneFormat: "agent-mem.public-prune-witness/v1"; pruneInputSetHash: string };
|
|
25
32
|
export interface GenerationDescriptor { schema: "agent-mem.generation/v1"; generation: string; legacySchema: "agent-hub.memory/v1" }
|
|
26
33
|
export interface GenerationSeal { schema: "agent-mem.generation-seal/v1"; generation: string; descriptorHash: string; sourceDigest: string }
|
|
27
34
|
export const PROFILE_LOCK_TIMEOUT_MS = 3000;
|
|
@@ -44,16 +51,69 @@ export function readProfileJSON(path: string, limit = LOCAL_METADATA_MAX_BYTES):
|
|
|
44
51
|
} catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; return unavailable(); }
|
|
45
52
|
}
|
|
46
53
|
function keys(value: object, allowed: string[]): boolean { return Object.keys(value).every(key => allowed.includes(key)); }
|
|
47
|
-
|
|
54
|
+
function selectorSnapshot(memoryDir: string, deadline: number): { selector?: ProfileSelector; revision: string } {
|
|
55
|
+
assertNoPublicPruneIntent(memoryDir, deadline);
|
|
48
56
|
const value = readProfileJSON(join(memoryDir, "profile.json"));
|
|
49
57
|
if (value === undefined) return { revision: "absent" };
|
|
50
|
-
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
58
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return unavailable();
|
|
51
59
|
const selector = value as ProfileSelector;
|
|
52
|
-
if (selector.schema
|
|
53
|
-
|
|
60
|
+
if (selector.schema === "agent-mem.profile/v2") {
|
|
61
|
+
if (Object.keys(selector).length !== 5 || !keys(selector, ["schema", "kind", "generation", "foreignPruneFormat", "pruneInputSetHash"])
|
|
62
|
+
|| selector.kind !== "generation" || !validGeneration(selector.generation) || selector.generation.length !== 32
|
|
63
|
+
|| selector.foreignPruneFormat !== "agent-mem.public-prune-witness/v1" || typeof selector.pruneInputSetHash !== "string"
|
|
64
|
+
|| selector.pruneInputSetHash.length !== 64 || !/^[a-f0-9]+$/.test(selector.pruneInputSetHash)) return unavailable();
|
|
65
|
+
const shard = readProfileJSON(join(memoryDir, "shards.json")) as Record<string, unknown> | undefined;
|
|
66
|
+
if (!shard || shard.schema !== "agent-mem.shards/v2" || shard.phase !== "active" || shard.generation !== selector.generation || shard.selectorRevision !== hashJSON(selector)) return unavailable();
|
|
67
|
+
readPublicPruneView(memoryDir, selector.pruneInputSetHash, selector.generation, deadline);
|
|
68
|
+
} else if (selector.schema !== "agent-mem.profile/v1" || !keys(selector, ["schema", "kind", "generation"])
|
|
69
|
+
|| !["shared-v1", "generation"].includes(selector.kind) || (selector.kind === "generation" ? !validGeneration(selector.generation) : selector.generation !== undefined)) return unavailable();
|
|
70
|
+
else if ((readProfileJSON(join(memoryDir, "shards.json")) as {schema?:unknown}|undefined)?.schema === "agent-mem.shards/v2") return unavailable();
|
|
54
71
|
return { selector, revision: hashJSON(selector) };
|
|
55
72
|
}
|
|
73
|
+
|
|
74
|
+
/** A v2 writer can replace its two markers between individual reads. Retry its
|
|
75
|
+
* bounded completed publication rather than exposing a transient mixed pair. */
|
|
76
|
+
export function readSelector(memoryDir: string, absoluteDeadline?: number): { selector?: ProfileSelector; revision: string } {
|
|
77
|
+
const deadline = Math.min(absoluteDeadline ?? Infinity, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
|
|
78
|
+
for (let attempt = 0; attempt < 6 && Date.now() <= deadline; attempt++) {
|
|
79
|
+
try {
|
|
80
|
+
const result = selectorSnapshot(memoryDir, deadline); assertNoPublicPruneIntent(memoryDir, deadline);
|
|
81
|
+
const current = readProfileJSON(join(memoryDir, "profile.json"));
|
|
82
|
+
if (result.revision === (current === undefined ? "absent" : hashJSON(current))) return result;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof Error && error.message === "public prune view unavailable") throw error; // A spent wait budget is never restarted.
|
|
85
|
+
if (!lstatSync(join(memoryDir, PUBLIC_PRUNE_INTENT), { throwIfNoEntry: false }) && !lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw error;
|
|
86
|
+
waitForPublicPruneWriter(memoryDir, deadline);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return unavailable();
|
|
90
|
+
}
|
|
91
|
+
|
|
56
92
|
export const selectorRevision = (memoryDir: string): string => readSelector(memoryDir).revision;
|
|
93
|
+
/** Refresh the same shared profile object only across proved monotone same-generation v2 views.
|
|
94
|
+
* Unopened realms contribute set metadata only; their descriptors and bodies stay unopened. */
|
|
95
|
+
export function rebindPublicPruneProfile(profile: BoundProfile): void {
|
|
96
|
+
if (!profile.foreignPruneFormat) return;
|
|
97
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
98
|
+
const current = readSelector(profile.home), selector = current.selector;
|
|
99
|
+
if (current.revision === profile.selectorRevision) return;
|
|
100
|
+
if (!profile.sharded || profile.kind !== "generation" || !profile.generation || !profile.pruneInputSetHash
|
|
101
|
+
|| selector?.schema !== "agent-mem.profile/v2" || selector.generation !== profile.generation || selector.foreignPruneFormat !== profile.foreignPruneFormat) return unavailable();
|
|
102
|
+
const previous = readPublicPruneView(profile.home, profile.pruneInputSetHash, profile.generation);
|
|
103
|
+
const next = readPublicPruneView(profile.home, selector.pruneInputSetHash, profile.generation);
|
|
104
|
+
if (!Object.keys(previous.realms).every(key => Object.hasOwn(next.realms, key))) return unavailable();
|
|
105
|
+
for (const [key, hash] of Object.entries(next.realms)) {
|
|
106
|
+
const old = previous.realms[key]; if (old === hash) continue;
|
|
107
|
+
const dir = key === "user" ? userShardDir(profile.home) : shardDir(profile.home, key.slice(8));
|
|
108
|
+
const chain = readPublicPruneInputSetChain({ dir, receivingGeneration: profile.generation, inputSetHash: hash });
|
|
109
|
+
if (old !== undefined && !chain.commitments.includes(old)) return unavailable();
|
|
110
|
+
}
|
|
111
|
+
if (readSelector(profile.home).revision !== current.revision) continue;
|
|
112
|
+
profile.selectorRevision = current.revision; profile.pruneInputSetHash = selector.pruneInputSetHash;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
return unavailable();
|
|
116
|
+
}
|
|
57
117
|
export function generationDescriptor(generation: string): GenerationDescriptor {
|
|
58
118
|
if (!validGeneration(generation)) return unavailable();
|
|
59
119
|
return { schema: "agent-mem.generation/v1", generation, legacySchema: "agent-hub.memory/v1" };
|
|
@@ -83,8 +143,9 @@ export function validateGeneration(memoryDir: string, generation: string): strin
|
|
|
83
143
|
export function resolveProfile(memoryDir: string, explicit?: ProfileInput): BoundProfile {
|
|
84
144
|
const home = existsSync(memoryDir) ? realPath(memoryDir) : canonicalPath(memoryDir), selected = readSelector(home);
|
|
85
145
|
if (explicit !== undefined && explicit !== "shared-v1" && (!explicit || typeof explicit !== "object" || !keys(explicit, ["generation"]) || !validGeneration(explicit.generation))) return unavailable();
|
|
146
|
+
if (selected.selector?.schema === "agent-mem.profile/v2" && explicit !== undefined) return unavailable();
|
|
86
147
|
const generation = explicit === "shared-v1" ? undefined : explicit?.generation ?? (selected.selector?.kind === "generation" ? selected.selector.generation : undefined);
|
|
87
|
-
return generation ? { kind: "generation", home, dir: validateGeneration(home, generation), generation, selectorRevision: selected.revision }
|
|
148
|
+
return generation ? { kind: "generation", home, dir: validateGeneration(home, generation), generation, selectorRevision: selected.revision, ...(selected.selector?.schema === "agent-mem.profile/v2" ? { foreignPruneFormat: selected.selector.foreignPruneFormat, pruneInputSetHash: selected.selector.pruneInputSetHash } : {}) }
|
|
88
149
|
: { kind: "shared-v1", home, dir: home, selectorRevision: selected.revision };
|
|
89
150
|
}
|
|
90
151
|
|
|
@@ -142,7 +203,12 @@ export async function withProfileLock<T>(memoryDir: string, work: () => T | Prom
|
|
|
142
203
|
await new Promise(resolve => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
|
|
143
204
|
continue;
|
|
144
205
|
}
|
|
145
|
-
try {
|
|
206
|
+
try {
|
|
207
|
+
const result = work();
|
|
208
|
+
// A synchronous publisher must release its lock before a queued sync reader can block this event loop.
|
|
209
|
+
if (result !== null && (typeof result === "object" || typeof result === "function") && typeof (result as PromiseLike<T>).then === "function") return await result;
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
146
212
|
finally {
|
|
147
213
|
// A live owner's token cannot be reclaimed; do not remove a file another actor replaced.
|
|
148
214
|
if (existsSync(file) && !lstatSync(file).isSymbolicLink() && readFileSync(file, "utf8") === owner) { unlinkSync(file); fsyncDirectory(memoryDir); }
|