@staix/agent-mem 0.2.0 → 0.3.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.
@@ -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
+ }
@@ -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
- this.dir = opts.home === undefined ? memoryHome() : join(opts.home, "memory");
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
454
  /** Whether a task was ever settled, for the hub's reconciliation of approvals a crash cut off before their settle entry. */
413
- settled(task: string): boolean { return !!this.db.query("SELECT 1 FROM ops WHERE op = 'settle' AND target = ?").get(task); }
455
+ settled(task: string): boolean {
456
+ return !!this.db.query(this.generationIndex
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("SELECT target FROM ops WHERE op = 'settle'").all() as { target: string }[]).map((r) => r.target));
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
- for (const o of ops) if (o.op === "settle" && !settled.has(o.target)) settled.set(o.target, deadline(o));
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
- if (r.tier === "short" && !demoted.has(id) && !r.expires && r.task && settled.has(r.task) && PROMOTED_KINDS.includes(r.kind as Kind)) return null;
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 ? settled.get(r.task) ?? new Date(Date.parse(r.at!) + UNSETTLED_MS).toISOString() : new Date(Date.parse(r.at!) + this.ttl).toISOString());
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
- return { short: all.filter((v) => v.tier === "short").length, long: all.filter((v) => v.tier === "long").length, segments: this.segments("log").length + this.segments("local").length, machine: this.machine };
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
+ }
@@ -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");