@staix/agent-mem 0.8.0 → 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.
@@ -359,7 +359,7 @@ export class CaptureStore {
359
359
  this.db.run("CREATE TRIGGER IF NOT EXISTS rows_ad AFTER DELETE ON rows BEGIN INSERT INTO fts (fts, rowid, body) VALUES ('delete', old.id, old.excerpt); END");
360
360
  this.db.run("CREATE TRIGGER IF NOT EXISTS rows_au AFTER UPDATE ON rows BEGIN INSERT INTO fts (fts, rowid, body) VALUES ('delete', old.id, old.excerpt); INSERT INTO fts (rowid, body) VALUES (new.id, new.excerpt); END");
361
361
  // Rows written before FTS existed (or before a platform gained it) are indexed once; triggers keep it level.
362
- if (!had && (this.db.query("SELECT COUNT(*) AS n FROM rows").get() as { n: number }).n > 0) this.db.run("INSERT INTO fts (fts) VALUES ('rebuild')");
362
+ if (!had && (this.db.query("SELECT COUNT(*) AS n FROM rows").get() as { n: number }).n > 0) this.db.run("INSERT INTO fts (rowid, body) SELECT id, excerpt FROM rows");
363
363
  }
364
364
 
365
365
  private mcpReady = false;
@@ -619,17 +619,15 @@ export class CaptureStore {
619
619
  /** Exact-project, bounded body pages; MCP screens before filling its public candidate window. */
620
620
  readPage(window: ReadWindow & { query?: string; match?: "substring" }): { rows: CaptureRow[]; next?: ReadRef; engine?: "fts5" | "substring" } {
621
621
  this.checkReadBinding();
622
- const { columns, where, bind } = readWindowSql(window, "id");
622
+ const { columns, where, bind } = readWindowSql(window, "id", "integer");
623
623
  if (this.recentReadIds) {
624
624
  if (window.query !== undefined) throw new Error("recent capture brief does not support query indexes");
625
625
  where.push("id IN (SELECT value FROM json_each(?))"); bind.push(JSON.stringify(this.recentReadIds));
626
626
  }
627
627
  const engine = window.query === undefined ? undefined : window.match !== "substring" && this.ensureMcpFts() ? "fts5" : "substring";
628
- if (window.query !== undefined && engine === "fts5") {
629
- const match = ftsTerms(window.query); if (!match) return { rows: [], engine };
630
- where.push("id IN (SELECT rowid FROM mcp_fts WHERE mcp_fts MATCH ?)"); bind.push(match);
631
- }
632
- const rows = this.db.query(`SELECT *, ${columns} FROM rows WHERE ${where.join(" AND ")} ORDER BY candidate_position, candidate_id LIMIT ?`)
628
+ const plan = captureReadPlan(window, engine, where, bind);
629
+ if (!plan) return { rows: [], engine };
630
+ const rows = this.db.query(`SELECT rows.*, ${columns} FROM ${plan.from} WHERE ${where.join(" AND ")} ORDER BY ${plan.order} LIMIT ?`)
633
631
  .all(...bind, window.limit) as (RawRow & { candidate_id: string; candidate_position: number })[];
634
632
  return { rows: rows.map(toRow), ...(engine ? { engine } : {}), ...(rows.length ? { next: readRef(rows[rows.length - 1]!) } : {}) };
635
633
  }
@@ -644,6 +642,26 @@ export class CaptureStore {
644
642
  close(): void { this.db.close(); }
645
643
  }
646
644
 
645
+ /** Keep chronological readers intact while exhaustive search consumes FTS's rowid range directly. */
646
+ function captureReadPlan(window: ReadWindow & { query?: string; match?: "substring" }, engine: "fts5" | "substring" | undefined,
647
+ where: string[], bind: (string | number)[]): { from: string; order: string } | undefined {
648
+ let from = "rows", order = "candidate_position, candidate_id";
649
+ if (window.query !== undefined && engine === "fts5") {
650
+ const match = ftsTerms(window.query); if (!match) return;
651
+ if (window.order === "id") {
652
+ from = "mcp_fts JOIN rows ON rows.id = mcp_fts.rowid"; order = "mcp_fts.rowid";
653
+ where.push("mcp_fts MATCH ?"); bind.push(match);
654
+ if (window.after) {
655
+ const cursor = where.indexOf("id > ?");
656
+ if (cursor < 0) throw Error("missing capture identity cursor");
657
+ // Qualify the existing predicate so FTS receives the rowid range, without a second condition/bind.
658
+ where[cursor] = "mcp_fts.rowid > ?";
659
+ }
660
+ } else { where.push("id IN (SELECT rowid FROM mcp_fts WHERE mcp_fts MATCH ?)"); bind.push(match); }
661
+ }
662
+ return { from, order };
663
+ }
664
+
647
665
  /** One shard's capture totals for the catalog and the capacity advisor, without opening a writer. */
648
666
  export function captureShardStats(dir: string): { rows: number; bytes: number; lastAt: string | null } {
649
667
  if (!existsSync(join(dir, "capture.db"))) return { rows: 0, bytes: 0, lastAt: null };
@@ -15,6 +15,8 @@ export const AMEM_NAMES: CommandNames = { memory: "amem", goal: "amem goal" };
15
15
  export interface MemoryCommandOptions {
16
16
  /** Host-authenticated person intent. Never accept this from model arguments. Required for retained history/as-of. */
17
17
  person?: boolean;
18
+ /** Standalone person CLI opt-in. Hosts omit this to retain their screened substring recall. */
19
+ rankedSearch?: boolean;
18
20
  /** Defaults to amem's own names. */
19
21
  names?: CommandNames;
20
22
  /** Startup-bound canonical project supplied by a host; no model-facing selector. */
@@ -47,7 +49,7 @@ export interface GoalCommandOptions {
47
49
  * supersede and rebuild are a person's (the identity gate refuses agent shells).
48
50
  */
49
51
  /** The usage line of the memory commands under `name` (`amem` by default; agent-hub passes `ahub memory`). */
50
- export const memoryUsage = (name: string = AMEM_NAMES.memory): string => `usage: ${name} [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> [--history] [--as-of <UTC>] | show <id> [--history] [--as-of <UTC>] | graph <id|node> [--hops 1|2] [--history] [--as-of <UTC>] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <suggestion id> [--json]`;
52
+ export const memoryUsage = (name: string = AMEM_NAMES.memory): string => `usage: ${name} [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> [--history] [--as-of <UTC>] | show <id> [--history] [--as-of <UTC>] | graph <id|node> [--hops 1|2] [--history] [--as-of <UTC>] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] [--restart] [--capture-days N] | dedupe [--apply] | suggestions | confirm|reject <suggestion id> [--json]`;
51
53
  export const MEMORY_USAGE = memoryUsage();
52
54
  /** The usage line of the goal commands under `name` (`amem goal` by default; agent-hub passes `ahub goal`). */
53
55
  export const goalUsage = (name: string = AMEM_NAMES.goal): string => `usage: ${name} [list] [--all] | show <id> | new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task id> | state <id> open|active|blocked|done|dropped | done <id> | take <id> [--force] [--json]`;
@@ -67,6 +69,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
67
69
  const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
68
70
  const names = opts.names ?? AMEM_NAMES, usage = memoryUsage(names.memory);
69
71
  if (args.some(arg => arg === "--history" || arg === "--as-of" || arg.startsWith("--history=") || arg.startsWith("--as-of=")) && opts.person !== true) fail("historical recall requires authenticated person intent from the host");
72
+ if (opts.rankedSearch && opts.person !== true) fail("ranked CLI search requires person intent");
70
73
  const store = opts.store ?? opts.open();
71
74
  const actor = "user";
72
75
  const project = opts.project ?? memoryProjectKey(cwd);
@@ -89,8 +92,14 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
89
92
  if (kind && !KINDS.includes(kind as Kind)) fail(`--kind takes ${KINDS.join(", ")}`);
90
93
  if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
91
94
  const q = sub === "search" ? rest.join(" ") : undefined;
92
- const state = recallRows(store, project, { person: opts.person, history, asOf, query: q, limit: Number.MAX_SAFE_INTEGER });
93
- const all = state.views.filter(view => (!tier || view.tier === tier) && (!kind || view.kind === kind) && (!peer || view.peer === peer) && (!machine || view.machine === machine) && (!since || Date.parse(view.at) >= Date.parse(since)));
95
+ const ranked = opts.rankedSearch && q?.trim() && !history && asOf === undefined;
96
+ const state = recallRows(store, project, { person: opts.person, history, asOf, query: ranked ? undefined : q, limit: Number.MAX_SAFE_INTEGER });
97
+ // Retained history has its own snapshot; the current index cannot rank it. Empty queries keep list semantics.
98
+ // ponytail: fetch up to the current curated-view count before display caps, so metadata/readable exclusions consume no slots.
99
+ // Upgrade to paged ranked views if this full-pool person read reaches the R3 ceiling.
100
+ const candidates = ranked ? store.withDeferredPromotions(() => store.search(q!, { project, includeCapture: false, limit: Math.max(1, state.views.length) }))
101
+ .hits.flatMap(hit => hit.view ? [hit.view] : []) : state.views;
102
+ const all = candidates.filter(view => (!tier || view.tier === tier) && (!kind || view.kind === kind) && (!peer || view.peer === peer) && (!machine || view.machine === machine) && (!since || Date.parse(view.at) >= Date.parse(since)));
94
103
  const readable = opts.readable(all), rows = readable.slice(0, limit ? Number(limit) : 50);
95
104
  ready(rows);
96
105
  if (history || asOf !== undefined) {
@@ -154,7 +163,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
154
163
  const db = flag("--db"), limit = flag("--limit");
155
164
  const restart = rest.includes("--restart");
156
165
  if (restart) rest.splice(rest.indexOf("--restart"), 1);
157
- if (rest[0] !== "claude-mem" || rest.length !== 1) fail(`usage: ${names.memory} import claude-mem [--db <path>] [--limit N] [--restart]`);
166
+ if (rest[0] !== "claude-mem" || rest.length !== 1) fail(`usage: ${names.memory} import claude-mem [--db <path>] [--limit N] [--restart] [--capture-days N]`);
158
167
  if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
159
168
  const isPii = opts.importPolicy();
160
169
  const key = opts.project ?? memoryProjectKey(cwd);
@@ -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),
@@ -7,6 +7,11 @@ import { join } from "node:path";
7
7
  import { KINDS, looksSecret, type Kind, type MemoryStore } from "./store.ts";
8
8
  import { aliasedKeys, resolveAlias } from "./shard.ts";
9
9
  import { WorkListStore, type WorkListFields, type WorkListPolicy } from "./work-lists.ts";
10
+ import { CaptureStore, type CaptureInput } from "./capture.ts";
11
+ import { CAPTURE_MAX_EXCERPT } from "./capture-input.ts";
12
+ import { configuredPolicyOptions } from "./config.ts";
13
+ import { createStandalonePolicy, type StandalonePolicyOptions } from "./policy.ts";
14
+ import { stripPrivateSpans } from "./private-spans.ts";
10
15
 
11
16
  /** The claude-mem store is another program's: opened read-only, never written, its locks waited on briefly. */
12
17
  export const CLAUDE_MEM_BUSY_TIMEOUT_MS = 3000;
@@ -15,6 +20,92 @@ const MODEL_ANSWER = "ahub ask (model answer)";
15
20
 
16
21
  export interface ImportResult { read: number; added: number; skipped: { model: number; pii: number; secret: number }; more: boolean; cursor: number }
17
22
  export interface WorkStateImportResult { read: number; added: number; skipped: { invalid: number; policy: number }; more: boolean; cursor: number }
23
+ export interface CaptureImportResult { read: number; added: number; spooled: number; skipped: number; more: boolean }
24
+
25
+ type ClaudeMemCaptureRow = { id: number; created_at_epoch: number; origin_device_id: string | null; origin_local_id: string | null } & Record<string, any>;
26
+ const CLAUDE_MEM_CAPTURE_KINDS = [
27
+ { table: "observations", event: "observation", columns: "id, title, text, narrative, created_at_epoch" },
28
+ { table: "session_summaries", event: "summary", columns: "id, request, investigated, learned, completed, next_steps, notes, created_at_epoch" },
29
+ ] as const;
30
+ type ClaudeMemCaptureKind = typeof CLAUDE_MEM_CAPTURE_KINDS[number];
31
+ interface ClaudeMemCaptureContext {
32
+ db: Database; store: CaptureStore; policy: ReturnType<typeof createStandalonePolicy>;
33
+ aliases: string[]; project: string; sourceMachine: string; cutoff: number; limit: number;
34
+ }
35
+
36
+ function claudeMemCaptureText(row: ClaudeMemCaptureRow, kind: ClaudeMemCaptureKind): string {
37
+ if (kind.table === "observations") return stripPrivateSpans([row.title, row.text ?? row.narrative].filter(value => typeof value === "string" && value.trim()).join("\n")).trim();
38
+ const fields = [["Request", row.request], ["Investigated", row.investigated], ["Learned", row.learned], ["Completed", row.completed], ["Next steps", row.next_steps], ["Notes", row.notes]];
39
+ return stripPrivateSpans(fields.filter(([, value]) => typeof value === "string" && value.trim()).map(([label, value]) => `${label}: ${value}`).join("\n")).trim();
40
+ }
41
+
42
+ async function appendClaudeMemCapture(row: ClaudeMemCaptureRow, kind: ClaudeMemCaptureKind, context: ClaudeMemCaptureContext): Promise<"added" | "spooled" | "skipped"> {
43
+ const text = claudeMemCaptureText(row, kind);
44
+ if (!text || !Number.isFinite(row.created_at_epoch)) return "skipped";
45
+ let at: string;
46
+ try { at = new Date(row.created_at_epoch).toISOString(); } catch { return "skipped"; }
47
+ const input: CaptureInput = { project: context.project, agent: "claude-mem", event: kind.event, excerpt: text, at };
48
+ if (!await context.policy.screen(input)) return "skipped";
49
+ input.excerpt = text.slice(0, CAPTURE_MAX_EXCERPT);
50
+ const synced = typeof row.origin_device_id === "string" && !!row.origin_device_id && typeof row.origin_local_id === "string" && !!row.origin_local_id;
51
+ const receipt = createHash("sha256").update(JSON.stringify(["claude-mem", kind.table, synced ? row.origin_device_id : context.sourceMachine, synced ? row.origin_local_id : String(row.id)])).digest("hex");
52
+ const appended = context.store.append(input, { receipt });
53
+ return "spooled" in appended ? "spooled" : appended.already ? "skipped" : "added";
54
+ }
55
+
56
+ async function importClaudeMemCaptureTable(kind: ClaudeMemCaptureKind, context: ClaudeMemCaptureContext, initial: number, onCursor: (cursor: number) => void): Promise<CaptureImportResult> {
57
+ const result: CaptureImportResult = { read: 0, added: 0, spooled: 0, skipped: 0, more: false };
58
+ const columns = new Set((context.db.query(`PRAGMA table_info(${kind.table})`).all() as { name: string }[]).map(row => row.name));
59
+ const origin = columns.has("origin_device_id") && columns.has("origin_local_id"), where = `project IN (${context.aliases.map(() => "?").join(",")})`;
60
+ const max = context.db.query(`SELECT MAX(id) AS id FROM ${kind.table} WHERE ${where}`).get(...context.aliases) as { id: number | null };
61
+ let cursor = (max.id ?? 0) < initial ? 0 : initial; onCursor(cursor);
62
+ const rows = context.db.query(`SELECT ${kind.columns}${origin ? ", origin_device_id, origin_local_id" : ", NULL AS origin_device_id, NULL AS origin_local_id"} FROM ${kind.table} WHERE ${where} AND created_at_epoch >= ? AND id > ? ORDER BY id LIMIT ?`)
63
+ .all(...context.aliases, context.cutoff, cursor, context.limit) as ClaudeMemCaptureRow[];
64
+ result.read = rows.length; result.more = rows.length === context.limit;
65
+ for (const row of rows) {
66
+ result[await appendClaudeMemCapture(row, kind, context)]++;
67
+ cursor = row.id; onCursor(cursor);
68
+ }
69
+ return result;
70
+ }
71
+
72
+ /** Import only reviewed aliases into machine-local capture. ponytail: --limit is per source table; a merged cursor could add one combined ceiling. */
73
+ export async function importClaudeMemCapture(opts: { base: string; project: string; cwd: string; days: number; db?: string; limit?: number; restart?: boolean; now?: number; policy?: StandalonePolicyOptions; log?: (line: string) => void }): Promise<CaptureImportResult> {
74
+ const file = opts.db ?? join(homedir(), ".claude-mem", "claude-mem.db"), limit = opts.limit ?? 10_000;
75
+ if (!existsSync(file)) throw new Error(`no claude-mem store at ${file}`);
76
+ if (!Number.isSafeInteger(opts.days) || opts.days <= 0) throw new Error("--capture-days takes a positive integer");
77
+ if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("--limit takes a positive integer");
78
+ const now = opts.now ?? Date.now(); if (!Number.isFinite(now)) throw new Error("capture import clock unavailable");
79
+ const project = resolveAlias(opts.base, opts.project), aliases = aliasedKeys(opts.base, project);
80
+ const result: CaptureImportResult = { read: 0, added: 0, spooled: 0, skipped: 0, more: false };
81
+ if (!aliases.length) return result;
82
+ const db = new Database(file, { readonly: true });
83
+ let store: CaptureStore | undefined, state: string | undefined;
84
+ const progress = new Map<string, number>();
85
+ try {
86
+ db.run(`PRAGMA busy_timeout = ${CLAUDE_MEM_BUSY_TIMEOUT_MS}`);
87
+ const available = CLAUDE_MEM_CAPTURE_KINDS.filter(kind => db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(kind.table));
88
+ if (!available.length) return result;
89
+ store = new CaptureStore({ memoryDir: opts.base, project, cwd: opts.cwd, now: () => now });
90
+ state = join(store.dir, "import-state.json");
91
+ const context: ClaudeMemCaptureContext = { db, store, aliases, project, limit,
92
+ sourceMachine: createHash("sha256").update(realPath(file)).digest("hex"), cutoff: Math.max(0, now - opts.days * 86_400_000),
93
+ policy: createStandalonePolicy(configuredPolicyOptions(opts.base, opts.cwd, opts.policy)) };
94
+ const cursors = readCursors(state);
95
+ for (const kind of available) {
96
+ const key = createHash("sha256").update(JSON.stringify([context.sourceMachine, aliases, project, kind.table])).digest("hex");
97
+ progress.set(key, opts.restart ? 0 : cursors[key] ?? 0);
98
+ const imported = await importClaudeMemCaptureTable(kind, context, progress.get(key)!, cursor => progress.set(key, cursor));
99
+ for (const field of ["read", "added", "spooled", "skipped"] as const) result[field] += imported[field];
100
+ result.more ||= imported.more;
101
+ }
102
+ } finally {
103
+ db.close();
104
+ if (state) for (const [key, cursor] of progress) writeCursor(state, key, cursor, opts.log ?? console.error);
105
+ store?.close();
106
+ }
107
+ return result;
108
+ }
18
109
 
19
110
  /**
20
111
  * Import only the source project names explicitly mapped by the person's D2 alias table. A basename or even
@@ -49,13 +49,17 @@ export function realPath(path: string): string {
49
49
  export const DENY_SEGMENTS = [".maru/secrets", ".agenthub/state", ".agenthub/archive"];
50
50
  /** Regex sources, matched against a file's basename. */
51
51
  export const DENY_NAMES = ["^\\.env", "\\.pem$", "\\.key$", "^id_rsa", "^id_ed25519", "^credentials(\\.|$)", "^auth\\.json$", "^\\.netrc$", "^\\.npmrc$", "^\\.pypirc$"];
52
+ const denyNamePatterns = new Map(DENY_NAMES.map(source => [source, new RegExp(source)]));
52
53
 
53
54
  const hasSegment = (rel: string, seg: string) => `/${rel}/`.includes(`/${seg}/`);
54
55
 
55
56
  /** True when a path names something the worker must never read, write or report. `extra`: substrings from `local.deny`. */
56
57
  export function isDenied(path: string, extra: string[] = []): boolean {
57
58
  const norm = path.split(sep).join("/");
58
- return DENY_SEGMENTS.some((s) => hasSegment(norm, s)) || DENY_NAMES.some((re) => new RegExp(re).test(basename(norm))) || extra.some((e) => e && norm.includes(e));
59
+ if (DENY_SEGMENTS.some((s) => hasSegment(norm, s))) return true;
60
+ const name = basename(norm);
61
+ // Reuse the fixed patterns without caching arbitrary additions to the exported list.
62
+ return DENY_NAMES.some((re) => (denyNamePatterns.get(re) ?? new RegExp(re)).test(name)) || extra.some((e) => e && norm.includes(e));
59
63
  }
60
64
 
61
65
  /**
@@ -1,7 +1,7 @@
1
1
  import { posix } from "node:path";
2
2
  import { types } from "node:util";
3
3
  import { looksSecret } from "./screen.ts";
4
- import { isDenied } from "./platform.ts";
4
+ import { DENY_NAMES, DENY_SEGMENTS, isDenied } from "./platform.ts";
5
5
  import { nodePath, pathCandidates } from "./project.ts";
6
6
 
7
7
  /** True means the complete value is public. Hosts may narrow, never replace, standalone policy. */
@@ -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");
@@ -115,18 +161,51 @@ export function createStandalonePolicy(options: StandalonePolicyOptions = {}): S
115
161
  const pii = literals(descriptors.piiPatterns?.value, "PII patterns");
116
162
  const host = descriptors.host?.value as MemoryPolicy | undefined;
117
163
  if (host !== undefined && (!host || typeof host.screen !== "function")) throw new Error("invalid host policy");
164
+ // ponytail: retain at most 512 exact strings / 64 KiB per immutable policy configuration.
165
+ // Evict the batch when full; a measured need for better hit rates can upgrade this to an LRU.
166
+ const textChecks = new Map<string, boolean>(); let textBytes = 0;
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;
172
+ const publicText = (text: string): boolean => {
173
+ const cached = textChecks.get(text);
174
+ if (cached !== undefined) return cached;
175
+ const check = () => {
176
+ if (looksSecret(text)) return false;
177
+ const unquoted = text.replace(/["']/g, "");
178
+ if (unquoted !== text && looksSecret(unquoted)) return false;
179
+ const normalized = fold(text);
180
+ if (defaultPii(normalized) || pii.some(pattern => normalized.includes(pattern))) return false;
181
+ if (!mayNeedPathCheck(normalized, pathProbe, deny)) return true;
182
+ const candidates = normalized.includes("/") || /\.[a-z]/.test(normalized) ? pathCandidates(normalized) : [];
183
+ const paths = [normalized, nodePath(normalized), ...candidates, ...normalized.split(/[\s"'`(){}\[\],;:=<>]+/)];
184
+ return ![...new Set(paths)].some(path => path && isDenied(posix.normalize(path), deny));
185
+ };
186
+ const allowed = check(), bytes = Buffer.byteLength(text);
187
+ if (bytes <= 1024) {
188
+ if (textChecks.size >= 512 || textBytes + bytes > 64 * 1024) { textChecks.clear(); textBytes = 0; }
189
+ textChecks.set(text, allowed); textBytes += bytes;
190
+ }
191
+ return allowed;
192
+ };
118
193
  const standalone = (value: unknown): { ok: boolean; value?: unknown } => {
119
194
  try {
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);
198
+ const revision = JSON.stringify([DENY_NAMES, DENY_SEGMENTS]);
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;
120
204
  const complete = snapshot(value);
121
205
  if (!complete) return { ok: false };
122
- for (const text of complete.texts) {
123
- if (looksSecret(text) || looksSecret(text.replace(/["']/g, ""))) return { ok: false };
124
- const normalized = fold(text);
125
- if (defaultPii(normalized) || pii.some(pattern => normalized.includes(pattern))) return { ok: false };
126
- const candidates = normalized.includes("/") || /\.[a-z]/.test(normalized) ? pathCandidates(normalized) : [];
127
- const paths = [normalized, nodePath(normalized), ...candidates, ...normalized.split(/[\s"'`(){}\[\],;:=<>]+/)];
128
- for (const path of paths) if (path && isDenied(posix.normalize(path), deny)) return { ok: false };
129
- }
206
+ // Only pure text checks are reused. Shape/bounds/private flags and the host's detached
207
+ // snapshot are fresh on every call; no row or host verdict is cached.
208
+ for (const text of complete.texts) if (!publicText(text)) return { ok: false };
130
209
  return { ok: true, value: complete.value };
131
210
  } catch { return { ok: false }; }
132
211
  };
@@ -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
  }
@@ -102,10 +102,9 @@ export function decodeShardPack(path: string, bytes: Uint8Array, realm: ShardSyn
102
102
  catch { throw new ShardSyncFormatError(label, "malformed pack row"); }
103
103
  const row = publicRow(value, label);
104
104
  if (hash(row.machine) !== match[1]) throw new ShardSyncFormatError(label, "pack author mismatch");
105
- if (row.op === "prune") {
106
- // ponytail: #404 needs a generation-neutral public-witness transport; never retag or skip a foreign prune.
107
- if (row.generation !== generation) throw new ShardSyncFormatError(label, "foreign-generation prune");
108
- } else if (row.op === "note" || row.op === "goal") {
105
+ // Generation is preserved in the original prune payload. Acceptance here is structural,
106
+ // never permission to apply it: the engine proves each original generation and holds foreign rows.
107
+ if (row.op === "note" || row.op === "goal") {
109
108
  if (realm.scope === "project" ? row.scope !== "project" || row.project !== realm.key : row.scope !== "shared") {
110
109
  throw new ShardSyncFormatError(label, "row scope mismatch");
111
110
  }