@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/cli.ts +51 -2
- package/src/setup-drift.ts +3 -3
- package/src/setup-jcode.ts +17 -8
- package/src/setup-owned.ts +62 -6
- package/src/setup-toml.ts +58 -0
- package/src/setup.ts +62 -15
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.
|
|
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
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.");
|
package/src/setup-drift.ts
CHANGED
|
@@ -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 {
|
package/src/setup-jcode.ts
CHANGED
|
@@ -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
|
|
35
|
-
if (existing.includes(command)) continue;
|
|
36
|
-
|
|
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)) ?? {};
|
|
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
|
-
|
|
60
|
-
const
|
|
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;
|
package/src/setup-owned.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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),
|
|
462
|
-
if (!Array.isArray(
|
|
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(
|
|
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
|
|
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")); }
|