@staix/agent-mem 0.19.0 → 0.20.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.
@@ -26,7 +26,7 @@ export interface SetupDriftReport {
26
26
  }
27
27
 
28
28
  interface ReceiptChange { key: string; path: string; before: ConfigImage; after: ConfigImage }
29
- interface Receipt { version: 1; target: SetupTarget; phase: "prepared" | "applied"; changes: ReceiptChange[] }
29
+ interface Receipt { version: 1 | 2; target: SetupTarget; phase: "prepared" | "applied"; changes: ReceiptChange[] }
30
30
  const JOURNAL_BYTES = 12 * SETUP_CONFIG_BYTES;
31
31
  // ponytail: at most 32 MiB of receipt/config input per doctor call; use metadata-only receipts before widening.
32
32
  const READ_BYTES = 32 * 1024 * 1024;
@@ -60,14 +60,14 @@ function parseImage(value: unknown): ConfigImage {
60
60
  }
61
61
 
62
62
  function parseReceipt(value: unknown, target: SetupTarget, expectedPaths: readonly string[]): Receipt {
63
- if (!plain(value) || value.version !== 1 || value.target !== target || !/^[a-f0-9]{64}$/.test(String(value.intent)) || !["applied", "prepared"].includes(String(value.phase)) || !Array.isArray(value.changes) || value.changes.length !== expectedPaths.length && !(target === "jcode" && value.changes.length === 1)) throw Error("invalid receipt");
63
+ if (!plain(value) || (value.version !== 1 && value.version !== 2) || value.target !== target || !/^[a-f0-9]{64}$/.test(String(value.intent)) || !["applied", "prepared"].includes(String(value.phase)) || !Array.isArray(value.changes) || value.changes.length !== expectedPaths.length && !(target === "jcode" && value.changes.length === 1)) throw Error("invalid receipt");
64
64
  const changes: ReceiptChange[] = [];
65
65
  for (let index = 0; index < value.changes.length; index++) {
66
66
  const item: unknown = value.changes[index];
67
67
  if (!plain(item) || item.key !== `${target}:${index}` || typeof item.path !== "string" || !isAbsolute(item.path) || item.path.length > 4096 || item.path.includes("\0")) throw Error("invalid receipt");
68
68
  changes.push({ key: `${target}:${index}`, path: item.path, before: parseImage(item.before), after: parseImage(item.after) });
69
69
  }
70
- return { version: 1, target, phase: value.phase as Receipt["phase"], changes };
70
+ return { version: value.version as 1 | 2, target, phase: value.phase as Receipt["phase"], changes };
71
71
  }
72
72
 
