@staix/agent-mem 0.9.1 → 0.10.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.
@@ -88,7 +88,7 @@ export interface EvalQuestionResult {
88
88
  /** `recall` and `hit` are null when no question counts (no answerable question), never 0: an empty average is not a measured zero. */
89
89
  export interface EvalAggregate { questions: number; recall: Record<number, number | null>; hit: Record<number, number | null>; mrr: number | null }
90
90
  export interface EvalReport {
91
- /** The search that was scored: the ranked, project-scoped `store.search` (BM25 with tier and recency weights) that MCP and hub recall use, NOT the `amem search` command's substring filter. */
91
+ /** Ranked project-scoped `store.search` (BM25 with tier and recency weights), the engine used by default current curated `amem search`; `--filter` keeps legacy substring matching. */
92
92
  engine: string;
93
93
  ks: number[];
94
94
  /** Every question. A question whose expected entry is not in the store scores 0, as a search that cannot find it would. */
@@ -142,7 +142,7 @@ const pct = (value: number | null): string => value === null ? " n/a" : value.t
142
142
  export function evalText(report: EvalReport, label: string): string {
143
143
  const head = (name: string, row: EvalAggregate) => `${name.padEnd(11)} ${String(row.questions).padStart(4)} ${report.ks.map(k => `${pct(row.recall[k] ?? null)}/${pct(row.hit[k] ?? null)}`.padStart(13)).join(" ")} ${pct(row.mrr).padStart(6)}`;
144
144
  return [
145
- `eval: ${label} (${report.engine === "substring" ? "substring fallback" : "ranked store.search, engine " + report.engine}; this is the ranking MCP and hub recall use, not the \`amem search\` filter)`,
145
+ `eval: ${label} (${report.engine === "substring" ? "substring fallback" : "ranked store.search, engine " + report.engine}; same ranked search path as default \`amem search\`; \`--filter\` keeps legacy substring matching)`,
146
146
  `${"".padEnd(11)} ${"n".padStart(4)} ${report.ks.map(k => `@${k} recall/hit`.padStart(13)).join(" ")} ${"MRR".padStart(6)}`,
147
147
  head("all", report.all),
148
148
  head("answerable", report.answerable),
@@ -99,6 +99,7 @@ function literals(value: unknown, label: string): string[] {
99
99
 
100
100
  function defaultPii(text: string): boolean {
101
101
  if (/\b(?:\d{6}-\d{7}|\d{3}-\d{2}-\d{4})\b/.test(text)) return true;
102
+ if (!text.includes("@")) return false;
102
103
  // Bound each email candidate before matching; arbitrary user regexes are never run.
103
104
  for (const token of text.split(/[\s<>()\[\]{}",;:]+/)) if (token.includes("@")) {
104
105
  if (token.length > 320 || /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i.test(token)) return true;
@@ -106,6 +107,51 @@ function defaultPii(text: string): boolean {
106
107
  return false;
107
108
  }
108
109
 
110
+ interface DefaultPathProbe { names: string[]; segments: string[] }
111
+
112
+ /** A mandatory literal, not a deny verdict. All other regex grammar uses the canonical checker. */
113
+ function necessaryDenyLiteral(source: string): string | undefined {
114
+ if (source.length > 256) return;
115
+ let index = source.startsWith("^") ? 1 : 0, literal = "";
116
+ for (; index < source.length; index++) {
117
+ const character = source[index]!;
118
+ if (/^[A-Za-z0-9_-]$/.test(character)) literal += character;
119
+ else if (character === "\\" && source[index + 1] === ".") { literal += "."; index++; }
120
+ else break;
121
+ }
122
+ const rest = source.slice(index);
123
+ return literal && (rest === "" || rest === "$" || rest === "(\\.|$)") ? literal : undefined;
124
+ }
125
+
126
+ /** Do not add getter reads or assumptions about malformed/mutated exported defaults. */
127
+ function plainDefaultStrings(values: readonly string[]): boolean {
128
+ if (types.isProxy(values) || !Array.isArray(values) || Object.getPrototypeOf(values) !== Array.prototype || values.length > 64 || Object.getOwnPropertySymbols(values).length || "toJSON" in values) return false;
129
+ if (Object.getOwnPropertyNames(values).length !== values.length + 1) return false;
130
+ for (let index = 0; index < values.length; index++) {
131
+ const descriptor = Object.getOwnPropertyDescriptor(values, String(index));
132
+ if (!descriptor || !("value" in descriptor) || typeof descriptor.value !== "string") return false;
133
+ }
134
+ return true;
135
+ }
136
+
137
+ function defaultPathProbe(): DefaultPathProbe | undefined {
138
+ const needles = DENY_NAMES.map(necessaryDenyLiteral);
139
+ return needles.every((value): value is string => value !== undefined) ? { names: needles, segments: [...DENY_SEGMENTS] } : undefined;
140
+ }
141
+
142
+ function containsPathLiteral(text: string, probe: DefaultPathProbe, deny: readonly string[]): boolean {
143
+ return text.includes("/.") || text.includes("//") || deny.some(entry => text.includes(entry))
144
+ || probe.segments.some(entry => text.includes(entry)) || probe.names.some(needle => text.includes(needle));
145
+ }
146
+
147
+ function mayNeedPathCheck(text: string, probe: DefaultPathProbe | undefined, deny: readonly string[]): boolean {
148
+ if (!probe || containsPathLiteral(text, probe, deny)) return true;
149
+ // Raw/node/token paths are substrings of text; pathCandidates takes substrings after NFC.
150
+ // Inspect both: removing ignorables or lowercasing after NFKC can leave a non-NFC result.
151
+ const canonical = text.normalize("NFC");
152
+ return canonical !== text && containsPathLiteral(canonical, probe, deny);
153
+ }
154
+
109
155
  /** No filesystem, model, network or storage work occurs while constructing or screening this policy. */
110
156
  export function createStandalonePolicy(options: StandalonePolicyOptions = {}): StandalonePolicy {
111
157
  if (!options || typeof options !== "object" || types.isProxy(options) || Object.getPrototypeOf(options) !== Object.prototype || Object.getOwnPropertySymbols(options).length) throw new Error("invalid standalone policy options");
@@ -119,16 +165,23 @@ export function createStandalonePolicy(options: StandalonePolicyOptions = {}): S
119
165
  // Evict the batch when full; a measured need for better hit rates can upgrade this to an LRU.
120
166
  const textChecks = new Map<string, boolean>(); let textBytes = 0;
121
167
  let defaultDenyRevision = "";
168
+ // ponytail: the negative probe supports at most 64 dense default entries and 256-character simple name sources.
169
+ // Everything else falls back; extend the grammar only with differential proof, never an approximate verdict.
170
+ let pathProbe: DefaultPathProbe | undefined;
171
+ let defaultsWerePlain = false;
122
172
  const publicText = (text: string): boolean => {
123
173
  const cached = textChecks.get(text);
124
174
  if (cached !== undefined) return cached;
125
175
  const check = () => {
126
- if (looksSecret(text) || looksSecret(text.replace(/["']/g, ""))) return false;
176
+ if (looksSecret(text)) return false;
177
+ const unquoted = text.replace(/["']/g, "");
178
+ if (unquoted !== text && looksSecret(unquoted)) return false;
127
179
  const normalized = fold(text);
128
180
  if (defaultPii(normalized) || pii.some(pattern => normalized.includes(pattern))) return false;
181
+ if (!mayNeedPathCheck(normalized, pathProbe, deny)) return true;
129
182
  const candidates = normalized.includes("/") || /\.[a-z]/.test(normalized) ? pathCandidates(normalized) : [];
130
183
  const paths = [normalized, nodePath(normalized), ...candidates, ...normalized.split(/[\s"'`(){}\[\],;:=<>]+/)];
131
- return !paths.some(path => path && isDenied(posix.normalize(path), deny));
184
+ return ![...new Set(paths)].some(path => path && isDenied(posix.normalize(path), deny));
132
185
  };
133
186
  const allowed = check(), bytes = Buffer.byteLength(text);
134
187
  if (bytes <= 1024) {
@@ -140,8 +193,14 @@ export function createStandalonePolicy(options: StandalonePolicyOptions = {}): S
140
193
  const standalone = (value: unknown): { ok: boolean; value?: unknown } => {
141
194
  try {
142
195
  // The exported default lists can be tightened by a host between calls.
196
+ // A getter can change behavior without changing its first serialized value: validate shape every time.
197
+ const defaultsPlain = plainDefaultStrings(DENY_NAMES) && plainDefaultStrings(DENY_SEGMENTS);
143
198
  const revision = JSON.stringify([DENY_NAMES, DENY_SEGMENTS]);
144
- if (revision !== defaultDenyRevision) { textChecks.clear(); textBytes = 0; defaultDenyRevision = revision; }
199
+ const changed = revision !== defaultDenyRevision;
200
+ if (changed) { textChecks.clear(); textBytes = 0; defaultDenyRevision = revision; }
201
+ if (!defaultsPlain) pathProbe = undefined;
202
+ else if (changed || !defaultsWerePlain) pathProbe = defaultPathProbe();
203
+ defaultsWerePlain = defaultsPlain;
145
204
  const complete = snapshot(value);
146
205
  if (!complete) return { ok: false };
147
206
  // Only pure text checks are reused. Shape/bounds/private flags and the host's detached
@@ -187,6 +187,59 @@ export class SearchIndex {
187
187
  close(): void { this.db?.close(); }
188
188
  }
189
189
 
190
+ /** Screened query-local corpus in SQLite-owned TEMP storage, removed even when the process exits. */
191
+ export class ScreenedSearchIndex<T> {
192
+ readonly available = fts5Available();
193
+ private readonly db: Database;
194
+ private closed = false;
195
+
196
+ constructor() {
197
+ let db: Database | undefined;
198
+ try {
199
+ db = new Database(":memory:");
200
+ // SQLite owns the anonymous TEMP files; no named plaintext cache survives a killed MCP child.
201
+ if ((db.query("SELECT sqlite_compileoption_used('TEMP_STORE=3') AS memoryOnly").get() as { memoryOnly: number }).memoryOnly) throw Error("disk-backed search unavailable");
202
+ db.run("PRAGMA temp_store = FILE");
203
+ db.run(this.available
204
+ ? "CREATE VIRTUAL TABLE temp.docs USING fts5(body, ref UNINDEXED, tier UNINDEXED, at UNINDEXED, payload UNINDEXED, expires UNINDEXED)"
205
+ : "CREATE TEMP TABLE docs (body TEXT, ref TEXT, tier TEXT, at TEXT, payload TEXT, expires REAL)");
206
+ db.run("PRAGMA temp.cache_size = -2048");
207
+ // A platform spill minimum can exceed cache_size (macOS: 20,000 pages).
208
+ // Spill at the cache target so dirty TEMP pages cannot accumulate behind that minimum.
209
+ db.run("PRAGMA temp.cache_spill = 1");
210
+ this.db = db;
211
+ } catch (error) { db?.close(); throw error; }
212
+ }
213
+
214
+ append(rows: readonly { body: string; ref: string; tier: string; at: string; value: T; expiresAt?: string | null }[]): void {
215
+ const insert = this.db.query("INSERT INTO docs (body, ref, tier, at, payload, expires) VALUES (?, ?, ?, ?, ?, ?)");
216
+ this.db.transaction(() => {
217
+ for (const row of rows) {
218
+ const deadline = row.expiresAt == null ? null : Date.parse(row.expiresAt);
219
+ // Invalid deadlines fail closed; this sentinel predates every representable Date.
220
+ insert.run(row.body, row.ref, row.tier, row.at, JSON.stringify(row.value), deadline !== null && !Number.isFinite(deadline) ? Number.MIN_SAFE_INTEGER : deadline);
221
+ }
222
+ })();
223
+ }
224
+
225
+ best(query: string, limit: number, now: number, useFts = this.available): { value: T; score: number }[] {
226
+ if (useFts && !ftsTerms(query)) return [];
227
+ const rank = useFts ? "-bm25(docs)" : "1.0";
228
+ const instant = new Date(now).toISOString();
229
+ const where = "WHERE (expires IS NULL OR expires > ?)" + (useFts ? " AND docs MATCH ?" : "");
230
+ const rows = this.db.query(`SELECT payload, ${rank} * CASE tier WHEN 'long' THEN 1.0 WHEN 'capture' THEN 0.5 ELSE 0.9 END
231
+ / (1 + max(0, (julianday(?) - julianday(at)) / 30.0)) AS score FROM docs ${where} ORDER BY score DESC, ref LIMIT ?`)
232
+ .all(instant, now, ...(useFts ? [ftsTerms(query)] : []), limit) as { payload: string; score: number }[];
233
+ return rows.map(row => ({ value: JSON.parse(row.payload) as T, score: row.score }));
234
+ }
235
+
236
+ close(): void {
237
+ if (this.closed) return;
238
+ this.closed = true;
239
+ this.db.close();
240
+ }
241
+ }
242
+
190
243
  interface RawCaptureRow { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string }
191
244
 
192
245
  /**
@@ -222,20 +275,23 @@ export interface ReadWindow {
222
275
  anchor?: string;
223
276
  prefix?: string;
224
277
  id?: string;
278
+ /** Exhaustive screened scans need stable identity order, not a chronological relevance window. */
279
+ order?: "id";
225
280
  after?: { position: number; id: string };
226
281
  }
227
282
  export interface ReadRef { id: string; at: string; position: number }
228
283
  /** Fixed column names supplied by the three derived stores; all caller values remain SQL parameters. */
229
- export function readWindowSql(window: ReadWindow, id: "id" | "ref") {
284
+ export function readWindowSql(window: ReadWindow, id: "id" | "ref", identity: "text" | "integer" = "text") {
230
285
  if (!Number.isSafeInteger(window.limit) || window.limit < 1 || window.limit > 256) throw new Error("invalid read page limit");
231
286
  const bind: (string | number)[] = [];
232
- const position = window.anchor ? (bind.push(window.anchor), "abs(julianday(at) - julianday(?))") : "-julianday(at)";
287
+ const position = window.order === "id" ? (identity === "integer" ? id : "0") : window.anchor ? (bind.push(window.anchor), "abs(julianday(at) - julianday(?))") : "-julianday(at)";
233
288
  const columns = `CAST(${id} AS TEXT) AS candidate_id, at, ${position} AS candidate_position`;
234
289
  const where = ["project IN (SELECT value FROM json_each(?))"]; bind.push(JSON.stringify(window.projects ?? [window.project]));
235
290
  if (window.since) { where.push("julianday(at) >= julianday(?)"); bind.push(window.since); }
236
291
  if (window.id !== undefined) { where.push(`${id} = ?`); bind.push(window.id); }
237
292
  if (window.prefix !== undefined) { where.push(`substr(${id}, 1, length(?)) = ?`); bind.push(window.prefix, window.prefix); }
238
- if (window.after) {
293
+ if (window.after && window.order === "id") { where.push(`${id} > ?`); bind.push(window.after.id); }
294
+ else if (window.after) {
239
295
  where.push("(candidate_position > ? OR (candidate_position = ? AND candidate_id > ?))");
240
296
  bind.push(window.after.position, window.after.position, window.after.id);
241
297
  }
@@ -50,7 +50,11 @@ if (import.meta.main) {
50
50
  if (!(HOOK_EVENTS as readonly string[]).includes(event)) stop();
51
51
  const expectedScope = hookCaptureScope(options.cwd, options.base!);
52
52
  let project: string;
53
- try { project = resolveAlias(options.base!, await resolveHookProjectAsync({ cwd: options.cwd, base: options.base!, scope: expectedScope, timeRemaining: remaining, maintainCache: event !== "SessionStart" }, nativeUsage)); }
53
+ // Overlap the shared resolver's existing origin/metadata reads on a cold cache;
54
+ // serial native launches can exhaust every hook before any binding or replay is published.
55
+ // ponytail: cold identity must still fit one hook deadline. Revalidated durable
56
+ // identity progress is the upgrade if slower native reads keep exhausting that slice.
57
+ try { project = resolveAlias(options.base!, await resolveHookProjectAsync({ cwd: options.cwd, base: options.base!, scope: expectedScope, timeRemaining: remaining, maintainCache: event !== "SessionStart", prefetch: true }, nativeUsage)); }
54
58
  catch {
55
59
  if (expectedScope !== undefined) quarantineHookScope({ cwd: options.cwd, base: options.base!, scope: expectedScope });
56
60
  project = stop();
@@ -0,0 +1,9 @@
1
+ import type { ConfigImage } from "./setup-owned.ts";
2
+ import { createJsonMcpCodec } from "./setup-json.ts";
3
+
4
+ // ponytail: Antigravity setup supports strict JSON only. JSONC needs a comment-aware
5
+ // lexical view and trailing-comma ownership before byte-preserving edits can support it.
6
+ const codec = createJsonMcpCodec({ canonicalKey: "mcpServers", label: "Antigravity strict JSON" });
7
+ export function readAgyMcp(image: ConfigImage): unknown { return codec.read(image); }
8
+ export function putAgyMcp(image: ConfigImage, value: unknown): ConfigImage { return codec.put(image, value); }
9
+ export function restoreAgyMcp(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage { return codec.restore(before, after, current); }
@@ -0,0 +1,7 @@
1
+ import type { ConfigImage } from "./setup-owned.ts";
2
+ import { createJsonMcpCodec } from "./setup-json.ts";
3
+
4
+ const codec = createJsonMcpCodec({ canonicalKey: "servers", aliases: ["mcpServers"], label: "Jcode" });
5
+ export function readJcodeMcp(image: ConfigImage): unknown { return codec.read(image); }
6
+ export function putJcodeMcp(image: ConfigImage, value: unknown): ConfigImage { return codec.put(image, value); }
7
+ export function restoreJcodeMcp(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage { return codec.restore(before, after, current); }
@@ -0,0 +1,151 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import type { ConfigImage } from "./setup-owned.ts";
3
+ import { SETUP_CONFIG_BYTES } from "./setup-toml.ts";
4
+
5
+ interface Member { key: string; keyStart: number; keyEnd: number; valueStart: number; valueEnd: number; comma?: number }
6
+ interface ObjectSpan { close: number; members: Member[] }
7
+ interface Document { text: string; root: ObjectSpan; container?: Member; servers?: ObjectSpan; owned?: Member }
8
+ interface Edit { start: number; end: number; text: string }
9
+ const decode = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
10
+ const same = (a: ConfigImage, b: ConfigImage) => a.bytes === b.bytes && a.mode === b.mode;
11
+ export interface JsonMcpCodecOptions { canonicalKey: string; aliases?: readonly string[]; label: string }
12
+
13
+ /** Strict JSON only. Shared lexical edits never re-render unrelated configuration data. */
14
+ export function createJsonMcpCodec(options: JsonMcpCodecOptions) {
15
+ const { canonicalKey, label } = options, keys = new Set([canonicalKey, ...(options.aliases ?? [])]);
16
+ const fail = (): never => { throw Error(`setup ${label} MCP configuration unavailable or ambiguous; no configuration changed`); };
17
+ const whitespace = (text: string, start: number) => { let at = start; while (at < text.length && /[ \t\r\n]/.test(text[at]!)) at++; return at; };
18
+ function stringEnd(text: string, start: number): number {
19
+ let at = start + 1;
20
+ while (at < text.length) { if (text[at] === "\\") at += 2; else if (text[at++] === '"') return at; }
21
+ return fail();
22
+ }
23
+ /** JSON.parse validates syntax first; this scanner retains only lexical edit boundaries. */
24
+ function valueEnd(text: string, start: number): number {
25
+ if (text[start] === '"') return stringEnd(text, start);
26
+ if (text[start] === "{" || text[start] === "[") {
27
+ let depth = 0, at = start;
28
+ while (at < text.length) {
29
+ const char = text[at]!;
30
+ if (char === '"') { at = stringEnd(text, at); continue; }
31
+ if (char === "{" || char === "[") depth++;
32
+ else if ((char === "}" || char === "]") && --depth === 0) return at + 1;
33
+ at++;
34
+ }
35
+ return fail();
36
+ }
37
+ let at = start; while (at < text.length && !/[ \t\r\n,}\]]/.test(text[at]!)) at++; return at;
38
+ }
39
+ function objectSpan(text: string, start: number): ObjectSpan {
40
+ if (text[start] !== "{") return fail();
41
+ const members: Member[] = []; let at = whitespace(text, start + 1);
42
+ while (text[at] !== "}") {
43
+ const keyStart = at, keyEnd = stringEnd(text, at), key: unknown = JSON.parse(text.slice(keyStart, keyEnd));
44
+ if (typeof key !== "string") return fail();
45
+ const valueStart = whitespace(text, whitespace(text, keyEnd) + 1), end = valueEnd(text, valueStart), next = whitespace(text, end);
46
+ const comma = text[next] === "," ? next : undefined;
47
+ members.push({ key, keyStart, keyEnd, valueStart, valueEnd: end, ...(comma === undefined ? {} : { comma }) });
48
+ at = comma === undefined ? next : whitespace(text, comma + 1);
49
+ }
50
+ return { close: at, members };
51
+ }
52
+ /** Only the owned value requires recursive key uniqueness; unrelated JSON stays opaque. */
53
+ function uniqueOwnedKeys(text: string, member: Member): void {
54
+ const objects: (Set<string> | null)[] = [];
55
+ let at = member.valueStart;
56
+ while (at < member.valueEnd) {
57
+ const char = text[at]!;
58
+ if (char === '"') {
59
+ const end = stringEnd(text, at), current = objects.at(-1);
60
+ if (text[whitespace(text, end)] === ":" && current) {
61
+ const key = JSON.parse(text.slice(at, end)) as string;
62
+ if (current.has(key)) return fail();
63
+ current.add(key);
64
+ }
65
+ at = end;
66
+ } else {
67
+ if (char === "{") objects.push(new Set());
68
+ else if (char === "[") objects.push(null);
69
+ else if (char === "}" || char === "]") objects.pop();
70
+ at++;
71
+ }
72
+ }
73
+ }
74
+ function document(image: ConfigImage): Document {
75
+ let text = "{}";
76
+ if (image.bytes !== null) {
77
+ if (image.bytes.length > Math.ceil(SETUP_CONFIG_BYTES / 3) * 4) return fail();
78
+ const bytes = Buffer.from(image.bytes, "base64");
79
+ if (bytes.toString("base64") !== image.bytes || bytes.length > SETUP_CONFIG_BYTES) return fail();
80
+ try { text = decode.decode(bytes); } catch { return fail(); }
81
+ }
82
+ let value: unknown; try { value = JSON.parse(text); } catch { return fail(); }
83
+ if (!value || typeof value !== "object" || Array.isArray(value)) return fail();
84
+ const root = objectSpan(text, whitespace(text, 0));
85
+ const containers = root.members.filter(member => keys.has(member.key));
86
+ if (containers.length > 1) return fail();
87
+ const container = containers[0]; if (!container) return { text, root };
88
+ const servers = objectSpan(text, container.valueStart), owned = servers.members.filter(member => member.key === "amem");
89
+ if (owned.length > 1) return fail();
90
+ if (owned[0]) uniqueOwnedKeys(text, owned[0]);
91
+ return { text, root, container, servers, ...(owned[0] ? { owned: owned[0] } : {}) };
92
+ }
93
+ const entry = (doc: Document): unknown => doc.owned ? JSON.parse(doc.text.slice(doc.owned.valueStart, doc.owned.valueEnd)) : undefined;
94
+ function readMcp(image: ConfigImage): unknown { return entry(document(image)); }
95
+
96
+ function insert(object: ObjectSpan, text: string): Edit {
97
+ const last = object.members.at(-1);
98
+ const at = last?.valueEnd ?? object.close;
99
+ return { start: at, end: at, text: `${last ? "," : ""}${text}` };
100
+ }
101
+ function remove(object: ObjectSpan, member: Member): Edit {
102
+ if (member.comma !== undefined) return { start: member.keyStart, end: member.comma + 1, text: "" };
103
+ const previous = object.members[object.members.indexOf(member) - 1];
104
+ return { start: previous?.comma ?? member.keyStart, end: member.valueEnd, text: "" };
105
+ }
106
+ function publish(image: ConfigImage, doc: Document, edits: Edit[]): ConfigImage {
107
+ let text = doc.text;
108
+ for (const edit of edits.sort((a, b) => b.start - a.start)) text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
109
+ if (text === doc.text) return image;
110
+ if (Buffer.byteLength(text) > SETUP_CONFIG_BYTES) return fail();
111
+ const result = { bytes: Buffer.from(text).toString("base64"), mode: image.mode };
112
+ document(result); return result;
113
+ }
114
+ function put(image: ConfigImage, value: unknown, options: { key?: string; keepEmpty?: boolean; rawValue?: string; emptyContainer?: string } = {}): ConfigImage {
115
+ const doc = document(image), edits: Edit[] = [], key = options.key ?? canonicalKey;
116
+ if (!doc.container) {
117
+ if (value === undefined && !options.keepEmpty) return image;
118
+ const body = value === undefined ? options.emptyContainer ?? "{}" : `{"amem":${options.rawValue ?? JSON.stringify(value)}}`;
119
+ edits.push(insert(doc.root, `${JSON.stringify(key)}:${body}`));
120
+ } else if (value === undefined && !options.keepEmpty && doc.servers!.members.every(member => member.key === "amem")) {
121
+ edits.push(remove(doc.root, doc.container));
122
+ } else {
123
+ if (doc.container.key !== key) edits.push({ start: doc.container.keyStart, end: doc.container.keyEnd, text: JSON.stringify(key) });
124
+ if (value === undefined && doc.owned) edits.push(remove(doc.servers!, doc.owned));
125
+ else if (value !== undefined && !isDeepStrictEqual(entry(doc), value)) {
126
+ const rendered = options.rawValue ?? JSON.stringify(value); if (rendered === undefined) return fail();
127
+ edits.push(doc.owned ? { start: doc.owned.valueStart, end: doc.owned.valueEnd, text: rendered } : insert(doc.servers!, `"amem":${rendered}`));
128
+ }
129
+ }
130
+ return publish(image, doc, edits);
131
+ }
132
+ /** Canonicalize only the owned container key; all unrelated bytes remain untouched. */
133
+ function putMcp(image: ConfigImage, value: unknown): ConfigImage { return put(image, value); }
134
+
135
+ /** Restore the owned entry (and our alias rename), preserving unrelated lexical edits. */
136
+ function restoreMcp(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage {
137
+ const original = document(before), installed = document(after), live = document(current);
138
+ if (same(current, after)) return before;
139
+ if (same(current, before)) return current;
140
+ const previous = entry(original), expected = entry(installed), present = entry(live);
141
+ const previousRestored = isDeepStrictEqual(present, previous);
142
+ const pendingLayout = !!original.container && !!installed.container && original.container.key !== installed.container.key && live.container?.key === installed.container.key;
143
+ if (previousRestored && !pendingLayout) return current;
144
+ if (!previousRestored && !isDeepStrictEqual(present, expected)) throw Error(`setup-owned ${label} MCP entry changed; refusing to overwrite it`);
145
+ const restored = put(current, previous, { key: original.container?.key ?? canonicalKey, keepEmpty: !!original.container && !original.servers!.members.length,
146
+ ...(original.owned ? { rawValue: original.text.slice(original.owned.valueStart, original.owned.valueEnd) } : {}),
147
+ ...(original.container && !original.servers!.members.length ? { emptyContainer: original.text.slice(original.container.valueStart, original.container.valueEnd) } : {}) });
148
+ return before.bytes === null && !document(restored).root.members.length ? { bytes: null, mode: current.mode } : restored;
149
+ }
150
+ return { read: readMcp, put: putMcp, restore: restoreMcp };
151
+ }
@@ -1,5 +1,7 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { SETUP_CONFIG_BYTES, setTomlPath, updateTomlArray } from "./setup-toml.ts";
3
+ import { readJcodeMcp, putJcodeMcp, restoreJcodeMcp } from "./setup-jcode.ts";
4
+ import { readAgyMcp, putAgyMcp, restoreAgyMcp } from "./setup-agy.ts";
3
5
  export interface ConfigImage { bytes: string | null; mode: number }
4
6
  export const sameImage = (a: ConfigImage, b: ConfigImage): boolean => a.bytes === b.bytes && a.mode === b.mode;
5
7
  const text = (image: ConfigImage) => image.bytes === null ? "" : new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(image.bytes, "base64"));
@@ -15,10 +17,14 @@ function finishJson(value: Record<string, unknown>, current: ConfigImage, origin
15
17
  return rendered(output, current);
16
18
  }
17
19
  export function mcpRecord(target: string, image: ConfigImage): unknown {
20
+ if (target === "agy") return readAgyMcp(image);
21
+ if (target === "jcode") return readJcodeMcp(image);
18
22
  const doc = target === "codex" ? toml(image) : json(image), servers = doc[target === "codex" ? "mcp_servers" : "mcpServers"];
19
23
  return servers === undefined ? undefined : object(servers).amem;
20
24
  }
21
25
  export function putMcpRecord(target: string, current: ConfigImage, value: unknown): ConfigImage {
26
+ if (target === "agy") return putAgyMcp(current, value);
27
+ if (target === "jcode") return putJcodeMcp(current, value);
22
28
  if (target === "codex") return rendered(setTomlPath(text(current), ["mcp_servers", "amem"], value), current);
23
29
  const doc = json(current), servers = { ...object(doc.mcpServers ?? {}) };
24
30
  if (value === undefined) delete servers.amem; else servers.amem = value;
@@ -26,19 +32,24 @@ export function putMcpRecord(target: string, current: ConfigImage, value: unknow
26
32
  return finishJson(doc, current, { bytes: null, mode: current.mode });
27
33
  }
28
34
  interface Hook { event: string; matcher: string; core: Record<string, unknown>; id: string; command: string }
35
+ // QwenLM/qwen-code 1ce1731 packages/core/src/hooks/types.ts HOOKS_CONFIG_FIELDS.
36
+ // These are user-owned controls, not event groups; setup never enables or restores them.
37
+ const qwenHookControls = new Set(["enabled", "disabled", "notifications"]);
29
38
  const matcher = (value: unknown) => value === undefined || value === "" || value === "*" ? "*" : String(value);
30
- function hook(event: string, group: Record<string, unknown>, value: unknown): Hook | undefined {
39
+ function hook(event: string, group: Record<string, unknown>, value: unknown, strict = false): Hook | undefined {
31
40
  const h = object(value);
32
- const core = h.type === "command" ? { type: h.type, command: h.command, ...(h.timeout !== undefined ? { timeout: h.timeout } : {}) }
41
+ const core = h.type === "command" && !strict ? { type: h.type, command: h.command, ...(h.timeout !== undefined ? { timeout: h.timeout } : {}) }
33
42
  : Object.fromEntries(Object.entries(h).filter(([key]) => !["trusted_hash", "trustedHash"].includes(key)).sort(([a], [b]) => a.localeCompare(b)));
34
43
  const match = matcher(group.matcher);
35
- return { event, matcher: match, core, command: typeof h.command === "string" ? h.command : "", id: JSON.stringify([event, match, core]) };
44
+ const groupCore = strict ? Object.fromEntries(Object.entries(group).filter(([key]) => !["matcher", "hooks"].includes(key)).sort(([a], [b]) => a.localeCompare(b))) : undefined;
45
+ return { event, matcher: match, core, command: typeof h.command === "string" ? h.command : "", id: JSON.stringify([event, match, core, ...(strict ? [groupCore] : [])]) };
36
46
  }
37
- function hooks(doc: Record<string, unknown>): Hook[] {
47
+ function hooks(doc: Record<string, unknown>, strict = false): Hook[] {
38
48
  const out: Hook[] = [];
39
49
  for (const [event, raw] of Object.entries(object(doc.hooks ?? {}))) {
50
+ if (strict && qwenHookControls.has(event)) continue;
40
51
  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); } }
52
+ 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, strict); if (row) out.push(row); } }
42
53
  }
43
54
  return out;
44
55
  }
@@ -46,8 +57,8 @@ function added<T>(before: T[], after: T[], key: (value: T) => string): T[] {
46
57
  const counts = new Map<string, number>(); for (const row of before) counts.set(key(row), (counts.get(key(row)) ?? 0) + 1);
47
58
  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
59
  }
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);
60
+ function restoreJsonHooks(before: ConfigImage, after: ConfigImage, current: ConfigImage, strict = false): ConfigImage {
61
+ const b = json(before), a = json(after), c = json(current), original = hooks(b, strict), installed = hooks(a, strict), live = hooks(c, strict);
51
62
  const ours = added(original, installed, row => row.id), remaining = new Map<string, number>();
52
63
  for (const row of ours) {
53
64
  const count = live.filter(value => value.id === row.id).length;
@@ -56,11 +67,12 @@ function restoreJsonHooks(before: ConfigImage, after: ConfigImage, current: Conf
56
67
  }
57
68
  const groups = object(c.hooks ?? {});
58
69
  for (const [event, raw] of Object.entries(groups)) {
70
+ if (strict && qwenHookControls.has(event)) continue;
59
71
  const next: unknown[] = [];
60
72
  for (const value of raw as unknown[]) {
61
73
  const group = object(value); let removed = false;
62
74
  const handlers = (group.hooks as unknown[]).filter(h => {
63
- const row = hook(event, group, h), n = row && (remaining.get(row.id) ?? 0);
75
+ const row = hook(event, group, h, strict), n = row && (remaining.get(row.id) ?? 0);
64
76
  if (!row || !n) return true; remaining.set(row.id, n - 1); removed = true; return false;
65
77
  });
66
78
  if (handlers.length || !removed) next.push({ ...group, hooks: handlers });
@@ -88,21 +100,26 @@ function restoreKimi(before: ConfigImage, after: ConfigImage, current: ConfigIma
88
100
  if (before.bytes === null && Object.keys(object(Bun.TOML.parse(result))).length === 0) return { ...before, mode: current.mode };
89
101
  return rendered(result, current);
90
102
  }
103
+ function restoreMcpOwned(target: string, before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage {
104
+ const b = mcpRecord(target, before), a = mcpRecord(target, after), c = mcpRecord(target, current);
105
+ 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;
106
+ if (c !== undefined && !isDeepStrictEqual(c, a)) throw Error("setup-owned MCP entry changed; refusing to overwrite it");
107
+ let result = putMcpRecord(target, current, b);
108
+ 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);
109
+ 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); }
110
+ if (result.bytes === null && before.bytes !== null) return target === "codex" ? rendered("", current) : finishJson({}, current, before);
111
+ if (before.bytes === null && Object.keys(target === "codex" ? toml(result) : json(result)).length === 0) return { ...before, mode: current.mode };
112
+ return result;
113
+ }
91
114
  /** Exact restore when untouched; otherwise reverse only this receipt's managed entry/rows. */
92
115
  export function restoreOwned(target: string, index: number, before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage {
116
+ if (target === "agy" && index === 0) return restoreAgyMcp(before, after, current);
117
+ if (target === "jcode" && index === 0) return restoreJcodeMcp(before, after, current);
93
118
  if (sameImage(current, after)) return before;
94
119
  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
- }
120
+ // Qwen keeps both managed surfaces in one settings file and one receipt image.
121
+ if (target === "qwen" && index === 0) return restoreJsonHooks(before, after, restoreMcpOwned(target, before, after, current), true);
122
+ if (index === 0) return restoreMcpOwned(target, before, after, current);
106
123
  if (target === "claude" || target === "codex") return restoreJsonHooks(before, after, current);
107
124
  if (target === "kimi") return restoreKimi(before, after, current);
108
125
  if (current.bytes === after.bytes) return { ...before, mode: current.mode };
@@ -110,6 +127,10 @@ export function restoreOwned(target: string, index: number, before: ConfigImage,
110
127
  throw Error("setup-owned Pi extension changed; refusing to overwrite it");
111
128
  }
112
129
  export function managedAfter(target: string, index: number, before: ConfigImage, after: ConfigImage, current: ConfigImage): boolean {
130
+ if (target === "qwen" && index === 0) {
131
+ const needed = added(hooks(json(before), true), hooks(json(after), true), row => row.id), live = hooks(json(current), true);
132
+ return isDeepStrictEqual(mcpRecord(target, after), mcpRecord(target, current)) && needed.every(row => live.some(value => value.id === row.id));
133
+ }
113
134
  if (index === 0) return isDeepStrictEqual(mcpRecord(target, after), mcpRecord(target, current));
114
135
  if (target === "claude" || target === "codex") {
115
136
  const needed = added(hooks(json(before)), hooks(json(after)), row => row.id), live = hooks(json(current));