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.
@@ -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
- markEmbedStart: (hash: string) => {
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.prepare(`UPDATE documents SET embed_state = 'pending', embed_error = NULL, embed_attempts = COALESCE(embed_attempts, 0) + 1 WHERE hash = ? AND active = 1`).run(hash);
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.prepare(`UPDATE documents SET embed_state = 'synced', embed_attempts = 0, embed_error = NULL WHERE hash = ? AND active = 1`).run(hash);
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.prepare(`UPDATE documents SET embed_state = 'failed', embed_error = ? WHERE hash = ? AND active = 1`).run(error, hash);
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. On a same-dimension model swap (which
3536
- // VecDimensionMismatchError cannot catch) this throws VecReadModelMismatchError rather than
3537
- // serving cosine-meaningless results. Cached per (db, model) the DISTINCT runs at most once
3538
- // per model per process.
3539
- assertQueryEmbedModelConsistent(db, embedResult.model);
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 d.hash, c.doc as body, MIN(d.path) as path, MIN(d.title) as title, MIN(d.collection) as collection
3708
- FROM documents d
3709
- JOIN content c ON d.hash = c.hash
3710
- LEFT JOIN content_vectors v ON d.hash = v.hash AND v.fragment_type IS NOT NULL
3711
- LEFT JOIN content_vectors v0 ON d.hash = v0.hash AND v0.seq = 0
3712
- WHERE d.active = 1
3713
- AND COALESCE(d.embed_attempts, 0) < 3
3714
- AND ((v.hash IS NULL OR v0.hash IS NULL) OR d.embed_state IN ('pending', 'failed'))
3715
- GROUP BY d.hash
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