clawmem 0.20.2 → 0.22.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/AGENTS.md +6 -6
- package/README.md +6 -3
- package/SKILL.md +6 -6
- package/docs/concepts/architecture.md +1 -1
- package/docs/concepts/composite-scoring.md +6 -4
- package/docs/guides/inference-services.md +11 -2
- package/docs/guides/upgrading.md +33 -3
- package/docs/introduction.md +1 -1
- package/docs/quickstart.md +1 -1
- package/docs/reference/cli.md +5 -3
- package/docs/reference/configuration.md +1 -0
- package/docs/reference/mcp-tools.md +15 -2
- package/docs/troubleshooting.md +7 -4
- package/package.json +1 -1
- package/src/busy-retry.ts +34 -0
- package/src/canary.ts +364 -0
- package/src/clawmem.ts +271 -22
- package/src/config.ts +29 -1
- package/src/graph-traversal.ts +32 -4
- package/src/mcp.ts +207 -63
- package/src/memory.ts +1 -0
- package/src/scoring-regime.ts +79 -0
- package/src/store.ts +366 -32
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized scoring-regime selection for the MCP direct vector routes (v0.22.0).
|
|
3
|
+
*
|
|
4
|
+
* Two regimes:
|
|
5
|
+
*
|
|
6
|
+
* "raw" — non-recency queries on the evidenced raw routes (`vsearch`,
|
|
7
|
+
* `memory_retrieve` semantic/discovery): results rank by RAW
|
|
8
|
+
* vector cosine DESC. Document metadata — including pin —
|
|
9
|
+
* participates ONLY inside groups of exactly-equal raw scores.
|
|
10
|
+
* The reported score IS the raw cosine (scoreBasis
|
|
11
|
+
* "vector-cosine"): specific to the embedding model that
|
|
12
|
+
* produced it, and not comparable to composite scores.
|
|
13
|
+
*
|
|
14
|
+
* "recency-composite" — hasRecencyIntent(query): the pre-v0.22.0 composite behavior,
|
|
15
|
+
* unchanged (RECENCY_WEIGHTS blend, multipliers, pin boost,
|
|
16
|
+
* contentType priority sort, composite-scale minScore default).
|
|
17
|
+
*
|
|
18
|
+
* Measured basis (BACKLOG Source 48): raw cosine ranked 16/19 judged targets #1
|
|
19
|
+
* (MRR 0.912) where the composite stack ranked 1/19 (MRR 0.307) and filtered 14/19
|
|
20
|
+
* below the old composite minScore — every metadata signal large enough to matter is
|
|
21
|
+
* larger than the 0.03–0.10 raw margins that separate right answers from wrong ones,
|
|
22
|
+
* so metadata may only ever break exact raw-score ties on these routes.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
applyCompositeScoring,
|
|
26
|
+
hasRecencyIntent,
|
|
27
|
+
type EnrichedResult,
|
|
28
|
+
type ScoredResult,
|
|
29
|
+
type CoActivationFn,
|
|
30
|
+
type CompositeScoringOptions,
|
|
31
|
+
} from "./memory.ts";
|
|
32
|
+
|
|
33
|
+
/** Reported score basis for raw-regime results: raw vector cosine. */
|
|
34
|
+
export const VECTOR_SCORE_BASIS = "vector-cosine" as const;
|
|
35
|
+
/** Reported score basis for composite-scored results. */
|
|
36
|
+
export const COMPOSITE_SCORE_BASIS = "composite" as const;
|
|
37
|
+
|
|
38
|
+
export type ScoringRegime = "raw" | "recency-composite";
|
|
39
|
+
|
|
40
|
+
/** One switch point for the raw-route regime decision (design R4). */
|
|
41
|
+
export function selectScoringRegime(query: string): ScoringRegime {
|
|
42
|
+
return hasRecencyIntent(query) ? "recency-composite" : "raw";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Raw-primary ranking (design R1/R2): raw score DESC; within a group of exactly-equal
|
|
47
|
+
* raw scores the deterministic tie order is pinned DESC, then legacy composite DESC,
|
|
48
|
+
* then displayPath ASC. `compositeScore` on the returned rows carries the RAW score —
|
|
49
|
+
* the reported score is the raw cosine.
|
|
50
|
+
*
|
|
51
|
+
* Callers gate on selectScoringRegime() first — this ranker is only meaningful for
|
|
52
|
+
* non-recency queries (the legacy composite computed here for tie keys therefore never
|
|
53
|
+
* takes the RECENCY_WEIGHTS branch). Inputs are document-unique on these routes
|
|
54
|
+
* (searchVecDetailed hydrates one row per document).
|
|
55
|
+
*
|
|
56
|
+
* `options.now` flows to the tie-key composite so frozen-clock evaluation (the v0.22.0
|
|
57
|
+
* acceptance bundle) is bit-reproducible.
|
|
58
|
+
*/
|
|
59
|
+
export function rankRawPrimary(
|
|
60
|
+
results: EnrichedResult[],
|
|
61
|
+
query: string,
|
|
62
|
+
coActivationFn?: CoActivationFn,
|
|
63
|
+
options?: CompositeScoringOptions
|
|
64
|
+
): ScoredResult[] {
|
|
65
|
+
const legacy = applyCompositeScoring(results, query, coActivationFn, options);
|
|
66
|
+
const legacyByPath = new Map(legacy.map(s => [s.filepath, s]));
|
|
67
|
+
const ranked = results.map(r => {
|
|
68
|
+
const l = legacyByPath.get(r.filepath) ?? ({ ...r, compositeScore: r.score, recencyScore: 0 } as ScoredResult);
|
|
69
|
+
return { row: { ...l, compositeScore: r.score } as ScoredResult, legacyComposite: l.compositeScore };
|
|
70
|
+
});
|
|
71
|
+
ranked.sort((a, b) => {
|
|
72
|
+
if (b.row.compositeScore !== a.row.compositeScore) return b.row.compositeScore - a.row.compositeScore;
|
|
73
|
+
const pinDelta = (b.row.pinned ? 1 : 0) - (a.row.pinned ? 1 : 0);
|
|
74
|
+
if (pinDelta !== 0) return pinDelta;
|
|
75
|
+
if (b.legacyComposite !== a.legacyComposite) return b.legacyComposite - a.legacyComposite;
|
|
76
|
+
return a.row.displayPath < b.row.displayPath ? -1 : a.row.displayPath > b.row.displayPath ? 1 : 0;
|
|
77
|
+
});
|
|
78
|
+
return ranked.map(r => r.row);
|
|
79
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -512,6 +512,31 @@ function initializeDatabase(db: Database, busyTimeoutMs: number = 15000): void {
|
|
|
512
512
|
)
|
|
513
513
|
`);
|
|
514
514
|
|
|
515
|
+
// Geometry-canary baseline (VSEARCH-TRUST-HARDENING (d)): per-profile probe vectors +
|
|
516
|
+
// measured pair-margins from the last healthy embed run. NOT content_vectors — canary
|
|
517
|
+
// probes must never pollute retrieval.
|
|
518
|
+
db.exec(`
|
|
519
|
+
CREATE TABLE IF NOT EXISTS embed_canary (
|
|
520
|
+
probe_id TEXT NOT NULL,
|
|
521
|
+
profile_key TEXT NOT NULL,
|
|
522
|
+
embedding BLOB NOT NULL,
|
|
523
|
+
pair_margins TEXT NOT NULL,
|
|
524
|
+
embedded_at TEXT NOT NULL,
|
|
525
|
+
PRIMARY KEY (probe_id, profile_key)
|
|
526
|
+
)
|
|
527
|
+
`);
|
|
528
|
+
|
|
529
|
+
// Durable vault health flags (T8-M1): e.g. embed_geometry_taint — a detected mid-run
|
|
530
|
+
// geometry change must survive the process so doctor stays nonzero until a verified
|
|
531
|
+
// full rebuild clears it.
|
|
532
|
+
db.exec(`
|
|
533
|
+
CREATE TABLE IF NOT EXISTS vault_flags (
|
|
534
|
+
flag TEXT PRIMARY KEY,
|
|
535
|
+
value TEXT NOT NULL,
|
|
536
|
+
updated_at TEXT NOT NULL
|
|
537
|
+
)
|
|
538
|
+
`);
|
|
539
|
+
|
|
515
540
|
// FTS - index filepath (collection/path), title, and content
|
|
516
541
|
db.exec(`
|
|
517
542
|
CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
|
|
@@ -641,6 +666,10 @@ function initializeDatabase(db: Database, busyTimeoutMs: number = 15000): void {
|
|
|
641
666
|
["fragment_type", "ALTER TABLE content_vectors ADD COLUMN fragment_type TEXT"],
|
|
642
667
|
["fragment_label", "ALTER TABLE content_vectors ADD COLUMN fragment_label TEXT"],
|
|
643
668
|
["canonical_id", "ALTER TABLE content_vectors ADD COLUMN canonical_id TEXT"],
|
|
669
|
+
// Embed-input fingerprint (VSEARCH-TRUST-HARDENING (d).4 / T4-M2): SHA-256 over the
|
|
670
|
+
// UTF-8 bytes of the exact final formatDocForEmbedding(...) string. Rows without it
|
|
671
|
+
// (legacy) are title-unverifiable at doctor time until their next re-embed.
|
|
672
|
+
["embed_input_fp", "ALTER TABLE content_vectors ADD COLUMN embed_input_fp TEXT"],
|
|
644
673
|
];
|
|
645
674
|
for (const [col, sql] of cvMigrations) {
|
|
646
675
|
if (!cvColNames.has(col)) {
|
|
@@ -1327,8 +1356,9 @@ export type Store = {
|
|
|
1327
1356
|
toVirtualPath: (absolutePath: string) => string | null;
|
|
1328
1357
|
|
|
1329
1358
|
// Search
|
|
1330
|
-
searchFTS: (query: string, limit?: number, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }) => SearchResult[];
|
|
1359
|
+
searchFTS: (query: string, limit?: number, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }, excludeCollections?: string[]) => SearchResult[];
|
|
1331
1360
|
searchVec: (query: string, model: string, limit?: number, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }, deadlineMs?: number) => Promise<SearchResult[]>;
|
|
1361
|
+
searchVecDetailed: (query: string, model: string, limit?: number, opts?: VecSearchDetailedOpts) => Promise<VecSearchDetailedResult>;
|
|
1332
1362
|
|
|
1333
1363
|
// Query expansion & reranking
|
|
1334
1364
|
expandQuery: (query: string, model?: string, intent?: string) => Promise<ExpandedQuery[]>;
|
|
@@ -1360,8 +1390,13 @@ export type Store = {
|
|
|
1360
1390
|
getHashesNeedingFragments: () => { hash: string; body: string; path: string; title: string; collection: string }[];
|
|
1361
1391
|
clearAllEmbeddings: (leaseGuard?: LeaseGuard) => void;
|
|
1362
1392
|
getVectorConsistency: () => { cvCount: number; vvCount: number; cvMissingVv: number; vvOrphan: number; pending: number };
|
|
1363
|
-
insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, fragmentType?: string, fragmentLabel?: string, canonicalId?: string, leaseGuard?: LeaseGuard) => void;
|
|
1393
|
+
insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, fragmentType?: string, fragmentLabel?: string, canonicalId?: string, leaseGuard?: LeaseGuard, embedInputFp?: string) => void;
|
|
1364
1394
|
cleanStaleEmbeddings: (leaseGuard?: LeaseGuard) => number;
|
|
1395
|
+
saveCanaryBaseline: (profileKey: string, probes: { probeId: string; embedding: Float32Array }[], pairMargins: Record<string, number>, leaseGuard?: LeaseGuard) => void;
|
|
1396
|
+
getCanaryBaseline: (profileKey: string) => { probes: Map<string, Float32Array>; pairMargins: Record<string, number>; embeddedAt: string } | null;
|
|
1397
|
+
setVaultFlag: (flag: string, value: string, leaseGuard?: LeaseGuard) => void;
|
|
1398
|
+
getVaultFlag: (flag: string) => string | null;
|
|
1399
|
+
clearVaultFlag: (flag: string, leaseGuard?: LeaseGuard) => void;
|
|
1365
1400
|
|
|
1366
1401
|
// SAME: Observation metadata
|
|
1367
1402
|
updateObservationFields: (docPath: string, collectionName: string, fields: { observation_type?: string; facts?: string; narrative?: string; concepts?: string; files_read?: string; files_modified?: string }) => void;
|
|
@@ -1386,9 +1421,9 @@ export type Store = {
|
|
|
1386
1421
|
snoozeDocument: (collection: string, path: string, until: string | null) => void;
|
|
1387
1422
|
|
|
1388
1423
|
// Embed state tracking
|
|
1389
|
-
markEmbedStart: (hash: string) => void;
|
|
1390
|
-
markEmbedSynced: (hash: string) => void;
|
|
1391
|
-
markEmbedFailed: (hash: string, error: string) => void;
|
|
1424
|
+
markEmbedStart: (hash: string, leaseGuard?: LeaseGuard) => void;
|
|
1425
|
+
markEmbedSynced: (hash: string, leaseGuard?: LeaseGuard) => void;
|
|
1426
|
+
markEmbedFailed: (hash: string, error: string, leaseGuard?: LeaseGuard) => void;
|
|
1392
1427
|
getEmbedStats: () => { pending: number; synced: number; failed: number };
|
|
1393
1428
|
|
|
1394
1429
|
// Beads integration
|
|
@@ -1521,8 +1556,9 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
|
|
|
1521
1556
|
toVirtualPath: (absolutePath: string) => toVirtualPath(db, absolutePath),
|
|
1522
1557
|
|
|
1523
1558
|
// Search
|
|
1524
|
-
searchFTS: (query: string, limit?: number, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }) => searchFTS(db, query, limit, collectionId, collections, dateRange),
|
|
1559
|
+
searchFTS: (query: string, limit?: number, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }, excludeCollections?: string[]) => searchFTS(db, query, limit, collectionId, collections, dateRange, excludeCollections),
|
|
1525
1560
|
searchVec: (query: string, model: string, limit?: number, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }, deadlineMs?: number) => searchVec(db, query, model, limit, collectionId, collections, dateRange, deadlineMs),
|
|
1561
|
+
searchVecDetailed: (query: string, model: string, limit?: number, opts?: VecSearchDetailedOpts) => searchVecDetailed(db, query, model, limit, opts),
|
|
1526
1562
|
|
|
1527
1563
|
// Query expansion & reranking
|
|
1528
1564
|
expandQuery: (query: string, model?: string, intent?: string) => expandQuery(query, model, db, intent),
|
|
@@ -1554,8 +1590,50 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
|
|
|
1554
1590
|
getHashesNeedingFragments: () => getHashesNeedingFragments(db),
|
|
1555
1591
|
clearAllEmbeddings: (leaseGuard?: LeaseGuard) => clearAllEmbeddings(db, leaseGuard),
|
|
1556
1592
|
getVectorConsistency: () => getVectorConsistency(db),
|
|
1557
|
-
insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, fragmentType?: string, fragmentLabel?: string, canonicalId?: string, leaseGuard?: LeaseGuard) => insertEmbedding(db, hash, seq, pos, embedding, model, embeddedAt, fragmentType, fragmentLabel, canonicalId, leaseGuard),
|
|
1593
|
+
insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, fragmentType?: string, fragmentLabel?: string, canonicalId?: string, leaseGuard?: LeaseGuard, embedInputFp?: string) => insertEmbedding(db, hash, seq, pos, embedding, model, embeddedAt, fragmentType, fragmentLabel, canonicalId, leaseGuard, embedInputFp),
|
|
1558
1594
|
cleanStaleEmbeddings: (leaseGuard?: LeaseGuard) => cleanStaleEmbeddings(db, leaseGuard),
|
|
1595
|
+
saveCanaryBaseline: (profileKey: string, probes: { probeId: string; embedding: Float32Array }[], pairMargins: Record<string, number>, leaseGuard?: LeaseGuard) => {
|
|
1596
|
+
const marginsJson = JSON.stringify(pairMargins);
|
|
1597
|
+
const now = new Date().toISOString();
|
|
1598
|
+
db.transaction(() => {
|
|
1599
|
+
assertLeaseHeld(db, leaseGuard);
|
|
1600
|
+
db.prepare(`DELETE FROM embed_canary WHERE profile_key = ?`).run(profileKey);
|
|
1601
|
+
const ins = db.prepare(`INSERT INTO embed_canary (probe_id, profile_key, embedding, pair_margins, embedded_at) VALUES (?, ?, ?, ?, ?)`);
|
|
1602
|
+
for (const p of probes) {
|
|
1603
|
+
ins.run(p.probeId, profileKey, new Uint8Array(p.embedding.buffer.slice(p.embedding.byteOffset, p.embedding.byteOffset + p.embedding.byteLength)), marginsJson, now);
|
|
1604
|
+
}
|
|
1605
|
+
}).immediate();
|
|
1606
|
+
},
|
|
1607
|
+
getCanaryBaseline: (profileKey: string) => {
|
|
1608
|
+
const rows = db.prepare(`SELECT probe_id, embedding, pair_margins, embedded_at FROM embed_canary WHERE profile_key = ?`).all(profileKey) as { probe_id: string; embedding: Uint8Array; pair_margins: string; embedded_at: string }[];
|
|
1609
|
+
if (rows.length === 0) return null;
|
|
1610
|
+
const probes = new Map<string, Float32Array>();
|
|
1611
|
+
for (const r of rows) {
|
|
1612
|
+
const buf = new Uint8Array(r.embedding);
|
|
1613
|
+
probes.set(r.probe_id, new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4));
|
|
1614
|
+
}
|
|
1615
|
+
let pairMargins: Record<string, number> = {};
|
|
1616
|
+
try { pairMargins = JSON.parse(rows[0]!.pair_margins); } catch { /* malformed → empty */ }
|
|
1617
|
+
return { probes, pairMargins, embeddedAt: rows[0]!.embedded_at };
|
|
1618
|
+
},
|
|
1619
|
+
// Lease-fenced (T9-M4): a holder reclaimed during an async end probe must not set or
|
|
1620
|
+
// clear taint written by its successor — ownership check and mutation share one txn.
|
|
1621
|
+
setVaultFlag: (flag: string, value: string, leaseGuard?: LeaseGuard) => {
|
|
1622
|
+
db.transaction(() => {
|
|
1623
|
+
assertLeaseHeld(db, leaseGuard);
|
|
1624
|
+
db.prepare(`INSERT OR REPLACE INTO vault_flags (flag, value, updated_at) VALUES (?, ?, ?)`).run(flag, value, new Date().toISOString());
|
|
1625
|
+
}).immediate();
|
|
1626
|
+
},
|
|
1627
|
+
getVaultFlag: (flag: string) => {
|
|
1628
|
+
const row = db.prepare(`SELECT value FROM vault_flags WHERE flag = ?`).get(flag) as { value: string } | undefined;
|
|
1629
|
+
return row?.value ?? null;
|
|
1630
|
+
},
|
|
1631
|
+
clearVaultFlag: (flag: string, leaseGuard?: LeaseGuard) => {
|
|
1632
|
+
db.transaction(() => {
|
|
1633
|
+
assertLeaseHeld(db, leaseGuard);
|
|
1634
|
+
db.prepare(`DELETE FROM vault_flags WHERE flag = ?`).run(flag);
|
|
1635
|
+
}).immediate();
|
|
1636
|
+
},
|
|
1559
1637
|
|
|
1560
1638
|
// SAME: Observation metadata
|
|
1561
1639
|
updateObservationFields: (docPath: string, collectionName: string, fields) => updateObservationFieldsFn(db, docPath, collectionName, fields),
|
|
@@ -1579,24 +1657,35 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
|
|
|
1579
1657
|
pinDocument: (collection: string, path: string, pinned: boolean) => pinDocumentFn(db, collection, path, pinned),
|
|
1580
1658
|
snoozeDocument: (collection: string, path: string, until: string | null) => snoozeDocumentFn(db, collection, path, until),
|
|
1581
1659
|
|
|
1582
|
-
// Embed state tracking
|
|
1583
|
-
|
|
1660
|
+
// Embed state tracking — lease-fenced (VSEARCH-TRUST-HARDENING (f).5): the ownership
|
|
1661
|
+
// check and the state write share one transaction, so a reclaimed old holder cannot
|
|
1662
|
+
// overwrite a successor's document state after its last fragment.
|
|
1663
|
+
markEmbedStart: (hash: string, leaseGuard?: LeaseGuard) => {
|
|
1584
1664
|
// Increment embed_attempts exactly ONCE per attempt, at the start, and set
|
|
1585
1665
|
// 'pending' so a crash mid-document leaves the doc retryable (and selected by
|
|
1586
1666
|
// getHashesNeedingFragments). The completion setters below are state-only — no
|
|
1587
1667
|
// further increment — so a start + a failure for the same attempt cannot
|
|
1588
1668
|
// double-count the retry budget.
|
|
1589
|
-
db.
|
|
1669
|
+
db.transaction(() => {
|
|
1670
|
+
assertLeaseHeld(db, leaseGuard);
|
|
1671
|
+
db.prepare(`UPDATE documents SET embed_state = 'pending', embed_error = NULL, embed_attempts = COALESCE(embed_attempts, 0) + 1 WHERE hash = ? AND active = 1`).run(hash);
|
|
1672
|
+
}).immediate();
|
|
1590
1673
|
},
|
|
1591
|
-
markEmbedSynced: (hash: string) => {
|
|
1674
|
+
markEmbedSynced: (hash: string, leaseGuard?: LeaseGuard) => {
|
|
1592
1675
|
// Success resets the retry budget: embed_attempts counts CONSECUTIVE failures
|
|
1593
1676
|
// of the current content, so a successful (re-)embed must clear it — otherwise
|
|
1594
1677
|
// a doc re-embedded many times (repeated content edits) accumulates attempts
|
|
1595
1678
|
// and is wrongly excluded by the worklist's `embed_attempts < 3` guard.
|
|
1596
|
-
db.
|
|
1679
|
+
db.transaction(() => {
|
|
1680
|
+
assertLeaseHeld(db, leaseGuard);
|
|
1681
|
+
db.prepare(`UPDATE documents SET embed_state = 'synced', embed_attempts = 0, embed_error = NULL WHERE hash = ? AND active = 1`).run(hash);
|
|
1682
|
+
}).immediate();
|
|
1597
1683
|
},
|
|
1598
|
-
markEmbedFailed: (hash: string, error: string) => {
|
|
1599
|
-
db.
|
|
1684
|
+
markEmbedFailed: (hash: string, error: string, leaseGuard?: LeaseGuard) => {
|
|
1685
|
+
db.transaction(() => {
|
|
1686
|
+
assertLeaseHeld(db, leaseGuard);
|
|
1687
|
+
db.prepare(`UPDATE documents SET embed_state = 'failed', embed_error = ? WHERE hash = ? AND active = 1`).run(error, hash);
|
|
1688
|
+
}).immediate();
|
|
1600
1689
|
},
|
|
1601
1690
|
getEmbedStats: () => {
|
|
1602
1691
|
const stats = db.prepare(`
|
|
@@ -3417,7 +3506,7 @@ function buildFTS5Query(query: string): string | null {
|
|
|
3417
3506
|
return terms.map(t => `"${t}"*`).join(' AND ');
|
|
3418
3507
|
}
|
|
3419
3508
|
|
|
3420
|
-
export function searchFTS(db: Database, query: string, limit: number = 20, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }): SearchResult[] {
|
|
3509
|
+
export function searchFTS(db: Database, query: string, limit: number = 20, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }, excludeCollections?: string[]): SearchResult[] {
|
|
3421
3510
|
const ftsQuery = buildFTS5Query(query);
|
|
3422
3511
|
if (!ftsQuery) return [];
|
|
3423
3512
|
|
|
@@ -3454,6 +3543,14 @@ export function searchFTS(db: Database, query: string, limit: number = 20, colle
|
|
|
3454
3543
|
params.push(dateRange.start, dateRange.end);
|
|
3455
3544
|
}
|
|
3456
3545
|
|
|
3546
|
+
// Visibility exclusion (VSEARCH-TRUST-HARDENING (b).1): excluded collections never enter
|
|
3547
|
+
// the candidate pool, so `limit` is satisfied with allowed content by construction.
|
|
3548
|
+
if (excludeCollections && excludeCollections.length > 0) {
|
|
3549
|
+
const exPlaceholders = excludeCollections.map(() => '?').join(',');
|
|
3550
|
+
sql += ` AND d.collection NOT IN (${exPlaceholders})`;
|
|
3551
|
+
params.push(...excludeCollections);
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3457
3554
|
// bm25 lower is better; sort ascending.
|
|
3458
3555
|
sql += ` ORDER BY bm25_score ASC LIMIT ?`;
|
|
3459
3556
|
params.push(limit);
|
|
@@ -3532,11 +3629,12 @@ export async function searchVecMatch(db: Database, query: string, model: string,
|
|
|
3532
3629
|
const embedResult = await getEmbedding(query, model, true, deadlineMs);
|
|
3533
3630
|
if (!embedResult) return [];
|
|
3534
3631
|
|
|
3535
|
-
// W1: read-path embedding-model consistency gate
|
|
3536
|
-
//
|
|
3537
|
-
//
|
|
3538
|
-
// per model per
|
|
3539
|
-
|
|
3632
|
+
// W1: read-path embedding-model consistency gate + dimension check via the SHARED guard
|
|
3633
|
+
// (same guard the eval-only precomputed-vector entry runs — the two paths cannot diverge).
|
|
3634
|
+
// On a same-dimension model swap this throws VecReadModelMismatchError rather than serving
|
|
3635
|
+
// cosine-meaningless results. Cached per (db, model) — the DISTINCT runs at most once per
|
|
3636
|
+
// model per process.
|
|
3637
|
+
assertQueryVectorCompatible(db, embedResult.model, embedResult.embedding.length);
|
|
3540
3638
|
|
|
3541
3639
|
const embedding = embedResult.embedding;
|
|
3542
3640
|
|
|
@@ -3654,6 +3752,233 @@ export async function searchVec(db: Database, query: string, model: string, limi
|
|
|
3654
3752
|
return hydrateVecResults(db, vecResults, limit, collectionId, collections, dateRange);
|
|
3655
3753
|
}
|
|
3656
3754
|
|
|
3755
|
+
// =============================================================================
|
|
3756
|
+
// Detailed vector search — visibility exclusion + escalation (VSEARCH-TRUST-HARDENING (b).1)
|
|
3757
|
+
// =============================================================================
|
|
3758
|
+
|
|
3759
|
+
/**
|
|
3760
|
+
* Shared query-vector compatibility guard — model consistency (W1) + dimension-vs-table
|
|
3761
|
+
* validation, called by BOTH the production embed path and the eval-only precomputed-vector
|
|
3762
|
+
* entry so the two can never diverge (design (e), T4-M4).
|
|
3763
|
+
*/
|
|
3764
|
+
function assertQueryVectorCompatible(db: Database, endpointModel: string, dim: number): void {
|
|
3765
|
+
assertQueryEmbedModelConsistent(db, endpointModel);
|
|
3766
|
+
const tableDim = getVecTableDim(db);
|
|
3767
|
+
if (tableDim !== null && tableDim !== dim) throw new VecDimensionMismatchError(tableDim, dim);
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
export interface VecSearchDetailedOpts {
|
|
3771
|
+
collectionId?: number;
|
|
3772
|
+
collections?: string[];
|
|
3773
|
+
excludeCollections?: string[];
|
|
3774
|
+
dateRange?: { start: string; end: string };
|
|
3775
|
+
deadlineMs?: number;
|
|
3776
|
+
/** Override the hard MATCH-depth cap (default 4096). Primarily for tests. */
|
|
3777
|
+
escalationCap?: number;
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
export interface VecSearchDetailedResult {
|
|
3781
|
+
results: SearchResult[];
|
|
3782
|
+
degraded: boolean;
|
|
3783
|
+
degradedReason?: "excluded-dominant" | "cap-truncation";
|
|
3784
|
+
scannedFragments: number;
|
|
3785
|
+
excludedDocsSeen: number;
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
// Hard MATCH-depth cap for exclusion escalation. Exhausting a 60k+ table per query is a
|
|
3789
|
+
// hot-path perf cliff; past this the result carries an explicit degraded marker instead.
|
|
3790
|
+
const VEC_ESCALATION_HARD_CAP = 4096;
|
|
3791
|
+
|
|
3792
|
+
// Hydration + visibility classification for one escalation round. Include-collections and
|
|
3793
|
+
// dateRange are SQL predicates (a row failing them was never a candidate); EXCLUSION is
|
|
3794
|
+
// classified in JS because the excluded-doc count is part of the degraded contract (T5-M2).
|
|
3795
|
+
function hydrateVecResultsClassified(
|
|
3796
|
+
db: Database,
|
|
3797
|
+
vecResults: { hash_seq: string; distance: number }[],
|
|
3798
|
+
limit: number,
|
|
3799
|
+
opts: VecSearchDetailedOpts,
|
|
3800
|
+
exclude: Set<string>
|
|
3801
|
+
): { results: SearchResult[]; allowedDocs: number; excludedDocsSeen: number } {
|
|
3802
|
+
if (vecResults.length === 0) return { results: [], allowedDocs: 0, excludedDocsSeen: 0 };
|
|
3803
|
+
|
|
3804
|
+
const hashSeqs = vecResults.map(r => r.hash_seq);
|
|
3805
|
+
const distanceMap = new Map(vecResults.map(r => [r.hash_seq, r.distance]));
|
|
3806
|
+
const placeholders = hashSeqs.map(() => '?').join(',');
|
|
3807
|
+
let docSql = `
|
|
3808
|
+
SELECT
|
|
3809
|
+
cv.hash || '_' || cv.seq as hash_seq,
|
|
3810
|
+
cv.hash,
|
|
3811
|
+
cv.pos,
|
|
3812
|
+
cv.fragment_type,
|
|
3813
|
+
cv.fragment_label,
|
|
3814
|
+
d.collection,
|
|
3815
|
+
'clawmem://' || d.collection || '/' || d.path as filepath,
|
|
3816
|
+
d.collection || '/' || d.path as display_path,
|
|
3817
|
+
d.title,
|
|
3818
|
+
d.modified_at,
|
|
3819
|
+
content.doc as body
|
|
3820
|
+
FROM content_vectors cv
|
|
3821
|
+
JOIN documents d ON d.hash = cv.hash AND d.active = 1 AND d.invalidated_at IS NULL
|
|
3822
|
+
JOIN content ON content.hash = d.hash
|
|
3823
|
+
WHERE cv.hash || '_' || cv.seq IN (${placeholders})
|
|
3824
|
+
`;
|
|
3825
|
+
const params: string[] = [...hashSeqs];
|
|
3826
|
+
|
|
3827
|
+
if (opts.collections && opts.collections.length > 0) {
|
|
3828
|
+
const colPlaceholders = opts.collections.map(() => '?').join(',');
|
|
3829
|
+
docSql += ` AND d.collection IN (${colPlaceholders})`;
|
|
3830
|
+
params.push(...opts.collections);
|
|
3831
|
+
} else if (opts.collectionId) {
|
|
3832
|
+
docSql += ` AND d.collection = ?`;
|
|
3833
|
+
params.push(String(opts.collectionId));
|
|
3834
|
+
}
|
|
3835
|
+
if (opts.dateRange) {
|
|
3836
|
+
docSql += ` AND d.modified_at >= ? AND d.modified_at <= ?`;
|
|
3837
|
+
params.push(opts.dateRange.start, opts.dateRange.end);
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
const docRows = db.prepare(docSql).all(...params) as {
|
|
3841
|
+
hash_seq: string; hash: string; pos: number; collection: string; filepath: string;
|
|
3842
|
+
display_path: string; title: string; body: string; modified_at: string;
|
|
3843
|
+
fragment_type: string | null; fragment_label: string | null;
|
|
3844
|
+
}[];
|
|
3845
|
+
|
|
3846
|
+
const excludedDocs = new Set<string>();
|
|
3847
|
+
const seen = new Map<string, { row: typeof docRows[0]; bestDist: number }>();
|
|
3848
|
+
for (const row of docRows) {
|
|
3849
|
+
if (exclude.has(row.collection)) {
|
|
3850
|
+
excludedDocs.add(row.filepath);
|
|
3851
|
+
continue;
|
|
3852
|
+
}
|
|
3853
|
+
const distance = distanceMap.get(row.hash_seq) ?? 1;
|
|
3854
|
+
const existing = seen.get(row.filepath);
|
|
3855
|
+
if (!existing || distance < existing.bestDist) {
|
|
3856
|
+
seen.set(row.filepath, { row, bestDist: distance });
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
3859
|
+
|
|
3860
|
+
const results = Array.from(seen.values())
|
|
3861
|
+
.sort((a, b) => a.bestDist - b.bestDist)
|
|
3862
|
+
.slice(0, limit)
|
|
3863
|
+
.map(({ row, bestDist }) => ({
|
|
3864
|
+
filepath: row.filepath,
|
|
3865
|
+
displayPath: row.display_path,
|
|
3866
|
+
title: row.title,
|
|
3867
|
+
hash: row.hash,
|
|
3868
|
+
docid: getDocid(row.hash),
|
|
3869
|
+
collectionName: row.collection,
|
|
3870
|
+
modifiedAt: row.modified_at || "",
|
|
3871
|
+
bodyLength: row.body.length,
|
|
3872
|
+
body: row.body,
|
|
3873
|
+
context: getContextForFile(db, row.filepath),
|
|
3874
|
+
score: 1 - bestDist,
|
|
3875
|
+
source: "vec" as const,
|
|
3876
|
+
chunkPos: row.pos,
|
|
3877
|
+
fragmentType: row.fragment_type ?? undefined,
|
|
3878
|
+
fragmentLabel: row.fragment_label ?? undefined,
|
|
3879
|
+
}));
|
|
3880
|
+
|
|
3881
|
+
return { results, allowedDocs: seen.size, excludedDocsSeen: excludedDocs.size };
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
/**
|
|
3885
|
+
* Eval-only + internal core: detailed vector search from a PRECOMPUTED query vector.
|
|
3886
|
+
* Runs the SAME shared compatibility guard as the production path (T3-M2/T4-M4) — the
|
|
3887
|
+
* endpointModel MUST come from the actual embed response that produced the vector.
|
|
3888
|
+
* Synchronous (MATCH + hydration only); writes nothing.
|
|
3889
|
+
*/
|
|
3890
|
+
export function searchVecDetailedWithVector(
|
|
3891
|
+
db: Database,
|
|
3892
|
+
queryVec: { embedding: Float32Array; endpointModel: string },
|
|
3893
|
+
limit: number = 20,
|
|
3894
|
+
opts: VecSearchDetailedOpts = {}
|
|
3895
|
+
): VecSearchDetailedResult {
|
|
3896
|
+
const empty: VecSearchDetailedResult = { results: [], degraded: false, scannedFragments: 0, excludedDocsSeen: 0 };
|
|
3897
|
+
const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
|
|
3898
|
+
if (!tableExists) return empty;
|
|
3899
|
+
|
|
3900
|
+
assertQueryVectorCompatible(db, queryVec.endpointModel, queryVec.embedding.length);
|
|
3901
|
+
|
|
3902
|
+
const exclude = new Set(opts.excludeCollections ?? []);
|
|
3903
|
+
const tableRows = (db.prepare(`SELECT count(*) as c FROM vectors_vec`).get() as { c: number }).c;
|
|
3904
|
+
if (tableRows === 0) return empty;
|
|
3905
|
+
const hardCap = opts.escalationCap ?? VEC_ESCALATION_HARD_CAP;
|
|
3906
|
+
const effectiveCap = Math.min(hardCap, tableRows);
|
|
3907
|
+
|
|
3908
|
+
const matchStmt = db.prepare(`SELECT hash_seq, distance FROM vectors_vec WHERE embedding MATCH ? AND k = ?`);
|
|
3909
|
+
|
|
3910
|
+
let k = Math.min(limit * 3, effectiveCap);
|
|
3911
|
+
let raw: { hash_seq: string; distance: number }[] = [];
|
|
3912
|
+
let classified: ReturnType<typeof hydrateVecResultsClassified> = { results: [], allowedDocs: 0, excludedDocsSeen: 0 };
|
|
3913
|
+
|
|
3914
|
+
// Escalation loop (exclusion-enabled callers only): grow MATCH depth x3 until `limit`
|
|
3915
|
+
// allowed DOCUMENTS (post-dedup) hydrate, the effective cap is hit, or the deadline passes.
|
|
3916
|
+
// Without exclusion this runs exactly once at limit*3 — today's semantics.
|
|
3917
|
+
for (;;) {
|
|
3918
|
+
raw = matchStmt.all(queryVec.embedding, k) as { hash_seq: string; distance: number }[];
|
|
3919
|
+
classified = hydrateVecResultsClassified(db, raw, limit, opts, exclude);
|
|
3920
|
+
const done =
|
|
3921
|
+
exclude.size === 0 ||
|
|
3922
|
+
classified.allowedDocs >= limit ||
|
|
3923
|
+
k >= effectiveCap ||
|
|
3924
|
+
raw.length < k || // MATCH returned fewer than requested: table exhausted below k
|
|
3925
|
+
(opts.deadlineMs !== undefined && Date.now() >= opts.deadlineMs);
|
|
3926
|
+
if (done) break;
|
|
3927
|
+
k = Math.min(k * 3, effectiveCap);
|
|
3928
|
+
}
|
|
3929
|
+
|
|
3930
|
+
// Degraded contract (T4-M1 + T5-M2): only the HARD cap preventing an exhaustive scan
|
|
3931
|
+
// counts — scanning the whole (sub-cap) table is ordinary corpus exhaustion, no marker.
|
|
3932
|
+
const scannedFragments = raw.length;
|
|
3933
|
+
let degraded = false;
|
|
3934
|
+
let degradedReason: VecSearchDetailedResult["degradedReason"];
|
|
3935
|
+
const underfilled = classified.allowedDocs < limit;
|
|
3936
|
+
const hardCapPreventedExhaustion = tableRows > hardCap && k >= hardCap;
|
|
3937
|
+
if (exclude.size > 0 && underfilled && hardCapPreventedExhaustion) {
|
|
3938
|
+
degraded = true;
|
|
3939
|
+
degradedReason = classified.excludedDocsSeen >= (limit - classified.allowedDocs)
|
|
3940
|
+
? "excluded-dominant"
|
|
3941
|
+
: "cap-truncation";
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3944
|
+
return {
|
|
3945
|
+
results: classified.results,
|
|
3946
|
+
degraded,
|
|
3947
|
+
degradedReason,
|
|
3948
|
+
scannedFragments,
|
|
3949
|
+
excludedDocsSeen: classified.excludedDocsSeen,
|
|
3950
|
+
};
|
|
3951
|
+
}
|
|
3952
|
+
|
|
3953
|
+
/**
|
|
3954
|
+
* Detailed vector search — embeds the query, then delegates to the precomputed-vector core.
|
|
3955
|
+
* The entry for every exclusion-enabled caller; carries the FULL searchVec parameter surface
|
|
3956
|
+
* (collections / collectionId / dateRange / deadlineMs) so temporal RRF is never contaminated
|
|
3957
|
+
* by dropped filters (T6-H2).
|
|
3958
|
+
*/
|
|
3959
|
+
export async function searchVecDetailed(
|
|
3960
|
+
db: Database,
|
|
3961
|
+
query: string,
|
|
3962
|
+
model: string,
|
|
3963
|
+
limit: number = 20,
|
|
3964
|
+
opts: VecSearchDetailedOpts = {}
|
|
3965
|
+
): Promise<VecSearchDetailedResult> {
|
|
3966
|
+
const empty: VecSearchDetailedResult = { results: [], degraded: false, scannedFragments: 0, excludedDocsSeen: 0 };
|
|
3967
|
+
const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
|
|
3968
|
+
if (!tableExists) return empty;
|
|
3969
|
+
|
|
3970
|
+
const embedResult = await getEmbedding(query, model, true, opts.deadlineMs);
|
|
3971
|
+
if (!embedResult) return empty;
|
|
3972
|
+
if (opts.deadlineMs !== undefined && Date.now() >= opts.deadlineMs) return empty;
|
|
3973
|
+
|
|
3974
|
+
return searchVecDetailedWithVector(
|
|
3975
|
+
db,
|
|
3976
|
+
{ embedding: new Float32Array(embedResult.embedding), endpointModel: embedResult.model },
|
|
3977
|
+
limit,
|
|
3978
|
+
opts
|
|
3979
|
+
);
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3657
3982
|
// =============================================================================
|
|
3658
3983
|
// Embeddings
|
|
3659
3984
|
// =============================================================================
|
|
@@ -3703,16 +4028,24 @@ export function getHashesNeedingFragments(db: Database): { hash: string; body: s
|
|
|
3703
4028
|
// Also retry docs left 'pending' (crash mid-doc) or 'failed' (partial fragment failure) so partial
|
|
3704
4029
|
// embeds are not permanently silent — bounded by embed_attempts < 3. The OR-branch is parenthesized
|
|
3705
4030
|
// so embed_attempts < 3 and d.active = 1 always apply to every selected row (SQL precedence).
|
|
4031
|
+
// The (collection, path, title) tuple must be ONE REAL document row (T9-M1): independent
|
|
4032
|
+
// MIN() per column can synthesize a tuple belonging to no document, which then produces a
|
|
4033
|
+
// canonicalDocId that matches nothing at doctor time. min(collection||'/'||path) picks a
|
|
4034
|
+
// deterministic real alias; the correlated join recovers that row's actual columns.
|
|
3706
4035
|
return db.prepare(`
|
|
3707
|
-
SELECT
|
|
3708
|
-
FROM
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
4036
|
+
SELECT g.hash, c.doc as body, d.path as path, d.title as title, d.collection as collection
|
|
4037
|
+
FROM (
|
|
4038
|
+
SELECT dd.hash, MIN(dd.collection || '/' || dd.path) as canon_key
|
|
4039
|
+
FROM documents dd
|
|
4040
|
+
LEFT JOIN content_vectors v ON dd.hash = v.hash AND v.fragment_type IS NOT NULL
|
|
4041
|
+
LEFT JOIN content_vectors v0 ON dd.hash = v0.hash AND v0.seq = 0
|
|
4042
|
+
WHERE dd.active = 1
|
|
4043
|
+
AND COALESCE(dd.embed_attempts, 0) < 3
|
|
4044
|
+
AND ((v.hash IS NULL OR v0.hash IS NULL) OR dd.embed_state IN ('pending', 'failed'))
|
|
4045
|
+
GROUP BY dd.hash
|
|
4046
|
+
) g
|
|
4047
|
+
JOIN documents d ON d.hash = g.hash AND d.active = 1 AND (d.collection || '/' || d.path) = g.canon_key
|
|
4048
|
+
JOIN content c ON g.hash = c.hash
|
|
3716
4049
|
`).all() as { hash: string; body: string; path: string; title: string; collection: string }[];
|
|
3717
4050
|
}
|
|
3718
4051
|
|
|
@@ -3784,7 +4117,8 @@ export function insertEmbedding(
|
|
|
3784
4117
|
fragmentType?: string,
|
|
3785
4118
|
fragmentLabel?: string,
|
|
3786
4119
|
canonicalId?: string,
|
|
3787
|
-
leaseGuard?: LeaseGuard
|
|
4120
|
+
leaseGuard?: LeaseGuard,
|
|
4121
|
+
embedInputFp?: string
|
|
3788
4122
|
): void {
|
|
3789
4123
|
const hashSeq = `${hash}_${seq}`;
|
|
3790
4124
|
// Atomic vec0 + metadata write: the DELETE (vec0's "upsert" — no INSERT OR
|
|
@@ -3805,8 +4139,8 @@ export function insertEmbedding(
|
|
|
3805
4139
|
db.prepare(`DELETE FROM vectors_vec WHERE hash_seq = ?`).run(hashSeq);
|
|
3806
4140
|
db.prepare(`INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`).run(hashSeq, embedding);
|
|
3807
4141
|
db.prepare(
|
|
3808
|
-
`INSERT OR REPLACE INTO content_vectors (hash, seq, pos, model, embedded_at, fragment_type, fragment_label, canonical_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
3809
|
-
).run(hash, seq, pos, model, embeddedAt, fragmentType ?? null, fragmentLabel ?? null, canonicalId ?? null);
|
|
4142
|
+
`INSERT OR REPLACE INTO content_vectors (hash, seq, pos, model, embedded_at, fragment_type, fragment_label, canonical_id, embed_input_fp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
4143
|
+
).run(hash, seq, pos, model, embeddedAt, fragmentType ?? null, fragmentLabel ?? null, canonicalId ?? null, embedInputFp ?? null);
|
|
3810
4144
|
}).immediate(); // immediate write lock: the lease assert reads under the lock, so a lost-lease write can't slip through
|
|
3811
4145
|
}
|
|
3812
4146
|
|