@staix/agent-mem 0.6.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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +12 -2
  3. package/README.md +135 -18
  4. package/package.json +1 -1
  5. package/src/cli.ts +476 -39
  6. package/src/commands-table.ts +53 -15
  7. package/src/hook-spool.ts +24 -2
  8. package/src/hooks.ts +9 -5
  9. package/src/index.ts +14 -0
  10. package/src/ingest.ts +3 -2
  11. package/src/mcp-memory.ts +141 -33
  12. package/src/mcp.ts +42 -7
  13. package/src/memory/aliases.ts +4 -0
  14. package/src/memory/bridge.ts +8 -2
  15. package/src/memory/candidate-pending.ts +119 -0
  16. package/src/memory/candidates.ts +144 -47
  17. package/src/memory/capacity-split.ts +76 -0
  18. package/src/memory/capacity.ts +139 -11
  19. package/src/memory/capture-input.ts +8 -3
  20. package/src/memory/capture.ts +30 -5
  21. package/src/memory/catalog.ts +43 -10
  22. package/src/memory/commands.ts +98 -44
  23. package/src/memory/config.ts +167 -0
  24. package/src/memory/cross-project.ts +75 -0
  25. package/src/memory/curated-read.ts +29 -0
  26. package/src/memory/decay-review.ts +136 -0
  27. package/src/memory/doctor.ts +412 -0
  28. package/src/memory/editor.ts +58 -0
  29. package/src/memory/eval-fixture.ts +137 -0
  30. package/src/memory/eval-set.json +443 -0
  31. package/src/memory/eval.ts +150 -0
  32. package/src/memory/gc.ts +97 -0
  33. package/src/memory/generation-index.ts +22 -17
  34. package/src/memory/generation-sync.ts +3 -0
  35. package/src/memory/goal-promotion.ts +71 -0
  36. package/src/memory/home.ts +15 -4
  37. package/src/memory/intent-brief.ts +89 -0
  38. package/src/memory/lifecycle-log.ts +54 -0
  39. package/src/memory/lifecycle.ts +61 -0
  40. package/src/memory/metrics.ts +2 -2
  41. package/src/memory/note-add.ts +100 -0
  42. package/src/memory/path-glob.ts +94 -0
  43. package/src/memory/plan-guard.ts +47 -0
  44. package/src/memory/policy.ts +44 -13
  45. package/src/memory/private-spans.ts +32 -0
  46. package/src/memory/project-export.ts +122 -0
  47. package/src/memory/recall-staleness.ts +56 -0
  48. package/src/memory/recall.ts +74 -0
  49. package/src/memory/repo-scope.ts +404 -0
  50. package/src/memory/shard-sync-format.ts +116 -0
  51. package/src/memory/shard-sync.ts +421 -0
  52. package/src/memory/store.ts +282 -27
  53. package/src/memory/sync-cadence.ts +139 -0
  54. package/src/memory/sync-screen.ts +86 -0
  55. package/src/memory/sync.ts +79 -11
  56. package/src/memory/usage.ts +35 -0
  57. package/src/memory/use-telemetry.ts +51 -0
  58. package/src/memory/work-lists.ts +10 -4
  59. package/src/session-brief-worker.ts +3 -2
  60. package/src/session-brief.ts +74 -15
  61. package/src/setup-owned.ts +120 -0
  62. package/src/setup-toml.ts +291 -0
  63. package/src/setup.ts +327 -0
