@staix/agent-mem 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +15 -0
- package/README.md +186 -21
- package/bin/amem.js +26 -3
- package/hooks/hooks.json +81 -0
- package/package.json +3 -1
- package/src/cli.ts +492 -39
- package/src/commands-table.ts +56 -15
- package/src/hook-contract.ts +3 -0
- package/src/hook-git.ts +42 -0
- package/src/hook-native.ts +92 -0
- package/src/hook-project.ts +206 -0
- package/src/hook-resources.ts +15 -0
- package/src/hook-spool.ts +566 -0
- package/src/hooks.ts +253 -0
- package/src/index.ts +18 -0
- package/src/ingest.ts +351 -0
- package/src/mcp-memory.ts +141 -33
- package/src/mcp.ts +42 -7
- package/src/memory/aliases.ts +87 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +144 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +162 -59
- package/src/memory/capture-input.ts +36 -0
- package/src/memory/capture.ts +153 -39
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/cross-project.ts +75 -0
- package/src/memory/curated-read.ts +29 -0
- package/src/memory/decay-review.ts +136 -0
- package/src/memory/doctor.ts +412 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +137 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +150 -0
- package/src/memory/gc.ts +97 -0
- package/src/memory/generation-index.ts +22 -17
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/goal-promotion.ts +71 -0
- package/src/memory/home.ts +15 -4
- package/src/memory/intent-brief.ts +89 -0
- package/src/memory/lifecycle-log.ts +54 -0
- package/src/memory/lifecycle.ts +61 -0
- package/src/memory/machine.ts +31 -0
- package/src/memory/metrics.ts +63 -0
- package/src/memory/mutation.ts +6 -4
- package/src/memory/note-add.ts +100 -0
- package/src/memory/path-glob.ts +94 -0
- package/src/memory/plan-guard.ts +47 -0
- package/src/memory/platform.ts +9 -4
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +122 -0
- package/src/memory/project.ts +129 -20
- package/src/memory/reader-lease.ts +22 -10
- package/src/memory/recall-staleness.ts +56 -0
- package/src/memory/recall.ts +74 -0
- package/src/memory/repo-scope.ts +404 -0
- package/src/memory/shard-sync-format.ts +116 -0
- package/src/memory/shard-sync.ts +421 -0
- package/src/memory/shard.ts +3 -80
- package/src/memory/store.ts +286 -56
- package/src/memory/sync-cadence.ts +139 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +79 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +115 -38
- package/src/session-brief-worker.ts +82 -0
- package/src/session-brief.ts +261 -0
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +291 -0
- package/src/setup.ts +327 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
|
|
3
|
+
export const SETUP_CONFIG_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
const MAX_NODES = 65536, MAX_DEPTH = 32;
|
|
5
|
+
const temporal = new Set(["[object Temporal.Instant]", "[object Temporal.PlainDate]", "[object Temporal.PlainTime]", "[object Temporal.PlainDateTime]"]);
|
|
6
|
+
const plain = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
7
|
+
const prefix = (left: readonly string[], right: readonly string[]) => left.length <= right.length && left.every((key, index) => key === right[index]);
|
|
8
|
+
const error = (): never => { throw Error("setup TOML unavailable or unsupported; no configuration changed"); };
|
|
9
|
+
function parse(text: string): Record<string, unknown> {
|
|
10
|
+
if (Buffer.byteLength(text) > SETUP_CONFIG_BYTES) return error();
|
|
11
|
+
try { const value: unknown = Bun.TOML.parse(text); return plain(value) ? value : error(); } catch { return error(); }
|
|
12
|
+
}
|
|
13
|
+
/** Compare Temporal values and nonfinite numbers explicitly rather than relying on enumerable object properties. */
|
|
14
|
+
function shape(value: unknown, depth = 0, count = { value: 0, bytes: 0 }, seen = new Set<object>()): unknown {
|
|
15
|
+
if (depth > MAX_DEPTH || ++count.value > MAX_NODES) return error();
|
|
16
|
+
if (typeof value === "string") { count.bytes += Buffer.byteLength(value); if (count.bytes > SETUP_CONFIG_BYTES) return error(); return ["string", value]; }
|
|
17
|
+
if (typeof value === "boolean") return ["boolean", value];
|
|
18
|
+
if (typeof value === "number") return ["number", Number.isNaN(value) ? "nan" : Object.is(value, -0) ? "-0" : String(value)];
|
|
19
|
+
if (!value || typeof value !== "object" || seen.has(value)) return error();
|
|
20
|
+
const tag = Object.prototype.toString.call(value);
|
|
21
|
+
if (temporal.has(tag)) return [tag, String(value)];
|
|
22
|
+
if (!Array.isArray(value) && !plain(value)) return error();
|
|
23
|
+
if (Object.getOwnPropertySymbols(value).length) return error();
|
|
24
|
+
seen.add(value);
|
|
25
|
+
try {
|
|
26
|
+
if (Array.isArray(value)) return ["array", Array.from({ length: value.length }, (_, index) => shape(value[index], depth + 1, count, seen))];
|
|
27
|
+
return ["table", Object.keys(value).sort().map(key => {
|
|
28
|
+
count.bytes += Buffer.byteLength(key); if (count.bytes > SETUP_CONFIG_BYTES) return error();
|
|
29
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
30
|
+
if (!descriptor || !("value" in descriptor)) return error();
|
|
31
|
+
return [key, shape(descriptor.value, depth + 1, count, seen)];
|
|
32
|
+
})];
|
|
33
|
+
} finally { seen.delete(value); }
|
|
34
|
+
}
|
|
35
|
+
function inline(value: unknown): string {
|
|
36
|
+
shape(value);
|
|
37
|
+
// Bun emits scalars and many arrays as one assignment. Reuse that literal when it is safe in this scope.
|
|
38
|
+
// Objects/AOTs may become headers; those need the inline form below to avoid changing subsequent table scope.
|
|
39
|
+
let rendered = "";
|
|
40
|
+
try { rendered = Bun.TOML.stringify({ setup_value: value }); } catch { /* The validated inline encoder covers values the built-in writer cannot emit. */ }
|
|
41
|
+
const literal = /^setup_value\s*=\s*([^\r\n]*)\r?\n?$/.exec(rendered)?.[1];
|
|
42
|
+
if (literal !== undefined && isDeepStrictEqual(shape(parse(rendered).setup_value), shape(value))) return literal;
|
|
43
|
+
const encode = (part: unknown): string => {
|
|
44
|
+
if (typeof part === "string") return JSON.stringify(part);
|
|
45
|
+
if (typeof part === "boolean") return String(part);
|
|
46
|
+
if (typeof part === "number") return Number.isNaN(part) ? "nan" : part === Infinity ? "inf" : part === -Infinity ? "-inf" : Object.is(part, -0) ? "-0.0" : String(part);
|
|
47
|
+
if (temporal.has(Object.prototype.toString.call(part))) return String(part);
|
|
48
|
+
if (Array.isArray(part)) return `[${part.map(encode).join(", ")}]`;
|
|
49
|
+
if (plain(part)) return `{ ${Object.keys(part).map(key => `${JSON.stringify(key)} = ${encode(part[key])}`).join(", ")} }`;
|
|
50
|
+
return error();
|
|
51
|
+
};
|
|
52
|
+
return encode(value);
|
|
53
|
+
}
|
|
54
|
+
function at(document: Record<string, unknown>, path: readonly string[]): unknown {
|
|
55
|
+
let value: unknown = document;
|
|
56
|
+
for (const key of path) { if (!plain(value) || !Object.hasOwn(value, key)) return undefined; value = value[key]; }
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
function edit(document: Record<string, unknown>, path: readonly string[], value: unknown): void {
|
|
60
|
+
let parent = document;
|
|
61
|
+
for (const key of path.slice(0, -1)) {
|
|
62
|
+
if (!Object.hasOwn(parent, key)) Object.defineProperty(parent, key, { value: Object.create(null), writable: true, configurable: true, enumerable: true });
|
|
63
|
+
if (!plain(parent[key])) return error(); // String paths cannot identify one element of an array of tables.
|
|
64
|
+
parent = parent[key] as Record<string, unknown>;
|
|
65
|
+
}
|
|
66
|
+
const key = path.at(-1)!;
|
|
67
|
+
if (value === undefined) delete parent[key];
|
|
68
|
+
else Object.defineProperty(parent, key, { value, writable: true, configurable: true, enumerable: true });
|
|
69
|
+
}
|
|
70
|
+
interface Statement { start: number; end: number; equal?: number; comment?: number; path: string[]; header: boolean; array: boolean }
|
|
71
|
+
/** Bun decodes quoted/dotted keys; the lexical scan below only locates complete statement boundaries. */
|
|
72
|
+
function keyPath(source: string, header: boolean): string[] {
|
|
73
|
+
let value: unknown = parse(header ? source : `${source} = 0`); const keys: string[] = [];
|
|
74
|
+
while (plain(value) && Object.keys(value).length === 1) {
|
|
75
|
+
const key = Object.keys(value)[0]!; keys.push(key); value = value[key];
|
|
76
|
+
if (Array.isArray(value)) { if (value.length !== 1) return error(); value = value[0]; }
|
|
77
|
+
}
|
|
78
|
+
if (!keys.length || header && (!plain(value) || Object.keys(value).length)) return error();
|
|
79
|
+
return keys;
|
|
80
|
+
}
|
|
81
|
+
function statements(text: string): Statement[] {
|
|
82
|
+
const out: Statement[] = []; let offset = 0, table: string[] = [];
|
|
83
|
+
while (offset < text.length) {
|
|
84
|
+
while (offset < text.length) {
|
|
85
|
+
if (/\s/.test(text[offset]!)) { offset++; continue; }
|
|
86
|
+
if (text[offset] === "#") { while (offset < text.length && text[offset] !== "\n") offset++; continue; }
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
if (offset >= text.length) break;
|
|
90
|
+
const start = offset; let equal: number | undefined, comment: number | undefined, depth = 0, quote: string | undefined, multi = false;
|
|
91
|
+
while (offset < text.length) {
|
|
92
|
+
const char = text[offset]!;
|
|
93
|
+
if (quote) {
|
|
94
|
+
if (quote === '"' && char === "\\") { offset += Math.min(2, text.length - offset); continue; }
|
|
95
|
+
if (char === quote) {
|
|
96
|
+
let run = 1; while (text[offset + run] === quote) run++;
|
|
97
|
+
if (!multi || run >= 3) { quote = undefined; offset += multi ? run : 1; multi = false; continue; }
|
|
98
|
+
offset += run; continue;
|
|
99
|
+
}
|
|
100
|
+
offset++; continue;
|
|
101
|
+
}
|
|
102
|
+
if (char === '"' || char === "'") { quote = char; multi = text.slice(offset, offset + 3) === char.repeat(3); offset += multi ? 3 : 1; continue; }
|
|
103
|
+
if (char === "#") {
|
|
104
|
+
if (depth === 0) comment = offset;
|
|
105
|
+
while (offset < text.length && text[offset] !== "\n") offset++;
|
|
106
|
+
if (depth === 0) { if (offset < text.length) offset++; break; } continue;
|
|
107
|
+
}
|
|
108
|
+
if (char === "[" || char === "{") depth++;
|
|
109
|
+
else if (char === "]" || char === "}") depth--;
|
|
110
|
+
else if (char === "=" && depth === 0 && equal === undefined) equal = offset;
|
|
111
|
+
if (depth < 0 || depth > MAX_DEPTH) return error();
|
|
112
|
+
offset++;
|
|
113
|
+
if (char === "\n" && depth === 0) break;
|
|
114
|
+
}
|
|
115
|
+
if (quote || depth !== 0 || out.length >= MAX_NODES) return error();
|
|
116
|
+
const header = text[start] === "[", array = header && text[start + 1] === "[";
|
|
117
|
+
if (header) table = keyPath(text.slice(start, comment ?? offset).trim(), true);
|
|
118
|
+
else if (equal === undefined) return error();
|
|
119
|
+
out.push({ start, end: offset, equal, comment, path: header ? [...table] : [...table, ...keyPath(text.slice(start, equal), false)], header, array });
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
interface Replacement { start: number; end: number; text: string }
|
|
124
|
+
function replace(text: string, changes: Replacement[]): string {
|
|
125
|
+
for (const change of changes.sort((a, b) => b.start - a.start || b.end - a.end)) text = text.slice(0, change.start) + change.text + text.slice(change.end);
|
|
126
|
+
return text;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Change one configuration subtree while preserving other statements/comments byte-for-byte.
|
|
130
|
+
* ponytail: <=8 MiB, depth32 and string table paths only. Array-element editing requires a richer host API;
|
|
131
|
+
* unsupported layouts/values refuse after Bun parse/semantic verification, never fall back to rewriting the document.
|
|
132
|
+
*/
|
|
133
|
+
export function setTomlPath(text: string, path: readonly string[], value: unknown | undefined): string {
|
|
134
|
+
return editTomlPath(text, path, value);
|
|
135
|
+
}
|
|
136
|
+
function editTomlPath(text: string, path: readonly string[], value: unknown | undefined, eol = text.includes("\r\n") ? "\r\n" : "\n"): string {
|
|
137
|
+
if (!path.length || path.length > 8 || path.some(key => typeof key !== "string" || Buffer.byteLength(key) > 256 || /[\p{Cc}\p{Cf}]/u.test(key))) return error();
|
|
138
|
+
const original = parse(text), current = at(original, path);
|
|
139
|
+
shape(original);
|
|
140
|
+
let parent: unknown = original;
|
|
141
|
+
for (const key of path.slice(0, -1)) {
|
|
142
|
+
if (!plain(parent)) return error();
|
|
143
|
+
if (!Object.hasOwn(parent, key)) break;
|
|
144
|
+
parent = parent[key]; if (!plain(parent)) return error();
|
|
145
|
+
}
|
|
146
|
+
if (value === undefined && current === undefined || value !== undefined && current !== undefined && isDeepStrictEqual(shape(current), shape(value))) return text;
|
|
147
|
+
if (value !== undefined) shape(value);
|
|
148
|
+
const expected = parse(text); edit(expected, path, value); shape(expected);
|
|
149
|
+
const units = statements(text), ancestor = units.find(unit => !unit.header && prefix(unit.path, path));
|
|
150
|
+
let output: string;
|
|
151
|
+
if (ancestor) {
|
|
152
|
+
const replacement = at(expected, ancestor.path);
|
|
153
|
+
const suffix = ancestor.comment !== undefined ? " " + text.slice(ancestor.comment, ancestor.end) : /\r?\n$/.exec(text.slice(ancestor.start, ancestor.end))?.[0] ?? "";
|
|
154
|
+
output = replace(text, [{ start: ancestor.start, end: ancestor.end, text: replacement === undefined ? "" : `${text.slice(ancestor.start, ancestor.equal! + 1)} ${inline(replacement)}${suffix}` }]);
|
|
155
|
+
} else {
|
|
156
|
+
const changes: Replacement[] = units.filter(unit => prefix(path, unit.path)).map(unit => ({ start: unit.start, end: unit.end, text: "" }));
|
|
157
|
+
if (value !== undefined) {
|
|
158
|
+
const parent = units.filter(unit => unit.header && !unit.array && unit.path.length < path.length && prefix(unit.path, path)).sort((a, b) => b.path.length - a.path.length)[0];
|
|
159
|
+
const position = parent?.end ?? units.find(unit => unit.header)?.start ?? text.length;
|
|
160
|
+
const key = path.slice(parent?.path.length ?? 0).map(part => JSON.stringify(part)).join(".");
|
|
161
|
+
const newline = position > 0 && text[position - 1] !== "\n" ? eol : "";
|
|
162
|
+
changes.push({ start: position, end: position, text: `${newline}${key} = ${inline(value)}${eol}` });
|
|
163
|
+
}
|
|
164
|
+
output = replace(text, changes);
|
|
165
|
+
// Removing the only child of an implicit table must retain its parsed empty parent value.
|
|
166
|
+
for (let depth = path.length - 1; depth > 0; depth--) {
|
|
167
|
+
const parent = path.slice(0, depth), wanted = at(expected, parent);
|
|
168
|
+
if (plain(wanted) && !Object.keys(wanted).length && at(parse(output), parent) === undefined) output += `${output && !output.endsWith("\n") ? eol : ""}[${parent.map(part => JSON.stringify(part)).join(".")}]${eol}`;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (!isDeepStrictEqual(shape(parse(output)), shape(expected))) return error();
|
|
172
|
+
return output;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function quotedEnd(text: string, start: number): number {
|
|
176
|
+
const quote = text[start]!, multi = text.slice(start, start + 3) === quote.repeat(3); let offset = start + (multi ? 3 : 1);
|
|
177
|
+
while (offset < text.length) {
|
|
178
|
+
if (quote === '"' && text[offset] === "\\") { offset += 2; continue; }
|
|
179
|
+
if (text[offset] === quote) {
|
|
180
|
+
let run = 1; while (text[offset + run] === quote) run++;
|
|
181
|
+
if (!multi || run >= 3) return offset + (multi ? run : 1);
|
|
182
|
+
offset += run; continue;
|
|
183
|
+
}
|
|
184
|
+
offset++;
|
|
185
|
+
}
|
|
186
|
+
return error();
|
|
187
|
+
}
|
|
188
|
+
interface ArrayElement { start: number; end: number; removeFrom: number; comma?: number }
|
|
189
|
+
function arrayElements(text: string, open: number): { close: number; elements: ArrayElement[] } {
|
|
190
|
+
if (text[open] !== "[") return error();
|
|
191
|
+
let offset = open + 1; const elements: ArrayElement[] = [];
|
|
192
|
+
while (offset < text.length) {
|
|
193
|
+
let removeFrom = offset;
|
|
194
|
+
while (offset < text.length) {
|
|
195
|
+
if (/\s/.test(text[offset]!)) { offset++; continue; }
|
|
196
|
+
if (text[offset] === "#") {
|
|
197
|
+
while (offset < text.length && text[offset] !== "\n") offset++;
|
|
198
|
+
// Trim indentation after a comment, but preserve its terminating newline.
|
|
199
|
+
removeFrom = offset < text.length ? offset + 1 : offset; continue;
|
|
200
|
+
}
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
if (text[offset] === "]") return { close: offset, elements };
|
|
204
|
+
const start = offset; let end = offset, depth = 0;
|
|
205
|
+
while (offset < text.length) {
|
|
206
|
+
const char = text[offset]!;
|
|
207
|
+
if (char === '"' || char === "'") { offset = quotedEnd(text, offset); end = offset; continue; }
|
|
208
|
+
if (char === "#") { while (offset < text.length && text[offset] !== "\n") offset++; continue; }
|
|
209
|
+
if (char === "," && depth === 0) { elements.push({ start, end, removeFrom, comma: offset }); offset++; break; }
|
|
210
|
+
if (char === "]" && depth === 0) { elements.push({ start, end, removeFrom }); return { close: offset, elements }; }
|
|
211
|
+
if (char === "[" || char === "{") depth++;
|
|
212
|
+
else if (char === "]" || char === "}") depth--;
|
|
213
|
+
if (depth < 0 || depth > MAX_DEPTH) return error();
|
|
214
|
+
offset++; if (!/\s/.test(char)) end = offset;
|
|
215
|
+
}
|
|
216
|
+
if (elements.length > MAX_NODES) return error();
|
|
217
|
+
}
|
|
218
|
+
return error();
|
|
219
|
+
}
|
|
220
|
+
/** Keep an ordered subsequence of existing elements, optionally adding new elements only at the tail. */
|
|
221
|
+
function arrayEdit(before: readonly unknown[], after: readonly unknown[]): { keep: Set<number>; append: readonly unknown[] } {
|
|
222
|
+
const old = before.map(value => JSON.stringify(shape(value))), next = after.map(value => JSON.stringify(shape(value)));
|
|
223
|
+
const keep = new Set<number>(); let cursor = 0, tail = next.length;
|
|
224
|
+
for (let i = 0; i < next.length; i++) {
|
|
225
|
+
while (cursor < old.length && old[cursor] !== next[i]) cursor++;
|
|
226
|
+
if (cursor === old.length) { tail = i; break; }
|
|
227
|
+
keep.add(cursor++);
|
|
228
|
+
}
|
|
229
|
+
const removed = new Set(old.filter((_value, index) => !keep.has(index)));
|
|
230
|
+
// A reorder must not masquerade as deleting an old element and appending its normalized serialization.
|
|
231
|
+
if (next.slice(tail).some(value => removed.has(value))) return error();
|
|
232
|
+
if (keep.size < before.length && tail < after.length) return error(); // Refuse replacement disguised as simultaneous remove/append.
|
|
233
|
+
return { keep, append: after.slice(tail) };
|
|
234
|
+
}
|
|
235
|
+
function appendedTables(path: readonly string[], values: readonly unknown[], eol: string): string {
|
|
236
|
+
if (!values.every(plain)) return error();
|
|
237
|
+
const document: Record<string, unknown> = {}; edit(document, path, [...values]);
|
|
238
|
+
let source: string; try { source = Bun.TOML.stringify(document); } catch { return error(); }
|
|
239
|
+
const sourceUnits = statements(source), rows = sourceUnits.filter(unit => prefix(path, unit.path));
|
|
240
|
+
if (rows.filter(unit => unit.header && unit.array && unit.path.length === path.length).length !== values.length) return error();
|
|
241
|
+
return rows.map(unit => source.slice(unit.start, unit.end)).join("").replace(/\r?\n/g, eol);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Append/remove an array subsequence without reserializing surviving elements or their comments/numeric spelling.
|
|
245
|
+
* ponytail: exact array assignments and arrays of tables only; arrays nested inside inline parent tables and
|
|
246
|
+
* simultaneous remove+append, element replacement and reordering refuse. Use setTomlPath for an explicitly authorized whole-subtree replacement.
|
|
247
|
+
*/
|
|
248
|
+
export function updateTomlArray(text: string, path: readonly string[], expectedArray: readonly unknown[]): string {
|
|
249
|
+
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();
|
|
250
|
+
const original = parse(text), current = at(original, path); shape(original); shape(expectedArray);
|
|
251
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
252
|
+
if (current === undefined) return editTomlPath(text, path, [...expectedArray], eol);
|
|
253
|
+
if (!Array.isArray(current)) return error();
|
|
254
|
+
if (isDeepStrictEqual(shape(current), shape(expectedArray))) return text;
|
|
255
|
+
const expected = parse(text); edit(expected, path, [...expectedArray]);
|
|
256
|
+
const { keep, append } = arrayEdit(current, expectedArray), units = statements(text);
|
|
257
|
+
const assignment = units.find(unit => !unit.header && unit.path.length === path.length && prefix(path, unit.path));
|
|
258
|
+
const changes: Replacement[] = []; let output: string;
|
|
259
|
+
if (assignment) {
|
|
260
|
+
let open = assignment.equal! + 1; while (/\s/.test(text[open] ?? "")) open++;
|
|
261
|
+
const array = arrayElements(text, open);
|
|
262
|
+
if (array.elements.length !== current.length) return error();
|
|
263
|
+
for (let index = 0; index < array.elements.length; index++) if (!keep.has(index)) {
|
|
264
|
+
const element = array.elements[index]!; changes.push({ start: element.removeFrom, end: element.end, text: "" });
|
|
265
|
+
if (element.comma !== undefined) changes.push({ start: element.comma, end: element.comma + 1, text: "" });
|
|
266
|
+
}
|
|
267
|
+
if (append.length) {
|
|
268
|
+
const last = [...keep].at(-1), element = last === undefined ? undefined : array.elements[last];
|
|
269
|
+
const adjacentComma = element && element.comma === undefined && element.end === array.close ? "," : "";
|
|
270
|
+
if (element && element.comma === undefined && !adjacentComma) changes.push({ start: element.end, end: element.end, text: "," });
|
|
271
|
+
const multiline = text.slice(open, array.close).includes("\n"), indent = /(?:^|\n)([\t ]*)$/.exec(text.slice(open, array.close))?.[1] ?? "";
|
|
272
|
+
changes.push({ start: array.close, end: array.close, text: `${adjacentComma} ${append.map(inline).join(multiline ? `,${eol}${indent}` : ", ")}${multiline ? eol + indent : ""}` });
|
|
273
|
+
}
|
|
274
|
+
output = replace(text, changes);
|
|
275
|
+
} else {
|
|
276
|
+
let index = -1, headers = 0;
|
|
277
|
+
for (const unit of units) {
|
|
278
|
+
if (unit.header && unit.array && unit.path.length === path.length && prefix(path, unit.path)) { index++; headers++; }
|
|
279
|
+
if (prefix(path, unit.path)) {
|
|
280
|
+
if (index < 0) return error();
|
|
281
|
+
if (!keep.has(index)) changes.push({ start: unit.start, end: unit.end, text: "" });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (headers !== current.length) return error();
|
|
285
|
+
output = replace(text, changes);
|
|
286
|
+
if (append.length) output += `${output && !output.endsWith("\n") ? eol : ""}${appendedTables(path, append, eol)}`;
|
|
287
|
+
if (!keep.size && !append.length) output = editTomlPath(output, path, [], eol);
|
|
288
|
+
}
|
|
289
|
+
if (!isDeepStrictEqual(shape(parse(output)), shape(expected))) return error();
|
|
290
|
+
return output;
|
|
291
|
+
}
|
package/src/setup.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { SETUP_CONFIG_BYTES, updateTomlArray } from "./setup-toml.ts";
|
|
2
|
+
import { managedAfter, mcpRecord, putMcpRecord, restoreOwned, sameImage, type ConfigImage } from "./setup-owned.ts";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { isDeepStrictEqual } from "node:util";
|
|
11
|
+
import { HOOK_EVENTS } from "./hook-contract.ts";
|
|
12
|
+
import { memoryHome } from "./memory/home.ts";
|
|
13
|
+
import { canonicalPath, processLiveness, processSignature, realPath } from "./memory/platform.ts";
|
|
14
|
+
|
|
15
|
+
export const SETUP_TARGETS = ["claude", "codex", "kimi", "pi"] as const;
|
|
16
|
+
export type SetupTarget = typeof SETUP_TARGETS[number];
|
|
17
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
18
|
+
type Image = ConfigImage;
|
|
19
|
+
interface PlannedHook { event: string; command: string; timeout: number }
|
|
20
|
+
interface Change { key: string; path: string; before: Image; after: Image; start?: Image }
|
|
21
|
+
interface Receipt { version: 1; target: SetupTarget; intent: string; phase: "prepared" | "applied"; changes: Change[] }
|
|
22
|
+
export interface SetupContext {
|
|
23
|
+
env?: Env; bun?: string; entry?: string; stdinTTY?: boolean;
|
|
24
|
+
/** Trusted confirmation seam; ordinary CLI use always asks the person through stdin. */
|
|
25
|
+
confirm?: (plan: SetupPlan) => Promise<boolean>;
|
|
26
|
+
/** Host-only race fixture, called after temporary-file fsync and before the final comparison/rename. */
|
|
27
|
+
beforePublish?: (file: string) => void;
|
|
28
|
+
/** Trusted test seam; production always runs the named native CLI in a private staging HOME. */
|
|
29
|
+
native?: (argv: readonly string[], env: Env, cwd: string) => void;
|
|
30
|
+
}
|
|
31
|
+
export interface SetupPlan {
|
|
32
|
+
targets: SetupTarget[]; mode: "preview" | "apply" | "undo";
|
|
33
|
+
actions: { target: SetupTarget; files: string[]; receipt: string; registration: string[] | null; lifecycle: string; available: boolean; hooks: PlannedHook[]; mcpChange: "add" | "replace" | "restore" | "unchanged"; commands: string[][]; state: string }[];
|
|
34
|
+
notes: string[];
|
|
35
|
+
}
|
|
36
|
+
const MAX = SETUP_CONFIG_BYTES, JOURNAL_MAX = 12 * MAX;
|
|
37
|
+
const digest = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
38
|
+
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
39
|
+
function record(value: unknown): Record<string, unknown> {
|
|
40
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("setup configuration must be an object");
|
|
41
|
+
return value as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
function json(image: Image): Record<string, unknown> {
|
|
44
|
+
try { return image.bytes === null ? {} : record(JSON.parse(Buffer.from(image.bytes, "base64").toString("utf8"))); }
|
|
45
|
+
catch { throw Error("setup configuration does not parse as a JSON object"); }
|
|
46
|
+
}
|
|
47
|
+
function toml(text: string): Record<string, unknown> {
|
|
48
|
+
try { return record(Bun.TOML.parse(text)); } catch { throw Error("setup configuration does not parse as TOML"); }
|
|
49
|
+
}
|
|
50
|
+
function image(text: string, mode = 0o600): Image { return { bytes: Buffer.from(text).toString("base64"), mode }; }
|
|
51
|
+
function objectImage(value: unknown, before: Image): Image { return image(JSON.stringify(value, null, 2) + "\n", before.mode); }
|
|
52
|
+
function names(file: string): void {
|
|
53
|
+
// Configuration files and their containing directories cannot redirect an apply/undo through a symlink.
|
|
54
|
+
let cursor = file;
|
|
55
|
+
while (true) {
|
|
56
|
+
try { if (lstatSync(cursor).isSymbolicLink()) throw Error("setup refuses symbolic-link configuration paths"); }
|
|
57
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
58
|
+
const parent = dirname(cursor); if (parent === cursor) break; cursor = parent;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function read(file: string, max = MAX): Image {
|
|
62
|
+
names(file);
|
|
63
|
+
let fd: number;
|
|
64
|
+
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
65
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { bytes: null, mode: 0o600 }; throw error; }
|
|
66
|
+
try {
|
|
67
|
+
const stat = fstatSync(fd);
|
|
68
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > max) throw Error("setup configuration unavailable or oversized");
|
|
69
|
+
const data = Buffer.alloc(stat.size + 1), size = readSync(fd, data, 0, data.length, 0), after = fstatSync(fd);
|
|
70
|
+
if (size !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) throw Error("setup configuration changed while reading");
|
|
71
|
+
return { bytes: data.subarray(0, size).toString("base64"), mode: stat.mode & 0o777 };
|
|
72
|
+
} finally { closeSync(fd); }
|
|
73
|
+
}
|
|
74
|
+
function write(file: string, value: Image, expected?: Image, beforePublish?: (file: string) => void): void {
|
|
75
|
+
names(file);
|
|
76
|
+
if (value.bytes === null) { beforePublish?.(file); if (expected && !sameImage(read(file), expected)) throw Error("setup configuration changed before removal"); rmSync(file, { force: true }); return; }
|
|
77
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); names(dirname(file));
|
|
78
|
+
const temporary = join(dirname(file), `.amem-setup-${randomUUID()}`);
|
|
79
|
+
try {
|
|
80
|
+
const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
81
|
+
try { writeFileSync(fd, Buffer.from(value.bytes, "base64")); fchmodSync(fd, value.mode); fsyncSync(fd); }
|
|
82
|
+
finally { closeSync(fd); }
|
|
83
|
+
beforePublish?.(file);
|
|
84
|
+
if (expected && !sameImage(read(file), expected)) throw Error("setup configuration changed before publication");
|
|
85
|
+
// ponytail: an external CLI can still write between this comparison and rename; a shared CLI lock or native transactional API is needed to close that window.
|
|
86
|
+
names(file); renameSync(temporary, file); const parent = openSync(dirname(file), constants.O_RDONLY); try { fsyncSync(parent); } finally { closeSync(parent); }
|
|
87
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
88
|
+
}
|
|
89
|
+
function context(options: SetupContext) {
|
|
90
|
+
const env = options.env ?? process.env, home = realPath(resolve(env.HOME?.trim() || homedir()));
|
|
91
|
+
const root = (key: string, fallback: string) => {
|
|
92
|
+
if (!env[key]?.trim()) return join(home, fallback);
|
|
93
|
+
const given = resolve(env[key]!), withinHome = relative(resolve(env.HOME?.trim() || homedir()), given);
|
|
94
|
+
// Canonicalize the HOME alias only; named configuration-directory symlinks must still be refused.
|
|
95
|
+
return withinHome !== ".." && !withinHome.startsWith("../") && !isAbsolute(withinHome) ? join(home, withinHome) : given;
|
|
96
|
+
};
|
|
97
|
+
const claude = root("CLAUDE_CONFIG_DIR", ".claude"), codex = root("CODEX_HOME", ".codex"), kimi = root("KIMI_CODE_HOME", ".kimi-code"), pi = root("PI_CODING_AGENT_DIR", ".pi/agent");
|
|
98
|
+
const paths: Record<SetupTarget, string[]> = {
|
|
99
|
+
claude: [env.CLAUDE_CONFIG_DIR?.trim() ? join(claude, ".claude.json") : join(home, ".claude.json"), join(claude, "settings.json")],
|
|
100
|
+
codex: [join(codex, "config.toml"), join(codex, "hooks.json")],
|
|
101
|
+
kimi: [join(kimi, "mcp.json"), join(kimi, "config.toml")],
|
|
102
|
+
pi: [join(pi, "mcp.json"), join(pi, "extensions", "amem-session.ts")],
|
|
103
|
+
};
|
|
104
|
+
const requestedBun = options.bun ?? "bun", bun = requestedBun === "bun" ? Bun.which("bun", { PATH: env.PATH ?? process.env.PATH }) : requestedBun;
|
|
105
|
+
const entry = options.entry ?? fileURLToPath(new URL("../bin/amem.js", import.meta.url));
|
|
106
|
+
if (!bun || !isAbsolute(bun) || !isAbsolute(entry)) throw Error("setup needs a PATH-resolved absolute Bun runtime and an absolute installed entry");
|
|
107
|
+
// The trusted native fixture may use a temporary package; ordinary CLI installation must outlive bunx's cache.
|
|
108
|
+
if (!options.native) {
|
|
109
|
+
const roots = [resolve(tmpdir()), realPath(tmpdir())], entries = [resolve(entry), realPath(entry)];
|
|
110
|
+
if (roots.some(root => entries.some(file => { const part = relative(root, file); return part !== ".." && !part.startsWith("../") && !isAbsolute(part); }))) throw Error("setup refuses a temporary package entry; install amem in a persistent location first");
|
|
111
|
+
}
|
|
112
|
+
return { env, home, paths, bun, entry, state: join(canonicalPath(memoryHome({ ...env, AGENTHUB_HOME: env.AGENTHUB_HOME?.trim() || join(home, ".agenthub") })), "setup") };
|
|
113
|
+
}
|
|
114
|
+
function command(target: SetupTarget, bun: string, entry: string): string[] | null {
|
|
115
|
+
if (target === "kimi") return null;
|
|
116
|
+
return [target, "mcp", "add", ...(target === "claude" ? ["--scope", "user", "--transport", "stdio"] : []), "amem", "--", bun, "--smol", entry, "mcp"];
|
|
117
|
+
}
|
|
118
|
+
function intent(c: ReturnType<typeof context>, target: SetupTarget): string { return digest([1, target, c.paths[target], c.bun, c.entry]); }
|
|
119
|
+
function journal(c: ReturnType<typeof context>, target: SetupTarget): string { return join(c.state, `${target}.json`); }
|
|
120
|
+
function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRuntime = false): Receipt | undefined {
|
|
121
|
+
const raw = read(journal(c, target), JOURNAL_MAX); if (raw.bytes === null) return;
|
|
122
|
+
const value = record(JSON.parse(Buffer.from(raw.bytes, "base64").toString("utf8")));
|
|
123
|
+
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) throw Error("setup receipt does not match this installation");
|
|
124
|
+
for (let i = 0; i < value.changes.length; i++) {
|
|
125
|
+
const change = record(value.changes[i]); if (change.path !== c.paths[target][i] || change.key !== `${target}:${i}`) throw Error("setup receipt target mismatch");
|
|
126
|
+
for (const key of ["before", "after", ...(change.start !== undefined ? ["start"] : [])]) {
|
|
127
|
+
const v = record(change[key]);
|
|
128
|
+
if (!Number.isInteger(v.mode) || Number(v.mode) < 0 || Number(v.mode) > 0o777 || v.bytes !== null && (typeof v.bytes !== "string" || v.bytes.length > 2 * MAX || Buffer.from(v.bytes, "base64").toString("base64") !== v.bytes)) throw Error("setup receipt invalid");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return value as unknown as Receipt;
|
|
132
|
+
}
|
|
133
|
+
function plannedHooks(c: ReturnType<typeof context>, target: SetupTarget): PlannedHook[] {
|
|
134
|
+
const launch = `${quote(c.bun)} --smol ${quote(c.entry)}`;
|
|
135
|
+
return target === "claude" ? HOOK_EVENTS.map(event => ({ event, command: `${launch} hook ${event}`, timeout: 1 }))
|
|
136
|
+
: [{ event: target === "pi" ? "session_shutdown" : "SessionEnd", command: `${launch} ingest ${target}`, timeout: target === "pi" ? 2.5 : 3 }];
|
|
137
|
+
}
|
|
138
|
+
const lifecycle = (target: SetupTarget) => target === "claude" ? "seven bounded native hooks" : target === "pi" ? "session_shutdown incremental ingest" : "SessionEnd incremental ingest";
|
|
139
|
+
export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", options: SetupContext = {}): SetupPlan {
|
|
140
|
+
if (target !== "all" && !SETUP_TARGETS.includes(target as SetupTarget)) throw Error("usage: amem setup claude|codex|kimi|pi|all [--apply|--undo]");
|
|
141
|
+
const c = context(options), targets = target === "all" ? [...SETUP_TARGETS] : [target as SetupTarget];
|
|
142
|
+
const pluginNotes: string[] = [];
|
|
143
|
+
if (targets.includes("claude")) {
|
|
144
|
+
const settings = json(read(c.paths.claude[1]!));
|
|
145
|
+
try {
|
|
146
|
+
const registry = json(read(join(dirname(c.paths.claude[1]!), "plugins", "installed_plugins.json")));
|
|
147
|
+
const ids = new Set([...Object.keys(record(settings.enabledPlugins ?? {})), ...Object.keys(record(registry.plugins ?? {}))].filter(key => /^agent-mem(?:@[A-Za-z0-9._-]+)?$/.test(key)));
|
|
148
|
+
for (const id of ids) pluginNotes.push(`Installed Claude plugin ${id} detected: if enabled, direct setup would duplicate its MCP server and seven hooks. Choose plugin installation or direct setup; disable/uninstall the plugin before applying direct setup.`);
|
|
149
|
+
} catch { /* Optional plugin metadata must never prevent setup or recovery; authoritative settings/receipts keep their guards. */ }
|
|
150
|
+
}
|
|
151
|
+
return { targets, mode, actions: targets.map(target => {
|
|
152
|
+
const prior = receipt(c, target, false); for (const file of c.paths[target]) read(file);
|
|
153
|
+
const original = read(c.paths[target][0]!);
|
|
154
|
+
const document = target === "codex" ? toml(original.bytes === null ? "" : Buffer.from(original.bytes, "base64").toString("utf8")) : json(original);
|
|
155
|
+
const hasAmem = Object.hasOwn(record(document[target === "codex" ? "mcp_servers" : "mcpServers"] ?? {}), "amem");
|
|
156
|
+
const unchanged = !!prior && prior.phase === "applied" && prior.intent === intent(c, target) && prior.changes.every((change, index) => { try { const live = read(change.path); restoreChange(prior, change, index, live); return managedAfter(target, index, change.before, change.after, live); } catch { return false; } });
|
|
157
|
+
const add = command(target, c.bun, c.entry), remove = [target, "mcp", "remove", ...(target === "claude" ? ["--scope", "user"] : []), "amem"];
|
|
158
|
+
return { target, available: available(c, target, options), files: c.paths[target], receipt: journal(c, target), registration: mode === "undo" ? null : add, lifecycle: mode === "undo" ? "restore original MCP/hook files" : lifecycle(target), hooks: mode === "undo" || unchanged ? [] : plannedHooks(c, target), mcpChange: mode === "undo" ? prior ? "restore" : "unchanged" : unchanged ? "unchanged" : hasAmem ? "replace" : "add", commands: mode === "undo" || prior?.intent === intent(c, target) || !add || !available(c, target, options) ? [] : [...(hasAmem ? [remove] : []), add], state: prior && prior.intent !== intent(c, target) ? "runtime changed; managed entries will update" : prior?.phase ?? "not installed" };
|
|
159
|
+
}), notes: [...pluginNotes, ...(/(?:\/installs\/bun\/[^/]+|\/Cellar\/bun\/[^/]+)\/(?:bin\/)?bun$/.test(c.bun) ? ["this bun path is version-specific; rerun setup --apply after changing Bun"] : []), "Preview only unless --apply or --undo is confirmed in a person's terminal.", "Native registration commands run in a private staging HOME; only the listed configuration files are published.", "Original file bytes restore exactly when untouched; otherwise undo preserves CLI-owned changes and restores only managed entries, refusing edits to those entries.", "No AGENTS.md, CLAUDE.md or rule files are edited.", `A private ${join(c.state, "setup.lock")} serializes mutations; temporary ${join(realPath(tmpdir()), "amem-setup-*")} staging homes are removed.`, "Codex requires its native /hooks review before newly registered hooks run; setup does not grant hook trust. Runtime registrations use the PATH-resolved absolute Bun path without resolving its stable symlink; setup all reports and skips missing clients."] };
|
|
160
|
+
}
|
|
161
|
+
function verifyNativeMcp(target: SetupTarget, env: Env, cwd: string): void {
|
|
162
|
+
// In older free-form Pi CLIs, an unknown subcommand can be treated as a model prompt.
|
|
163
|
+
const help = spawnSync(target, ["--help"], { cwd, env: { ...env }, encoding: "utf8", timeout: 5000, maxBuffer: MAX, stdio: ["ignore", "pipe", "pipe"] });
|
|
164
|
+
if (help.status !== 0 || !/^\s+(?:pi\s+)?mcp(?:\s|$)/m.test(help.stdout)) throw Error(`installed ${target} CLI does not expose MCP registration; no registration command was run`);
|
|
165
|
+
}
|
|
166
|
+
function native(argv: readonly string[], env: Env, cwd: string): void {
|
|
167
|
+
const result = spawnSync(argv[0]!, argv.slice(1), { cwd, env: { ...env }, encoding: "utf8", timeout: 10_000, maxBuffer: MAX, stdio: ["ignore", "pipe", "pipe"] });
|
|
168
|
+
if (result.status !== 0) throw Error(`${argv[0]} MCP registration failed in staging; no native output disclosed`);
|
|
169
|
+
}
|
|
170
|
+
function hookDocument(before: Image, events: readonly string[], commandFor: (event: string) => string, timeout = 1): Image {
|
|
171
|
+
const document = json(before), hooks = document.hooks === undefined ? {} : record(document.hooks);
|
|
172
|
+
for (const event of events) {
|
|
173
|
+
const rows = hooks[event] ?? []; if (!Array.isArray(rows)) throw Error("setup hook groups must be arrays");
|
|
174
|
+
const command = commandFor(event);
|
|
175
|
+
if (!rows.some(row => [undefined, "", "*"].includes(row?.matcher) && Array.isArray(row?.hooks) && row.hooks.some((h: unknown) => { const value = record(h); return value.type === "command" && value.command === command && value.timeout === timeout; }))) rows.push({ hooks: [{ type: "command", command, timeout }] });
|
|
176
|
+
hooks[event] = rows;
|
|
177
|
+
}
|
|
178
|
+
document.hooks = hooks; return objectImage(document, before);
|
|
179
|
+
}
|
|
180
|
+
function piExtension(bun: string, entry: string): string {
|
|
181
|
+
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`;
|
|
182
|
+
}
|
|
183
|
+
interface Prepared { value: Receipt; expected: Image[]; unchanged: boolean }
|
|
184
|
+
function restoreChange(value: Receipt, change: Change, index: number, live: Image): Image {
|
|
185
|
+
const start = value.phase === "prepared" && change.start && !sameImage(change.start, change.before) ? change.start : undefined;
|
|
186
|
+
if (start) {
|
|
187
|
+
const newest = managedAfter(value.target, index, change.before, change.after, live), previous = managedAfter(value.target, index, change.before, start, live);
|
|
188
|
+
if (newest && previous) return restoreOwned(value.target, index, change.before, start, restoreOwned(value.target, index, change.before, change.after, live));
|
|
189
|
+
if (previous) return restoreOwned(value.target, index, change.before, start, live);
|
|
190
|
+
if (newest) return restoreOwned(value.target, index, change.before, change.after, live);
|
|
191
|
+
}
|
|
192
|
+
return restoreOwned(value.target, index, change.before, change.after, live);
|
|
193
|
+
}
|
|
194
|
+
function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: SetupContext): Prepared {
|
|
195
|
+
const prior = receipt(c, target, false), expected = c.paths[target].map(path => read(path));
|
|
196
|
+
const restored = prior ? prior.changes.map((change, index) => restoreChange(prior, change, index, expected[index]!)) : expected;
|
|
197
|
+
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 };
|
|
198
|
+
const changes: Change[] = c.paths[target].map((path, i) => ({ key: `${target}:${i}`, path, before: restored[i]!, after: restored[i]!, start: expected[i]! }));
|
|
199
|
+
const staging = mkdtempSync(join(realPath(tmpdir()), "amem-setup-"));
|
|
200
|
+
try {
|
|
201
|
+
const env: Record<string, string | undefined> = { ...c.env, HOME: staging, AMEM_HOME: join(staging, "memory"), AGENTHUB_HOME: join(staging, "hub"), CODEX_HOME: join(staging, ".codex"), KIMI_CODE_HOME: join(staging, ".kimi-code"), PI_CODING_AGENT_DIR: join(staging, ".pi", "agent") };
|
|
202
|
+
Object.assign(env, { XDG_CONFIG_HOME: join(staging, ".config"), XDG_DATA_HOME: join(staging, ".local", "share"), XDG_CACHE_HOME: join(staging, ".cache"), XDG_STATE_HOME: join(staging, ".local", "state"), TMPDIR: join(staging, "tmp"), TMP: join(staging, "tmp"), TEMP: join(staging, "tmp") });
|
|
203
|
+
mkdirSync(env.TMPDIR!, { mode: 0o700 });
|
|
204
|
+
if (c.env.CLAUDE_CONFIG_DIR?.trim()) env.CLAUDE_CONFIG_DIR = join(staging, ".claude"); else delete env.CLAUDE_CONFIG_DIR;
|
|
205
|
+
const staged = context({ ...options, env });
|
|
206
|
+
// Codex refuses an explicit CODEX_HOME that does not exist, even for mcp add.
|
|
207
|
+
mkdirSync(dirname(staged.paths[target][0]!), { recursive: true, mode: 0o700 });
|
|
208
|
+
for (let i = 0; i < changes.length; i++) if (expected[i]!.bytes !== null) write(staged.paths[target][i]!, expected[i]!);
|
|
209
|
+
const argv = command(target, c.bun, c.entry);
|
|
210
|
+
if (argv && prior?.intent === intent(c, target)) {
|
|
211
|
+
changes[0]!.after = putMcpRecord(target, changes[0]!.before, mcpRecord(target, prior.changes[0]!.after));
|
|
212
|
+
} else if (argv) {
|
|
213
|
+
if (!options.native) verifyNativeMcp(target, env, staging);
|
|
214
|
+
const text = expected[0]!.bytes === null ? "" : Buffer.from(expected[0]!.bytes!, "base64").toString("utf8");
|
|
215
|
+
const document = target === "codex" ? toml(text) : json(expected[0]!);
|
|
216
|
+
const servers = document[target === "codex" ? "mcp_servers" : "mcpServers"];
|
|
217
|
+
if (servers && Object.hasOwn(record(servers), "amem")) (options.native ?? native)([target, "mcp", "remove", ...(target === "claude" ? ["--scope", "user"] : []), "amem"], env, staging);
|
|
218
|
+
(options.native ?? native)(argv, env, staging);
|
|
219
|
+
changes[0]!.after = { ...read(staged.paths[target][0]!), mode: changes[0]!.before.mode };
|
|
220
|
+
if (changes[0]!.after.bytes === null) throw Error("native registration produced no configuration");
|
|
221
|
+
const installedText = Buffer.from(changes[0]!.after.bytes!, "base64").toString("utf8");
|
|
222
|
+
const installed = target === "codex" ? toml(installedText) : json(changes[0]!.after);
|
|
223
|
+
const installedServer = record(record(installed[target === "codex" ? "mcp_servers" : "mcpServers"]).amem);
|
|
224
|
+
if (installedServer.command !== c.bun || JSON.stringify(installedServer.args) !== JSON.stringify(["--smol", c.entry, "mcp"]) || installedServer.enabled === false) throw Error("native registration did not produce the planned amem server");
|
|
225
|
+
const serverKey = target === "codex" ? "mcp_servers" : "mcpServers";
|
|
226
|
+
const withoutAmem = (value: Record<string, unknown>) => { const copy = { ...value }, servers = { ...record(value[serverKey] ?? {}) }; delete servers.amem; copy[serverKey] = servers; return copy; };
|
|
227
|
+
if (!isDeepStrictEqual(withoutAmem(document)[serverKey], withoutAmem(installed)[serverKey])) throw Error("native registration changed unrelated MCP servers; no configuration published");
|
|
228
|
+
if (target === "codex") {
|
|
229
|
+
if (!isDeepStrictEqual(withoutAmem(document), withoutAmem(installed))) throw Error("native registration changed unrelated settings; no configuration published");
|
|
230
|
+
} else {
|
|
231
|
+
// Keep all unrelated JSON settings from the original; use only the CLI-generated server record.
|
|
232
|
+
document.mcpServers = { ...record(document.mcpServers ?? {}), amem: installedServer };
|
|
233
|
+
changes[0]!.after = objectImage(document, changes[0]!.before);
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
const document = json(changes[0]!.before), servers = document.mcpServers === undefined ? {} : record(document.mcpServers);
|
|
237
|
+
servers.amem = { command: c.bun, args: ["--smol", c.entry, "mcp"] }; document.mcpServers = servers;
|
|
238
|
+
changes[0]!.after = objectImage(document, changes[0]!.before);
|
|
239
|
+
}
|
|
240
|
+
const capture = (kind: string) => `${quote(c.bun)} --smol ${quote(c.entry)} ingest ${kind}`;
|
|
241
|
+
if (target === "claude") changes[1]!.after = hookDocument(changes[1]!.before, HOOK_EVENTS, event => `${quote(c.bun)} --smol ${quote(c.entry)} hook ${event}`);
|
|
242
|
+
if (target === "codex") changes[1]!.after = hookDocument(changes[1]!.before, ["SessionEnd"], () => capture("codex"), 3);
|
|
243
|
+
if (target === "kimi") {
|
|
244
|
+
const old = changes[1]!.before.bytes === null ? "" : Buffer.from(changes[1]!.before.bytes!, "base64").toString("utf8");
|
|
245
|
+
const parsed = toml(old), hooks = parsed.hooks ?? [];
|
|
246
|
+
if (!Array.isArray(hooks)) throw Error("Kimi hooks must be an array");
|
|
247
|
+
if (!hooks.some(h => record(h).event === "SessionEnd" && record(h).command === capture("kimi"))) hooks.push({ event: "SessionEnd", command: capture("kimi"), timeout: 3 });
|
|
248
|
+
changes[1]!.after = image(updateTomlArray(old, ["hooks"], hooks), changes[1]!.before.mode);
|
|
249
|
+
}
|
|
250
|
+
if (target === "pi") {
|
|
251
|
+
const content = piExtension(c.bun, c.entry);
|
|
252
|
+
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");
|
|
253
|
+
changes[1]!.after = image(content, changes[1]!.before.mode);
|
|
254
|
+
}
|
|
255
|
+
// Never publish an image that this installer's own read/undo boundary cannot reopen.
|
|
256
|
+
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");
|
|
257
|
+
return { value: { version: 1, target, intent: intent(c, target), phase: "prepared", changes }, expected, unchanged: false };
|
|
258
|
+
} finally { rmSync(staging, { recursive: true, force: true }); }
|
|
259
|
+
}
|
|
260
|
+
function save(c: ReturnType<typeof context>, value: Receipt): void { write(journal(c, value.target), image(JSON.stringify(value) + "\n")); }
|
|
261
|
+
function available(c: ReturnType<typeof context>, target: SetupTarget, options: SetupContext): boolean { return !!options.native || Bun.which(target, { PATH: c.env.PATH ?? process.env.PATH }) !== null; }
|
|
262
|
+
export interface SetupApplyResult { applied: SetupTarget[]; unchanged: SetupTarget[]; skipped?: { target: SetupTarget; reason: string }[] }
|
|
263
|
+
/** Caller enforces person markers/TTY and confirmation; CLI-owned unrelated fields remain live. */
|
|
264
|
+
function applyUnlocked(plan: SetupPlan, options: SetupContext = {}): SetupApplyResult {
|
|
265
|
+
const c = context(options), skipped: { target: SetupTarget; reason: string }[] = [];
|
|
266
|
+
const targets = plan.targets.filter(target => { if (available(c, target, options)) return true; if (plan.targets.length === 1) throw Error(`${target} CLI is not installed`); skipped.push({ target, reason: "CLI not installed" }); return false; });
|
|
267
|
+
const prepared = targets.map(target => prepare(c, target, options));
|
|
268
|
+
for (const item of prepared) if (!item.unchanged) for (let i = 0; i < item.value.changes.length; i++) if (!sameImage(read(item.value.changes[i]!.path), item.expected[i]!)) throw Error("setup configuration changed during preparation");
|
|
269
|
+
const applied: SetupTarget[] = [], unchanged: SetupTarget[] = [];
|
|
270
|
+
for (const item of prepared) {
|
|
271
|
+
const value = item.value;
|
|
272
|
+
if (item.unchanged) { unchanged.push(value.target); continue; }
|
|
273
|
+
save(c, value);
|
|
274
|
+
for (let i = 0; i < value.changes.length; i++) { const change = value.changes[i]!; if (!sameImage(item.expected[i]!, change.after)) write(change.path, change.after, item.expected[i]!, options.beforePublish); }
|
|
275
|
+
value.phase = "applied"; save(c, value); applied.push(value.target);
|
|
276
|
+
}
|
|
277
|
+
return { applied, unchanged, ...(skipped.length ? { skipped } : {}) };
|
|
278
|
+
}
|
|
279
|
+
/** Byte-exact for untouched files, otherwise remove/restore only this installer's owned entries. */
|
|
280
|
+
function undoUnlocked(plan: SetupPlan, options: SetupContext = {}): { restored: SetupTarget[] } {
|
|
281
|
+
const c = context(options), receipts = plan.targets.map(target => receipt(c, target, false)).filter((value): value is Receipt => !!value);
|
|
282
|
+
const pending = receipts.map(value => ({ value, files: value.changes.map((change, index) => { const expected = read(change.path); return { change, expected, restored: restoreChange(value, change, index, expected) }; }) }));
|
|
283
|
+
for (const item of pending) for (const file of item.files) if (!sameImage(read(file.change.path), file.expected)) throw Error("setup configuration changed during undo preparation");
|
|
284
|
+
for (const item of pending) { for (const file of item.files) if (!sameImage(file.expected, file.restored)) write(file.change.path, file.restored, file.expected, options.beforePublish); rmSync(journal(c, item.value.target)); }
|
|
285
|
+
return { restored: receipts.map(value => value.target) };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function withSetupLock<T>(options: SetupContext, run: () => T): T {
|
|
289
|
+
const c = context(options); names(c.state); mkdirSync(c.state, { recursive: true, mode: 0o700 });
|
|
290
|
+
if (lstatSync(c.state).mode & 0o077) throw Error("setup backup directory must be private");
|
|
291
|
+
const file = join(c.state, "setup.lock"), signature = processSignature(process.pid);
|
|
292
|
+
if (!signature) throw Error("setup process identity unavailable");
|
|
293
|
+
let fd: number;
|
|
294
|
+
try { fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
|
|
295
|
+
catch (error) {
|
|
296
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
297
|
+
const original = read(file), owner = original.bytes && record(JSON.parse(Buffer.from(original.bytes, "base64").toString("utf8")));
|
|
298
|
+
if (!owner || !Number.isSafeInteger(owner.pid) || Number(owner.pid) <= 0 || typeof owner.signature !== "string") throw Error("setup is busy or its lock is unreadable");
|
|
299
|
+
const live = processLiveness(Number(owner.pid)), actual = live === "live" ? processSignature(Number(owner.pid)) : undefined;
|
|
300
|
+
const stale = live === "gone" || live === "live" && actual !== undefined && actual !== owner.signature;
|
|
301
|
+
if (!stale || digest(read(file)) !== digest(original)) throw Error("setup is busy; retry after the active setup exits");
|
|
302
|
+
rmSync(file); fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
303
|
+
}
|
|
304
|
+
const token = JSON.stringify({ pid: process.pid, signature, nonce: randomUUID() });
|
|
305
|
+
try { writeFileSync(fd, token); fsyncSync(fd); return run(); }
|
|
306
|
+
finally { closeSync(fd); if (read(file).bytes === Buffer.from(token).toString("base64")) rmSync(file); }
|
|
307
|
+
}
|
|
308
|
+
export function applySetup(plan: SetupPlan, options: SetupContext = {}): SetupApplyResult {
|
|
309
|
+
return withSetupLock(options, () => applyUnlocked(plan, options));
|
|
310
|
+
}
|
|
311
|
+
export function undoSetup(plan: SetupPlan, options: SetupContext = {}): { restored: SetupTarget[] } {
|
|
312
|
+
return withSetupLock(options, () => undoUnlocked(plan, options));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function runSetup(target: string, mode: SetupPlan["mode"], options: SetupContext = {}): Promise<string> {
|
|
316
|
+
const plan = planSetup(target, mode, options);
|
|
317
|
+
if (mode === "preview") return JSON.stringify(plan, null, 2);
|
|
318
|
+
if (!(options.stdinTTY ?? process.stdin.isTTY)) throw Error("setup --apply/--undo require a terminal on stdin");
|
|
319
|
+
const confirm = options.confirm ?? (async (shown: SetupPlan) => {
|
|
320
|
+
process.stdout.write(JSON.stringify(shown, null, 2) + "\n");
|
|
321
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
322
|
+
try { return (await terminal.question(`Type yes to ${mode} the listed configuration changes: `)).trim() === "yes"; }
|
|
323
|
+
finally { terminal.close(); }
|
|
324
|
+
});
|
|
325
|
+
if (!await confirm(plan)) return JSON.stringify({ cancelled: true });
|
|
326
|
+
return JSON.stringify(mode === "apply" ? applySetup(plan, options) : undoSetup(plan, options), null, 2);
|
|
327
|
+
}
|