@staix/agent-mem 0.3.0 → 0.4.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/README.md +74 -6
- package/package.json +4 -3
- package/src/cli.ts +116 -15
- package/src/commands-table.ts +15 -2
- package/src/index.ts +13 -0
- package/src/mcp-memory.ts +151 -0
- package/src/mcp.ts +122 -0
- package/src/memory/candidates.ts +12 -8
- package/src/memory/capacity.ts +179 -0
- package/src/memory/capture.ts +460 -0
- package/src/memory/catalog.ts +136 -0
- package/src/memory/commands.ts +7 -4
- package/src/memory/generation-index.ts +54 -11
- package/src/memory/generation.ts +41 -2
- package/src/memory/home.ts +5 -0
- package/src/memory/import.ts +60 -0
- package/src/memory/migrate.ts +8 -0
- package/src/memory/mutation.ts +13 -1
- package/src/memory/ops-contract.ts +19 -2
- package/src/memory/policy.ts +120 -0
- package/src/memory/project.ts +22 -1
- package/src/memory/reader-lease.ts +136 -0
- package/src/memory/screen.ts +8 -0
- package/src/memory/search-fts.ts +203 -0
- package/src/memory/shard-bridge.ts +19 -0
- package/src/memory/shard-migration.ts +562 -0
- package/src/memory/shard.ts +179 -0
- package/src/memory/store.ts +298 -60
- package/src/memory/vault.ts +40 -0
- package/src/memory/work-lists.ts +243 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readProfileJSON, writeProfileJSON } from "./generation.ts";
|
|
4
|
+
import { readerAdmissionDatabase } from "./mutation.ts";
|
|
5
|
+
import { processLiveness, processSignature } from "./platform.ts";
|
|
6
|
+
|
|
7
|
+
const READER_LEASE_SCHEMA = "agent-mem.reader-lease/v1";
|
|
8
|
+
const RENEW_MS = 25_000;
|
|
9
|
+
interface Lease { schema: typeof READER_LEASE_SCHEMA; pid: number; signature: string; renewedAt: string }
|
|
10
|
+
export interface ReaderLease { (): void; verify(): void }
|
|
11
|
+
interface OpenLease { refs: number; timer: ReturnType<typeof setInterval> }
|
|
12
|
+
const open = new Map<string, OpenLease>();
|
|
13
|
+
|
|
14
|
+
const fileFor = (memoryDir: string, pid: number, signature: string) => join(memoryDir, "readers", `${pid}-${signature}.json`);
|
|
15
|
+
const record = (pid: number, signature: string): Lease => ({ schema: READER_LEASE_SCHEMA, pid, signature, renewedAt: new Date().toISOString() });
|
|
16
|
+
|
|
17
|
+
function withFence<T>(memoryDir: string, work: () => T): T {
|
|
18
|
+
const db = readerAdmissionDatabase(memoryDir);
|
|
19
|
+
try { return db.transaction(work).immediate(); } finally { db.close(); }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function checkReadersDir(memoryDir: string): string {
|
|
23
|
+
const dir = join(memoryDir, "readers");
|
|
24
|
+
const stat = lstatSync(dir, { throwIfNoEntry: false });
|
|
25
|
+
if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) throw new Error("reader lease directory is not a regular directory");
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readLease(memoryDir: string, file: string): Lease | undefined {
|
|
30
|
+
try {
|
|
31
|
+
const value = readProfileJSON(file) as Partial<Lease> | undefined;
|
|
32
|
+
if (value === undefined) return undefined;
|
|
33
|
+
if (!value || value.schema !== READER_LEASE_SCHEMA || !Number.isSafeInteger(value.pid) || value.pid! <= 0
|
|
34
|
+
|| typeof value.signature !== "string" || !/^[0-9a-f]{64}$/.test(value.signature)
|
|
35
|
+
|| fileFor(memoryDir, value.pid!, value.signature) !== file || typeof value.renewedAt !== "string" || Number.isNaN(Date.parse(value.renewedAt))) throw new Error();
|
|
36
|
+
return value as Lease;
|
|
37
|
+
} catch { throw new Error("reader lease is unreadable or unverifiable"); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ownedLease(memoryDir: string, file: string, signature: string): void {
|
|
41
|
+
checkReadersDir(memoryDir);
|
|
42
|
+
const lease = readLease(memoryDir, file);
|
|
43
|
+
if (!lease || lease.pid !== process.pid || lease.signature !== signature) throw new Error("reader lease ownership is unavailable");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function admitReader(memoryDir: string): void {
|
|
47
|
+
// The profile lock is created under the same reader admission fence, then held across migration awaits.
|
|
48
|
+
// Even a malformed or stale marker refuses admission; only the profile-lock owner may reclaim it.
|
|
49
|
+
try {
|
|
50
|
+
if (lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume");
|
|
51
|
+
} catch { throw new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume"); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One lease per process, reference-counted across handles; an unref'd timer does not keep the process resident. */
|
|
55
|
+
export function registerReaderLease(memoryDir: string): ReaderLease {
|
|
56
|
+
const pid = process.pid, signature = processSignature(pid);
|
|
57
|
+
if (!signature) throw new Error("cannot establish reader lease identity");
|
|
58
|
+
const file = fileFor(memoryDir, process.pid, signature);
|
|
59
|
+
mkdirSync(memoryDir, { recursive: true, mode: 0o700 });
|
|
60
|
+
withFence(memoryDir, () => {
|
|
61
|
+
admitReader(memoryDir);
|
|
62
|
+
const current = open.get(file);
|
|
63
|
+
if (current) {
|
|
64
|
+
ownedLease(memoryDir, file, signature);
|
|
65
|
+
current.refs++;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const dir = checkReadersDir(memoryDir);
|
|
69
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
70
|
+
// A failed earlier close may have retained evidence. Never overwrite an unverifiable replacement.
|
|
71
|
+
const existing = readLease(memoryDir, file);
|
|
72
|
+
if (existing && (existing.pid !== process.pid || existing.signature !== signature)) throw new Error("reader lease ownership is unavailable");
|
|
73
|
+
writeProfileJSON(file, record(process.pid, signature));
|
|
74
|
+
const timer = setInterval(() => {
|
|
75
|
+
if (!open.has(file)) return;
|
|
76
|
+
try {
|
|
77
|
+
withFence(memoryDir, () => {
|
|
78
|
+
ownedLease(memoryDir, file, signature);
|
|
79
|
+
writeProfileJSON(file, record(process.pid, signature));
|
|
80
|
+
});
|
|
81
|
+
} catch { /* keep unknown evidence; migration admission remains fail-closed */ }
|
|
82
|
+
}, RENEW_MS);
|
|
83
|
+
timer.unref();
|
|
84
|
+
open.set(file, { refs: 1, timer });
|
|
85
|
+
});
|
|
86
|
+
let released = false;
|
|
87
|
+
const release = () => {
|
|
88
|
+
if (released) return;
|
|
89
|
+
released = true;
|
|
90
|
+
const held = open.get(file);
|
|
91
|
+
if (!held) return;
|
|
92
|
+
held.refs--;
|
|
93
|
+
if (held.refs > 0) return;
|
|
94
|
+
clearInterval(held.timer);
|
|
95
|
+
open.delete(file);
|
|
96
|
+
try {
|
|
97
|
+
withFence(memoryDir, () => {
|
|
98
|
+
ownedLease(memoryDir, file, signature);
|
|
99
|
+
rmSync(file);
|
|
100
|
+
});
|
|
101
|
+
} catch { /* another actor replaced it: never remove its lease */ }
|
|
102
|
+
};
|
|
103
|
+
return Object.assign(release, {
|
|
104
|
+
verify(): void {
|
|
105
|
+
if (released || process.pid !== pid || !open.has(file)) throw new Error("reader lease ownership is unavailable");
|
|
106
|
+
// The held lifetime lease blocks legitimate migration. Verification reads only: no ps, DB fence or refcount mutation.
|
|
107
|
+
// ponytail: no atomic same-user concurrent path-swap claim; descriptor-relative ownership checks are the upgrade path.
|
|
108
|
+
admitReader(memoryDir);
|
|
109
|
+
ownedLease(memoryDir, file, signature);
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Fail closed on a live or unverifiable lease; remove only a pid confirmed gone, never by TTL. */
|
|
115
|
+
export function assertNoLiveReaders(memoryDir: string, options: { onDeadLeaseRemoved?: (event: { pid: number; lease: string }) => void } = {}): { removed: number } {
|
|
116
|
+
return withFence(memoryDir, () => {
|
|
117
|
+
const dir = checkReadersDir(memoryDir);
|
|
118
|
+
let names: string[] = [];
|
|
119
|
+
try { names = readdirSync(dir).filter((name) => name.endsWith(".json")).sort(); }
|
|
120
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { removed: 0 }; throw new Error("reader lease directory is unreadable"); }
|
|
121
|
+
let removed = 0;
|
|
122
|
+
for (const name of names) {
|
|
123
|
+
if (!/^[1-9][0-9]*-[0-9a-f]{64}\.json$/.test(name)) throw new Error("unverifiable reader lease filename");
|
|
124
|
+
const file = join(dir, name);
|
|
125
|
+
const lease = readLease(memoryDir, file);
|
|
126
|
+
if (!lease) throw new Error("reader lease is unreadable or unverifiable");
|
|
127
|
+
const liveness = processLiveness(lease.pid, lease.signature);
|
|
128
|
+
if (liveness === "live") throw new Error(`legacy reader is live (pid ${lease.pid}); close it before migration`);
|
|
129
|
+
if (liveness === "unknown") throw new Error(`legacy reader liveness is unknown (pid ${lease.pid})`);
|
|
130
|
+
rmSync(file);
|
|
131
|
+
removed++;
|
|
132
|
+
options.onDeadLeaseRemoved?.({ pid: lease.pid, lease: name });
|
|
133
|
+
}
|
|
134
|
+
return { removed };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in secret screen, a leaf module so the capture tier (#13) can screen excerpts without importing the
|
|
3
|
+
* curated store. `store.ts` re-exports `looksSecret`; the public entry keeps its original name and behavior.
|
|
4
|
+
*/
|
|
5
|
+
const SECRETS = [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, /\bAKIA[0-9A-Z]{16}\b/, /\bgh[pousr]_[A-Za-z0-9]{30,}/, /\bgithub_pat_[A-Za-z0-9_]{40,}/, /\bsk-[A-Za-z0-9_-]{20,}/,
|
|
6
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}/, /\bAIza[0-9A-Za-z_-]{35}\b/, /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, /\b(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*\S{8,}/i];
|
|
7
|
+
/** A secret-looking string: refused before anything is written, since long-term entries leave the machine. */
|
|
8
|
+
export const looksSecret = (text: string): boolean => SECRETS.some((re) => re.test(text));
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { chmodSync, existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { CaptureRow } from "./capture.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* #13 R5: full-text search over the curated tier with SQLite FTS5 and BM25, replacing substring matching where the
|
|
8
|
+
* platform's sqlite has FTS5 (verified in bun:sqlite on Bun 1.4.2, macOS and Linux). The index lives in its own
|
|
9
|
+
* `search.db` beside the memory logs: `index.db`'s table set is part of the shared-v1 golden contract and must not
|
|
10
|
+
* gain tables, and the sync .gitignore already keeps every other file machine-local. `search.db` is derived only
|
|
11
|
+
* from curated rows: every add is idempotent by entry id, and a store rebuild, a prune, a lock or any count drift
|
|
12
|
+
* forces a full resync, so the incremental index equals a rebuild.
|
|
13
|
+
*
|
|
14
|
+
* The copy is a DERIVED cache: a locked or failing `search.db` never fails or stalls a curated write — the apply
|
|
15
|
+
* path marks the copy stale and the next search resyncs it. Its busy timeout is zero: derived writes fail fast and
|
|
16
|
+
* degrade, never queue behind another process's lock (R2's 150 ms hook budget includes the write path).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Bumped when the docs table's shape changes: an older copy is dropped and rebuilt from the curated rows. */
|
|
20
|
+
const SEARCH_SCHEMA = "2";
|
|
21
|
+
|
|
22
|
+
let probe: boolean | undefined;
|
|
23
|
+
/** One runtime probe; the answer is cached for the process. */
|
|
24
|
+
export function fts5Available(): boolean {
|
|
25
|
+
if (probe !== undefined) return probe;
|
|
26
|
+
try {
|
|
27
|
+
const db = new Database(":memory:");
|
|
28
|
+
try {
|
|
29
|
+
db.run("CREATE VIRTUAL TABLE probe USING fts5(body)");
|
|
30
|
+
db.run("INSERT INTO probe (body) VALUES ('availability')");
|
|
31
|
+
db.query("SELECT bm25(probe) AS rank FROM probe WHERE probe MATCH 'availability'").get();
|
|
32
|
+
probe = true;
|
|
33
|
+
} finally { db.close(); }
|
|
34
|
+
} catch { probe = false; }
|
|
35
|
+
return probe;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Tests force the fallback path; `undefined` restores the real probe. Not for host use. */
|
|
39
|
+
export function setFts5AvailableForTest(value: boolean | undefined): void {
|
|
40
|
+
probe = value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const KOREAN_PARTICLES = ["에는", "에서", "으로", "에게", "한테", "처럼", "보다", "까지", "부터", "마다", "조차", "마저", "이나", "이랑", "하고", "은", "는", "이", "가", "을", "를", "에", "로", "와", "과", "도", "만", "의", "랑", "나", "라"];
|
|
44
|
+
|
|
45
|
+
/** The original term plus a common-particle-stripped Korean alternative, so both 메모리 and 메모리는 match either form. */
|
|
46
|
+
function queryVariants(term: string): string[] {
|
|
47
|
+
if (!/[가-힣]/u.test(term)) return [term];
|
|
48
|
+
const particle = KOREAN_PARTICLES.find((p) => term.endsWith(p) && term.length - p.length >= 2);
|
|
49
|
+
return particle ? [term, term.slice(0, -particle.length)] : [term];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A free-text query as a safe FTS5 MATCH string: unicode61-ish terms (the index tokenizer's alphabet), each a
|
|
54
|
+
* quoted PREFIX term (`"word" *` — inflections and Korean particles like 메모리/메모리를 match), quoted so query
|
|
55
|
+
* punctuation can never become MATCH syntax, joined with OR so natural multi-word queries rank partial matches
|
|
56
|
+
* instead of requiring every word like the substring filter did (the P0 recall gap).
|
|
57
|
+
*/
|
|
58
|
+
export function ftsTerms(q: string): string {
|
|
59
|
+
const terms = [...new Set(q.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [])].slice(0, 32);
|
|
60
|
+
const variants = [...new Set(terms.flatMap(queryVariants))].slice(0, 64);
|
|
61
|
+
return variants.map((t) => `"${t.replaceAll('"', '""')}" *`).join(" OR ");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CuratedDoc { id: string; at: string; project: string; tier: string; scope: string; body: string }
|
|
65
|
+
export interface RankedRef { ref: string; project: string; tier: string; at: string; rank: number }
|
|
66
|
+
|
|
67
|
+
export class SearchIndex {
|
|
68
|
+
readonly available: boolean;
|
|
69
|
+
private readonly db?: Database;
|
|
70
|
+
/** A write that failed (a locked or failing derived file): the next search resyncs, never the curated writer. */
|
|
71
|
+
private stale = false;
|
|
72
|
+
|
|
73
|
+
constructor(file: string) {
|
|
74
|
+
if (!fts5Available()) { this.available = false; return; }
|
|
75
|
+
try {
|
|
76
|
+
const db = new Database(file, { create: true });
|
|
77
|
+
db.run("PRAGMA busy_timeout = 0"); // derived: fail fast and mark stale; a curated write never waits on this file
|
|
78
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
79
|
+
db.run("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
|
|
80
|
+
if ((db.query("SELECT value FROM meta WHERE key = 'schema'").get() as { value: string } | null)?.value !== SEARCH_SCHEMA) {
|
|
81
|
+
db.run("DROP TABLE IF EXISTS docs");
|
|
82
|
+
db.run("DROP TABLE IF EXISTS refs");
|
|
83
|
+
db.run("DELETE FROM meta");
|
|
84
|
+
db.query("INSERT INTO meta (key, value) VALUES ('schema', ?)").run(SEARCH_SCHEMA);
|
|
85
|
+
}
|
|
86
|
+
db.run("CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5(body, ref UNINDEXED, project UNINDEXED, tier UNINDEXED, at UNINDEXED, scope UNINDEXED)");
|
|
87
|
+
// FTS5 has no unique constraint: refs remembers what is indexed, so a repeated or rebuilt row is never doubled.
|
|
88
|
+
db.run("CREATE TABLE IF NOT EXISTS refs (ref TEXT PRIMARY KEY)");
|
|
89
|
+
try { chmodSync(file, 0o600); } catch { /* best effort */ }
|
|
90
|
+
this.db = db;
|
|
91
|
+
this.available = true;
|
|
92
|
+
} catch { this.available = false; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private meta(key: string): string | undefined {
|
|
96
|
+
return (this.db!.query("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | null)?.value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Called from the curated store's apply path, after the entry row committed. Idempotent by entry id. Never throws. */
|
|
100
|
+
addCurated(doc: CuratedDoc): void {
|
|
101
|
+
const db = this.db;
|
|
102
|
+
if (!db) return;
|
|
103
|
+
try {
|
|
104
|
+
db.transaction(() => {
|
|
105
|
+
// bun:sqlite's run().changes is not reliable for an ignored insert inside a transaction (the FTS triggers
|
|
106
|
+
// confuse it), so idempotency is a plain lookup, never the statement's change count.
|
|
107
|
+
if (db.query("SELECT 1 FROM refs WHERE ref = ?").get(doc.id)) return;
|
|
108
|
+
db.query("INSERT INTO refs (ref) VALUES (?)").run(doc.id);
|
|
109
|
+
db.query("INSERT INTO docs (body, ref, project, tier, at, scope) VALUES (?, ?, ?, ?, ?, ?)").run(doc.body, doc.id, doc.project, doc.tier, doc.at, doc.scope);
|
|
110
|
+
db.query("INSERT INTO meta (key, value) VALUES ('curated', '1') ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1").run();
|
|
111
|
+
}).immediate();
|
|
112
|
+
} catch { this.markDirty(); } // the FTS copy degrades, the curated write stands
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A curated prune removed rows the FTS copy cannot subtract incrementally: the next search resyncs instead. */
|
|
116
|
+
markDirty(): void {
|
|
117
|
+
this.stale = true;
|
|
118
|
+
try { this.db?.query("INSERT INTO meta (key, value) VALUES ('dirty', '1') ON CONFLICT(key) DO UPDATE SET value = '1'").run(); } catch { /* the in-memory flag is enough for this process */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** True when the FTS copy no longer mirrors the curated rows (a prune, a crashed apply, a lock or a rebuilt index). */
|
|
122
|
+
needsResync(curatedCount: number): boolean {
|
|
123
|
+
if (!this.db) return false;
|
|
124
|
+
if (this.stale) return true;
|
|
125
|
+
try { return this.meta("dirty") === "1" || Number(this.meta("curated") ?? "0") !== curatedCount; }
|
|
126
|
+
catch { return true; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Rebuild the FTS copy from the curated rows; the caller scans them. */
|
|
130
|
+
resync(load: () => Iterable<CuratedDoc>): void {
|
|
131
|
+
const db = this.db;
|
|
132
|
+
if (!db) return;
|
|
133
|
+
try {
|
|
134
|
+
db.transaction(() => {
|
|
135
|
+
db.run("DELETE FROM docs");
|
|
136
|
+
db.run("DELETE FROM refs");
|
|
137
|
+
let n = 0;
|
|
138
|
+
for (const doc of load()) {
|
|
139
|
+
db.query("INSERT INTO refs (ref) VALUES (?)").run(doc.id);
|
|
140
|
+
db.query("INSERT INTO docs (body, ref, project, tier, at, scope) VALUES (?, ?, ?, ?, ?, ?)").run(doc.body, doc.id, doc.project, doc.tier, doc.at, doc.scope);
|
|
141
|
+
n++;
|
|
142
|
+
}
|
|
143
|
+
db.query("INSERT INTO meta (key, value) VALUES ('curated', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(String(n));
|
|
144
|
+
db.query("DELETE FROM meta WHERE key = 'dirty'").run();
|
|
145
|
+
}).immediate();
|
|
146
|
+
this.stale = false;
|
|
147
|
+
} catch { this.stale = true; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The store drops and re-reads every curated row: the FTS copy starts over and the apply path refills it. */
|
|
151
|
+
reset(): void {
|
|
152
|
+
const db = this.db;
|
|
153
|
+
if (!db) return;
|
|
154
|
+
try {
|
|
155
|
+
db.transaction(() => {
|
|
156
|
+
db.run("DELETE FROM docs");
|
|
157
|
+
db.run("DELETE FROM refs");
|
|
158
|
+
db.run("DELETE FROM meta WHERE key != 'schema'");
|
|
159
|
+
}).immediate();
|
|
160
|
+
this.stale = false;
|
|
161
|
+
} catch { this.stale = true; }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Best BM25 first (rank is negative: smaller wins). `project` filters inside the MATCH (its own rows plus shared
|
|
166
|
+
* ones), so a busy sibling project can never starve a project's LIMIT. Visibility stays with the caller.
|
|
167
|
+
*/
|
|
168
|
+
query(q: string, limit: number, project?: string, offset = 0): RankedRef[] {
|
|
169
|
+
if (!this.db) return [];
|
|
170
|
+
const match = ftsTerms(q);
|
|
171
|
+
if (!match) return [];
|
|
172
|
+
const where = project ? "docs MATCH ? AND (project = ? OR scope = 'shared')" : "docs MATCH ?";
|
|
173
|
+
const bind = project ? [match, project] : [match];
|
|
174
|
+
return this.db.query(`SELECT ref, project, tier, at, bm25(docs) AS rank FROM docs WHERE ${where} ORDER BY rank, ref LIMIT ? OFFSET ?`).all(...bind, limit, offset) as RankedRef[];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
close(): void { this.db?.close(); }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
interface RawCaptureRow { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string }
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The capture leg of #13 search: BM25 over the excerpts of one capture shard (`machine/<id>` of a project shard,
|
|
184
|
+
* #15 T1). Empty when FTS5 or the shard is absent; a contended or corrupted shard degrades recall, never the search.
|
|
185
|
+
* Rows older than `notBefore` (an ISO time) are left out; the store passes the shard's retention cutoff, so an expired
|
|
186
|
+
* row is not recalled before a later append evicts it.
|
|
187
|
+
*/
|
|
188
|
+
export function captureSearch(dir: string, q: string, limit: number, notBefore?: string): { row: CaptureRow; rank: number }[] {
|
|
189
|
+
const match = ftsTerms(q);
|
|
190
|
+
if (!match || !fts5Available() || !existsSync(join(dir, "capture.db"))) return [];
|
|
191
|
+
let db: Database | undefined;
|
|
192
|
+
try {
|
|
193
|
+
db = new Database(join(dir, "capture.db"), { readonly: true });
|
|
194
|
+
db.run("PRAGMA busy_timeout = 3000");
|
|
195
|
+
if (!db.query("SELECT 1 FROM sqlite_master WHERE name = 'fts' AND type = 'table'").get()) return [];
|
|
196
|
+
// `notBefore` (retention cutoff, `since`) runs inside the query: rows past it never take a LIMIT slot.
|
|
197
|
+
const rows = (notBefore === undefined
|
|
198
|
+
? db.query(`SELECT r.*, bm25(fts) AS rank FROM fts JOIN rows r ON r.id = fts.rowid WHERE fts MATCH ? ORDER BY rank LIMIT ?`).all(match, limit)
|
|
199
|
+
: db.query(`SELECT r.*, bm25(fts) AS rank FROM fts JOIN rows r ON r.id = fts.rowid WHERE fts MATCH ? AND r.at >= ? ORDER BY rank LIMIT ?`).all(match, notBefore, limit)) as (RawCaptureRow & { rank: number })[];
|
|
200
|
+
return rows.map((r) => ({ row: { id: r.id, at: r.at, project: r.project, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt }, rank: r.rank }));
|
|
201
|
+
} catch { return []; }
|
|
202
|
+
finally { db?.close(); }
|
|
203
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readLegacySnapshot, type LegacyCheckpoint, type LegacySegment, type LegacySnapshotOptions } from "./bridge.ts";
|
|
2
|
+
import type { BoundProfile } from "./generation.ts";
|
|
3
|
+
|
|
4
|
+
/** Copied bridge roots keep their original birth realm; no foreign project root is scanned. */
|
|
5
|
+
export function readShardLegacySnapshot(profile: BoundProfile, options: Pick<LegacySnapshotOptions, "checkpoints" | "limits" | "reader" | "incremental">): LegacySegment[] {
|
|
6
|
+
const local = (rows: readonly LegacyCheckpoint[] | undefined) => rows?.filter(row => row.source === "legacy-local");
|
|
7
|
+
const main = (rows: readonly LegacyCheckpoint[] | undefined) => rows?.filter(row => row.source === "legacy-main").map(row => ({ ...row, source: "legacy-local", commit: undefined }));
|
|
8
|
+
const result = readLegacySnapshot(profile.legacyHome ?? profile.home, {
|
|
9
|
+
...options, checkpoints: local(options.checkpoints), ...(options.limits ? { limits: local(options.limits) } : {}),
|
|
10
|
+
});
|
|
11
|
+
if (!profile.legacyMainHome) {
|
|
12
|
+
if ((main(options.checkpoints)?.length ?? 0) || (main(options.limits)?.length ?? 0)) throw new Error("curated shard public bridge is unavailable");
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
const publicRows = readLegacySnapshot(profile.legacyMainHome, {
|
|
16
|
+
...options, checkpoints: main(options.checkpoints), ...(options.limits ? { limits: main(options.limits) } : {}),
|
|
17
|
+
}).map(segment => ({ ...segment, source: "legacy-main" }));
|
|
18
|
+
return [...result, ...publicRows];
|
|
19
|
+
}
|