@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +15 -5
- package/package.json +1 -1
- package/src/cli.ts +9 -6
- package/src/commands-table.ts +5 -4
- package/src/ingest.ts +98 -7
- package/src/mcp-memory.ts +42 -34
- package/src/memory/capture.ts +24 -6
- package/src/memory/commands.ts +11 -2
- package/src/memory/eval.ts +2 -2
- package/src/memory/policy.ts +62 -3
- package/src/memory/search-fts.ts +59 -3
- package/src/session-brief-worker.ts +5 -1
- package/src/setup-agy.ts +9 -0
- package/src/setup-jcode.ts +7 -0
- package/src/setup-json.ts +151 -0
- package/src/setup-owned.ts +40 -19
- package/src/setup.ts +147 -34
package/src/memory/eval.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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};
|
|
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),
|
package/src/memory/policy.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
package/src/memory/search-fts.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/src/setup-agy.ts
ADDED
|
@@ -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
|
+
}
|
package/src/setup-owned.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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));
|