@@ -0,0 +1,120 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { SETUP_CONFIG_BYTES, setTomlPath, updateTomlArray } from "./setup-toml.ts";
3
+ export interface ConfigImage { bytes: string | null; mode: number }
4
+ export const sameImage = (a: ConfigImage, b: ConfigImage): boolean => a.bytes === b.bytes && a.mode === b.mode;
5
+ const text = (image: ConfigImage) => image.bytes === null ? "" : new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(image.bytes, "base64"));
6
+ const object = (value: unknown): Record<string, unknown> => { if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("setup configuration must be an object"); return value as Record<string, unknown>; };
7
+ const json = (image: ConfigImage) => image.bytes === null ? {} : object(JSON.parse(text(image)));
8
+ const toml = (image: ConfigImage) => image.bytes === null ? {} : object(Bun.TOML.parse(text(image)));
9
+ const rendered = (value: string, current: ConfigImage): ConfigImage => ({ bytes: Buffer.from(value).toString("base64"), mode: current.mode });
10
+ function finishJson(value: Record<string, unknown>, current: ConfigImage, original: ConfigImage): ConfigImage {
11
+ if (isDeepStrictEqual(value, json(current))) return current;
12
+ if (original.bytes === null && Object.keys(value).length === 0) return { ...original, mode: current.mode };
13
+ const pretty = JSON.stringify(value, null, 2) + "\n", output = Buffer.byteLength(pretty) <= SETUP_CONFIG_BYTES ? pretty : JSON.stringify(value) + "\n";
14
+ if (Buffer.byteLength(output) > SETUP_CONFIG_BYTES) throw Error("semantic setup restore exceeds the size bound");
15
+ return rendered(output, current);
16
+ }
17
+ export function mcpRecord(target: string, image: ConfigImage): unknown {
18
+ const doc = target === "codex" ? toml(image) : json(image), servers = doc[target === "codex" ? "mcp_servers" : "mcpServers"];
19
+ return servers === undefined ? undefined : object(servers).amem;
20
+ }
21
+ export function putMcpRecord(target: string, current: ConfigImage, value: unknown): ConfigImage {
22
+ if (target === "codex") return rendered(setTomlPath(text(current), ["mcp_servers", "amem"], value), current);
23
+ const doc = json(current), servers = { ...object(doc.mcpServers ?? {}) };
24
+ if (value === undefined) delete servers.amem; else servers.amem = value;
25
+ if (Object.keys(servers).length) doc.mcpServers = servers; else delete doc.mcpServers;
26
+ return finishJson(doc, current, { bytes: null, mode: current.mode });
27
+ }
28
+ interface Hook { event: string; matcher: string; core: Record<string, unknown>; id: string; command: string }
29
+ const matcher = (value: unknown) => value === undefined || value === "" || value === "*" ? "*" : String(value);
30
+ function hook(event: string, group: Record<string, unknown>, value: unknown): Hook | undefined {
31
+ const h = object(value);
32
+ const core = h.type === "command" ? { type: h.type, command: h.command, ...(h.timeout !== undefined ? { timeout: h.timeout } : {}) }
33
+ : Object.fromEntries(Object.entries(h).filter(([key]) => !["trusted_hash", "trustedHash"].includes(key)).sort(([a], [b]) => a.localeCompare(b)));
34
+ const match = matcher(group.matcher);
35
+ return { event, matcher: match, core, command: typeof h.command === "string" ? h.command : "", id: JSON.stringify([event, match, core]) };
36
+ }
37
+ function hooks(doc: Record<string, unknown>): Hook[] {
38
+ const out: Hook[] = [];
39
+ for (const [event, raw] of Object.entries(object(doc.hooks ?? {}))) {
40
+ if (!Array.isArray(raw)) throw Error("setup hook groups must be arrays");
41
+ for (const value of raw) { const group = object(value); if (!Array.isArray(group.hooks)) throw Error("setup hook handlers must be arrays"); for (const h of group.hooks) { const row = hook(event, group, h); if (row) out.push(row); } }
42
+ }
43
+ return out;
44
+ }
45
+ function added<T>(before: T[], after: T[], key: (value: T) => string): T[] {
46
+ const counts = new Map<string, number>(); for (const row of before) counts.set(key(row), (counts.get(key(row)) ?? 0) + 1);
47
+ return after.filter(row => { const id = key(row), n = counts.get(id) ?? 0; if (!n) return true; counts.set(id, n - 1); return false; });
48
+ }
49
+ function restoreJsonHooks(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage {
50
+ const b = json(before), a = json(after), c = json(current), original = hooks(b), installed = hooks(a), live = hooks(c);
51
+ const ours = added(original, installed, row => row.id), remaining = new Map<string, number>();
52
+ for (const row of ours) {
53
+ const count = live.filter(value => value.id === row.id).length;
54
+ if (!count && live.some(value => value.command === row.command && !original.some(old => old.id === value.id))) throw Error("setup-owned hook changed; refusing to overwrite it");
55
+ if (count) remaining.set(row.id, (remaining.get(row.id) ?? 0) + 1);
56
+ }
57
+ const groups = object(c.hooks ?? {});
58
+ for (const [event, raw] of Object.entries(groups)) {
59
+ const next: unknown[] = [];
60
+ for (const value of raw as unknown[]) {
61
+ const group = object(value); let removed = false;
62
+ const handlers = (group.hooks as unknown[]).filter(h => {
63
+ const row = hook(event, group, h), n = row && (remaining.get(row.id) ?? 0);
64
+ if (!row || !n) return true; remaining.set(row.id, n - 1); removed = true; return false;
65
+ });
66
+ if (handlers.length || !removed) next.push({ ...group, hooks: handlers });
67
+ }
68
+ if (!next.length && object(b.hooks ?? {})[event] === undefined) delete groups[event]; else groups[event] = next;
69
+ }
70
+ if (!Object.keys(groups).length && b.hooks === undefined) delete c.hooks; else if (c.hooks !== undefined) c.hooks = groups;
71
+ return finishJson(c, current, before);
72
+ }
73
+ function kimiHooks(image: ConfigImage): Record<string, unknown>[] {
74
+ const value = toml(image).hooks ?? []; if (!Array.isArray(value)) throw Error("Kimi hooks must be an array"); return value.map(object);
75
+ }
76
+ const kimiKey = (row: Record<string, unknown>) => JSON.stringify([row.event, row.command, matcher(row.matcher), row.timeout]);
77
+ function restoreKimi(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage {
78
+ const b = kimiHooks(before), a = kimiHooks(after), c = kimiHooks(current), ours = added(b, a, kimiKey);
79
+ for (const row of ours) {
80
+ const at = c.findIndex(value => kimiKey(value) === kimiKey(row));
81
+ if (at >= 0) c.splice(at, 1);
82
+ 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");
83
+ }
84
+ if (isDeepStrictEqual(c, kimiHooks(current))) return current;
85
+ const original = toml(before);
86
+ const value = !c.length && original.hooks === undefined ? undefined : c;
87
+ const result = value === undefined ? setTomlPath(text(current), ["hooks"], undefined) : updateTomlArray(text(current), ["hooks"], value);
88
+ if (before.bytes === null && Object.keys(object(Bun.TOML.parse(result))).length === 0) return { ...before, mode: current.mode };
89
+ return rendered(result, current);
90
+ }
91
+ /** Exact restore when untouched; otherwise reverse only this receipt's managed entry/rows. */
92
+ export function restoreOwned(target: string, index: number, before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage {
93
+ if (sameImage(current, after)) return before;
94
+ if (sameImage(current, before)) return current;
95
+ if (index === 0) {
96
+ const b = mcpRecord(target, before), a = mcpRecord(target, after), c = mcpRecord(target, current);
97
+ if (isDeepStrictEqual(c, b) && !(target === "codex" && b === undefined && toml(before).mcp_servers === undefined && toml(current).mcp_servers !== undefined && Object.keys(object(toml(current).mcp_servers)).length === 0)) return current;
98
+ if (c !== undefined && !isDeepStrictEqual(c, a)) throw Error("setup-owned MCP entry changed; refusing to overwrite it");
99
+ let result = putMcpRecord(target, current, b);
100
+ if (target === "codex" && b === undefined && toml(before).mcp_servers === undefined && Object.keys(object(toml(result).mcp_servers ?? {})).length === 0) result = rendered(setTomlPath(text(result), ["mcp_servers"], undefined), current);
101
+ if (target !== "codex" && json(before).mcpServers !== undefined && json(result).mcpServers === undefined) { const value = json(result); value.mcpServers = {}; result = rendered(JSON.stringify(value, null, 2) + "\n", current); }
102
+ if (result.bytes === null && before.bytes !== null) return target === "codex" ? rendered("", current) : finishJson({}, current, before);
103
+ if (before.bytes === null && Object.keys(target === "codex" ? toml(result) : json(result)).length === 0) return { ...before, mode: current.mode };
104
+ return result;
105
+ }
106
+ if (target === "claude" || target === "codex") return restoreJsonHooks(before, after, current);
107
+ if (target === "kimi") return restoreKimi(before, after, current);
108
+ if (current.bytes === after.bytes) return { ...before, mode: current.mode };
109
+ if (current.bytes === before.bytes) return current;
110
+ throw Error("setup-owned Pi extension changed; refusing to overwrite it");
111
+ }
112
+ export function managedAfter(target: string, index: number, before: ConfigImage, after: ConfigImage, current: ConfigImage): boolean {
113
+ if (index === 0) return isDeepStrictEqual(mcpRecord(target, after), mcpRecord(target, current));
114
+ if (target === "claude" || target === "codex") {
115
+ const needed = added(hooks(json(before)), hooks(json(after)), row => row.id), live = hooks(json(current));
116
+ return needed.every(row => live.some(value => value.id === row.id));
117
+ }
118
+ if (target === "kimi") return added(kimiHooks(before), kimiHooks(after), kimiKey).every(row => kimiHooks(current).some(value => kimiKey(value) === kimiKey(row)));
119
+ return current.bytes === after.bytes;
120
+ }
@@ -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
+ }