@staix/agent-mem 0.9.1 → 0.11.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.
@@ -15,7 +15,7 @@ const NOTE_KEYS = new Set(["schema", "id", "at", "machine", "op", "project", "sc
15
15
  const OPERATIONS_WARNING = "Supersede, demote, settle and forget operations are not exported; re-import restores authored note rows only.";
16
16
 
17
17
  type ImportedNote = { id: string; at: string; machine: string; note: NoteInput };
18
- type Provenance = { source: Record<string, number>; peer: Record<string, number>; scope: Record<string, number> };
18
+ type Provenance = { source: Record<string, number>; peer: Record<string, number>; scope: Record<string, number>; machine: Record<string, number> };
19
19
  export interface ProjectImportResult {
20
20
  applied: boolean;
21
21
  project: string;
@@ -46,13 +46,18 @@ function increment(counts: Map<string, number>, value: string | undefined): void
46
46
  }
47
47
 
48
48
  function provenance(notes: Iterable<ImportedNote>): Provenance {
49
- const source = new Map<string, number>(), peer = new Map<string, number>(), scope = new Map<string, number>();
50
- for (const { note } of notes) {
49
+ const source = new Map<string, number>(), peer = new Map<string, number>(), scope = new Map<string, number>(), machine = new Map<string, number>();
50
+ for (const { note, machine: origin } of notes) {
51
51
  increment(source, note.source);
52
52
  increment(peer, note.peer);
53
53
  increment(scope, note.scope);
54
+ increment(machine, origin);
54
55
  }
55
- return { source: Object.fromEntries(source), peer: Object.fromEntries(peer), scope: Object.fromEntries(scope) };
56
+ return { source: Object.fromEntries(source), peer: Object.fromEntries(peer), scope: Object.fromEntries(scope), machine: Object.fromEntries(machine) };
57
+ }
58
+
59
+ function relationTargets(imported: ImportedNote): string[] {
60
+ return [imported.note.supersedes, imported.note.promotedFrom].filter((target): target is string => typeof target === "string");
56
61
  }
57
62
 
58
63
  function unchangedAfterNormalization(note: NoteInput): boolean {
@@ -71,10 +76,10 @@ export async function importProject(store: MemoryStore, opts: {
71
76
  const fd = openSync(opts.from, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
72
77
  const stream = createReadStream(opts.from, { fd, encoding: "utf8" });
73
78
  const result: ProjectImportResult = { applied: opts.confirm === true, project: opts.project, read: 0, added: 0, wouldAdd: 0,
74
- skipped: { invalid: 0, policy: 0, other: 0 }, provenance: { source: {}, peer: {}, scope: {} }, warnings: [OPERATIONS_WARNING] };
79
+ skipped: { invalid: 0, policy: 0, other: 0 }, provenance: { source: {}, peer: {}, scope: {}, machine: {} }, warnings: [OPERATIONS_WARNING] };
75
80
  let header = false, bytes = 0;
76
81
  let aliases: string[] = [];
77
- const pending = new Map<string, ImportedNote>(), accepted: ImportedNote[] = [];
82
+ const pending = new Map<string, ImportedNote>(), accepted = new Map<string, ImportedNote>();
78
83
  try {
79
84
  const stat = fstatSync(fd);
80
85
  if (!stat.isFile() || stat.size > PROJECT_IMPORT_MAX_BYTES) throw new Error("project import needs a regular JSONL file of at most 64 MiB");
@@ -121,22 +126,34 @@ export async function importProject(store: MemoryStore, opts: {
121
126
  if (!header) throw new Error("project import has no header");
122
127
 
123
128
  for (const imported of pending.values()) {
124
- if (["supersedes", "promotedFrom"].some(field => {
125
- const target = imported.note[field as "supersedes" | "promotedFrom"] as string | null | undefined;
126
- return target !== undefined && target !== null && !pending.has(target);
127
- })) {
129
+ if (relationTargets(imported).some(target => !pending.has(target))) {
128
130
  result.skipped.invalid++;
129
131
  continue;
130
132
  }
131
133
  const screened = stripPrivateDeep(imported.note);
132
134
  if (looksSecret(JSON.stringify(screened)) || !await opts.policy(screened)) { result.skipped.policy++; continue; }
133
- accepted.push(imported);
135
+ accepted.set(imported.id, imported);
136
+ }
137
+ // A policy-refused target cannot authorize its dependents. Propagate removals before any append,
138
+ // regardless of file order, in linear time even when a long relation chain is refused.
139
+ const dependents = new Map<string, string[]>(), refused: string[] = [];
140
+ for (const imported of accepted.values()) for (const target of relationTargets(imported)) {
141
+ const rows = dependents.get(target) ?? [];
142
+ rows.push(imported.id); dependents.set(target, rows);
143
+ if (!accepted.has(target)) refused.push(imported.id);
144
+ }
145
+ for (let at = 0; at < refused.length; at++) {
146
+ const id = refused[at]!;
147
+ if (!accepted.delete(id)) continue;
148
+ result.skipped.invalid++;
149
+ for (const dependent of dependents.get(id) ?? []) refused.push(dependent);
134
150
  }
135
- result.provenance = provenance(accepted);
151
+ result.provenance = provenance(accepted.values());
136
152
  const existing = new Set(store.portableNotes(opts.project).map(note => note.id));
137
- result.wouldAdd = accepted.filter(note => !existing.has(note.id)).length;
153
+ result.wouldAdd = [...accepted.keys()].filter(id => !existing.has(id)).length;
138
154
  if (opts.confirm === true) {
139
- for (const imported of accepted) {
155
+ store.assertImportable([...accepted.values()].map(imported => imported.note));
156
+ for (const imported of accepted.values()) {
140
157
  const written = store.importNote(imported.note, imported.at, { machine: imported.machine, expectedId: imported.id });
141
158
  if (written.added) result.added++;
142
159
  }
@@ -1,3 +1,4 @@
1
+ import { rankedScoreSql, rankedScoreSqlDetails, type ScoreFactors } from "./search-score.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import { chmodSync, existsSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -187,6 +188,67 @@ export class SearchIndex {
187
188
  close(): void { this.db?.close(); }
188
189
  }
189
190
 
191
+ const SCREENED_SCORE = rankedScoreSql(), SCREENED_SUBSTRING_SCORE = rankedScoreSql("-1.0");
192
+
193
+ /** Screened query-local corpus in SQLite-owned TEMP storage, removed even when the process exits. */
194
+ export class ScreenedSearchIndex<T> {
195
+ readonly available = fts5Available();
196
+ private readonly db: Database;
197
+ private closed = false;
198
+
199
+ constructor() {
200
+ let db: Database | undefined;
201
+ try {
202
+ db = new Database(":memory:");
203
+ // SQLite owns the anonymous TEMP files; no named plaintext cache survives a killed MCP child.
204
+ if ((db.query("SELECT sqlite_compileoption_used('TEMP_STORE=3') AS memoryOnly").get() as { memoryOnly: number }).memoryOnly) throw Error("disk-backed search unavailable");
205
+ db.run("PRAGMA temp_store = FILE");
206
+ db.run(this.available
207
+ ? "CREATE VIRTUAL TABLE temp.docs USING fts5(body, ref UNINDEXED, tier UNINDEXED, at UNINDEXED, payload UNINDEXED, expires UNINDEXED)"
208
+ : "CREATE TEMP TABLE docs (body TEXT, ref TEXT, tier TEXT, at TEXT, payload TEXT, expires REAL)");
209
+ db.run("PRAGMA temp.cache_size = -2048");
210
+ // A platform spill minimum can exceed cache_size (macOS: 20,000 pages).
211
+ // Spill at the cache target so dirty TEMP pages cannot accumulate behind that minimum.
212
+ db.run("PRAGMA temp.cache_spill = 1");
213
+ this.db = db;
214
+ } catch (error) { db?.close(); throw error; }
215
+ }
216
+
217
+ append(rows: readonly { body: string; ref: string; tier: string; at: string; value: T; expiresAt?: string | null }[]): void {
218
+ const insert = this.db.query("INSERT INTO docs (body, ref, tier, at, payload, expires) VALUES (?, ?, ?, ?, ?, ?)");
219
+ this.db.transaction(() => {
220
+ for (const row of rows) {
221
+ const deadline = row.expiresAt == null ? null : Date.parse(row.expiresAt);
222
+ // Invalid deadlines fail closed; this sentinel predates every representable Date.
223
+ insert.run(row.body, row.ref, row.tier, row.at, JSON.stringify(row.value), deadline !== null && !Number.isFinite(deadline) ? Number.MIN_SAFE_INTEGER : deadline);
224
+ }
225
+ })();
226
+ }
227
+
228
+ best(query: string, limit: number, now: number, useFts = this.available, explain = false): { value: T; score: number; scoreDetails?: ScoreFactors }[] {
229
+ if (useFts && !ftsTerms(query)) return [];
230
+ const score = useFts ? SCREENED_SCORE : SCREENED_SUBSTRING_SCORE;
231
+ const instant = new Date(now).toISOString();
232
+ const where = "WHERE (expires IS NULL OR expires > ?)" + (useFts ? " AND docs MATCH ?" : "");
233
+ const factors = explain ? rankedScoreSqlDetails(useFts ? "bm25(docs)" : "-1.0", useFts) : undefined;
234
+ const columns = factors ? `, ${factors.rawBm25Rank} AS rawBm25Rank, ${factors.tierFactor} AS tierFactor, ${factors.recencyFactor} AS recencyFactor` : "";
235
+ const statement = this.db.query(`SELECT payload, ${score} AS score${columns} FROM docs ${where} ORDER BY score DESC, ref LIMIT ?`);
236
+ const rows = (explain ? statement.all(instant, instant, now, ...(useFts ? [ftsTerms(query)] : []), limit)
237
+ : statement.all(instant, now, ...(useFts ? [ftsTerms(query)] : []), limit)) as { payload: string; score: number; rawBm25Rank?: number | null; tierFactor?: number | null; recencyFactor?: number | null }[];
238
+ return rows.map(row => {
239
+ const hit: { value: T; score: number; scoreDetails?: ScoreFactors } = { value: JSON.parse(row.payload) as T, score: row.score };
240
+ if (explain) hit.scoreDetails = { rawBm25Rank: row.rawBm25Rank ?? null, tierFactor: row.tierFactor ?? null, recencyFactor: row.recencyFactor ?? null, finalScore: row.score, matchMode: useFts ? "fts5" : "substring" };
241
+ return hit;
242
+ });
243
+ }
244
+
245
+ close(): void {
246
+ if (this.closed) return;
247
+ this.closed = true;
248
+ this.db.close();
249
+ }
250
+ }
251
+
190
252
  interface RawCaptureRow { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string }
191
253
 
192
254
  /**
@@ -222,20 +284,23 @@ export interface ReadWindow {
222
284
  anchor?: string;
223
285
  prefix?: string;
224
286
  id?: string;
287
+ /** Exhaustive screened scans need stable identity order, not a chronological relevance window. */
288
+ order?: "id";
225
289
  after?: { position: number; id: string };
226
290
  }
227
291
  export interface ReadRef { id: string; at: string; position: number }
228
292
  /** Fixed column names supplied by the three derived stores; all caller values remain SQL parameters. */
229
- export function readWindowSql(window: ReadWindow, id: "id" | "ref") {
293
+ export function readWindowSql(window: ReadWindow, id: "id" | "ref", identity: "text" | "integer" = "text") {
230
294
  if (!Number.isSafeInteger(window.limit) || window.limit < 1 || window.limit > 256) throw new Error("invalid read page limit");
231
295
  const bind: (string | number)[] = [];
232
- const position = window.anchor ? (bind.push(window.anchor), "abs(julianday(at) - julianday(?))") : "-julianday(at)";
296
+ const position = window.order === "id" ? (identity === "integer" ? id : "0") : window.anchor ? (bind.push(window.anchor), "abs(julianday(at) - julianday(?))") : "-julianday(at)";
233
297
  const columns = `CAST(${id} AS TEXT) AS candidate_id, at, ${position} AS candidate_position`;
234
298
  const where = ["project IN (SELECT value FROM json_each(?))"]; bind.push(JSON.stringify(window.projects ?? [window.project]));
235
299
  if (window.since) { where.push("julianday(at) >= julianday(?)"); bind.push(window.since); }
236
300
  if (window.id !== undefined) { where.push(`${id} = ?`); bind.push(window.id); }
237
301
  if (window.prefix !== undefined) { where.push(`substr(${id}, 1, length(?)) = ?`); bind.push(window.prefix, window.prefix); }
238
- if (window.after) {
302
+ if (window.after && window.order === "id") { where.push(`${id} > ?`); bind.push(window.after.id); }
303
+ else if (window.after) {
239
304
  where.push("(candidate_position > ? OR (candidate_position = ? AND candidate_id > ?))");
240
305
  bind.push(window.after.position, window.after.position, window.after.id);
241
306
  }
@@ -0,0 +1,66 @@
1
+ /** One fixed score recipe for numeric library ranking and native screened SQL. */
2
+ export interface ScoreFactors {
3
+ rawBm25Rank: number | null;
4
+ tierFactor: number | null;
5
+ recencyFactor: number | null;
6
+ finalScore: number | null;
7
+ matchMode: "fts5" | "substring";
8
+ }
9
+ export interface SearchScoreDetails extends ScoreFactors { source: "curated" | "capture" | "repo" }
10
+
11
+ type ScoreExpr = { op: "rank" | "tier" | "age" } | { op: "constant"; value: number }
12
+ | { op: "negative" | "reciprocal"; value: ScoreExpr }
13
+ | { op: "add" | "multiply" | "max"; left: ScoreExpr; right: ScoreExpr };
14
+ const MONTH_DAYS = 30;
15
+ const TIER_WEIGHTS: Readonly<Record<string, number>> = { long: 1, capture: 0.5 };
16
+ const DEFAULT_TIER_WEIGHT = 0.9;
17
+ const TIER_FACTOR: ScoreExpr = { op: "tier" };
18
+ const DENOMINATOR: ScoreExpr = { op: "add", left: { op: "constant", value: 1 }, right: { op: "max", left: { op: "constant", value: 0 }, right: { op: "age" } } };
19
+ const RECENCY_FACTOR: ScoreExpr = { op: "reciprocal", value: DENOMINATOR };
20
+ const SCORE: ScoreExpr = { op: "multiply", left: { op: "multiply", left: { op: "negative", value: { op: "rank" } }, right: TIER_FACTOR }, right: RECENCY_FACTOR };
21
+ // ponytail: this represents only the fixed rank/tier/age recipe, not arbitrary expressions;
22
+ // extend its operations with numeric/SQL parity tests when the ranking contract changes.
23
+ function numeric(expr: ScoreExpr, rank: number, tier: string, at: string, now: number): number {
24
+ switch (expr.op) {
25
+ case "rank": return rank;
26
+ case "tier": return Object.hasOwn(TIER_WEIGHTS, tier) ? TIER_WEIGHTS[tier]! : DEFAULT_TIER_WEIGHT;
27
+ case "age": return (now - Date.parse(at)) / (MONTH_DAYS * 24 * 3600_000);
28
+ case "constant": return expr.value;
29
+ case "negative": return -numeric(expr.value, rank, tier, at, now);
30
+ case "reciprocal": return 1 / numeric(expr.value, rank, tier, at, now);
31
+ case "add": return numeric(expr.left, rank, tier, at, now) + numeric(expr.right, rank, tier, at, now);
32
+ case "multiply": return numeric(expr.left, rank, tier, at, now) * numeric(expr.right, rank, tier, at, now);
33
+ case "max": return Math.max(numeric(expr.left, rank, tier, at, now), numeric(expr.right, rank, tier, at, now));
34
+ }
35
+ }
36
+ const real = (value: number): string => Number.isInteger(value) ? `${value}.0` : String(value);
37
+ function sql(expr: ScoreExpr, rank: string): string {
38
+ const get = (part: ScoreExpr): string => sql(part, rank);
39
+ switch (expr.op) {
40
+ case "rank": return rank;
41
+ case "tier": return `CASE tier ${Object.entries(TIER_WEIGHTS).map(([name, value]) => `WHEN '${name}' THEN ${real(value)}`).join(" ")} ELSE ${real(DEFAULT_TIER_WEIGHT)} END`;
42
+ case "age": return `((julianday(?) - julianday(at)) / ${real(MONTH_DAYS)})`;
43
+ case "constant": return real(expr.value);
44
+ case "negative": return `(-(${get(expr.value)}))`;
45
+ case "reciprocal": return `(1.0 / ${get(expr.value)})`;
46
+ case "add": return `(${get(expr.left)} + ${get(expr.right)})`;
47
+ // Preserve the existing SQL quotient's floating-point order; numeric ranking
48
+ // retains its original reciprocal product. Both interpret the same recipe.
49
+ case "multiply": return expr.right.op === "reciprocal" ? `(${get(expr.left)} / ${get(expr.right.value)})` : `(${get(expr.left)} * ${get(expr.right)})`;
50
+ case "max": return `max(${get(expr.left)}, ${get(expr.right)})`;
51
+ }
52
+ }
53
+ export function rankedScore(rank: number, tier: string, at: string, now: number): number {
54
+ return numeric(SCORE, rank, tier, at, now);
55
+ }
56
+ /** SQL uses fixed internal identifiers and bound clock values, never interpolated row/query input. */
57
+ export function rankedScoreSql(rank = "bm25(docs)"): string { return sql(SCORE, rank); }
58
+ export function rankedScoreSqlDetails(rank = "bm25(docs)", useFts = true): { rawBm25Rank: string; tierFactor: string; recencyFactor: string } {
59
+ return { rawBm25Rank: useFts ? rank : "NULL", tierFactor: sql(TIER_FACTOR, rank), recencyFactor: sql(RECENCY_FACTOR, rank) };
60
+ }
61
+ export function rankedScoreDetails(source: SearchScoreDetails["source"], rank: number, tier: string, at: string, now: number, finalScore: number): SearchScoreDetails {
62
+ return { source, rawBm25Rank: rank, tierFactor: numeric(TIER_FACTOR, rank, tier, at, now), recencyFactor: numeric(RECENCY_FACTOR, rank, tier, at, now), finalScore, matchMode: "fts5" };
63
+ }
64
+ export function unrankedScoreDetails(source: SearchScoreDetails["source"], finalScore: number | null): SearchScoreDetails {
65
+ return { source, rawBm25Rank: null, tierFactor: null, recencyFactor: null, finalScore, matchMode: "substring" };
66
+ }
@@ -10,6 +10,8 @@ import { memoryHome } from "./home.ts";
10
10
  import { machineId, type Machine } from "./machine.ts";
11
11
  import { assertProjectActive } from "./lifecycle.ts";
12
12
  export { machineId, type Machine } from "./machine.ts";
13
+ import { rankedScore, rankedScoreDetails, unrankedScoreDetails, type SearchScoreDetails } from "./search-score.ts";
14
+ export type { SearchScoreDetails } from "./search-score.ts";
13
15
  import { readWindowSql, readRef, type ReadWindow, type ReadRef, SearchIndex, captureSearch } from "./search-fts.ts";
14
16
  import { assertCuratedShardPaths, ensureShardDescriptor, readShardDescriptor, readShardedSelection, aliasedKeys, resolveAlias, resolveShardProfile, shardDir, shardKeyHash, shardMachineDir, userShardDir } from "./shard.ts";
15
17
  import { readCaptureConfig, type CaptureRow } from "./capture.ts";
@@ -168,10 +170,12 @@ export interface Filter { project?: string; tier?: Tier; kind?: Kind; peer?: str
168
170
  /** #13 search: `Filter` plus the capture-tier switch (on by default; capture is recall, never curated evidence). */
169
171
  export interface SearchFilter extends Filter {
170
172
  includeCapture?: boolean;
173
+ /** Attach score factors only when explicitly requested. */
174
+ explain?: boolean;
171
175
  /** A host that writes capture with a `retentionDays` override passes the same value here; files decide otherwise. */
172
176
  captureRetentionDays?: number;
173
177
  }
174
- export interface SearchHit { source: "curated" | "capture"; score: number; view?: View; capture?: CaptureRow }
178
+ export interface SearchHit { source: "curated" | "capture"; score: number; scoreDetails?: SearchScoreDetails; view?: View; capture?: CaptureRow }
175
179
  export interface SearchResult { engine: "fts5" | "substring"; hits: SearchHit[] }
176
180
  export interface Graph { nodes: { id: Node; type: string; label: string; kind?: string; tier?: Tier }[]; edges: { from: Node; to: Node; rel: string }[] }
177
181
 
@@ -490,18 +494,30 @@ export class MemoryStore {
490
494
  // `ownClock`: a note asked for short-term on purpose expires on its own clock, open task or not, and is never promoted.
491
495
  note(n: NoteInput, ownClock = false): string { return this.append(n.tier === "short" && !n.expires && (ownClock || !n.task || this.settled(n.task)) ? { ...n, expires: this.until() } : n, n.tier === "short"); }
492
496
 
493
- /**
494
- * An entry from another store with its own time (`ahub memory import claude-mem`): the same source row always gives
495
- * the same id, so a second import writes nothing. Returns the id and whether it was new.
496
- */
497
- importNote(n: NoteInput, at: string, identity: { machine?: string; expectedId?: string } = {}): { id: string; added: boolean } {
497
+ /** The already-opened owner of an imported row, shared by preflight and append. */
498
+ private importOwner(n: NoteInput): MemoryStore {
498
499
  if (this.bootstrapOnly) throw new Error("migration bootstrap cannot import memory");
499
- if (this.userOverlay && n.scope === "shared") return this.userOverlay.importNote(n, at, identity);
500
+ if (this.userOverlay && n.scope === "shared") return this.userOverlay.importOwner(n);
500
501
  if (this.profile.sharded && !this.profile.shardUser && n.scope === "project" && n.project !== this.profile.shardKey) {
501
502
  const owner = this.aliasStores.find(store => store.profile.shardKey === n.project);
502
- if (owner) return owner.importNote(n, at, identity);
503
+ if (owner) return owner.importOwner(n);
503
504
  }
504
505
  if (this.profile.sharded && (this.profile.shardUser ? n.scope !== "shared" : n.scope !== "project" || n.project !== this.profile.shardKey)) throw new Error("entry does not belong to this curated shard");
506
+ return this;
507
+ }
508
+
509
+ /** Check every import route before a multi-row caller appends; never creates a missing alias shard. */
510
+ assertImportable(notes: Iterable<NoteInput>): void {
511
+ for (const note of notes) this.importOwner(note);
512
+ }
513
+
514
+ /**
515
+ * An entry from another store with its own time (`ahub memory import claude-mem`): the same source row always gives
516
+ * the same id, so a second import writes nothing. Returns the id and whether it was new.
517
+ */
518
+ importNote(n: NoteInput, at: string, identity: { machine?: string; expectedId?: string } = {}): { id: string; added: boolean } {
519
+ const owner = this.importOwner(n);
520
+ if (owner !== this) return owner.importNote(n, at, identity);
505
521
  assertSharedV1Op(n.op);
506
522
  // A fixed machine: the same observation imported on two machines is one entry once they sync. A project import preserves the exported identity instead.
507
523
  const body = { schema: MEMORY_SCHEMA, at, machine: identity.machine ?? "claude-mem", ...n, text: stripPrivateSpans(n.text).slice(0, MAX_TEXT), ...(n.title ? { title: stripPrivateSpans(n.title).slice(0, MAX_TITLE) } : {}) } as Omit<Entry, "id">;
@@ -1057,12 +1073,15 @@ export class MemoryStore {
1057
1073
  */
1058
1074
  search(q: string, f: SearchFilter = {}): SearchResult {
1059
1075
  const limit = f.limit ?? 200;
1060
- if (!this.searchIndex.available) return { engine: "substring", hits: this.list({ ...f, q }).map((view) => ({ source: "curated" as const, score: 0, view })) };
1076
+ if (!this.searchIndex.available) return { engine: "substring", hits: this.list({ ...f, q }).map(view => {
1077
+ const hit: SearchHit = { source: "curated", score: 0, view };
1078
+ if (f.explain) hit.scoreDetails = unrankedScoreDetails("curated", hit.score);
1079
+ return hit;
1080
+ }) };
1061
1081
  const visible = new Map(this.live(f).filter((v) => (!f.project || this.inScope(v.project, f.project) || v.scope === "shared")
1062
1082
  && (!f.since || Date.parse(v.at) >= Date.parse(f.since)) && (f.includeSuperseded || !v.supersededBy)).map((v) => [v.id, v]));
1063
1083
  for (const store of this.readStores()) store.syncSearchIndex();
1064
1084
  const now = this.now();
1065
- const recency = (at: string): number => 1 / (1 + Math.max(0, now - Date.parse(at)) / (30 * 24 * 3600_000));
1066
1085
  const hits: SearchHit[] = [];
1067
1086
  // The project filter runs inside the FTS query (its rows plus shared ones), so a busy sibling project can never
1068
1087
  // starve this project's LIMIT.
@@ -1082,7 +1101,9 @@ export class MemoryStore {
1082
1101
  if (!view || seen.has(hit.ref)) continue;
1083
1102
  seen.add(hit.ref);
1084
1103
  found++;
1085
- hits.push({ source: "curated", score: -hit.rank * (view.tier === "long" ? 1 : 0.9) * recency(hit.at), view });
1104
+ const selected: SearchHit = { source: "curated", score: rankedScore(hit.rank, view.tier, hit.at, now), view };
1105
+ if (f.explain) selected.scoreDetails = rankedScoreDetails("curated", hit.rank, view.tier, hit.at, now, selected.score);
1106
+ hits.push(selected);
1086
1107
  }
1087
1108
  if (ranked.length < page) break;
1088
1109
  }
@@ -1097,12 +1118,18 @@ export class MemoryStore {
1097
1118
  const retained = Number.isFinite(cutoff.getTime()) ? cutoff.toISOString() : undefined;
1098
1119
  const since = f.since && Number.isFinite(Date.parse(f.since)) ? new Date(Date.parse(f.since)).toISOString() : undefined;
1099
1120
  for (const { row, rank } of captureSearch(shard, q, Math.max(limit * 4, 32), since && (!retained || since > retained) ? since : retained)) {
1100
- hits.push({ source: "capture", score: -rank * 0.5 * recency(row.at), capture: row });
1121
+ const selected: SearchHit = { source: "capture", score: rankedScore(rank, "capture", row.at, now), capture: row };
1122
+ if (f.explain) selected.scoreDetails = rankedScoreDetails("capture", rank, "capture", row.at, now, selected.score);
1123
+ hits.push(selected);
1101
1124
  }
1102
1125
  }
1103
1126
  // Where FTS misses entirely (a stopword-only query, a tokenization edge), the substring filter is the safety net.
1104
1127
  if (!hits.length && q.trim()) {
1105
- for (const view of this.list({ ...f, q })) hits.push({ source: "curated", score: 0, view });
1128
+ for (const view of this.list({ ...f, q })) {
1129
+ const hit: SearchHit = { source: "curated", score: 0, view };
1130
+ if (f.explain) hit.scoreDetails = unrankedScoreDetails("curated", hit.score);
1131
+ hits.push(hit);
1132
+ }
1106
1133
  }
1107
1134
  const ref = (h: SearchHit): string => h.view?.id ?? String(h.capture?.id ?? 0).padStart(12, "0");
1108
1135
  hits.sort((a, b) => b.score - a.score || ref(a).localeCompare(ref(b)));
@@ -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
+ }