@staix/agent-mem 0.2.0 → 0.3.1
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/README.md +48 -1
- package/package.json +4 -2
- package/src/cli.ts +66 -4
- package/src/commands-table.ts +8 -0
- package/src/index.ts +4 -0
- package/src/memory/bridge.ts +376 -0
- package/src/memory/commands.ts +6 -1
- package/src/memory/generation-index.ts +245 -0
- package/src/memory/generation-sync.ts +248 -0
- package/src/memory/generation.ts +143 -0
- package/src/memory/migrate.ts +216 -0
- package/src/memory/ops-contract.ts +159 -0
- package/src/memory/platform.ts +8 -1
- package/src/memory/prune.ts +259 -0
- package/src/memory/store.ts +124 -15
- package/src/memory/sync.ts +22 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { canonicalJSON, hashJSON, validGeneration } from "./generation.ts";
|
|
2
|
+
import { entryId, type Entry, type NoteEntry } from "./store.ts";
|
|
3
|
+
|
|
4
|
+
export const PRUNE_ROW_MAX_BYTES = 16_384, PRUNE_PLAN_MAX_BYTES = 262_144;
|
|
5
|
+
export const PRUNE_ROW_MAX_TARGETS = 32, PRUNE_PLAN_MAX_ROWS = 16, PRUNE_PLAN_MAX_TARGETS = 256;
|
|
6
|
+
export type PrunePlacement = "local" | "synced";
|
|
7
|
+
export interface PruneTarget {
|
|
8
|
+
id: string; rowHash: string; expiredAt: string;
|
|
9
|
+
basis: { kind: "note" | "demote" | "settle"; id: string; rowHash: string };
|
|
10
|
+
}
|
|
11
|
+
export interface PruneRow {
|
|
12
|
+
schema: "agent-mem.prune/v1"; op: "prune"; generation: string; at: string; machine: string; actor: "person";
|
|
13
|
+
placement: PrunePlacement; cutoff: string; targets: PruneTarget[]; id: string;
|
|
14
|
+
}
|
|
15
|
+
export interface PrunePlan {
|
|
16
|
+
schema: "agent-mem.prune-plan/v1"; generation: string; selectorRevision: string; project: string; policyRevision: string;
|
|
17
|
+
rows: { placement: PrunePlacement; payload: PruneRow }[];
|
|
18
|
+
counts: { eligible: number; selected: number; local: number; synced: number }; hasMore: boolean; seal: string;
|
|
19
|
+
}
|
|
20
|
+
export interface PruneSourceRow { row: Entry; placement: PrunePlacement; publicWitness: boolean; own?: boolean; legacy?: boolean }
|
|
21
|
+
export interface PruneExpiry { targetId: string; basisId: string; expiredAt: string }
|
|
22
|
+
export interface PruneSnapshot {
|
|
23
|
+
rows: readonly PruneSourceRow[];
|
|
24
|
+
/** Effective, explicit serialized deadlines only; implicit TTL/backstop expiry is never eligible. */
|
|
25
|
+
expiries: readonly PruneExpiry[];
|
|
26
|
+
pruned?: readonly string[];
|
|
27
|
+
durablePruneIds?: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
export interface PruneStorePort {
|
|
30
|
+
generation: string; machine: string;
|
|
31
|
+
selectorRevision(): string;
|
|
32
|
+
now(): number;
|
|
33
|
+
snapshot(): PruneSnapshot;
|
|
34
|
+
withMutation<T>(work: () => T): T;
|
|
35
|
+
/** Must persist/fsync exactly this local plan before append, refusing mismatched existing journals. */
|
|
36
|
+
persistPrunePlan(plan: PrunePlan): void;
|
|
37
|
+
/** Must fsync complete bytes; resume only an exact persisted-plan prefix, never rewrite a complete line. */
|
|
38
|
+
appendPrune(row: PruneRow, line: string, planSeal: string): "written" | "already-durable";
|
|
39
|
+
refresh(): void;
|
|
40
|
+
}
|
|
41
|
+
export interface PrunePolicyBinding { generation: string; project: string; payloadHash: string; policyRevision: string }
|
|
42
|
+
export interface PruneAuthorization {
|
|
43
|
+
project: string; actor: "person" | "agent";
|
|
44
|
+
/** The host authenticates intent. Neither this boolean nor actor is an authentication token. */
|
|
45
|
+
authorize: () => boolean;
|
|
46
|
+
policy?: {
|
|
47
|
+
revision: () => string;
|
|
48
|
+
classify: (row: PruneSourceRow) => "public" | "private" | "unknown";
|
|
49
|
+
attest: (binding: PrunePolicyBinding) => boolean;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const bytes = (value: unknown) => Buffer.byteLength(canonicalJSON(value), "utf8");
|
|
53
|
+
const fullId = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{32}$/.test(value);
|
|
54
|
+
const sha = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
55
|
+
const revision = (value: unknown): value is string => value === "absent" || sha(value);
|
|
56
|
+
const utc = (value: unknown): value is string => typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
57
|
+
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
58
|
+
const exact = (value: Record<string, unknown>, keys: readonly string[]) => Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
59
|
+
const fail = (): never => { throw new Error("prune unavailable: invalid, unavailable or withheld request"); };
|
|
60
|
+
function guard(condition: unknown): asserts condition { if (!condition) fail(); }
|
|
61
|
+
/** Full canonical authored row INCLUDING id, unlike the v1 entry-id hash which excludes id. */
|
|
62
|
+
export const pruneRowHash = (row: Entry): string => hashJSON(row);
|
|
63
|
+
export const prunePayloadHash = (row: PruneRow): string => hashJSON(row);
|
|
64
|
+
export function pruneId(row: Omit<PruneRow, "id">): string { return hashJSON(row).slice(0, 32); }
|
|
65
|
+
function protectedCall<T>(work: () => T): T { try { return work(); } catch { return fail(); } }
|
|
66
|
+
|
|
67
|
+
export function validatePruneRow(input: unknown, generation?: string, placement?: PrunePlacement): PruneRow {
|
|
68
|
+
return protectedCall(() => {
|
|
69
|
+
guard(object(input) && bytes(input) <= PRUNE_ROW_MAX_BYTES);
|
|
70
|
+
const row = input as Record<string, unknown>;
|
|
71
|
+
guard(exact(row, ["schema", "op", "generation", "at", "machine", "actor", "placement", "cutoff", "targets", "id"]));
|
|
72
|
+
guard(row.schema === "agent-mem.prune/v1" && row.op === "prune" && validGeneration(row.generation) && utc(row.at) && utc(row.cutoff));
|
|
73
|
+
guard(typeof row.machine === "string" && /^[a-f0-9]{16}$/.test(row.machine) && row.actor === "person" && ["local", "synced"].includes(String(row.placement)));
|
|
74
|
+
guard((generation === undefined || row.generation === generation) && (placement === undefined || row.placement === placement));
|
|
75
|
+
guard(String(row.cutoff) <= String(row.at));
|
|
76
|
+
guard(Array.isArray(row.targets) && row.targets.length >= 1 && row.targets.length <= PRUNE_ROW_MAX_TARGETS);
|
|
77
|
+
let previous = "";
|
|
78
|
+
for (const value of row.targets as unknown[]) {
|
|
79
|
+
guard(object(value) && exact(value, ["id", "rowHash", "expiredAt", "basis"]));
|
|
80
|
+
const target = value as Record<string, unknown>, basis = target.basis;
|
|
81
|
+
guard(fullId(target.id) && sha(target.rowHash) && utc(target.expiredAt) && target.id > previous && String(target.expiredAt) <= String(row.cutoff));
|
|
82
|
+
guard(object(basis) && exact(basis, ["kind", "id", "rowHash"]) && ["note", "demote", "settle"].includes(String(basis.kind)) && fullId(basis.id) && sha(basis.rowHash));
|
|
83
|
+
previous = target.id as string;
|
|
84
|
+
}
|
|
85
|
+
guard(fullId(row.id));
|
|
86
|
+
const { id, ...body } = row;
|
|
87
|
+
guard(pruneId(body as Omit<PruneRow, "id">) === id);
|
|
88
|
+
return structuredClone(row) as unknown as PruneRow;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function sources(snapshot: PruneSnapshot): Map<string, PruneSourceRow> {
|
|
92
|
+
const result = new Map<string, PruneSourceRow>();
|
|
93
|
+
for (const source of snapshot.rows) {
|
|
94
|
+
const row = source.row, previous = result.get(row.id);
|
|
95
|
+
guard(fullId(row.id) && utc(row.at) && row.schema === "agent-hub.memory/v1");
|
|
96
|
+
const { id, ...body } = row;
|
|
97
|
+
guard(entryId(body) === id);
|
|
98
|
+
guard(!previous || canonicalJSON(previous.row) === canonicalJSON(row));
|
|
99
|
+
// Any verified public occurrence establishes provenance; local-only occurrences never establish it.
|
|
100
|
+
if (!previous || source.publicWitness && source.placement === "synced") result.set(row.id, source);
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
function proof(target: PruneTarget, row: PruneRow, all: Map<string, PruneSourceRow>): "valid" | "pending" | "invalid" {
|
|
105
|
+
const note = all.get(target.id)?.row, basis = all.get(target.basis.id)?.row;
|
|
106
|
+
if (!note || !basis) return "pending";
|
|
107
|
+
if (note.op !== "note" || pruneRowHash(note) !== target.rowHash || pruneRowHash(basis) !== target.basis.rowHash || note.at > row.cutoff || basis.at > row.cutoff || basis.op !== target.basis.kind) return "invalid";
|
|
108
|
+
if (basis.op === "note") return basis.id === note.id && utc(basis.expires) && basis.expires === target.expiredAt ? "valid" : "invalid";
|
|
109
|
+
if (basis.op === "demote") return basis.target === note.id && utc(basis.by) && basis.by === target.expiredAt ? "valid" : "invalid";
|
|
110
|
+
if (basis.op === "settle") return !!note.task && basis.target === note.task && utc(basis.by) && basis.by === target.expiredAt ? "valid" : "invalid";
|
|
111
|
+
return "invalid";
|
|
112
|
+
}
|
|
113
|
+
/** Pure exact-entry reducer. No clock, writer policy, ancestry traversal, append or mutation occurs here. */
|
|
114
|
+
export function reducePruneRows(rows: readonly { row: unknown; placement: PrunePlacement }[], snapshot: PruneSnapshot, generation: string): { targets: Set<string>; pending: string[]; invalid: number } {
|
|
115
|
+
const targets = new Set<string>(), pending: string[] = [];
|
|
116
|
+
let invalid = 0;
|
|
117
|
+
const all = sources(snapshot);
|
|
118
|
+
for (const input of rows) {
|
|
119
|
+
let row: PruneRow;
|
|
120
|
+
try { row = validatePruneRow(input.row, generation, input.placement); } catch { invalid++; continue; }
|
|
121
|
+
const states = row.targets.map(target => proof(target, row, all));
|
|
122
|
+
if (states.includes("invalid")) { invalid++; continue; }
|
|
123
|
+
if (states.includes("pending")) { pending.push(row.id); continue; }
|
|
124
|
+
if (row.placement === "synced" && row.targets.some(target => [target.id, target.basis.id].some(id => !all.get(id)?.publicWitness || all.get(id)?.placement !== "synced"))) { invalid++; continue; }
|
|
125
|
+
for (const target of row.targets) targets.add(target.id);
|
|
126
|
+
}
|
|
127
|
+
return { targets, pending: [...new Set(pending)].sort(), invalid };
|
|
128
|
+
}
|
|
129
|
+
function authenticate(port: PruneStorePort, auth: PruneAuthorization): void {
|
|
130
|
+
guard(validGeneration(port.generation) && /^[a-f0-9]{16}$/.test(port.machine) && revision(port.selectorRevision()));
|
|
131
|
+
guard(auth.actor === "person" && typeof auth.authorize === "function" && auth.authorize() === true && typeof auth.project === "string" && auth.project.length > 0);
|
|
132
|
+
}
|
|
133
|
+
function policyRevision(auth: PruneAuthorization): string {
|
|
134
|
+
if (!auth.policy) return "local-only";
|
|
135
|
+
const current = auth.policy.revision();
|
|
136
|
+
guard(typeof current === "string" && current.length > 0 && Buffer.byteLength(current, "utf8") <= 1024 && current !== "local-only");
|
|
137
|
+
return current;
|
|
138
|
+
}
|
|
139
|
+
function visible(note: NoteEntry, project: string): boolean { return note.project === project || note.scope === "shared"; }
|
|
140
|
+
function targetFor(id: string, snapshot: PruneSnapshot, all: Map<string, PruneSourceRow>, cutoff: string, project: string): PruneTarget | undefined {
|
|
141
|
+
const found = all.get(id), expiry = snapshot.expiries.find(item => item.targetId === id);
|
|
142
|
+
if (!found || found.row.op !== "note" || !visible(found.row, project) || !expiry || !utc(expiry.expiredAt) || expiry.expiredAt > cutoff) return undefined;
|
|
143
|
+
const basis = all.get(expiry.basisId)?.row;
|
|
144
|
+
if (!basis || !["note", "demote", "settle"].includes(basis.op)) return undefined;
|
|
145
|
+
const target: PruneTarget = { id, rowHash: pruneRowHash(found.row), expiredAt: expiry.expiredAt, basis: { kind: basis.op as "note" | "demote" | "settle", id: basis.id, rowHash: pruneRowHash(basis) } };
|
|
146
|
+
const shell = { cutoff } as PruneRow;
|
|
147
|
+
return proof(target, shell, all) === "valid" ? target : undefined;
|
|
148
|
+
}
|
|
149
|
+
function placementFor(target: PruneTarget, all: Map<string, PruneSourceRow>, auth: PruneAuthorization): PrunePlacement {
|
|
150
|
+
if (!auth.policy) return "local";
|
|
151
|
+
const sources = [all.get(target.id)!, all.get(target.basis.id)!];
|
|
152
|
+
const verdicts = sources.map(source => auth.policy!.classify(structuredClone(source)));
|
|
153
|
+
guard(verdicts.every(value => ["public", "private", "unknown"].includes(value)));
|
|
154
|
+
return sources.every(source => source.placement === "synced" && source.publicWitness) && verdicts.every(verdict => verdict === "public") ? "synced" : "local";
|
|
155
|
+
}
|
|
156
|
+
function attest(row: PruneRow, auth: PruneAuthorization, expectedRevision: string): void {
|
|
157
|
+
guard(policyRevision(auth) === expectedRevision);
|
|
158
|
+
if (row.placement === "synced") guard(!!auth.policy && auth.policy.attest({ generation: row.generation, project: auth.project, payloadHash: prunePayloadHash(row), policyRevision: expectedRevision }) === true);
|
|
159
|
+
guard(policyRevision(auth) === expectedRevision);
|
|
160
|
+
}
|
|
161
|
+
function sealPlan(plan: Omit<PrunePlan, "seal">): PrunePlan { return { ...plan, seal: hashJSON(plan) }; }
|
|
162
|
+
function checkPlanSize(plan: PrunePlan): boolean { return bytes(plan) <= PRUNE_PLAN_MAX_BYTES && plan.rows.length <= PRUNE_PLAN_MAX_ROWS && plan.counts.selected <= PRUNE_PLAN_MAX_TARGETS; }
|
|
163
|
+
|
|
164
|
+
export function planPrune(port: PruneStorePort, input: { cutoff: string; targets?: readonly string[] }, auth: PruneAuthorization): PrunePlan {
|
|
165
|
+
return protectedCall(() => {
|
|
166
|
+
authenticate(port, auth); guard(utc(input.cutoff) && input.cutoff <= new Date(port.now()).toISOString());
|
|
167
|
+
const explicit = input.targets !== undefined;
|
|
168
|
+
if (explicit) guard(Array.isArray(input.targets) && input.targets!.length >= 1 && input.targets!.length <= PRUNE_PLAN_MAX_TARGETS && input.targets!.every(fullId) && new Set(input.targets).size === input.targets!.length);
|
|
169
|
+
const snapshot = port.snapshot(), all = sources(snapshot), already = new Set(snapshot.pruned ?? []);
|
|
170
|
+
const ids = explicit ? [...input.targets!] : snapshot.expiries.map(expiry => expiry.targetId).filter(id => !already.has(id));
|
|
171
|
+
const candidates: { target: PruneTarget; placement: PrunePlacement }[] = [];
|
|
172
|
+
for (const id of [...new Set(ids)].sort()) {
|
|
173
|
+
const target = targetFor(id, snapshot, all, input.cutoff, auth.project);
|
|
174
|
+
if (!target || already.has(id)) { if (explicit) fail(); else continue; }
|
|
175
|
+
guard(target !== undefined);
|
|
176
|
+
candidates.push({ target, placement: placementFor(target, all, auth) });
|
|
177
|
+
}
|
|
178
|
+
const currentPolicy = policyRevision(auth), selector = port.selectorRevision(), at = new Date(port.now()).toISOString();
|
|
179
|
+
const build = (selected: typeof candidates): PrunePlan => {
|
|
180
|
+
const rows: PrunePlan["rows"] = [];
|
|
181
|
+
for (const placement of ["local", "synced"] as const) {
|
|
182
|
+
const targets = selected.filter(item => item.placement === placement).map(item => item.target);
|
|
183
|
+
for (let index = 0; index < targets.length; index += PRUNE_ROW_MAX_TARGETS) {
|
|
184
|
+
const body: Omit<PruneRow, "id"> = { schema: "agent-mem.prune/v1", op: "prune", generation: port.generation, at, machine: port.machine, actor: "person", placement, cutoff: input.cutoff, targets: targets.slice(index, index + PRUNE_ROW_MAX_TARGETS) };
|
|
185
|
+
const payload = validatePruneRow({ ...body, id: pruneId(body) }); rows.push({ placement, payload });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return sealPlan({ schema: "agent-mem.prune-plan/v1", generation: port.generation, selectorRevision: selector, project: auth.project, policyRevision: currentPolicy, rows,
|
|
189
|
+
counts: { eligible: candidates.length, selected: selected.length, local: selected.filter(item => item.placement === "local").length, synced: selected.filter(item => item.placement === "synced").length }, hasMore: selected.length < candidates.length });
|
|
190
|
+
};
|
|
191
|
+
let selected = candidates.slice(0, PRUNE_PLAN_MAX_TARGETS), plan = build(selected);
|
|
192
|
+
while (!checkPlanSize(plan) && selected.length) { if (explicit) fail(); selected = selected.slice(0, -1); plan = build(selected); }
|
|
193
|
+
guard(checkPlanSize(plan));
|
|
194
|
+
for (const row of plan.rows) attest(row.payload, auth, currentPolicy);
|
|
195
|
+
guard(port.selectorRevision() === selector && policyRevision(auth) === currentPolicy);
|
|
196
|
+
return plan;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function validatePrunePlan(input: unknown): PrunePlan {
|
|
201
|
+
return protectedCall(() => {
|
|
202
|
+
if (typeof input === "string") { guard(Buffer.byteLength(input, "utf8") <= PRUNE_PLAN_MAX_BYTES); input = JSON.parse(input); }
|
|
203
|
+
guard(object(input) && bytes(input) <= PRUNE_PLAN_MAX_BYTES);
|
|
204
|
+
const p = input as Record<string, unknown>;
|
|
205
|
+
guard(exact(p, ["schema", "generation", "selectorRevision", "project", "policyRevision", "rows", "counts", "hasMore", "seal"]));
|
|
206
|
+
guard(p.schema === "agent-mem.prune-plan/v1" && validGeneration(p.generation) && revision(p.selectorRevision) && typeof p.project === "string" && p.project.length > 0 && typeof p.policyRevision === "string" && p.policyRevision.length > 0 && typeof p.hasMore === "boolean" && sha(p.seal));
|
|
207
|
+
guard(Array.isArray(p.rows) && p.rows.length <= PRUNE_PLAN_MAX_ROWS && object(p.counts) && exact(p.counts, ["eligible", "selected", "local", "synced"]));
|
|
208
|
+
const counts = p.counts as Record<string, unknown>;
|
|
209
|
+
guard(Object.values(counts).every(value => Number.isSafeInteger(value) && Number(value) >= 0));
|
|
210
|
+
let total = 0, local = 0, previous = "";
|
|
211
|
+
const seen = new Set<string>();
|
|
212
|
+
for (const value of p.rows as unknown[]) {
|
|
213
|
+
guard(object(value) && exact(value, ["placement", "payload"]) && ["local", "synced"].includes(String(value.placement)));
|
|
214
|
+
const row = validatePruneRow(value.payload, p.generation as string, value.placement as PrunePlacement);
|
|
215
|
+
const order = `${row.placement === "local" ? "0" : "1"}:${row.targets[0]!.id}`; guard(order > previous); previous = order;
|
|
216
|
+
for (const target of row.targets) { guard(!seen.has(target.id)); seen.add(target.id); }
|
|
217
|
+
total += row.targets.length; if (row.placement === "local") local += row.targets.length;
|
|
218
|
+
}
|
|
219
|
+
guard(total <= PRUNE_PLAN_MAX_TARGETS && total === counts.selected && local === counts.local && total - local === counts.synced && Number(counts.eligible) >= total && p.hasMore === (Number(counts.eligible) > total));
|
|
220
|
+
const { seal, ...body } = p; guard(hashJSON(body) === seal);
|
|
221
|
+
return structuredClone(p) as unknown as PrunePlan;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
export function applyPrune(port: PruneStorePort, input: unknown, auth: PruneAuthorization): { completed: string[] } {
|
|
225
|
+
return protectedCall(() => {
|
|
226
|
+
authenticate(port, auth);
|
|
227
|
+
const plan = validatePrunePlan(input);
|
|
228
|
+
return port.withMutation(() => {
|
|
229
|
+
authenticate(port, auth);
|
|
230
|
+
guard(plan.generation === port.generation && plan.project === auth.project && plan.selectorRevision === port.selectorRevision() && plan.policyRevision === policyRevision(auth));
|
|
231
|
+
const verify = () => {
|
|
232
|
+
const snapshot = port.snapshot(), all = sources(snapshot), durable = new Set(snapshot.durablePruneIds ?? []);
|
|
233
|
+
for (const item of plan.rows) {
|
|
234
|
+
guard(item.payload.machine === port.machine);
|
|
235
|
+
if (durable.has(item.payload.id)) continue;
|
|
236
|
+
for (const target of item.payload.targets) {
|
|
237
|
+
const current = targetFor(target.id, snapshot, all, item.payload.cutoff, auth.project);
|
|
238
|
+
guard(!!current && canonicalJSON(current) === canonicalJSON(target) && placementFor(target, all, auth) === item.placement);
|
|
239
|
+
}
|
|
240
|
+
guard(item.payload.at <= new Date(port.now()).toISOString());
|
|
241
|
+
attest(item.payload, auth, plan.policyRevision);
|
|
242
|
+
}
|
|
243
|
+
guard(plan.selectorRevision === port.selectorRevision() && plan.policyRevision === policyRevision(auth));
|
|
244
|
+
return durable;
|
|
245
|
+
};
|
|
246
|
+
verify(); verify(); // A policy callback can mutate external state; re-read immutable proofs before effects.
|
|
247
|
+
port.persistPrunePlan(plan);
|
|
248
|
+
const completed: string[] = [];
|
|
249
|
+
for (const item of plan.rows) {
|
|
250
|
+
verify();
|
|
251
|
+
// Parsed complete bytes are not proof of a successful fsync; the backend must reconfirm durability.
|
|
252
|
+
port.appendPrune(item.payload, canonicalJSON(item.payload) + "\n", plan.seal);
|
|
253
|
+
completed.push(item.payload.id);
|
|
254
|
+
}
|
|
255
|
+
port.refresh();
|
|
256
|
+
return { completed };
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
package/src/memory/store.ts
CHANGED
|
@@ -5,6 +5,12 @@ import { isAbsolute, join, normalize } from "node:path";
|
|
|
5
5
|
import { isDenied } from "./platform.ts";
|
|
6
6
|
import { mutationDatabase } from "./mutation.ts";
|
|
7
7
|
import { memoryHome } from "./home.ts";
|
|
8
|
+
import { assertSharedV1Op } from "./ops-contract.ts";
|
|
9
|
+
import { generationDescriptor, generationDir, canonicalJSON, readProfileJSON, resolveProfile, type BoundProfile, type ProfileInput } from "./generation.ts";
|
|
10
|
+
import { GenerationIndex, explicitExpirySnapshot, GENERATION_TABLES } from "./generation-index.ts";
|
|
11
|
+
import { legacyEntries, type LegacyCheckpoint } from "./bridge.ts";
|
|
12
|
+
import type { PruneStorePort } from "./prune.ts";
|
|
13
|
+
import { isDeepStrictEqual } from "node:util";
|
|
8
14
|
|
|
9
15
|
/**
|
|
10
16
|
* #258: the hub's own memory. Append-only JSONL logs are the truth: `log/<machine>.jsonl` holds long-term entries and
|
|
@@ -174,10 +180,16 @@ export type PromotionScreen = (v: View) => "promote" | "keep" | "skip";
|
|
|
174
180
|
* `screen`: applied to every promotion. Without one, automatic promotions (a settlement, a held note whose replacement
|
|
175
181
|
* went) wait for a process that has one; a person's explicit promote goes through.
|
|
176
182
|
*/
|
|
177
|
-
export interface StoreOptions { home?: string; ttlMs?: number; now?: () => number; log?: (line: string) => void; screen?: PromotionScreen; deferPromotions?: boolean
|
|
183
|
+
export interface StoreOptions { home?: string; ttlMs?: number; now?: () => number; log?: (line: string) => void; screen?: PromotionScreen; deferPromotions?: boolean; profile?: ProfileInput;
|
|
184
|
+
}
|
|
178
185
|
|
|
186
|
+
const MIGRATION_BOOTSTRAP = Symbol("migration index bootstrap");
|
|
187
|
+
interface BootstrapBinding { token: typeof MIGRATION_BOOTSTRAP; profile: BoundProfile; limits: readonly LegacyCheckpoint[]; commit?: string }
|
|
179
188
|
export class MemoryStore {
|
|
180
189
|
readonly dir: string;
|
|
190
|
+
readonly profile: BoundProfile;
|
|
191
|
+
private readonly generationIndex?: GenerationIndex;
|
|
192
|
+
private readonly bootstrapOnly: boolean;
|
|
181
193
|
readonly machine: Machine;
|
|
182
194
|
private readonly db: Database;
|
|
183
195
|
private readonly mutationDb: Database;
|
|
@@ -186,8 +198,16 @@ export class MemoryStore {
|
|
|
186
198
|
private readonly log: (line: string) => void;
|
|
187
199
|
private readonly deferPromotions: boolean;
|
|
188
200
|
|
|
189
|
-
constructor(opts: StoreOptions = {}) {
|
|
190
|
-
|
|
201
|
+
constructor(opts: StoreOptions = {}, bootstrap?: BootstrapBinding) {
|
|
202
|
+
if (bootstrap && bootstrap.token !== MIGRATION_BOOTSTRAP) throw new Error("invalid migration bootstrap");
|
|
203
|
+
this.bootstrapOnly = bootstrap !== undefined;
|
|
204
|
+
const home = opts.home === undefined ? memoryHome() : join(opts.home, "memory");
|
|
205
|
+
this.profile = bootstrap?.profile ?? resolveProfile(home, opts.profile);
|
|
206
|
+
if (bootstrap && (this.profile.kind !== "generation" || !this.profile.generation
|
|
207
|
+
|| this.profile.dir !== generationDir(this.profile.home, this.profile.generation)
|
|
208
|
+
|| canonicalJSON(readProfileJSON(join(this.profile.dir, "generation.json"))) !== canonicalJSON(generationDescriptor(this.profile.generation)))) throw new Error("invalid migration profile binding");
|
|
209
|
+
// Shared-v1 preserves its historical lexical dir API; profile.home is still canonical for selector identity.
|
|
210
|
+
this.dir = this.profile.kind === "shared-v1" ? home : this.profile.dir;
|
|
191
211
|
mkdirSync(join(this.dir, "log"), { recursive: true, mode: 0o700 });
|
|
192
212
|
mkdirSync(join(this.dir, "local"), { recursive: true, mode: 0o700 });
|
|
193
213
|
chmodSync(this.dir, 0o700); // mkdir's mode applies only when it creates the directory
|
|
@@ -216,9 +236,24 @@ export class MemoryStore {
|
|
|
216
236
|
this.db = db;
|
|
217
237
|
this.mutationDb = mutationDatabase(this.dir);
|
|
218
238
|
try { chmodSync(join(this.dir, "index.db"), 0o600); } catch { /* best effort */ }
|
|
239
|
+
if (this.profile.kind === "generation") this.generationIndex = new GenerationIndex({ profile: this.profile, db, now: this.now,
|
|
240
|
+
apply: (line, source) => this.apply(line, source), withMutation: work => this.withMutation(work),
|
|
241
|
+
...(bootstrap ? { limits: bootstrap.limits } : {}), ...(bootstrap?.commit ? { commit: bootstrap.commit } : {}) });
|
|
219
242
|
this.refresh();
|
|
220
243
|
}
|
|
221
244
|
|
|
245
|
+
/** The host must authenticate person intent before authorizing plan/apply through this port. */
|
|
246
|
+
prunePort(): PruneStorePort {
|
|
247
|
+
if (this.bootstrapOnly || !this.generationIndex) throw new Error("durable prune requires an active generation handle");
|
|
248
|
+
return this.generationIndex.port(this.machine.id);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Full derived receipt for golden replay; raw debug data, never model evidence. */
|
|
252
|
+
derivedTables(): Record<string, unknown[]> {
|
|
253
|
+
const tables = this.db.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() as { name: string }[];
|
|
254
|
+
return Object.fromEntries(tables.map(({ name }) => [name, (this.db.query(`SELECT * FROM "${name.replaceAll('"', '""')}"`).all() as unknown[]).sort((a, b) => canonicalJSON(a).localeCompare(canonicalJSON(b)))]));
|
|
255
|
+
}
|
|
256
|
+
|
|
222
257
|
close(): void { this.db.close(); this.mutationDb.close(); }
|
|
223
258
|
|
|
224
259
|
/** A synchronous host-authorized mutation whose reads must not implicitly promote unrelated notes. */
|
|
@@ -271,8 +306,11 @@ export class MemoryStore {
|
|
|
271
306
|
}
|
|
272
307
|
|
|
273
308
|
private appendBody(body: Omit<Entry, "id">, local: boolean): string {
|
|
309
|
+
if (this.bootstrapOnly) throw new Error("migration bootstrap cannot author memory");
|
|
310
|
+
assertSharedV1Op(body.op);
|
|
274
311
|
// Lock the authoritative log independently: a busy derived index must never prevent an ordinary append.
|
|
275
312
|
return this.withMutation(() => {
|
|
313
|
+
this.generationIndex?.assertAppendable(local, this.machine.id);
|
|
276
314
|
const id = entryId(body);
|
|
277
315
|
appendFileSync(join(this.dir, local ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
|
|
278
316
|
// The line is durable even if indexing fails; the next refresh catches up from the authoritative log.
|
|
@@ -291,10 +329,13 @@ export class MemoryStore {
|
|
|
291
329
|
* the same id, so a second import writes nothing. Returns the id and whether it was new.
|
|
292
330
|
*/
|
|
293
331
|
importNote(n: NoteInput, at: string): { id: string; added: boolean } {
|
|
332
|
+
if (this.bootstrapOnly) throw new Error("migration bootstrap cannot import memory");
|
|
333
|
+
assertSharedV1Op(n.op);
|
|
294
334
|
// A fixed machine: the same observation imported on two machines is one entry once they sync.
|
|
295
335
|
const body = { schema: MEMORY_SCHEMA, at, machine: "claude-mem", ...n, text: n.text.slice(0, MAX_TEXT), ...(n.title ? { title: n.title.slice(0, MAX_TITLE) } : {}) } as Omit<Entry, "id">;
|
|
296
336
|
const id = entryId(body);
|
|
297
337
|
return this.withMutation(() => {
|
|
338
|
+
this.generationIndex?.assertAppendable(n.tier === "short", this.machine.id);
|
|
298
339
|
// The source tuple also recognizes legacy ids whose nodes depended on the checkout or extractor version.
|
|
299
340
|
const known = this.db.query("SELECT id FROM entries WHERE id = ? OR (source = 'claude-mem' AND project = ? AND at = ? AND text = ?)").get(id, n.project, at, n.text.slice(0, MAX_TEXT)) as { id: string } | null;
|
|
300
341
|
if (known) return { id: known.id, added: false };
|
|
@@ -378,6 +419,7 @@ export class MemoryStore {
|
|
|
378
419
|
*/
|
|
379
420
|
promote(id: string, actor: string, project?: string, auto = false): string | undefined {
|
|
380
421
|
const v = this.get(id, project);
|
|
422
|
+
if (v && this.generationIndex && !this.generationIndex.own(v.id)) return undefined;
|
|
381
423
|
if (!v || v.tier !== "short") return v?.id;
|
|
382
424
|
if (v.supersededBy) return undefined;
|
|
383
425
|
// Screened here, whatever asked: a note saved under an older policy must not reach the synced log under a newer one.
|
|
@@ -406,11 +448,15 @@ export class MemoryStore {
|
|
|
406
448
|
}
|
|
407
449
|
private promoteSettled(task: string, actor: string): number {
|
|
408
450
|
// Only the notes held for promotion (no deadline): one saved short-term after the settlement stays short-term.
|
|
409
|
-
const moving = this.views().filter((v) => v.task === task && v.tier === "short" && v.local && !v.supersededBy && v.expiresAt === null && PROMOTED_KINDS.includes(v.kind));
|
|
451
|
+
const moving = this.views().filter((v) => (!this.generationIndex || this.generationIndex.own(v.id)) && v.task === task && v.tier === "short" && v.local && !v.supersededBy && v.expiresAt === null && PROMOTED_KINDS.includes(v.kind));
|
|
410
452
|
return moving.filter((v) => this.promote(v.id, actor, undefined, true) !== undefined).length;
|
|
411
453
|
}
|
|
412
|
-
/** Whether a task was
|
|
413
|
-
settled(task: string
|
|
454
|
+
/** Whether a task was settled in this profile. Reconciliation may opt into bridged legacy settlements. */
|
|
455
|
+
settled(task: string, options: { includeLegacy?: boolean } = {}): boolean {
|
|
456
|
+
return !!this.db.query(this.generationIndex && !options.includeLegacy
|
|
457
|
+
? "SELECT 1 FROM ops JOIN generation_rows ON generation_rows.id=ops.id WHERE op='settle' AND target=? AND own=1"
|
|
458
|
+
: "SELECT 1 FROM ops WHERE op = 'settle' AND target = ?").get(task);
|
|
459
|
+
}
|
|
414
460
|
/** Whether any entry was saved against the task. */
|
|
415
461
|
hasTask(task: string): boolean { return !!this.db.query("SELECT 1 FROM entries WHERE task = ?").get(task); }
|
|
416
462
|
private promoting = false;
|
|
@@ -423,9 +469,12 @@ export class MemoryStore {
|
|
|
423
469
|
if (this.promoting || this.deferPromotions) return;
|
|
424
470
|
this.promoting = true;
|
|
425
471
|
try {
|
|
426
|
-
const settled = new Set((this.db.query(
|
|
472
|
+
const settled = new Set((this.db.query(this.generationIndex
|
|
473
|
+
? "SELECT target FROM ops JOIN generation_rows ON generation_rows.id=ops.id WHERE op='settle' AND own=1"
|
|
474
|
+
: "SELECT target FROM ops WHERE op = 'settle'").all() as { target: string }[]).map((r) => r.target));
|
|
427
475
|
if (!settled.size) return;
|
|
428
476
|
for (const v of this.views()) {
|
|
477
|
+
if (this.generationIndex && !this.generationIndex.own(v.id)) continue; // The bridge never auto-promotes legacy inputs.
|
|
429
478
|
if (v.task && settled.has(v.task) && v.tier === "short" && v.local && !v.supersededBy && v.expiresAt === null && PROMOTED_KINDS.includes(v.kind)) {
|
|
430
479
|
try { this.promote(v.id, "hub", undefined, true); } catch { /* fail-open: tried again at the next read */ }
|
|
431
480
|
}
|
|
@@ -435,6 +484,7 @@ export class MemoryStore {
|
|
|
435
484
|
|
|
436
485
|
/** Index what the segments gained since the last read. A line still being written (no newline yet) waits for the next read. */
|
|
437
486
|
refresh(attempt = 0): void {
|
|
487
|
+
if (this.generationIndex) { this.generationIndex.refresh(); return; }
|
|
438
488
|
const files = [...this.segments("log"), ...this.segments("local")];
|
|
439
489
|
const seen = this.shape(files); // taken before reading: a line written meanwhile changes it again
|
|
440
490
|
// One statement, one snapshot: an offset and a head from two different commits would look like a rewrite.
|
|
@@ -496,6 +546,7 @@ export class MemoryStore {
|
|
|
496
546
|
|
|
497
547
|
/** Drop the index and read every segment again: the same rows as the incremental index. */
|
|
498
548
|
rebuild(): void {
|
|
549
|
+
if (this.generationIndex) { this.generationIndex.rebuild(); return; }
|
|
499
550
|
// One transaction: no reader sees the index empty, and no other process commits an offset in between.
|
|
500
551
|
this.db.transaction(() => { for (const t of ["segments", "heads", "entries", "ops", "edges", "goals"]) this.db.run(`DELETE FROM ${t}`); this.refresh(); }).immediate();
|
|
501
552
|
}
|
|
@@ -549,17 +600,29 @@ export class MemoryStore {
|
|
|
549
600
|
|
|
550
601
|
private viewRows(expired: boolean, selection: Filter | undefined, expiredOnly: boolean): View[] {
|
|
551
602
|
const now = this.now(), selecting = selection !== undefined || expiredOnly;
|
|
603
|
+
const pruned = this.generationIndex ? new Set((this.db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id)) : undefined;
|
|
604
|
+
if (this.generationIndex && pruned) this.generationIndex.pruned = pruned;
|
|
552
605
|
// Relations may cross projects and promoted copies: fold their small metadata globally, never filtered bodies.
|
|
553
606
|
const rows = this.db.query(selecting
|
|
554
607
|
? "SELECT id, at, tier, kind, task, expires, supersedes, promoted_from FROM entries"
|
|
555
608
|
: "SELECT * FROM entries").all() as Record<string, string | null>[];
|
|
556
|
-
const ops = this.db.query("SELECT op, at, target, by FROM ops ORDER BY at, id").all() as { op: string; at: string; target: string; by: string | null }[];
|
|
609
|
+
const ops = this.db.query("SELECT id, op, at, target, by FROM ops ORDER BY at, id").all() as { id: string; op: string; at: string; target: string; by: string | null }[];
|
|
610
|
+
const realms = this.generationIndex ? new Map((this.db.query("SELECT id,own,legacy FROM generation_rows").all() as { id: string; own: number; legacy: number }[]).map(row => [row.id, row])) : undefined;
|
|
611
|
+
const born = realms ? new Set([...realms.values()].filter(row => row.own).map(row => row.id)) : undefined;
|
|
557
612
|
const forgotten = new Set(ops.filter((o) => o.op === "forget").map((o) => o.target));
|
|
558
613
|
// Deadlines were fixed when the entries were written, so every process (whatever its own TTL) prunes the same rows.
|
|
559
614
|
const deadline = (o: { at: string; by: string | null }) => o.by && !Number.isNaN(Date.parse(o.by)) ? o.by : new Date(Date.parse(o.at) + this.ttl).toISOString();
|
|
560
615
|
const demoted = new Map(ops.filter((o) => o.op === "demote").map((o) => [o.target, deadline(o)]));
|
|
561
616
|
const settled = new Map<string, string>();
|
|
562
|
-
|
|
617
|
+
const settlementKey = (id: string, task: string) => born ? `${born.has(id)}:${task}` : task;
|
|
618
|
+
for (const o of ops) if (o.op === "settle") {
|
|
619
|
+
const source = realms?.get(o.id);
|
|
620
|
+
const keys = !realms ? [o.target] : [
|
|
621
|
+
...(source?.own ? [`true:${o.target}`] : []),
|
|
622
|
+
...(source?.legacy || !source?.own ? [`false:${o.target}`] : []),
|
|
623
|
+
];
|
|
624
|
+
for (const key of keys) if (!settled.has(key)) settled.set(key, deadline(o));
|
|
625
|
+
}
|
|
563
626
|
const superseded = new Map<string, string>(), supersededAt = new Map<string, string>();
|
|
564
627
|
// Relations from ops and from notes' own `supersedes` fold together by write time (then id): the latest intent wins.
|
|
565
628
|
const relate = (old: string, newer: string, when: string) => { if (!(when < (supersededAt.get(old) ?? ""))) { superseded.set(old, newer); supersededAt.set(old, when); } };
|
|
@@ -572,15 +635,17 @@ export class MemoryStore {
|
|
|
572
635
|
// A decision, contract or fail of a settled task not yet copied to long-term waits for its promotion, never expires
|
|
573
636
|
// first; a note saved short-term after the settlement carries its own deadline and stays short-term.
|
|
574
637
|
// A superseded one is held too: if its replacement goes, it shows again, and a rebuild must find it the same way.
|
|
575
|
-
|
|
638
|
+
const taskDeadline = r.task ? settled.get(settlementKey(id, r.task)) : undefined;
|
|
639
|
+
if (r.tier === "short" && !demoted.has(id) && !r.expires && taskDeadline && PROMOTED_KINDS.includes(r.kind as Kind)) return null;
|
|
576
640
|
// A deadline written into the note (no task, a late note, an explicit short horizon) is its own, whatever the task does.
|
|
577
|
-
return demoted.get(id) ?? r.expires ?? (r.task ?
|
|
641
|
+
return demoted.get(id) ?? r.expires ?? (r.task ? taskDeadline ?? new Date(Date.parse(r.at!) + UNSETTLED_MS).toISOString() : new Date(Date.parse(r.at!) + this.ttl).toISOString());
|
|
578
642
|
};
|
|
579
643
|
// A newer entry that was forgotten, expired or pruned no longer replaces anything: the older one shows again. A
|
|
580
644
|
// promoted source lives on as its copy.
|
|
581
645
|
const byId = new Map(rows.map((r) => [r.id!, r]));
|
|
582
646
|
const copyOf = new Map(rows.filter((r) => r.promoted_from).map((r) => [r.promoted_from!, r.id!]));
|
|
583
647
|
const gone = (id: string): boolean => {
|
|
648
|
+
if (pruned?.has(id)) return true;
|
|
584
649
|
const r = byId.get(id);
|
|
585
650
|
if (!r || forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from))) return true;
|
|
586
651
|
const copy = copyOf.get(id);
|
|
@@ -616,7 +681,7 @@ export class MemoryStore {
|
|
|
616
681
|
const hidden: string[] = [], expiredIds: string[] = [];
|
|
617
682
|
for (const r of rows) {
|
|
618
683
|
const id = r.id!;
|
|
619
|
-
if (forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id)) { hidden.push(id); continue; }
|
|
684
|
+
if (pruned?.has(id) || forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id)) { hidden.push(id); continue; }
|
|
620
685
|
const at = expiry(r);
|
|
621
686
|
if (at !== null && Date.parse(at) <= now) expiredIds.push(id);
|
|
622
687
|
}
|
|
@@ -644,7 +709,7 @@ export class MemoryStore {
|
|
|
644
709
|
for (const r of candidates) {
|
|
645
710
|
const id = r.id!;
|
|
646
711
|
// A forgotten entry is gone, and so is its promoted copy's source or copy: forgetting either forgets both.
|
|
647
|
-
if (forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id)) continue;
|
|
712
|
+
if (pruned?.has(id) || forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id)) continue;
|
|
648
713
|
const tier: Tier = demoted.has(id) ? "short" : r.tier as Tier;
|
|
649
714
|
const expiresAt = expiry(r);
|
|
650
715
|
if (!expired && expiresAt !== null && Date.parse(expiresAt) <= now) continue;
|
|
@@ -660,6 +725,7 @@ export class MemoryStore {
|
|
|
660
725
|
* the clock passes it once more. A durable prune entry in the log would close that gap.
|
|
661
726
|
*/
|
|
662
727
|
prune(): number {
|
|
728
|
+
if (this.generationIndex) return 0; // Generation raw rows remain authoritative; only explicit durable prune masks them.
|
|
663
729
|
const now = this.now();
|
|
664
730
|
const expired = this.views(true, undefined, true).filter((v) => v.expiresAt !== null && Date.parse(v.expiresAt) <= now);
|
|
665
731
|
// A copy that expires (it was demoted) takes its whole hidden promotion ancestry with it, or an ancestor would reappear.
|
|
@@ -673,6 +739,7 @@ export class MemoryStore {
|
|
|
673
739
|
|
|
674
740
|
/** Index deferred writes before any note, goal or suggestion read. */
|
|
675
741
|
private ensureFresh(): void {
|
|
742
|
+
if (this.generationIndex) { this.generationIndex.refresh(); return; }
|
|
676
743
|
// Another process (a CLI, another project's hub, a sync) may have written since this one last read the segments.
|
|
677
744
|
if (this.stale || this.shape() !== this.seen) try { this.stale = false; this.refresh(); } catch { this.stale = true; /* still busy: the next read tries again */ }
|
|
678
745
|
}
|
|
@@ -702,12 +769,13 @@ export class MemoryStore {
|
|
|
702
769
|
id = id.replace(/^mem:/, ""); // briefs and recall print `mem:<id>`
|
|
703
770
|
const all = this.live().filter((v) => !project || v.project === project || v.scope === "shared");
|
|
704
771
|
const pick = <T extends { id: string }>(rows: T[]) => rows.find((v) => v.id === id) ?? (id.length >= 6 ? (() => { const m = rows.filter((v) => v.id.startsWith(id)); return m.length === 1 ? m[0] : undefined; })() : undefined);
|
|
772
|
+
if (this.generationIndex?.pruned.has(id)) return undefined;
|
|
705
773
|
const found = pick(all);
|
|
706
774
|
if (found) return found;
|
|
707
775
|
// A short-term id an agent kept after its task was approved names the promoted copy now, here or on another machine
|
|
708
776
|
// that has only the synced copy (which names its source).
|
|
709
777
|
let at = pick(this.db.query("SELECT id FROM entries UNION SELECT promoted_from FROM entries WHERE promoted_from IS NOT NULL").all() as { id: string }[])?.id;
|
|
710
|
-
for (let copy = at; copy; copy = (this.db.query("SELECT id FROM entries WHERE promoted_from = ?").get(copy) as { id: string } | null)?.id) at = copy;
|
|
778
|
+
for (let copy = at; copy; copy = (this.db.query("SELECT id FROM entries WHERE promoted_from = ?").get(copy) as { id: string } | null)?.id) { if (this.generationIndex?.pruned.has(copy)) return undefined; at = copy; }
|
|
711
779
|
return at ? all.find((v) => v.id === at) : undefined;
|
|
712
780
|
}
|
|
713
781
|
|
|
@@ -742,12 +810,15 @@ export class MemoryStore {
|
|
|
742
810
|
|
|
743
811
|
/** The neighbourhood of one node for the dashboard: up to `hops` (1 or 2) steps, at most `cap` nodes. */
|
|
744
812
|
graph(project: string, start: Node, hops = 1, cap = 200): Graph {
|
|
813
|
+
this.ensureFresh();
|
|
814
|
+
if (start.startsWith("mem:") && this.generationIndex?.pruned.has(start.slice(4))) return { nodes: [], edges: [] };
|
|
745
815
|
const live = new Map(this.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }).map((v) => [`mem:${v.id}`, v]));
|
|
746
816
|
const nodes = new Map<Node, Graph["nodes"][number]>(), edges = new Map<string, Graph["edges"][number]>();
|
|
747
817
|
const goals = new Map(this.goals(project).map((g) => [`goal:${g.id}`, g]));
|
|
748
818
|
const suggestions = this.suggestions().filter((x) => x.status !== "rejected");
|
|
749
819
|
const taskNode = (key: string) => `task:${key}`; // a link's task key is the task node's own (id and proposal time)
|
|
750
820
|
const node = (id: Node) => {
|
|
821
|
+
if (id.startsWith("mem:") && this.generationIndex?.pruned.has(id.slice(4))) return false;
|
|
751
822
|
if (nodes.has(id) || nodes.size >= cap) return nodes.has(id);
|
|
752
823
|
const v = live.get(id), g = goals.get(id);
|
|
753
824
|
nodes.set(id, v ? { id, type: "memory", label: (v.title ?? v.text).slice(0, 60), kind: v.kind, tier: v.tier } : g ? { id, type: "goal", label: g.title.slice(0, 60), kind: g.state } : { id, type: id.slice(0, id.indexOf(":")), label: id.slice(id.indexOf(":") + 1) });
|
|
@@ -882,10 +953,18 @@ export class MemoryStore {
|
|
|
882
953
|
});
|
|
883
954
|
}
|
|
884
955
|
|
|
956
|
+
/** Person-local diagnostic receipt; malformed legacy bodies are never included. */
|
|
957
|
+
bridgeStatus(): { skippedRows: number } | undefined {
|
|
958
|
+
return this.generationIndex ? { skippedRows: this.generationIndex.skippedRows() } : undefined;
|
|
959
|
+
}
|
|
960
|
+
|
|
885
961
|
/** What the segments hold, for `ahub memory` and the dashboard header. */
|
|
886
962
|
counts(project?: string): { short: number; long: number; segments: number; machine: Machine } {
|
|
887
963
|
const all = this.list({ ...(project ? { project } : {}), limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true });
|
|
888
|
-
|
|
964
|
+
const segments = this.generationIndex
|
|
965
|
+
? (this.db.query("SELECT COUNT(*) AS n FROM generation_segments").get() as { n: number }).n
|
|
966
|
+
: this.segments("log").length + this.segments("local").length;
|
|
967
|
+
return { short: all.filter((v) => v.tier === "short").length, long: all.filter((v) => v.tier === "long").length, segments, machine: this.machine };
|
|
889
968
|
}
|
|
890
969
|
|
|
891
970
|
/** The index's rows in a fixed order: what a rebuild must reproduce. */
|
|
@@ -893,3 +972,33 @@ export class MemoryStore {
|
|
|
893
972
|
return JSON.stringify(["entries", "ops", "edges", "goals"].map((t) => this.db.query(`SELECT * FROM ${t} ORDER BY 1, 2`).all()));
|
|
894
973
|
}
|
|
895
974
|
}
|
|
975
|
+
|
|
976
|
+
/** Migration builds only its generation-owned index over exact captured source prefixes. */
|
|
977
|
+
export function buildGenerationIndex(profile: BoundProfile, sources: readonly LegacyCheckpoint[], legacyCommit?: string): void {
|
|
978
|
+
const store = new MemoryStore({ deferPromotions: true }, { token: MIGRATION_BOOTSTRAP, profile, limits: sources, ...(legacyCommit ? { commit: legacyCommit } : {}) });
|
|
979
|
+
try { const before = store.derivedTables(); store.rebuild(); if (!isDeepStrictEqual(before, store.derivedTables())) throw new Error("generation rebuild differs from incremental index"); }
|
|
980
|
+
finally { store.close(); }
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** Person-only migration receipt; this inspector reads raw rows without opening a store or writing any index. */
|
|
984
|
+
export function summarizeRawExpiry(rawLines: string[], now: number, tailBytes: number): import("./migrate.ts").MigrationCounts {
|
|
985
|
+
const bytes = Buffer.from(rawLines.filter(Boolean).join("\n") + (rawLines.length ? "\n" : ""));
|
|
986
|
+
let malformed = 0;
|
|
987
|
+
const rows = legacyEntries([{ source: "legacy-local", path: "log/inspection.jsonl", bytes, offset: bytes.length, hash: createHash("sha256").update(bytes).digest("hex"), local: false, tailBytes: 0 }], { onMalformed: () => malformed++ });
|
|
988
|
+
const byOp: Record<string, number> = {}, byTier: Record<string, number> = {};
|
|
989
|
+
for (const row of rows) { byOp[row.op] = (byOp[row.op] ?? 0) + 1; if (row.op === "note") byTier[row.tier] = (byTier[row.tier] ?? 0) + 1; }
|
|
990
|
+
// Explicit proof eligibility is shared with prune planning; implicit TTL is reported but never used as a prune proof.
|
|
991
|
+
const located = rows.map(row => ({ row, placement: "local" as const, publicWitness: false }));
|
|
992
|
+
const explicit = explicitExpirySnapshot(located).filter(e => Date.parse(e.expiredAt) <= now);
|
|
993
|
+
const proved = new Map(explicitExpirySnapshot(located).map(e => [e.targetId, e.expiredAt]));
|
|
994
|
+
const settled = new Set(rows.flatMap(e => e.op === "settle" ? [e.target] : []));
|
|
995
|
+
const expiredRows = rows.filter(row => {
|
|
996
|
+
if (row.op !== "note") return false;
|
|
997
|
+
const fixed = proved.get(row.id);
|
|
998
|
+
if (fixed) return Date.parse(fixed) <= now;
|
|
999
|
+
if (row.tier !== "short" || row.task && settled.has(row.task) && PROMOTED_KINDS.includes(row.kind)) return false;
|
|
1000
|
+
// Diagnostic only, at the CLI's default TTL. Such derived expiry is never a durable-prune witness.
|
|
1001
|
+
return Date.parse(row.at) + (row.task ? UNSETTLED_MS : DEFAULT_TTL_MS) <= now;
|
|
1002
|
+
}).length;
|
|
1003
|
+
return { validRows: rows.length, expiredRows, eligibleRows: explicit.length, skippedRows: malformed, tailBytes, byOp, byTier };
|
|
1004
|
+
}
|
package/src/memory/sync.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { memoryGitArgv, memoryGitEnv, processLiveness, processSignature } from "./platform.ts";
|
|
4
4
|
import { acquireMutation } from "./mutation.ts";
|
|
5
5
|
import type { MemoryStore } from "./store.ts";
|
|
6
|
+
import { syncGenerationMemory } from "./generation-sync.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* #258 phase 2: hub memory between a person's machines through a git remote they configure. The memory directory is a
|
|
@@ -29,6 +30,26 @@ async function git(dir: string, args: string[], signal?: AbortSignal, ms = STEP_
|
|
|
29
30
|
} finally { clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill); }
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
export { git as syncGit };
|
|
34
|
+
|
|
35
|
+
/** Generation-only process isolation; shared-v1 above retains its pre-P9 spawn and environment contract. */
|
|
36
|
+
export async function generationSyncGit(dir: string, args: string[], signal?: AbortSignal, ms = STEP_MS): Promise<{ code: number; out: string; err: string }> {
|
|
37
|
+
if (signal?.aborted) return { code: -1, out: "", err: "stopped" };
|
|
38
|
+
// A fixed child-only umask keeps Git-created metadata private without changing the daemon's process umask.
|
|
39
|
+
// Args are positional shell parameters, never interpolated or evaluated as shell code.
|
|
40
|
+
const p = Bun.spawn(["/bin/sh", "-c", 'umask 077; exec "$@"', "amem-git", ...memoryGitArgv(["-C", dir, ...args], { credentials: true })], { stdout: "pipe", stderr: "pipe", stdin: "ignore", env: memoryGitEnv(process.env, { repositoryIsolation: true }) });
|
|
41
|
+
// SIGTERM first, so git removes its lock files; SIGKILL only if it is still there after a grace period.
|
|
42
|
+
let hard: ReturnType<typeof setTimeout> | undefined;
|
|
43
|
+
const kill = () => { p.kill("SIGTERM"); hard ??= setTimeout(() => p.kill("SIGKILL"), 2_000); };
|
|
44
|
+
const timer = setTimeout(kill, ms);
|
|
45
|
+
signal?.addEventListener("abort", kill, { once: true });
|
|
46
|
+
try {
|
|
47
|
+
const [code, out, err] = await Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
48
|
+
return { code, out: out.trim(), err: err.trim() };
|
|
49
|
+
} finally { clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
32
53
|
/** GitHub remotes are asked through `gh`; any other host, or a `gh` that cannot answer, is unknown. */
|
|
33
54
|
export async function remoteVisibility(url: string): Promise<Visibility> {
|
|
34
55
|
const repo = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(url)?.[1];
|
|
@@ -72,6 +93,7 @@ export interface SyncOptions {
|
|
|
72
93
|
}
|
|
73
94
|
|
|
74
95
|
export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Promise<SyncResult> {
|
|
96
|
+
if (store.profile.kind === "generation") return syncGenerationMemory(store, opts);
|
|
75
97
|
const dir = store.dir;
|
|
76
98
|
const fail = (error: string): SyncResult => ({ ok: false, pushed: false, entries: 0, error });
|
|
77
99
|
if (opts.signal?.aborted) return fail("stopped");
|