@gmickel/gno 2.8.0 → 2.8.1

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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/assets/skill/SKILL.md +5 -2
  3. package/assets/skill/recipes/memory-scoped-recall.md +10 -5
  4. package/assets/spa-production.json.gz +0 -0
  5. package/browser-extension/artifacts/{gno-browser-clipper-v2.8.0.zip → gno-browser-clipper-v2.8.1.zip} +0 -0
  6. package/browser-extension/artifacts/gno-browser-clipper-v2.8.1.zip.sha256 +1 -0
  7. package/browser-extension/dist/manifest.json +1 -1
  8. package/package.json +1 -1
  9. package/spec/cli.md +41 -6
  10. package/spec/mcp.md +8 -4
  11. package/spec/output-schemas/memory-recall.schema.json +1 -1
  12. package/spec/output-schemas/status.schema.json +3 -3
  13. package/src/cli/commands/graph.ts +3 -1
  14. package/src/cli/commands/links.ts +27 -49
  15. package/src/cli/commands/status.ts +1 -0
  16. package/src/core/audit-links.ts +56 -4
  17. package/src/core/audit-outside-index.ts +215 -0
  18. package/src/core/audit-workspace.ts +13 -7
  19. package/src/core/audit.ts +9 -1
  20. package/src/core/link-inventory-markdown.ts +2 -3
  21. package/src/core/links.ts +40 -17
  22. package/src/core/memory-recall.ts +254 -15
  23. package/src/core/memory-types.ts +12 -0
  24. package/src/core/memory.ts +2 -0
  25. package/src/ingestion/sync.ts +5 -3
  26. package/src/mcp/tools/links.ts +71 -93
  27. package/src/mcp/tools/status.ts +1 -0
  28. package/src/pipeline/search.ts +2 -0
  29. package/src/pipeline/types.ts +4 -0
  30. package/src/sdk/client.ts +1 -0
  31. package/src/serve/public/components/editor/MarkdownPreview.tsx +5 -3
  32. package/src/serve/routes/graph.ts +3 -1
  33. package/src/serve/routes/links.ts +32 -50
  34. package/src/serve/server.ts +2 -1
  35. package/src/serve/status.ts +1 -0
  36. package/src/store/sqlite/adapter.ts +87 -106
  37. package/src/store/sqlite/graph-link-resolver.ts +7 -0
  38. package/src/store/sqlite/graph-similarity.ts +96 -0
  39. package/src/store/sqlite/workspace-link-resolver.ts +119 -31
  40. package/src/store/types.ts +15 -2
  41. package/src/store/vector/status.ts +27 -0
  42. package/src/store/vector/stored-vectors.ts +158 -0
  43. package/src/store/vector/types.ts +6 -0
  44. package/src/store/vector/variant-search.ts +30 -14
  45. package/browser-extension/artifacts/gno-browser-clipper-v2.8.0.zip.sha256 +0 -1
@@ -167,6 +167,7 @@ import {
167
167
  listVectorPartitions,
168
168
  vectorRuntimeStatus,
169
169
  } from "../vector/status";