73
73
  function workspaceRoot(cwd: string): string | undefined {
@@ -1,6 +1,6 @@
1
1
  import type { ConfigImage } from "./setup-owned.ts";
2
2
  import { createJsonMcpCodec } from "./setup-json.ts";
3
- import { setTomlPath } from "./setup-toml.ts";
3
+ import { restoreRemovedItems, setTomlPath } from "./setup-toml.ts";
4
4
 
5
5
  const codec = createJsonMcpCodec({ canonicalKey: "servers", aliases: ["mcpServers"], label: "Jcode" });
6
6
  export function readJcodeMcp(image: ConfigImage): unknown { return codec.read(image); }
@@ -27,13 +27,14 @@ function commands(value: unknown): string[] {
27
27
  throw Error("Jcode hook commands must be a string or an array of strings");
28
28
  }
29
29
  /** Adds `command` to each capture event, keeping any existing user command ahead of it (array form). */
30
- export function putJcodeHooks(before: ConfigImage, command: string): ConfigImage {
30
+ export function putJcodeHooks(before: ConfigImage, command: string, previous: readonly string[] = []): ConfigImage {
31
31
  let text = tomlText(before);
32
32
  const hooks = hooksTable(text) ?? {};
33
33
  for (const event of JCODE_CAPTURE_EVENTS) {
34
- const existing = commands(hooks[event]);
35
- if (existing.includes(command)) continue;
36
- text = setTomlPath(text, ["hooks", event], existing.length ? [...existing, command] : command);
34
+ const current = commands(hooks[event]), existing = previous.length ? current.filter(value => !previous.includes(value)) : current;
35
+ if (existing.includes(command) && existing.length === current.length) continue;
36
+ const next = existing.includes(command) ? existing : [...existing, command];
37
+ text = setTomlPath(text, ["hooks", event], next.length === 1 && !existing.length ? command : next);
37
38
  }
38
39
  return tomlImage(text, before.mode);
39
40
  }
@@ -43,8 +44,14 @@ function ownedCommands(before: ConfigImage, after: ConfigImage): Map<string, str
43
44
  for (const event of JCODE_CAPTURE_EVENTS) { const prior = commands(b[event]); owned.set(event, commands(a[event]).filter(item => !prior.includes(item))); }
44
45
  return owned;
45
46
  }
47
+ /** True only when the receipt removes an original command, requiring a restoring reader. */
48
+ export function jcodeRetiresOriginalHook(before: ConfigImage, after: ConfigImage): boolean {
49
+ return [...ownedCommands(after, before).values()].some(commands => commands.length > 0);
50
+ }
46
51
  export function jcodeHooksManaged(before: ConfigImage, after: ConfigImage, current: ConfigImage): boolean {
47
- try { const hooks = hooksTable(tomlText(current)) ?? {}; return [...ownedCommands(before, after)].every(([event, owned]) => owned.every(item => commands(hooks[event]).includes(item))); }
52
+ try { const hooks = hooksTable(tomlText(current)) ?? {}; const original = hooksTable(tomlText(before)) ?? {}, installed = hooksTable(tomlText(after)) ?? {};
53
+ return [...ownedCommands(before, after)].every(([event, owned]) => owned.every(item => commands(hooks[event]).includes(item))
54
+ && commands(original[event]).filter(item => !commands(installed[event]).includes(item)).every(item => !commands(hooks[event]).includes(item))); }
48
55
  catch { return false; }
49
56
  }
50
57
  /** Exact restore when untouched; otherwise removes only amem's command elements, keeping user commands and later edits. */
@@ -56,8 +63,10 @@ export function restoreJcodeHooks(before: ConfigImage, after: ConfigImage, curre
56
63
  const hooks = hooksTable(text) ?? {};
57
64
  for (const event of JCODE_CAPTURE_EVENTS) {
58
65
  const live = commands(hooks[event]), mine = owned.get(event) ?? [];
59
- if (!live.some(item => mine.includes(item))) continue;
60
- const kept = live.filter(item => !mine.includes(item)), prior = original[event];
66
+ const prior = original[event], priorCommands = commands(prior), installed = commands((hooksTable(tomlText(after)) ?? {})[event]);
67
+ const removed = priorCommands.filter(item => !installed.includes(item));
68
+ if (!live.some(item => mine.includes(item)) && !removed.length) continue;
69
+ const kept = restoreRemovedItems(priorCommands, installed, live.filter(item => !mine.includes(item)), value => value);
61
70
  // Return the user's original spelling when only amem's element was added; otherwise keep the remaining array.
62
71
  const value = !kept.length ? (prior !== undefined && !commands(prior).length ? prior : undefined)
63
72
  : kept.length === 1 && typeof prior === "string" && prior === kept[0] ? prior : kept;
@@ -1,6 +1,6 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
- import { SETUP_CONFIG_BYTES, setTomlPath, updateTomlArray } from "./setup-toml.ts";
3
- import { readJcodeMcp, putJcodeMcp, restoreJcodeMcp, jcodeHooksManaged, restoreJcodeHooks } from "./setup-jcode.ts";
2
+ import { SETUP_CONFIG_BYTES, insertTomlArrayItems, restoreRemovedItems, setTomlPath, updateTomlArray } from "./setup-toml.ts";
3
+ import { readJcodeMcp, putJcodeMcp, restoreJcodeMcp, jcodeHooksManaged, jcodeRetiresOriginalHook, restoreJcodeHooks } from "./setup-jcode.ts";
4
4
  import { readAgyMcp, putAgyMcp, restoreAgyMcp } from "./setup-agy.ts";
5
5
  import { readOpencodeMcp, putOpencodeMcp, restoreOpencodeMcp } from "./setup-opencode.ts";
6
6
  export interface ConfigImage { bytes: string | null; mode: number }
@@ -82,6 +82,38 @@ function restoreJsonHooks(before: ConfigImage, after: ConfigImage, current: Conf
82
82
  }
83
83
  if (!next.length && object(b.hooks ?? {})[event] === undefined) delete groups[event]; else groups[event] = next;
84
84
  }
85
+ // Replacement removes adopted rows too; restore their original position/group,
86
+ // keeping current groups, native metadata and unrelated handlers intact.
87
+ const removed = added(installed, original, row => row.id);
88
+ c.hooks = groups;
89
+ if (removed.some(row => hooks(c, strict).some(value => value.command === row.command && !original.some(old => old.id === value.id))))
90
+ throw Error("setup-owned hook changed; refusing to overwrite it");
91
+ for (const [event, raw] of Object.entries(object(b.hooks ?? {}))) {
92
+ if (strict && qwenHookControls.has(event)) continue;
93
+ const entries = (rows: readonly unknown[]) => rows.flatMap(value => { const group = object(value);
94
+ return (group.hooks as unknown[]).flatMap(value => { const row = hook(event, group, value, strict); return row ? [{ group, sourceGroup: group, value, row }] : []; }); });
95
+ const beforeRows = entries(raw as unknown[]), afterRows = entries((object(a.hooks ?? {})[event] as unknown[] | undefined) ?? []);
96
+ const liveGroups = (groups[event] as Record<string, unknown>[] | undefined) ?? [], liveRows = entries(liveGroups), existing = new Set(liveRows);
97
+ const order = restoreRemovedItems(beforeRows, afterRows, liveRows, value => value.row.id), placed = new Map<Record<string, unknown>, Record<string, unknown>>();
98
+ const originalIds = new Map((raw as Record<string, unknown>[]).map(group => [group, new Set(beforeRows.filter(row => row.group === group && afterRows.some(installed => installed.row.id === row.row.id)).map(row => row.row.id))]));
99
+ for (let index = 0; index < order.length; index++) {
100
+ const row = order[index]!; if (existing.has(row)) continue;
101
+ const source = row.group, ids = originalIds.get(source)!;
102
+ const next = order.slice(index + 1).find(value => existing.has(value) && ids.has(value.row.id));
103
+ const prior = order.slice(0, index).reverse().find(value => existing.has(value) ? ids.has(value.row.id) : value.sourceGroup === source);
104
+ let group = placed.get(source) ?? next?.group ?? prior?.group;
105
+ if (!group) {
106
+ group = { ...source, hooks: [] };
107
+ const following = order.slice(index + 1).find(value => existing.has(value))?.group;
108
+ liveGroups.splice(following ? liveGroups.indexOf(following) : liveGroups.length, 0, group);
109
+ }
110
+ const handlers = group.hooks as unknown[], nextAt = next?.group === group ? handlers.indexOf(next.value) : -1;
111
+ const priorAt = prior?.group === group ? handlers.indexOf(prior.value) : -1;
112
+ handlers.splice(nextAt >= 0 ? nextAt : priorAt >= 0 ? priorAt + 1 : handlers.length, 0, row.value);
113
+ placed.set(source, group); row.group = group;
114
+ }
115
+ if (order.length) groups[event] = liveGroups;
116
+ }
85
117
  if (!Object.keys(groups).length && b.hooks === undefined) delete c.hooks; else if (c.hooks !== undefined) c.hooks = groups;
86
118
  return finishJson(c, current, before);
87
119
  }
@@ -96,10 +128,15 @@ function restoreKimi(before: ConfigImage, after: ConfigImage, current: ConfigIma
96
128
  if (at >= 0) c.splice(at, 1);
97
129
  else if (c.some(value => value.command === row.command && !b.some(old => kimiKey(old) === kimiKey(value)))) throw Error("setup-owned Kimi hook changed; refusing to overwrite it");
98
130
  }
131
+ const withoutInstalled = [...c];
132
+ for (const row of added(a, b, kimiKey)) if (c.some(value => value.command === row.command && !b.some(old => kimiKey(old) === kimiKey(value))))
133
+ throw Error("setup-owned Kimi hook changed; refusing to overwrite it");
134
+ c.splice(0, c.length, ...restoreRemovedItems(b, a, c, kimiKey));
99
135
  if (isDeepStrictEqual(c, kimiHooks(current))) return current;
100
136
  const original = toml(before);
101
137
  const value = !c.length && original.hooks === undefined ? undefined : c;
102
- const result = value === undefined ? setTomlPath(text(current), ["hooks"], undefined) : updateTomlArray(text(current), ["hooks"], value);
138
+ const retired = updateTomlArray(text(current), ["hooks"], withoutInstalled);
139
+ const result = value === undefined ? setTomlPath(retired, ["hooks"], undefined) : insertTomlArrayItems(retired, ["hooks"], value);
103
140
  if (before.bytes === null && Object.keys(object(Bun.TOML.parse(result))).length === 0) return { ...before, mode: current.mode };
104
141
  return rendered(result, current);
105
142
  }
