@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +19 -7
- package/package.json +1 -1
- package/src/cli.ts +23 -12
- package/src/commands-table.ts +7 -6
- package/src/ingest.ts +98 -7
- package/src/mcp-memory.ts +42 -34
- package/src/memory/capture.ts +25 -7
- package/src/memory/commands.ts +13 -4
- package/src/memory/eval.ts +2 -2
- package/src/memory/import.ts +91 -0
- package/src/memory/platform.ts +5 -1
- package/src/memory/policy.ts +88 -9
- package/src/memory/search-fts.ts +59 -3
- package/src/memory/shard-sync-format.ts +3 -4
- package/src/memory/shard-sync.ts +135 -17
- 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/capture.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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 };
|
package/src/memory/commands.ts
CHANGED
|
@@ -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
|
|
93
|
-
const
|
|
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);
|
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/import.ts
CHANGED
|
@@ -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
|
package/src/memory/platform.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/memory/policy.ts
CHANGED
|
@@ -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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
};
|
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
|
}
|
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
}
|