170
+ import { loadSqliteVec } from "../vector/variants";
170
171
  import {
171
172
  deleteSavedCapsuleRegistration as deleteStoredSavedCapsuleRegistration,
172
173
  getSavedCapsuleRegistration as getStoredSavedCapsuleRegistration,
@@ -225,6 +226,7 @@ import {
225
226
  } from "./graph-link-resolver";
226
227
  import { queryGraphNeighborsForSeeds } from "./graph-neighbors";
227
228
  import { createGraphReferenceStore } from "./graph-reference-state";
229
+ import { hasSqliteVec, storedSimilarityEdges } from "./graph-similarity";
228
230
  import {
229
231
  snapshotLegacyTitles,
230
232
  reconcileLegacyTitles,
@@ -3218,9 +3220,18 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3218
3220
  ...(options.chunkLanguage ? [options.chunkLanguage] : []),
3219
3221
  limit,
3220
3222
  ];
3221
- const rows = db
3223
+ const allRows = db
3222
3224
  .query<FtsRow, (string | number)[]>(sql)
3223
3225
  .all(...queryParams);
3226
+ // Raw bm25() is negative and rows are best-first.
3227
+ const floor =
3228
+ options.minRelativeScore !== undefined && allRows[0]
3229
+ ? allRows[0].score * options.minRelativeScore
3230
+ : undefined;
3231
+ const rows =
3232
+ floor === undefined
3233
+ ? allRows
3234
+ : allRows.filter((row) => row.score <= floor);
3224
3235
 
3225
3236
  return ok(
3226
3237
  rows.map((r) => ({
@@ -5452,13 +5463,11 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5452
5463
 
5453
5464
  const warnings: string[] = [];
5454
5465
 
5455
- // Always probe sqlite-vec availability (not just when similarity requested)
5456
- let similarAvailable = false;
5457
- try {
5458
- db.query("SELECT vec_version()").get();
5459
- similarAvailable = true;
5460
- } catch {
5461
- // sqlite-vec not loaded
5466
+ // Always report sqlite-vec availability (not just when similarity
5467
+ // requested); this connection loads it on first use.
5468
+ let similarAvailable = hasSqliteVec(db);
5469
+ if (!similarAvailable && (await loadSqliteVec(db))) {
5470
+ similarAvailable = hasSqliteVec(db);
5462
5471
  }
5463
5472
 
5464
5473
  interface ResolvedEdgeRow {
@@ -5767,110 +5776,45 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5767
5776
  );
5768
5777
  }
5769
5778
 
5770
- // Track if any similarity queries fail
5771
- let similarityFailures = 0;
5772
-
5773
- const mirrorByDocid = new Map<string, string>();
5774
- if (nodesForSimilarity.length > 0) {
5775
- const placeholders = nodesForSimilarity.map(() => "?").join(",");
5776
- const mirrorRows = db
5777
- .query<{ docid: string; mirror_hash: string }, string[]>(
5778
- `SELECT docid, mirror_hash
5779
- FROM documents
5780
- WHERE active = 1
5781
- AND docid IN (${placeholders})`
5782
- )
5783
- .all(...nodesForSimilarity);
5784
- for (const row of mirrorRows) {
5785
- if (row.mirror_hash) {
5786
- mirrorByDocid.set(row.docid, row.mirror_hash);
5787
- }
5788
- }
5789
- }
5790
- const allowedMirrorHashes = [...mirrorByDocid.values()];
5791
- if (allowedMirrorHashes.length === 0) {
5792
- warnings.push("Similarity unavailable: no embedded nodes in graph");
5793
- }
5794
- const allowedPlaceholders = allowedMirrorHashes
5795
- .map(() => "?")
5796
- .join(",");
5797
-
5798
- // Get kNN for each node
5799
- // Query content_vectors for embedded chunks, find similar
5800
- for (const docid of nodesForSimilarity) {
5801
- if (allowedMirrorHashes.length === 0) break;
5802
- const mirrorHash = mirrorByDocid.get(docid);
5803
- if (!mirrorHash) continue;
5804
-
5805
- // Find similar docs using vec_distance, aggregate by doc to get max score
5806
- interface SimilarRow {
5807
- target_docid: string;
5808
- score: number;
5809
- }
5810
-
5811
- // Use GROUP BY to get one best score per doc (avoids duplicate rows from multi-chunk docs)
5812
- const similarQuery = `
5813
- SELECT
5814
- d.docid as target_docid,
5815
- MAX(1 - vec_distance_cosine(v1.embedding, v2.embedding)) as score
5816
- FROM content_vectors v1
5817
- JOIN content_vectors v2 ON v2.model = v1.model
5818
- AND v2.mirror_hash != v1.mirror_hash
5819
- AND v2.seq = 0
5820
- JOIN documents d ON d.mirror_hash = v2.mirror_hash AND d.active = 1
5821
- WHERE v1.mirror_hash = ? AND v1.seq = 0
5822
- AND d.docid != ?
5823
- AND v2.mirror_hash IN (${allowedPlaceholders})
5824
- GROUP BY d.docid
5825
- HAVING score >= ?
5826
- ORDER BY score DESC
5827
- LIMIT ?
5828
- `;
5829
-
5830
- try {
5831
- const similarRows = db
5832
- .query<SimilarRow, (string | number)[]>(similarQuery)
5833
- .all(
5834
- mirrorHash,
5835
- docid,
5836
- ...allowedMirrorHashes,
5837
- threshold,
5838
- similarTopK
5839
- );
5840
-
5841
- for (const sim of similarRows) {
5842
- if (!nodeDocids.has(sim.target_docid)) continue;
5843
-
5844
- // Clamp score to [0, 1] for schema compliance
5845
- const clampedScore = Math.max(0, Math.min(1, sim.score));
5846
-
5779
+ const embedModel = options?.embedModel;
5780
+ if (embedModel) {
5781
+ const similarityEdges = storedSimilarityEdges(
5782
+ db,
5783
+ embedModel,
5784
+ nodesForSimilarity,
5785
+ threshold,
5786
+ similarTopK
5787
+ );
5788
+ if (similarityEdges === null) {
5789
+ warnings.push(
5790
+ "Similarity query failed; similarity edges are unavailable"
5791
+ );
5792
+ } else if (similarityEdges.embeddedNodes === 0) {
5793
+ warnings.push("Similarity unavailable: no embedded nodes in graph");
5794
+ } else {
5795
+ for (const edge of similarityEdges.edges) {
5847
5796
  // Canonicalize by lexicographic order (undirected edge)
5848
5797
  const [a, b] =
5849
- docid < sim.target_docid
5850
- ? [docid, sim.target_docid]
5851
- : [sim.target_docid, docid];
5798
+ edge.source < edge.target
5799
+ ? [edge.source, edge.target]
5800
+ : [edge.target, edge.source];
5852
5801
  const key = `${a}:${b}:similar`;
5853
5802
 
5854
5803
  // Keep max score
5855
5804
  const existing = edgeMap.get(key);
5856
- if (!existing || clampedScore > existing.weight) {
5805
+ if (!existing || edge.score > existing.weight) {
5857
5806
  edgeMap.set(key, {
5858
5807
  type: "similar",
5859
- weight: clampedScore,
5808
+ weight: edge.score,
5860
5809
  confidence: "similarity",
5861
- audit: { resolution: "similarity", score: clampedScore },
5810
+ audit: { resolution: "similarity", score: edge.score },
5862
5811
  });
5863
5812
  }
5864
5813
  }
5865
- } catch {
5866
- similarityFailures++;
5867
5814
  }
5868
- }
5869
-
5870
- // Report partial failures
5871
- if (similarityFailures > 0) {
5815
+ } else {
5872
5816
  warnings.push(
5873
- `Similarity query failed for ${similarityFailures} nodes; results may be incomplete`
5817
+ "Similarity unavailable: no embedding model configured"
5874
5818
  );
5875
5819
  }
5876
5820
  } else if (includeSimilar && !similarAvailable) {
@@ -6010,9 +5954,15 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
6010
5954
  embedModel?: string;
6011
5955
  embedFingerprint?: string;
6012
5956
  chunking?: Partial<ChunkingParams>;
5957
+ configuredCollections?: readonly string[];
6013
5958
  }): Promise<StoreResult<IndexStatus>> {
6014
5959
  try {
6015
5960
  const db = this.ensureOpen();
5961
+ // JSON array of configured names, or null when the caller has no config
5962
+ // (every indexed collection is reported).
5963
+ const configuredJson = options?.configuredCollections
5964
+ ? JSON.stringify(options.configuredCollections)
5965
+ : null;
6016
5966
  const embedModel = options?.embedModel ?? null;
6017
5967
  const embedFingerprint =
6018
5968
  options?.embedFingerprint ??
@@ -6055,7 +6005,16 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
6055
6005
  }
6056
6006
 
6057
6007
  const collectionStats = db
6058
- .query<CollectionStat, [string | null, string | null, string | null]>(
6008
+ .query<
6009
+ CollectionStat,
6010
+ [
6011
+ string | null,
6012
+ string | null,
6013
+ string | null,
6014
+ string | null,
6015
+ string | null,
6016
+ ]
6017
+ >(
6059
6018
  `
6060
6019
  WITH document_stats AS (
6061
6020
  SELECT
@@ -6110,29 +6069,51 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
6110
6069
  FROM collections c
6111
6070
  LEFT JOIN document_stats ds ON ds.collection = c.name
6112
6071
  LEFT JOIN collection_chunks ch ON ch.collection = c.name
6072
+ WHERE ? IS NULL OR c.name IN (SELECT value FROM json_each(?))
6113
6073
  ORDER BY c.name
6114
6074
  `
6115
6075
  )
6116
- .all(embedModel, embedModel, embedFingerprint);
6076
+ .all(
6077
+ embedModel,
6078
+ embedModel,
6079
+ embedFingerprint,
6080
+ configuredJson,
6081
+ configuredJson
6082
+ );
6117
6083
 
6118
- // Get totals
6084
+ // Totals cover the configured collections only, so a collection removed
6085
+ // from config stops counting before the next update prunes its rows.
6119
6086
  const totalsRow = db
6120
- .query<{ total: number; active: number }, []>(
6087
+ .query<
6088
+ { total: number; active: number },
6089
+ [string | null, string | null]
6090
+ >(
6121
6091
  `
6122
6092
  SELECT
6123
6093
  COUNT(*) as total,
6124
6094
  SUM(CASE WHEN active = 1 THEN 1 ELSE 0 END) as active
6125
6095
  FROM documents
6096
+ WHERE ? IS NULL OR collection IN (SELECT value FROM json_each(?))
6126
6097
  `
6127
6098
  )
6128
- .get();
6099
+ .get(configuredJson, configuredJson);
6129
6100
 
6101
+ // Chunks of active documents only (deduplicated by canonical chunk);
6102
+ // content_chunks keeps rows of deleted documents until cleanup.
6130
6103
  const chunkCount =
6131
6104
  db
6132
- .query<{ count: number }, []>(
6133
- "SELECT COUNT(*) as count FROM content_chunks"
6105
+ .query<{ count: number }, [string | null, string | null]>(
6106
+ `
6107
+ SELECT COUNT(*) as count
6108
+ FROM content_chunks
6109
+ WHERE mirror_hash IN (
6110
+ SELECT mirror_hash FROM documents
6111
+ WHERE active = 1 AND mirror_hash IS NOT NULL
6112
+ AND (? IS NULL OR collection IN (SELECT value FROM json_each(?)))
6113
+ )
6114
+ `
6134
6115
  )
6135
- .get()?.count ?? 0;
6116
+ .get(configuredJson, configuredJson)?.count ?? 0;
6136
6117
 
6137
6118
  // Embedding backlog: chunks from active docs without vectors
6138
6119
  // Uses EXISTS to avoid duplicates when multiple docs share mirror_hash
@@ -116,6 +116,11 @@ export interface AuditLinkSnapshotLink {
116
116
  endLine: number;
117
117
  endCol: number;
118
118
  resolved: ResolvedGraphLinkTarget | null;
119
+ /**
120
+ * Unresolved workspace wiki link whose target exists as a file in the
121
+ * workspace but is not an indexed document. Existence only: no edge.
122
+ */
123
+ outsideIndex?: boolean;
119
124
  }
120
125
 
121
126
  export interface AuditLinkSnapshot {
@@ -128,6 +133,8 @@ export interface AuditLinkSnapshot {
128
133
  */
129
134
  scopeCollections?: string[];
130
135
  links: AuditLinkSnapshotLink[];
136
+ /** Why some workspace file listings were incomplete, when they were. */
137
+ outsideIndexDiagnostic?: string;
131
138
  totals: { documents: number; links: number };
132
139
  truncated: { documents: boolean; links: boolean };
133
140
  metrics: {
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Graph similarity edges scored from stored document vectors.
3
+ *
4
+ * @module src/store/sqlite/graph-similarity
5
+ */
6
+ import type { Database } from "bun:sqlite";
7
+
8
+ import {
9
+ readStoredDocumentVectors,
10
+ resolveStoredVectorSource,
11
+ } from "../vector/stored-vectors";
12
+
13
+ /** True when sqlite-vec is loaded on this connection. */
14
+ export function hasSqliteVec(db: Database): boolean {
15
+ try {
16
+ db.query("SELECT vec_version()").get();
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ export interface StoredSimilarityEdge {
24
+ source: string;
25
+ target: string;
26
+ /** Cosine similarity clamped to [0, 1] */
27
+ score: number;
28
+ }
29
+
30
+ function unit(vector: Float32Array): Float32Array {
31
+ let norm = 0;
32
+ for (const value of vector) norm += value * value;
33
+ norm = Math.sqrt(norm);
34
+ return norm > 0 ? vector.map((value) => value / norm) : vector;
35
+ }
36
+
37
+ function dot(a: Float32Array, b: Float32Array): number {
38
+ let sum = 0;
39
+ for (let i = 0; i < a.length; i++) sum += (a[i] ?? 0) * (b[i] ?? 0);
40
+ return sum;
41
+ }
42
+
43
+ /**
44
+ * Top-K most similar other nodes per node, by the first-chunk vector each
45
+ * document stores for `model`. Documents sharing content are not similarity
46
+ * edges. Null when the stored vectors cannot be read.
47
+ */
48
+ export function storedSimilarityEdges(
49
+ db: Database,
50
+ model: string,
51
+ docids: string[],
52
+ threshold: number,
53
+ topK: number
54
+ ): { embeddedNodes: number; edges: StoredSimilarityEdge[] } | null {
55
+ try {
56
+ const documents = db
57
+ .query<{ id: number; docid: string; mirrorHash: string }, [string]>(`
58
+ SELECT id, docid, mirror_hash AS mirrorHash FROM documents
59
+ WHERE active = 1 AND mirror_hash IS NOT NULL
60
+ AND docid IN (SELECT value FROM json_each(?))
61
+ ORDER BY docid
62
+ `)
63
+ .all(JSON.stringify(docids));
64
+ const vectors = readStoredDocumentVectors(
65
+ db,
66
+ resolveStoredVectorSource(db, model),
67
+ documents,
68
+ { firstChunkOnly: true }
69
+ );
70
+ const nodes = documents.flatMap((document) => {
71
+ const vector = vectors.get(document.id)?.[0];
72
+ return vector ? [{ ...document, vector: unit(vector) }] : [];
73
+ });
74
+ const edges: StoredSimilarityEdge[] = [];
75
+ for (const node of nodes) {
76
+ const scored: StoredSimilarityEdge[] = [];
77
+ for (const other of nodes) {
78
+ if (
79
+ other.mirrorHash === node.mirrorHash ||
80
+ other.vector.length !== node.vector.length
81
+ )
82
+ continue;
83
+ const score = Math.max(0, Math.min(1, dot(node.vector, other.vector)));
84
+ if (score >= threshold)
85
+ scored.push({ source: node.docid, target: other.docid, score });
86
+ }
87
+ scored.sort(
88
+ (a, b) => b.score - a.score || a.target.localeCompare(b.target)
89
+ );
90
+ edges.push(...scored.slice(0, topK));
91
+ }
92
+ return { embeddedNodes: nodes.length, edges };
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
@@ -293,6 +293,73 @@ class WorkspaceCatalog {
293
293
  }
294
294
  }
295
295
 
296
+ /** A normalized wiki target, ready to be matched against workspace paths. */
297
+ interface WorkspaceTargetShape {
298
+ normalized: string;
299
+ base: string;
300
+ exact: Set<string>;
301
+ relative: boolean;
302
+ hasPath: boolean;
303
+ }
304
+
305
+ const workspaceTargetShape = (
306
+ targetRefNorm: string,
307
+ sourceWsFolderNorm: string
308
+ ): WorkspaceTargetShape | null => {
309
+ const normalized = normalizeWorkspaceTarget(
310
+ targetRefNorm,
311
+ sourceWsFolderNorm
312
+ );
313
+ if (normalized === null || normalized.length === 0) return null;
314
+ const base = stripWikiMdExt(normalized);
315
+ // A relative target stays a path even when it normalizes to a root name.
316
+ const relative = RELATIVE_TARGET.test(targetRefNorm.trim());
317
+ return {
318
+ normalized,
319
+ base,
320
+ exact: new Set([base, `${base}.md`]),
321
+ relative,
322
+ hasPath: relative || normalized.includes("/"),
323
+ };
324
+ };
325
+
326
+ /**
327
+ * Match class of one same-basename file for a target, or null when it does
328
+ * not match: 0 exact workspace path, 1 exact path relative to the source's
329
+ * collection, 2 same folder as the source, 3 any other name or path-suffix
330
+ * match. Exact path classes apply to path targets only; a plain name ranks
331
+ * by folder and depth like any other same-named file. A relative target is
332
+ * already a full workspace path: only the exact workspace path matches.
333
+ */
334
+ const workspaceMatchClass = (
335
+ shape: WorkspaceTargetShape,
336
+ file: {
337
+ wsNorm: string;
338
+ wsFolderNorm: string;
339
+ /** Path relative to the source's collection root, when it is in it. */
340
+ sourceCollectionRelNorm: string | null;
341
+ sourceWsFolderNorm: string;
342
+ }
343
+ ): number | null => {
344
+ if (shape.hasPath && shape.exact.has(file.wsNorm)) return 0;
345
+ if (shape.relative) return null;
346
+ if (
347
+ shape.hasPath &&
348
+ file.sourceCollectionRelNorm !== null &&
349
+ shape.exact.has(file.sourceCollectionRelNorm)
350
+ ) {
351
+ return 1;
352
+ }
353
+ if (
354
+ !shape.hasPath ||
355
+ file.wsNorm.endsWith(`/${shape.base}`) ||
356
+ file.wsNorm.endsWith(`/${shape.base}.md`)
357
+ ) {
358
+ return file.wsFolderNorm === file.sourceWsFolderNorm ? 2 : 3;
359
+ }
360
+ return null;
361
+ };
362
+
296
363
  interface RankedCandidate {
297
364
  doc: CatalogDoc;
298
365
  klass: number;
@@ -345,37 +412,17 @@ const rankTarget = (
345
412
  source: { collection: string; wsFolderNorm: string },
346
413
  targetRefNorm: string
347
414
  ): WorkspaceTargetResolution | null => {
348
- const normalized = normalizeWorkspaceTarget(
349
- targetRefNorm,
350
- source.wsFolderNorm
351
- );
352
- if (normalized === null || normalized.length === 0) return null;
353
- const base = stripWikiMdExt(normalized);
354
- const exact = new Set([base, `${base}.md`]);
355
- // A relative target stays a path even when it normalizes to a root name.
356
- const relative = RELATIVE_TARGET.test(targetRefNorm.trim());
357
- const hasPath = relative || normalized.includes("/");
415
+ const shape = workspaceTargetShape(targetRefNorm, source.wsFolderNorm);
416
+ if (shape === null) return null;
358
417
  const matches: RankedCandidate[] = [];
359
- for (const doc of catalog.byBasename(wsKey, basenameKeys(normalized))) {
360
- let klass: number | null = null;
361
- // Exact path classes apply to path targets only; a plain name ranks by
362
- // folder and depth like any other same-named file. A relative target is
363
- // already a full workspace path: only the exact workspace path matches.
364
- if (hasPath && exact.has(doc.wsNorm)) klass = 0;
365
- else if (relative) klass = null;
366
- else if (
367
- hasPath &&
368
- doc.collection === source.collection &&
369
- exact.has(doc.relNorm)
370
- ) {
371
- klass = 1;
372
- } else if (
373
- !hasPath ||
374
- doc.wsNorm.endsWith(`/${base}`) ||
375
- doc.wsNorm.endsWith(`/${base}.md`)
376
- ) {
377
- klass = doc.wsFolderNorm === source.wsFolderNorm ? 2 : 3;
378
- }
418
+ for (const doc of catalog.byBasename(wsKey, basenameKeys(shape.normalized))) {
419
+ const klass = workspaceMatchClass(shape, {
420
+ wsNorm: doc.wsNorm,
421
+ wsFolderNorm: doc.wsFolderNorm,
422
+ sourceCollectionRelNorm:
423
+ doc.collection === source.collection ? doc.relNorm : null,
424
+ sourceWsFolderNorm: source.wsFolderNorm,
425
+ });
379
426
  if (klass !== null) matches.push({ doc, klass });
380
427
  }
381
428
  const distinct = distinctSources(matches, source.collection);
@@ -411,7 +458,7 @@ const rankTarget = (
411
458
  }
412
459
  // Fallback: frontmatter title inside the source collection only; relative
413
460
  // targets are paths and never fall back to titles.
414
- if (relative) return null;
461
+ if (shape.relative) return null;
415
462
  for (const [rank, keys] of titleLookups(targetRefNorm)) {
416
463
  const docs = catalog
417
464
  .byTitleIn(source.collection, wsKey, keys)
@@ -513,6 +560,47 @@ export const createInMemoryWorkspaceResolver = (
513
560
  };
514
561
  };
515
562
 
563
+ /**
564
+ * Existence matcher over workspace files that are not indexed documents
565
+ * (attachments, notes in unindexed or excluded folders). A target matches a
566
+ * file under the same rules as an indexed note: basename with `.md` optional
567
+ * (so a non-Markdown target needs its extension), exact or suffix path for
568
+ * path targets, exact workspace path for `./` and `../` targets. The path
569
+ * relative to the source's collection needs no separate check here: that
570
+ * file's workspace path equals the target or ends with `/target`. File names
571
+ * fold case fully, as Obsidian matches link targets case-insensitively.
572
+ */
573
+ export const createWorkspaceFileMatcher = (
574
+ workspacePaths: Iterable<string>
575
+ ): ((targetRefNorm: string, sourceWsPath: string) => boolean) => {
576
+ const byBase = new Map<string, Array<{ wsNorm: string; folder: string }>>();
577
+ for (const path of workspacePaths) {
578
+ const wsNorm = path.normalize("NFC").toLowerCase();
579
+ const base = lastSegment(wsNorm);
580
+ const files = byBase.get(base) ?? [];
581
+ files.push({ wsNorm, folder: folderOf(wsNorm) });
582
+ byBase.set(base, files);
583
+ }
584
+ return (targetRefNorm, sourceWsPath) => {
585
+ const sourceWsFolderNorm = folderOf(
586
+ sourceWsPath.normalize("NFC").toLowerCase()
587
+ );
588
+ const shape = workspaceTargetShape(targetRefNorm, sourceWsFolderNorm);
589
+ if (shape === null) return false;
590
+ return basenameKeys(shape.normalized).some((key) =>
591
+ (byBase.get(key) ?? []).some(
592
+ (file) =>
593
+ workspaceMatchClass(shape, {
594
+ wsNorm: file.wsNorm,
595
+ wsFolderNorm: file.folder,
596
+ sourceCollectionRelNorm: null,
597
+ sourceWsFolderNorm,
598
+ }) !== null
599
+ )
600
+ );
601
+ };
602
+ };
603
+
516
604
  export interface WorkspaceTargetInput {
517
605
  targetRefNorm: string;
518
606
  source: WorkspaceLinkSource;
@@ -742,6 +742,11 @@ export interface FtsSearchOptions extends DocumentEligibilityOptions {
742
742
  snippet?: boolean;
743
743
  /** Match documents containing ANY positive term instead of ALL of them. */
744
744
  anyTerm?: boolean;
745
+ /**
746
+ * Drop rows whose raw BM25 score is below this fraction (0-1) of the best
747
+ * row's score, so matches carried only by near-zero-IDF terms fall away.
748
+ */
749
+ minRelativeScore?: number;
745
750
  }
746
751
 
747
752
  /** Managed-memory eligibility query (unbounded, executed in one SQL query). */
@@ -833,11 +838,11 @@ export interface IndexStatus {
833
838
  ftsTokenizer: FtsTokenizer;
834
839
  /** Per-collection status */
835
840
  collections: CollectionStatus[];
836
- /** Total documents across all collections */
841
+ /** Total documents across the reported collections */
837
842
  totalDocuments: number;
838
843
  /** Active (non-deleted) documents */
839
844
  activeDocuments: number;
840
- /** Total chunks across all collections */
845
+ /** Distinct chunks of active documents across the reported collections */
841
846
  totalChunks: number;
842
847
  /** Chunks without embeddings */
843
848
  embeddingBacklog: number;
@@ -1077,6 +1082,8 @@ export interface GetGraphOptions {
1077
1082
  linkedOnly?: boolean;
1078
1083
  /** Top-K similar docs per node (default 5, clamped 1-20) */
1079
1084
  similarTopK?: number;
1085
+ /** Embedding model whose stored vectors score similarity edges */
1086
+ embedModel?: string;
1080
1087
  }
1081
1088
 
1082
1089
  /** Options for seed-scoped one-hop graph neighbor lookup (query-time expansion). */
@@ -2386,6 +2393,12 @@ export interface StorePort {
2386
2393
  embedModel?: string;
2387
2394
  embedFingerprint?: string;
2388
2395
  chunking?: Partial<ChunkingParams>;
2396
+ /**
2397
+ * Configured collection names. When given, the collection list and the
2398
+ * document/chunk totals cover only these collections, so a collection
2399
+ * removed from config stops being reported before its rows are pruned.
2400
+ */
2401
+ configuredCollections?: readonly string[];
2389
2402
  }): Promise<StoreResult<IndexStatus>>;
2390
2403
 
2391
2404
  // ─────────────────────────────────────────────────────────────────────────
@@ -148,6 +148,33 @@ function retrievalPartition(
148
148
  : candidates.find((p) => p.partition_id === selection);
149
149
  }
150
150
 
151
+ /**
152
+ * The activated partition that stored-vector readers (document similarity)
153
+ * use for `model`, chosen as status counts it. Undefined leaves legacy
154
+ * `content_vectors` authority, exactly as vector search does before any
155
+ * partition activates.
156
+ */
157
+ export function storedVectorPartition(
158
+ db: Database,
159
+ model: string
160
+ ): { partitionId: string; dimensions: number } | undefined {
161
+ return db.transaction(() => {
162
+ if (!hasPartitionTable(db)) return undefined;
163
+ const partition = countedPartition(
164
+ db,
165
+ model,
166
+ readPartitions(db, model),
167
+ readSelections(db).get(model)
168
+ );
169
+ return partition && activated(partition)
170
+ ? {
171
+ partitionId: partition.partition_id,
172
+ dimensions: partition.dimensions,
173
+ }
174
+ : undefined;
175
+ })();
176
+ }
177
+
151
178
  function readPartitions(db: Database, model: string | null): Partition[] {
152
179
  return db
153
180
  .query<Partition, [string | null, string | null]>(`