@@ -131,18 +168,37 @@ export function restoreOwned(target: string, index: number, before: ConfigImage,
131
168
  if (current.bytes === before.bytes) return current;
132
169
  throw Error("setup-owned Pi extension changed; refusing to overwrite it");
133
170
  }
171
+ /** Only removal of an original/adopted hook needs receipt v2; added-only receipts remain v1-readable. */
172
+ export function retiresOriginalHook(target: string, index: number, before: ConfigImage, after: ConfigImage): boolean {
173
+ if (target === "qwen" && index === 0 || ["claude", "codex"].includes(target) && index === 1) {
174
+ const strict = target === "qwen";
175
+ return added(hooks(json(after), strict), hooks(json(before), strict), row => row.id).length > 0;
176
+ }
177
+ if (index !== 1) return false;
178
+ if (target === "kimi") return added(kimiHooks(after), kimiHooks(before), kimiKey).length > 0;
179
+ if (target === "jcode") return jcodeRetiresOriginalHook(before, after);
180
+ return target === "pi" && before.bytes !== null && before.bytes !== after.bytes;
181
+ }
134
182
  export function managedAfter(target: string, index: number, before: ConfigImage, after: ConfigImage, current: ConfigImage): boolean {
135
183
  if (target === "qwen" && index === 0) {
136
184
  const needed = added(hooks(json(before), true), hooks(json(after), true), row => row.id), live = hooks(json(current), true);
137
- return isDeepStrictEqual(mcpRecord(target, after), mcpRecord(target, current)) && needed.every(row => live.some(value => value.id === row.id));
185
+ const retired = added(hooks(json(after), true), hooks(json(before), true), row => row.id), installed = hooks(json(after), true);
186
+ return isDeepStrictEqual(mcpRecord(target, after), mcpRecord(target, current)) && needed.every(row => live.some(value => value.id === row.id))
187
+ && retired.every(row => live.filter(value => value.id === row.id).length <= installed.filter(value => value.id === row.id).length);
138
188
  }
139
189
  if (index === 0) return isDeepStrictEqual(mcpRecord(target, after), mcpRecord(target, current));
140
190
  if (target === "jcode") return jcodeHooksManaged(before, after, current);
141
191
  if (target === "claude" || target === "codex") {
142
192
  const needed = added(hooks(json(before)), hooks(json(after)), row => row.id), live = hooks(json(current));
143
- return needed.every(row => live.some(value => value.id === row.id));
193
+ const retired = added(hooks(json(after)), hooks(json(before)), row => row.id), installed = hooks(json(after));
194
+ return needed.every(row => live.some(value => value.id === row.id))
195
+ && retired.every(row => live.filter(value => value.id === row.id).length <= installed.filter(value => value.id === row.id).length);
196
+ }
197
+ if (target === "kimi") {
198
+ const original = kimiHooks(before), installed = kimiHooks(after), live = kimiHooks(current);
199
+ return added(original, installed, kimiKey).every(row => live.some(value => kimiKey(value) === kimiKey(row)))
200
+ && added(installed, original, kimiKey).every(row => live.filter(value => kimiKey(value) === kimiKey(row)).length <= installed.filter(value => kimiKey(value) === kimiKey(row)).length);
144
201
  }
145
- if (target === "kimi") return added(kimiHooks(before), kimiHooks(after), kimiKey).every(row => kimiHooks(current).some(value => kimiKey(value) === kimiKey(row)));
146
202
  return current.bytes === after.bytes;
147
203
  }
