@staix/agent-mem 0.20.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-mem",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "Bounded project memory tools and seven deterministic capture hooks, without a resident worker.",
5
5
  "mcpServers": {
6
6
  "agent-mem": {
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Curated memory with append-only JSONL logs, a derived SQLite index, short- and long-term tiers, a typed graph, goals with leases, and git sync between machines. Extracted from [agent-hub](https://github.com/STAIxBWLB/agent-hub), with compatible storage and entry IDs.
4
4
 
5
- Version: 0.20.0. Requires Bun 1.4.2 or later; no runtime dependencies.
5
+ Version: 0.20.1. Requires Bun 1.4.2 or later; no runtime dependencies.
6
6
 
7
7
  ## Quick start
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-mem",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "Curated, shared memory for coding agents: append-only logs, a derived index, tiers and a typed graph.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -223,6 +223,53 @@ export function manifest() {
223
223
  commands: Object.keys(COMMANDS) };
224
224
  }
225
225
 
226
+ /** Setup apply errors can contain configuration paths or native output. Classify only
227
+ * this explicit operation into fixed text; never include the caught message or cause. */
228
+ function markedSetupApplyError(parsed: ReturnType<typeof parseCommand>, error: unknown): Error | undefined {
229
+ const target = parsed.positionals[0];
230
+ if (parsed.path.length !== 1 || parsed.path[0] !== "setup" || parsed.options["--apply"] !== true || parsed.options["--undo"] === true
231
+ || parsed.positionals.length !== 1 || target === "console" || !COMMANDS.setup?.args?.[0]?.values?.includes(target!)) return undefined;
232
+ const detail = error && typeof error === "object" ? error as { message?: unknown; code?: unknown; exitCode?: unknown } : {};
233
+ const text = typeof detail.message === "string" ? detail.message : "";
234
+ const code = typeof detail.code === "string" ? detail.code : "";
235
+ let cause = "setup_apply_failed", next = "Review the setup preview and saved setup ownership state before retrying.", partial = true;
236
+ if (text === "Memory operation unavailable or refused; no partial result is shown.") {
237
+ cause = "setup_policy_refused"; next = "Review setup policy with the person and keep denied configuration private."; partial = false;
238
+ } else if (/^installed (?:claude|codex|kimi|pi|qwen|jcode|opencode|gemini|agy) CLI does not expose MCP registration; no registration command was run$/.test(text)) {
239
+ cause = "native_capability_unverified"; next = "Verify that the installed client can start and exposes MCP administration, then rerun setup preview."; partial = false;
240
+ } else if (/^(?:claude|codex|pi|qwen|gemini) MCP registration timed out in staging; no native output disclosed$/.test(text)) {
241
+ cause = "native_registration_timeout"; next = "Check that the installed client starts promptly, then rerun setup preview."; partial = false;
242
+ } else if (/^qwen MCP registration rejected command arguments in staging; no native output disclosed$/.test(text)) {
243
+ cause = "native_arguments_rejected"; next = "Check the installed client's supported MCP command syntax, then rerun setup preview."; partial = false;
244
+ } else if (/^(?:claude|codex|pi|qwen|gemini) MCP registration failed in staging; no native output disclosed$/.test(text)) {
245
+ cause = "native_registration_failed"; next = "Check the installed client's MCP administration locally, then rerun setup preview."; partial = false;
246
+ } else if (/^(?:claude|codex|pi|qwen|gemini) native registration (?:produced no configuration|produced invalid configuration; no configuration published|did not produce the planned amem server|changed unrelated MCP servers; no configuration published)$/.test(text)
247
+ || text === "codex native registration changed unrelated settings; no configuration published") {
248
+ cause = "native_result_mismatch"; next = "Review the installed client's configuration format and version, then rerun setup preview."; partial = false;
249
+ } else if (text === "setup receipt version is unsupported; run setup or undo with the release that wrote it") {
250
+ cause = "setup_receipt_version_unsupported"; next = "Run setup or undo with the amem release that wrote the receipt."; partial = false;
251
+ } else if (/^setup-owned .+ changed; refusing to overwrite it$/.test(text) || text === "Pi amem-session extension already exists and is not this setup's file"
252
+ || text === "previous-runtime hook settings are customized; review hook configuration before retrying setup"
253
+ || /^setup receipt (?:does not match this installation|target mismatch|invalid|directory ownership invalid)$/.test(text)) {
254
+ cause = "setup_ownership_conflict"; next = "Review the managed entries and saved setup receipt before retrying; keep unrelated configuration intact."; partial = false;
255
+ } else if (/^setup is busy(?: or its lock is unreadable|; retry after the active setup exits)$/.test(text)) {
256
+ cause = "setup_busy"; next = "Wait for the active setup to finish, then rerun setup preview."; partial = false;
257
+ } else if (/^setup configuration changed (?:while reading|before removal|before publication|during preparation)$/.test(text)) {
258
+ cause = "setup_configuration_changed"; next = "Wait for other configuration writers to finish, then rerun setup preview.";
259
+ } else if (/^setup (?:configuration (?:must be an object|does not parse as a JSON object|does not parse as TOML|unavailable or oversized)|hook (?:groups|handlers) must be arrays|TOML unavailable or unsupported; no configuration changed)$/.test(text)) {
260
+ cause = "setup_configuration_unavailable"; next = "Review the configuration's supported format and size, then rerun setup preview."; partial = false;
261
+ } else if (["EACCES", "EPERM", "EROFS"].includes(code)) {
262
+ cause = "setup_filesystem_permissions"; next = "Review the setup file and directory permissions, then rerun setup preview.";
263
+ } else if (["ENOSPC", "EDQUOT"].includes(code)) {
264
+ cause = "setup_storage_capacity"; next = "Check available storage and quotas, then rerun setup preview.";
265
+ } else if (["EIO", "ENOENT", "ENOTDIR", "EISDIR", "EMFILE", "ENFILE"].includes(code)) {
266
+ cause = "setup_filesystem_unavailable"; next = "Check that the setup configuration and storage are available, then rerun setup preview.";
267
+ }
268
+ const safe = new Error(`Setup apply failed [cause: ${cause}]. Next step: ${next} Recheck with amem setup ${target} --json before another apply.${partial ? " Some setup changes may already be applied; review current state before retrying." : ""}`);
269
+ if (typeof detail.exitCode === "number" && Number.isSafeInteger(detail.exitCode)) Object.assign(safe, { exitCode: detail.exitCode });
270
+ return safe;
271
+ }
272
+
226
273
  export async function runCli(argv: readonly string[], context: CliContext = {}): Promise<string> {
227
274
  // Native capture and the MCP server retain their existing surfaces and attribution.
228
275
  if (argv[0] === "hook" || argv[0] === "ingest" || argv.length === 1 && argv[0] === "mcp") return runCliCommand(argv, context);
@@ -262,9 +309,11 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
262
309
  if (marked && error instanceof AgentUsageError) throw error;
263
310
  if (marked) {
264
311
  // parseCommand is pure: when it rejects argv, the error it raised is a usage error, not memory data.
265
- let usage: Error | undefined;
266
- try { parseCommand(argv); } catch (parseError) { usage = parseError as Error; }
312
+ let usage: Error | undefined, parsed: ReturnType<typeof parseCommand> | undefined;
313
+ try { parsed = parseCommand(argv); } catch (parseError) { usage = parseError as Error; }
267
314
  if (usage) throw Object.assign(new Error(usage.message), (error as { exitCode?: number }).exitCode === undefined ? {} : { exitCode: (error as { exitCode?: number }).exitCode });
315
+ const setupError = parsed && markedSetupApplyError(parsed, error);
316
+ if (setupError) throw setupError;
268
317
  }
269
318
  // Marked administrative failures must not reveal a target's private state or native errors.
270
319
  if (marked) throw Error("Memory operation unavailable or refused; no partial result is shown.");
@@ -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")); }