148
204
 
package/src/setup-toml.ts CHANGED
@@ -217,6 +217,26 @@ function arrayElements(text: string, open: number): { close: number; elements: A
217
217
  }
218
218
  return error();
219
219
  }
220
+ /** Reinsert only rows removed by this installer, anchored to surviving original rows.
221
+ * Current rows stay in their own order; unrelated additions/deletions are never reversed. */
222
+ export function restoreRemovedItems<T>(before: readonly T[], after: readonly T[], current: readonly T[], key: (value: T) => string): T[] {
223
+ const counts = (rows: readonly T[]) => { const found = new Map<string, number>(); for (const row of rows) found.set(key(row), (found.get(key(row)) ?? 0) + 1); return found; };
224
+ const original = counts(before), installed = counts(after), live = counts(current), seen = new Map<string, number>();
225
+ const ordinals = before.map(row => { const id = key(row), ordinal = seen.get(id) ?? 0; seen.set(id, ordinal + 1); return ordinal; });
226
+ const result = [...current];
227
+ const anchor = (index: number) => { const id = key(before[index]!); let ordinal = ordinals[index]!;
228
+ return result.findIndex(row => key(row) === id && ordinal-- === 0); };
229
+ for (let index = 0; index < before.length; index++) {
230
+ const row = before[index]!, id = key(row), retained = installed.get(id) ?? 0;
231
+ if (retained) { installed.set(id, retained - 1); continue; }
232
+ if ((live.get(id) ?? 0) >= original.get(id)!) continue;
233
+ let at = result.length, found = false;
234
+ for (let next = index + 1; next < before.length; next++) { const position = anchor(next); if (position >= 0) { at = position; found = true; break; } }
235
+ if (!found) for (let prior = index - 1; prior >= 0; prior--) { const position = anchor(prior); if (position >= 0) { at = position + 1; break; } }
236
+ result.splice(at, 0, row); live.set(id, (live.get(id) ?? 0) + 1);
237
+ }
238
+ return result;
239
+ }
220
240
  /** Keep an ordered subsequence of existing elements, optionally adding new elements only at the tail. */
221
241
  function arrayEdit(before: readonly unknown[], after: readonly unknown[]): { keep: Set<number>; append: readonly unknown[] } {
222
242
  const old = before.map(value => JSON.stringify(shape(value))), next = after.map(value => JSON.stringify(shape(value)));
@@ -294,3 +314,41 @@ export function updateTomlArray(text: string, path: readonly string[], expectedA
294
314
  if (!isDeepStrictEqual(shape(parse(output)), shape(expected))) return error();
295
315
  return output;
296
316
  }
317
+
318
+ /** Insert restored rows without deleting/reordering or reserializing surviving TOML elements. */
319
+ export function insertTomlArrayItems(text: string, path: readonly string[], expectedArray: readonly unknown[]): string {
320
+ if (!path.length || path.length > 8 || path.some(key => typeof key !== "string" || Buffer.byteLength(key) > 256 || /[\p{Cc}\p{Cf}]/u.test(key)) || !Array.isArray(expectedArray)) return error();
321
+ const original = parse(text), current = at(original, path); shape(original); shape(expectedArray);
322
+ if (current === undefined || Array.isArray(current) && current.length === 0) return updateTomlArray(text, path, expectedArray);
323
+ if (!Array.isArray(current)) return error();
324
+ const key = (value: unknown) => JSON.stringify(shape(value)), pending = new Map<number, unknown[]>(); let cursor = 0;
325
+ for (const value of expectedArray) {
326
+ if (cursor < current.length && key(value) === key(current[cursor])) cursor++;
327
+ else pending.set(cursor, [...(pending.get(cursor) ?? []), value]);
328
+ }
329
+ if (cursor !== current.length) return error(); // Every original element must survive in order.
330
+ if (!pending.size) return text;
331
+ const units = statements(text), assignment = units.find(unit => !unit.header && unit.path.length === path.length && prefix(path, unit.path));
332
+ const eol = text.includes("\r\n") ? "\r\n" : "\n", changes: Replacement[] = [];
333
+ if (assignment) {
334
+ let open = assignment.equal! + 1; while (/\s/.test(text[open] ?? "")) open++;
335
+ const array = arrayElements(text, open); if (array.elements.length !== current.length) return error();
336
+ for (const [index, values] of pending) if (index < current.length) {
337
+ const start = index ? array.elements[index - 1]!.comma! + 1 : open + 1;
338
+ changes.push({ start, end: start, text: ` ${values.map(inline).join(", ")}, ` });
339
+ }
340
+ } else {
341
+ const headers = units.filter(unit => unit.header && unit.array && unit.path.length === path.length && prefix(path, unit.path));
342
+ if (headers.length !== current.length) return error();
343
+ for (const [index, values] of pending) if (index < current.length) {
344
+ const header = headers[index]!, prior = units[units.indexOf(header) - 1], start = prior?.end ?? 0;
345
+ changes.push({ start, end: start, text: `${start && text[start - 1] !== "\n" ? eol : ""}${appendedTables(path, values, eol)}` });
346
+ }
347
+ }
348
+ let output = replace(text, changes);
349
+ const tail = pending.get(current.length);
350
+ if (tail?.length) output = updateTomlArray(output, path, [...(at(parse(output), path) as unknown[]), ...tail]);
351
+ const expected = parse(text); edit(expected, path, [...expectedArray]);
352
+ if (!isDeepStrictEqual(shape(parse(output)), shape(expected))) return error();
353
+ return output;
354
+ }
package/src/setup.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { cliAttribution } from "./memory/cli-attribution.ts";
2
2
  import { configuredPolicy } from "./memory/config.ts";
3
3
  import { SETUP_CONFIG_BYTES, updateTomlArray } from "./setup-toml.ts";
4
- import { managedAfter, mcpRecord, putMcpRecord, restoreOwned, sameImage, type ConfigImage } from "./setup-owned.ts";
4
+ import { managedAfter, mcpRecord, putMcpRecord, restoreOwned, retiresOriginalHook, sameImage, type ConfigImage } from "./setup-owned.ts";
5
5
  import { opencodeServer } from "./setup-opencode.ts";
6
6
  import { assertGeminiHome, assertGeminiRuntime, GEMINI_REMOVE, geminiAddCommand } from "./setup-gemini.ts";
7
7
  import { resolveSetupPaths, opencodeConfigRoot } from "./setup-paths.ts";
@@ -34,7 +34,7 @@ type Image = ConfigImage;
34
34
  interface PlannedHook { event: string; command: string; timeout: number }
35
35
  interface Change { key: string; path: string; before: Image; after: Image; start?: Image }
36
36
  interface DirectoryIdentity { dev: number; ino: number }
37
- interface Receipt { createdDirectory?: DirectoryIdentity; version: 1; target: SetupTarget; intent: string; phase: "prepared" | "applied"; changes: Change[] }
37
+ interface Receipt { createdDirectory?: DirectoryIdentity; version: 1 | 2; target: SetupTarget; intent: string; phase: "prepared" | "applied"; changes: Change[] }
38
38
  export interface SetupContext {
39
39
  env?: Env; bun?: string; entry?: string; stdinTTY?: boolean; cwd?: string;
40
40
  /** `--root`: an absolute amem install root to register instead of the running one (#199). Ignored when `entry` is given. */
@@ -205,12 +205,13 @@ function assertOpencodeLayout(c: ReturnType<typeof context>, mode: SetupPlan["mo
205
205
  for (const name of ["opencode.jsonc", "config.json"]) if (read(join(dir, name)).bytes !== null) throw Error(`OpenCode setup supports one strict-JSON global opencode.json; ${name} beside it is merged too and is not supported`);
206
206
  if ([c.bun, c.entry].some(value => /\{(?:env|file):/.test(value))) throw Error("OpenCode substitutes {env:} and {file:} in configuration; setup cannot register this runtime exactly");
207
207
  }
208
- function intent(c: ReturnType<typeof context>, target: SetupTarget): string { return digest([1, target, c.paths[target], c.bun, c.entry, ...(["codex", "kimi", "qwen"].includes(target) ? ["capture-json-v1"] : []), ...(target === "jcode" ? ["jcode-hooks-v1"] : []), ...(target === "agy" ? [c.workspace!.root] : [])]); }
208
+ function intent(c: ReturnType<typeof context>, target: SetupTarget): string { return digest([1, target, c.paths[target], c.bun, c.entry, ...(["codex", "kimi", "qwen"].includes(target) ? ["capture-json-v1"] : []), ...(target === "jcode" ? ["jcode-hooks-v1"] : []), ...(["claude", "codex", "kimi", "pi", "qwen", "jcode"].includes(target) ? ["root-hooks-v2"] : []), ...(target === "agy" ? [c.workspace!.root] : [])]); }
209
209
  function journal(c: ReturnType<typeof context>, target: SetupTarget): string { return join(c.state, target === "agy" ? `agy-${digest(c.workspace!.root)}.json` : `${target}.json`); }
210
210
  function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRuntime = false): Receipt | undefined {
211
211
  const raw = read(journal(c, target), JOURNAL_MAX); if (raw.bytes === null) return;
212
212
  const value = record(JSON.parse(Buffer.from(raw.bytes, "base64").toString("utf8")));
213
- if (value.version !== 1 || value.target !== target || typeof value.intent !== "string" || !/^[a-f0-9]{64}$/.test(value.intent) || requireRuntime && value.intent !== intent(c, target) || !["prepared", "applied"].includes(String(value.phase)) || !Array.isArray(value.changes) || value.changes.length !== c.paths[target].length && !(target === "jcode" && value.changes.length === 1)) throw Error("setup receipt does not match this installation");
213
+ if (value.version !== 1 && value.version !== 2) throw Error("setup receipt version is unsupported; run setup or undo with the release that wrote it");
214
+ if (value.target !== target || typeof value.intent !== "string" || !/^[a-f0-9]{64}$/.test(value.intent) || requireRuntime && value.intent !== intent(c, target) || !["prepared", "applied"].includes(String(value.phase)) || !Array.isArray(value.changes) || value.changes.length !== c.paths[target].length && !(target === "jcode" && value.changes.length === 1)) throw Error("setup receipt does not match this installation");
214
215
  for (let i = 0; i < value.changes.length; i++) {
215
216
  const change = record(value.changes[i]); const currentPath = c.paths[target][i], legacyRedirect = c.redirects.some(row => row.target === target && row.from === change.path && row.to === currentPath);
216
217
  if (change.path !== currentPath && !legacyRedirect || change.key !== `${target}:${i}`) throw Error("setup receipt target mismatch");
@@ -353,10 +354,28 @@ function hasHook(rows: readonly unknown[], command: string, timeout: number): bo
353
354
  && group.hooks.some((h: unknown) => { const value = record(h); return value.type === "command" && value.command === command && value.timeout === timeout; });
354
355
  });
355
356
  }
356
- function hookDocument(before: Image, events: readonly string[], commandFor: (event: string) => string, timeout = 1): Image {
357
+ function hookDocument(before: Image, events: readonly string[], commandFor: (event: string) => string, timeout = 1, previousFor?: (event: string) => readonly string[]): Image {
357
358
  const document = json(before), hooks = document.hooks === undefined ? {} : record(document.hooks);
358
359
  for (const event of events) {
359
- const rows = hooks[event] ?? []; if (!Array.isArray(rows)) throw Error("setup hook groups must be arrays");
360
+ const rawRows = hooks[event] ?? []; if (!Array.isArray(rawRows)) throw Error("setup hook groups must be arrays");
361
+ let rows: unknown[] = rawRows;
362
+ const previous = previousFor?.(event);
363
+ if (previous?.length) rows = rows.flatMap(value => {
364
+ const group = record(value); if (!Array.isArray(group.hooks)) throw Error("setup hook handlers must be arrays");
365
+ let removed = false;
366
+ const kept = group.hooks.filter(h => {
367
+ const handler = record(h);
368
+ if (handler.type !== "command" || !previous.includes(String(handler.command))) return true;
369
+ // ponytail: upgrade generated shapes only; customized controls require review.
370
+ // Upgrade path: lexical command-only migration with native control validation.
371
+ if (![undefined, "", "*"].includes(group.matcher as string | undefined) || handler.timeout !== timeout
372
+ || Object.keys(group).some(key => !["matcher", "hooks", "trusted_hash", "trustedHash"].includes(key))
373
+ || Object.keys(handler).some(key => !["type", "command", "timeout", "trusted_hash", "trustedHash"].includes(key)))
374
+ throw Error("previous-runtime hook settings are customized; review hook configuration before retrying setup");
375
+ removed = true; return false;
376
+ });
377
+ return removed ? kept.length ? [{ ...group, hooks: kept }] : [] : [value];
378
+ });
360
379
  const command = commandFor(event);
361
380
  if (!hasHook(rows, command, timeout)) rows.push({ hooks: [{ type: "command", command, timeout }] });
362
381
  hooks[event] = rows;
@@ -366,6 +385,20 @@ function hookDocument(before: Image, events: readonly string[], commandFor: (eve
366
385
  function piExtension(bun: string, entry: string): string {
367
386
  return `// amem setup managed session-end ingest; no resident worker or instruction injection.\nimport { spawnSync } from 'node:child_process';\nexport default function (pi) {\n pi.on('session_shutdown', (_event, ctx) => {\n try { spawnSync(${JSON.stringify(bun)}, ['--smol', ${JSON.stringify(entry)}, 'ingest', 'pi'], { cwd: ctx.cwd, stdio: 'ignore', timeout: 2500 }); } catch {}\n });\n}\n`;
368
387
  }
388
+ /** Bind old hooks to exact current/receipted MCP launchers, including an adopted baseline.
389
+ * The feature intent tag forces one repair of receipts from the old add-only installer. */
390
+ function previousRuntimes(target: SetupTarget, images: readonly Image[], bun: string, entry: string): { bun: string; entry: string }[] {
391
+ const found = new Map<string, { bun: string; entry: string }>();
392
+ for (const image of images) {
393
+ const value = mcpRecord(target, image);
394
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
395
+ const server = value as Record<string, unknown>, args = server.args;
396
+ if (typeof server.command === "string" && isAbsolute(server.command) && Array.isArray(args) && args.length === 3
397
+ && args[0] === "--smol" && typeof args[1] === "string" && isAbsolute(args[1]) && args[2] === "mcp"
398
+ && (server.command !== bun || args[1] !== entry)) found.set(JSON.stringify([server.command, args[1]]), { bun: server.command, entry: args[1] });
399
+ }
400
+ return [...found.values()];
401
+ }
369
402
  interface Prepared { value: Receipt; expected: Image[]; unchanged: boolean }
370
403
  function restoreChange(value: Receipt, change: Change, index: number, live: Image): Image {
371
404
  const start = value.phase === "prepared" && change.start && !sameImage(change.start, change.before) ? change.start : undefined;
@@ -388,6 +421,9 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
388
421
  return prior && change && change.path === path ? restoreChange(prior, change, index, expected[index]!) : expected[index]!; });
389
422
  if (prior?.phase === "applied" && prior.intent === intent(c, target) && prior.changes.every((change, index) => managedAfter(target, index, change.before, change.after, expected[index]!))) return { value: prior, expected, unchanged: true };
390
423
  const changes: Change[] = c.paths[target].map((path, i) => ({ key: `${target}:${i}`, path, before: restored[i]!, after: restored[i]!, start: expected[i]! }));
424
+ const previousMcp = prior?.changes[0];
425
+ const previous = previousRuntimes(target, [expected[0]!, ...(previousMcp && previousMcp.path === c.paths[target][0] ? [previousMcp.before, previousMcp.after, ...(previousMcp.start ? [previousMcp.start] : [])] : [])], c.bun, c.entry);
426
+ const oldLaunches = previous.map(runtime => `${quote(runtime.bun)} --smol ${quote(runtime.entry)}`);
391
427
  const staging = mkdtempSync(join(realPath(tmpdir()), "amem-setup-"));
392
428
  try {
393
429
  const { env, staged } = stagingEnvironment(c, target, options, staging);
@@ -442,7 +478,7 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
442
478
  } else if (target === "jcode") {
443
479
  // Shared native pools use the daemon cwd; amem must bind the calling session's project.
444
480
  changes[0]!.after = putMcpRecord(target, changes[0]!.before, { command: c.bun, args: ["--smol", c.entry, "mcp"], shared: false });
445
- changes[1]!.after = putJcodeHooks(changes[1]!.before, jcodeHookCommand(c.bun, c.entry));
481
+ changes[1]!.after = putJcodeHooks(changes[1]!.before, jcodeHookCommand(c.bun, c.entry), previous.map(runtime => jcodeHookCommand(runtime.bun, runtime.entry)));
446
482
  } else if (target === "opencode") {
447
483
  // `opencode mcp add` is interactive for local servers and has no remove; edit the owned entry directly.
448
484
  changes[0]!.after = putMcpRecord(target, changes[0]!.before, opencodeServer(c.bun, c.entry));
@@ -452,25 +488,36 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
452
488
  changes[0]!.after = objectImage(document, changes[0]!.before);
453
489
  }
454
490
  const capture = (kind: string) => `${quote(c.bun)} --smol ${quote(c.entry)} ingest ${kind} --json`;
455
- if (target === "claude") changes[1]!.after = hookDocument(changes[1]!.before, HOOK_EVENTS, event => `${quote(c.bun)} --smol ${quote(c.entry)} hook ${event}`);
456
- if (target === "codex") changes[1]!.after = hookDocument(changes[1]!.before, ["SessionEnd"], () => capture("codex"), 3);
491
+ if (target === "claude") changes[1]!.after = hookDocument(changes[1]!.before, HOOK_EVENTS, event => `${quote(c.bun)} --smol ${quote(c.entry)} hook ${event}`, 1, event => oldLaunches.map(launch => `${launch} hook ${event}`));
492
+ if (target === "codex") changes[1]!.after = hookDocument(changes[1]!.before, ["SessionEnd"], () => capture("codex"), 3, () => oldLaunches.map(launch => `${launch} ingest codex --json`));
457
493
  // Qwen's user MCP and hooks share settings.json: publish and journal one combined image.
458
- if (target === "qwen") changes[0]!.after = hookDocument(changes[0]!.after, ["SessionEnd"], () => capture("qwen"), 3);
494
+ if (target === "qwen") changes[0]!.after = hookDocument(changes[0]!.after, ["SessionEnd"], () => capture("qwen"), 3, () => oldLaunches.map(launch => `${launch} ingest qwen --json`));
459
495
  if (target === "kimi") {
460
496
  const old = changes[1]!.before.bytes === null ? "" : Buffer.from(changes[1]!.before.bytes!, "base64").toString("utf8");
461
- const parsed = toml(old), hooks = parsed.hooks ?? [];
462
- if (!Array.isArray(hooks)) throw Error("Kimi hooks must be an array");
497
+ const parsed = toml(old), rawHooks = parsed.hooks ?? [];
498
+ if (!Array.isArray(rawHooks)) throw Error("Kimi hooks must be an array");
499
+ let hooks: unknown[] = rawHooks;
500
+ if (oldLaunches.length) hooks = hooks.filter(h => {
501
+ const row = record(h);
502
+ if (row.event !== "SessionEnd" || !oldLaunches.some(launch => row.command === `${launch} ingest kimi --json`)) return true;
503
+ if (![undefined, "", "*"].includes(row.matcher as string | undefined) || row.timeout !== 3
504
+ || Object.keys(row).some(key => !["event", "command", "matcher", "timeout"].includes(key)))
505
+ throw Error("previous-runtime hook settings are customized; review hook configuration before retrying setup");
506
+ return false;
507
+ });
508
+ const retired = updateTomlArray(old, ["hooks"], hooks);
463
509
  if (!hooks.some(h => record(h).event === "SessionEnd" && record(h).command === capture("kimi"))) hooks.push({ event: "SessionEnd", command: capture("kimi"), timeout: 3 });
464
- changes[1]!.after = image(updateTomlArray(old, ["hooks"], hooks), changes[1]!.before.mode);
510
+ changes[1]!.after = image(updateTomlArray(retired, ["hooks"], hooks), changes[1]!.before.mode);
465
511
  }
466
512
  if (target === "pi") {
467
513
  const content = piExtension(c.bun, c.entry);
468
- if (!prior && changes[1]!.before.bytes !== null && changes[1]!.before.bytes !== image(content).bytes) throw Error("Pi amem-session extension already exists and is not this setup's file");
514
+ if (!prior && changes[1]!.before.bytes !== null && changes[1]!.before.bytes !== image(content).bytes
515
+ && !previous.some(runtime => changes[1]!.before.bytes === image(piExtension(runtime.bun, runtime.entry)).bytes)) throw Error("Pi amem-session extension already exists and is not this setup's file");
469
516
  changes[1]!.after = image(content, changes[1]!.before.mode);
470
517
  }
471
518
  // Never publish an image that this installer's own read/undo boundary cannot reopen.
472
519
  if (changes.some(change => change.after.bytes !== null && Buffer.from(change.after.bytes, "base64").byteLength > MAX)) throw Error("planned setup configuration exceeds the size bound; no configuration published");
473
- return { value: { version: 1, target, intent: intent(c, target), phase: "prepared", changes, ...(prior?.createdDirectory ? { createdDirectory: prior.createdDirectory } : {}) }, expected, unchanged: false };
520
+ return { value: { version: previous.length && changes.some((change, index) => retiresOriginalHook(target, index, change.before, change.after)) ? 2 : 1, target, intent: intent(c, target), phase: "prepared", changes, ...(prior?.createdDirectory ? { createdDirectory: prior.createdDirectory } : {}) }, expected, unchanged: false };
474
521
  } finally { rmSync(staging, { recursive: true, force: true }); }
475
522
  }
476
523
  function save(c: ReturnType<typeof context>, value: Receipt): void { write(journal(c, value.target), image(JSON.stringify(value) + "\n")); }