@gmickel/gno 2.7.1 → 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 (96) hide show
  1. package/README.md +3 -2
  2. package/assets/skill/SKILL.md +11 -1
  3. package/assets/skill/cli-reference.md +8 -1
  4. package/assets/skill/examples.md +2 -1
  5. package/assets/skill/mcp-reference.md +3 -1
  6. package/assets/skill/recipes/memory-scoped-recall.md +10 -5
  7. package/assets/spa-production.json.gz +0 -0
  8. package/browser-extension/artifacts/{gno-browser-clipper-v2.7.1.zip → gno-browser-clipper-v2.8.1.zip} +0 -0
  9. package/browser-extension/artifacts/gno-browser-clipper-v2.8.1.zip.sha256 +1 -0
  10. package/browser-extension/dist/manifest.json +1 -1
  11. package/package.json +1 -1
  12. package/spec/cli.md +86 -9
  13. package/spec/db/schema.sql +0 -1
  14. package/spec/mcp.md +26 -7
  15. package/spec/output-schemas/audit-report.schema.json +18 -4
  16. package/spec/output-schemas/backlinks.schema.json +4 -0
  17. package/spec/output-schemas/collection-list.schema.json +13 -0
  18. package/spec/output-schemas/graph.schema.json +2 -0
  19. package/spec/output-schemas/links-list.schema.json +4 -0
  20. package/spec/output-schemas/memory-recall.schema.json +1 -1
  21. package/spec/output-schemas/status.schema.json +18 -3
  22. package/src/cli/commands/audit.ts +23 -4
  23. package/src/cli/commands/collection/list.ts +39 -5
  24. package/src/cli/commands/embed.ts +3 -3
  25. package/src/cli/commands/graph.ts +3 -1
  26. package/src/cli/commands/links.ts +61 -180
  27. package/src/cli/commands/shared.ts +7 -0
  28. package/src/cli/commands/status.ts +6 -0
  29. package/src/cli/program.ts +12 -2
  30. package/src/config/loader.ts +43 -0
  31. package/src/config/types.ts +8 -0
  32. package/src/core/audit-contract.ts +16 -4
  33. package/src/core/audit-freshness.ts +11 -1
  34. package/src/core/audit-links.ts +197 -25
  35. package/src/core/audit-outside-index.ts +215 -0
  36. package/src/core/audit-provenance.ts +11 -4
  37. package/src/core/audit-workspace.ts +30 -9
  38. package/src/core/audit.ts +76 -16
  39. package/src/core/context-compiler.ts +3 -0
  40. package/src/core/context-evidence.ts +11 -0
  41. package/src/core/graph-edge-confidence.ts +23 -1
  42. package/src/core/host-paths.ts +1 -0
  43. package/src/core/knowledge-impact.ts +28 -0
  44. package/src/core/link-inventory-markdown.ts +2 -3
  45. package/src/core/link-workspace.ts +324 -0
  46. package/src/core/links.ts +40 -17
  47. package/src/core/memory-recall.ts +254 -15
  48. package/src/core/memory-types.ts +12 -0
  49. package/src/core/memory.ts +2 -0
  50. package/src/core/retrieval-replay-candidate.ts +6 -0
  51. package/src/core/retrieval-trace-request.ts +3 -0
  52. package/src/index.ts +14 -1
  53. package/src/ingestion/graph-reconciliation.ts +77 -15
  54. package/src/ingestion/source-availability/darwin-path.ts +9 -3
  55. package/src/ingestion/sync.ts +27 -4
  56. package/src/ingestion/types.ts +14 -0
  57. package/src/llm/inference-scope.ts +4 -3
  58. package/src/mcp/http-egress.ts +42 -3
  59. package/src/mcp/tools/audit.ts +11 -2
  60. package/src/mcp/tools/changes.ts +1 -0
  61. package/src/mcp/tools/links.ts +74 -93
  62. package/src/mcp/tools/sessions.ts +33 -4
  63. package/src/mcp/tools/status.ts +4 -0
  64. package/src/pipeline/expansion.ts +19 -31
  65. package/src/pipeline/graph-retrieval.ts +22 -2
  66. package/src/pipeline/hybrid.ts +1 -1
  67. package/src/pipeline/search.ts +2 -0
  68. package/src/pipeline/types.ts +10 -3
  69. package/src/sdk/client.ts +1 -0
  70. package/src/serve/findings-pass.ts +1 -1
  71. package/src/serve/public/components/editor/MarkdownPreview.tsx +5 -3
  72. package/src/serve/public/pages/GraphView.tsx +2 -0
  73. package/src/serve/routes/changes.ts +6 -1
  74. package/src/serve/routes/graph.ts +3 -1
  75. package/src/serve/routes/links.ts +45 -50
  76. package/src/serve/routes/sessions.ts +41 -53
  77. package/src/serve/server.ts +2 -1
  78. package/src/serve/status.ts +1 -0
  79. package/src/sessions/config-refresh.ts +111 -0
  80. package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
  81. package/src/store/migrations/034-collection-link-workspace.ts +47 -0
  82. package/src/store/migrations/index.ts +4 -0
  83. package/src/store/sqlite/adapter.ts +477 -337
  84. package/src/store/sqlite/eligibility.ts +8 -2
  85. package/src/store/sqlite/graph-link-resolver.ts +259 -5
  86. package/src/store/sqlite/graph-neighbors.ts +147 -40
  87. package/src/store/sqlite/graph-reference-state.ts +13 -2
  88. package/src/store/sqlite/graph-similarity.ts +96 -0
  89. package/src/store/sqlite/workspace-link-resolver.ts +742 -0
  90. package/src/store/types.ts +64 -5
  91. package/src/store/vector/stats.ts +1 -1
  92. package/src/store/vector/status.ts +27 -0
  93. package/src/store/vector/stored-vectors.ts +158 -0
  94. package/src/store/vector/types.ts +6 -0
  95. package/src/store/vector/variant-search.ts +30 -14
  96. package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +0 -1
@@ -5,6 +5,10 @@
5
5
  import type { Collection } from "../../../config";
6
6
 
7
7
  import { loadConfig } from "../../../config";
8
+ import {
9
+ detectCollectionWorkspace,
10
+ formatLinkWorkspace,
11
+ } from "../../../core/link-workspace";
8
12
  import { bold, cyan, dim } from "../../colors";
9
13
  import { CliError } from "../../errors";
10
14
 
@@ -23,7 +27,30 @@ interface ListOptions {
23
27
  md?: boolean;
24
28
  }
25
29
 
26
- function formatMarkdown(collections: Collection[]): string {
30
+ type ListedCollection = Collection & {
31
+ /** Effective link workspace root (detected or configured). */
32
+ effectiveWorkspaceRoot?: string;
33
+ workspaceSource: ReturnType<typeof detectCollectionWorkspace>["source"];
34
+ };
35
+
36
+ /** Resolve each collection's effective link workspace from the filesystem. */
37
+ const withLinkWorkspaces = (collections: Collection[]): ListedCollection[] =>
38
+ collections.map((collection) => {
39
+ const workspace = detectCollectionWorkspace(collection);
40
+ return {
41
+ ...collection,
42
+ ...(workspace.root ? { effectiveWorkspaceRoot: workspace.root } : {}),
43
+ workspaceSource: workspace.source,
44
+ };
45
+ });
46
+
47
+ const linkWorkspaceLine = (collection: ListedCollection): string | null =>
48
+ formatLinkWorkspace({
49
+ workspaceRoot: collection.effectiveWorkspaceRoot,
50
+ workspaceSource: collection.workspaceSource,
51
+ });
52
+
53
+ function formatMarkdown(collections: ListedCollection[]): string {
27
54
  const lines: string[] = ["# Collections", ""];
28
55
  if (collections.length === 0) {
29
56
  lines.push("No collections configured.");
@@ -43,12 +70,14 @@ function formatMarkdown(collections: Collection[]): string {
43
70
  if (coll.updateCmd) {
44
71
  lines.push(`- **Update Command:** \`${coll.updateCmd}\``);
45
72
  }
73
+ const workspace = linkWorkspaceLine(coll);
74
+ if (workspace) lines.push(`- **Link workspace:** ${workspace}`);
46
75
  lines.push("");
47
76
  }
48
77
  return lines.join("\n");
49
78
  }
50
79
 
51
- function formatTerminal(collections: Collection[]): string {
80
+ function formatTerminal(collections: ListedCollection[]): string {
52
81
  if (collections.length === 0) {
53
82
  return dim("No collections configured.");
54
83
  }
@@ -78,6 +107,10 @@ function formatTerminal(collections: Collection[]): string {
78
107
  if (updateCmd) {
79
108
  lines.push(` ${dim("Update:")} ${updateCmd}`);
80
109
  }
110
+ const workspace = linkWorkspaceLine(coll);
111
+ if (workspace) {
112
+ lines.push(` ${dim("Links:")} workspace ${sanitize(workspace)}`);
113
+ }
81
114
  lines.push("");
82
115
  }
83
116
  return lines.join("\n");
@@ -96,13 +129,14 @@ export async function collectionList(options: ListOptions): Promise<void> {
96
129
  const config = result.value;
97
130
 
98
131
  // Format and output
132
+ const collections = withLinkWorkspaces(config.collections);
99
133
  let output: string;
100
134
  if (options.json) {
101
- output = JSON.stringify(config.collections, null, 2);
135
+ output = JSON.stringify(collections, null, 2);
102
136
  } else if (options.md) {
103
- output = formatMarkdown(config.collections);
137
+ output = formatMarkdown(collections);
104
138
  } else {
105
- output = formatTerminal(config.collections);
139
+ output = formatTerminal(collections);
106
140
  }
107
141
 
108
142
  process.stdout.write(`${output}\n`);
@@ -818,7 +818,7 @@ function getActiveChunkCount(
818
818
  `
819
819
  SELECT COUNT(*) as count FROM content_chunks c
820
820
  WHERE EXISTS (
821
- SELECT 1 FROM documents d
821
+ SELECT 1 FROM documents d INDEXED BY idx_documents_mirror_hash
822
822
  WHERE d.mirror_hash = c.mirror_hash AND d.active = 1${collectionClause}
823
823
  )
824
824
  `
@@ -851,7 +851,7 @@ function getActiveChunks(
851
851
  'force' as reason
852
852
  FROM content_chunks c
853
853
  WHERE EXISTS (
854
- SELECT 1 FROM documents d
854
+ SELECT 1 FROM documents d INDEXED BY idx_documents_mirror_hash
855
855
  WHERE d.mirror_hash = c.mirror_hash AND d.active = 1${collectionClause}
856
856
  )
857
857
  AND (c.mirror_hash > ? OR (c.mirror_hash = ? AND c.seq > ?))
@@ -864,7 +864,7 @@ function getActiveChunks(
864
864
  'force' as reason
865
865
  FROM content_chunks c
866
866
  WHERE EXISTS (
867
- SELECT 1 FROM documents d
867
+ SELECT 1 FROM documents d INDEXED BY idx_documents_mirror_hash
868
868
  WHERE d.mirror_hash = c.mirror_hash AND d.active = 1${collectionClause}
869
869
  )
870
870
  ORDER BY c.mirror_hash, c.seq
@@ -14,6 +14,7 @@ import type {
14
14
 
15
15
  import { normalizeContentTypes } from "../../config";
16
16
  import { diagnoseGraphQuery } from "../../core/graph-query";
17
+ import { getActivePreset } from "../../llm/registry";
17
18
  import { initStore } from "./shared";
18
19
 
19
20
  // ─────────────────────────────────────────────────────────────────────────────
@@ -125,7 +126,7 @@ export async function graph(
125
126
  if (!initResult.ok) {
126
127
  return { success: false, error: initResult.error };
127
128
  }
128
- const { store } = initResult;
129
+ const { store, config } = initResult;
129
130
 
130
131
  try {
131
132
  const storeOptions: GetGraphOptions = {
@@ -136,6 +137,7 @@ export async function graph(
136
137
  threshold: options.threshold,
137
138
  linkedOnly: !options.includeIsolated,
138
139
  similarTopK: options.similarTopK,
140
+ embedModel: getActivePreset(config).embed,
139
141
  };
140
142
 
141
143
  const result = await store.getGraph(storeOptions);
@@ -5,16 +5,8 @@
5
5
  * @module src/cli/commands/links
6
6
  */
7
7
 
8
- import { basename } from "node:path";
8
+ import type { DocEdgeRow, DocLinkRow, StorePort } from "../../store/types";
9
9
 
10
- import type {
11
- DocEdgeRow,
12
- DocLinkRow,
13
- DocumentRow,
14
- StorePort,
15
- } from "../../store/types";
16
-
17
- import { normalizeWikiName } from "../../core/links";
18
10
  import { resolveDocRef } from "../../core/ref-parser";
19
11
  import { initStore } from "./shared";
20
12
 
@@ -51,6 +43,8 @@ export interface LinkWithResolution {
51
43
  resolved: boolean;
52
44
  resolvedDocid?: string;
53
45
  resolvedUri?: string;
46
+ /** Collection of the resolved target (may differ from the source's). */
47
+ resolvedCollection?: string;
54
48
  }
55
49
 
56
50
  export interface SemanticLinkItem {
@@ -155,6 +149,8 @@ export interface BacklinkItem {
155
149
  sourceDocid: string;
156
150
  sourceUri: string;
157
151
  sourceTitle?: string;
152
+ /** Collection of the linking document. */
153
+ sourceCollection?: string;
158
154
  linkText?: string;
159
155
  startLine: number;
160
156
  startCol: number;
@@ -233,96 +229,13 @@ export type SimilarResult =
233
229
  | { success: true; data: SimilarResponse }
234
230
  | { success: false; error: string; isValidation?: boolean };
235
231
 
236
- // ─────────────────────────────────────────────────────────────────────────────
237
- // Helper: Build resolution indexes (cached per collection)
238
- // ─────────────────────────────────────────────────────────────────────────────
239
-
240
- interface ResolutionIndexes {
241
- // Map: normalized wiki name -> DocumentRow
242
- wikiIndex: Map<string, DocumentRow>;
243
- // Map: relPath -> DocumentRow
244
- pathIndex: Map<string, DocumentRow>;
245
- }
246
-
247
- /** Normalize markdown link path for matching (strip ./, collapse ..) */
248
- function normalizeMarkdownPath(path: string): string {
249
- // Strip leading ./
250
- let normalized = path.replace(/^\.\//, "");
251
- // Collapse simple parent refs (a/b/../c -> a/c)
252
- while (normalized.includes("/../")) {
253
- normalized = normalized.replace(/[^/]+\/\.\.\//, "");
254
- }
255
- return normalized;
256
- }
257
-
258
- async function buildResolutionIndexes(
259
- store: StorePort,
260
- collection: string,
261
- cache: Map<string, ResolutionIndexes>
262
- ): Promise<ResolutionIndexes> {
263
- const cached = cache.get(collection);
264
- if (cached) {
265
- return cached;
266
- }
267
-
268
- const indexes: ResolutionIndexes = {
269
- wikiIndex: new Map(),
270
- pathIndex: new Map(),
271
- };
272
-
273
- const docsResult = await store.listDocuments(collection);
274
- if (!docsResult.ok) {
275
- // Collection may not exist or store error - return empty indexes
276
- // Links to this collection will show as unresolved
277
- cache.set(collection, indexes);
278
- return indexes;
279
- }
280
-
281
- for (const d of docsResult.value) {
282
- if (!d.active) continue;
283
-
284
- // Index by relPath for markdown links (exact match)
285
- indexes.pathIndex.set(d.relPath, d);
286
-
287
- // Also index by normalized path (without ./) for common variants
288
- const normalizedPath = normalizeMarkdownPath(d.relPath);
289
- if (
290
- normalizedPath !== d.relPath &&
291
- !indexes.pathIndex.has(normalizedPath)
292
- ) {
293
- indexes.pathIndex.set(normalizedPath, d);
294
- }
295
-
296
- // Index by normalized title for wiki links
297
- if (d.title) {
298
- const wikiKey = normalizeWikiName(d.title);
299
- indexes.wikiIndex.set(wikiKey, d);
300
- }
301
-
302
- // Also index by filename stem as fallback for wiki links
303
- const stem = basename(d.relPath).replace(/\.[^.]+$/, "");
304
- if (stem) {
305
- const stemKey = normalizeWikiName(stem);
306
- // Don't overwrite title match
307
- if (!indexes.wikiIndex.has(stemKey)) {
308
- indexes.wikiIndex.set(stemKey, d);
309
- }
310
- }
311
- }
312
-
313
- cache.set(collection, indexes);
314
- return indexes;
315
- }
316
-
317
232
  // ─────────────────────────────────────────────────────────────────────────────
318
233
  // Helper: Map DocLinkRow to output format (avoids null leakage)
319
234
  // ─────────────────────────────────────────────────────────────────────────────
320
235
 
321
236
  function mapLinkToOutput(
322
237
  link: DocLinkRow,
323
- resolved: boolean,
324
- resolvedDocid?: string,
325
- resolvedUri?: string
238
+ resolved: { docid: string; uri: string; collection?: string } | null
326
239
  ): LinkWithResolution {
327
240
  return {
328
241
  targetRef: link.targetRef,
@@ -336,9 +249,12 @@ function mapLinkToOutput(
336
249
  startCol: link.startCol,
337
250
  endLine: link.endLine,
338
251
  endCol: link.endCol,
339
- resolved,
340
- ...(resolvedDocid && { resolvedDocid }),
341
- ...(resolvedUri && { resolvedUri }),
252
+ resolved: resolved !== null,
253
+ ...(resolved && {
254
+ resolvedDocid: resolved.docid,
255
+ resolvedUri: resolved.uri,
256
+ ...(resolved.collection && { resolvedCollection: resolved.collection }),
257
+ }),
342
258
  };
343
259
  }
344
260
 
@@ -443,48 +359,29 @@ export async function linksList(
443
359
  return a.startCol - b.startCol;
444
360
  });
445
361
 
446
- // Build resolution indexes (cached per collection)
447
- const indexCache = new Map<string, ResolutionIndexes>();
448
- const linksWithResolution: LinkWithResolution[] = [];
449
-
450
- for (const link of links) {
451
- let resolvedDoc: DocumentRow | undefined;
452
-
453
- // Determine target collection (explicit or same as source)
454
- const targetCollection = link.targetCollection ?? doc.collection;
455
-
456
- // Get or build index for target collection
457
- const indexes = await buildResolutionIndexes(
458
- store,
459
- targetCollection,
460
- indexCache
461
- );
462
-
463
- // Safe fallback for targetRefNorm
464
- const targetNorm = link.targetRefNorm || link.targetRef;
465
-
466
- if (link.linkType === "wiki") {
467
- // Wiki links: match by normalized title or filename
468
- const wikiKey = normalizeWikiName(targetNorm);
469
- resolvedDoc = indexes.wikiIndex.get(wikiKey);
470
- } else {
471
- // Markdown links: match by relPath (try exact, then normalized)
472
- resolvedDoc = indexes.pathIndex.get(targetNorm);
473
- if (!resolvedDoc) {
474
- const normalizedTarget = normalizeMarkdownPath(targetNorm);
475
- resolvedDoc = indexes.pathIndex.get(normalizedTarget);
476
- }
477
- }
478
-
479
- linksWithResolution.push(
480
- mapLinkToOutput(
481
- link,
482
- !!resolvedDoc,
483
- resolvedDoc?.docid,
484
- resolvedDoc?.uri
485
- )
486
- );
362
+ // Resolve with the shared link resolver (workspace-aware for plain wiki
363
+ // links whose source sits in a link workspace).
364
+ const resolvedResult = await store.resolveLinks(
365
+ links.map((link) => ({
366
+ targetRefNorm: link.targetRefNorm || link.targetRef,
367
+ targetCollection: link.targetCollection ?? doc.collection,
368
+ linkType: link.linkType,
369
+ source: {
370
+ collection: doc.collection,
371
+ relPath: doc.relPath,
372
+ explicit: Boolean(link.targetCollection),
373
+ },
374
+ }))
375
+ );
376
+ if (!resolvedResult.ok) {
377
+ return { success: false, error: resolvedResult.error.message };
487
378
  }
379
+ const linksWithResolution: LinkWithResolution[] = links.map(
380
+ (link, index) => {
381
+ const resolved = resolvedResult.value[index] ?? null;
382
+ return mapLinkToOutput(link, resolved);
383
+ }
384
+ );
488
385
 
489
386
  const resolvedCount = linksWithResolution.filter((l) => l.resolved).length;
490
387
 
@@ -606,6 +503,7 @@ export async function backlinks(
606
503
  sourceDocid: bl.sourceDocid,
607
504
  sourceUri: bl.sourceDocUri,
608
505
  ...(bl.sourceDocTitle && { sourceTitle: bl.sourceDocTitle }),
506
+ ...(bl.sourceCollection && { sourceCollection: bl.sourceCollection }),
609
507
  ...(bl.linkText && { linkText: bl.linkText }),
610
508
  startLine: bl.startLine,
611
509
  startCol: bl.startCol,
@@ -689,27 +587,23 @@ export async function similar(
689
587
  }
690
588
  const db = store.getRawDb();
691
589
 
692
- // Get document embedding from content_vectors (prefer seq=0)
693
- interface VectorRow {
694
- embedding: Uint8Array;
695
- }
696
-
697
- const embedModel = modelPreset.embed;
698
- const vectorRow = db
699
- .query<VectorRow, [string, string]>(
700
- "SELECT embedding FROM content_vectors WHERE mirror_hash = ? AND model = ? AND seq = 0 LIMIT 1"
701
- )
702
- .get(doc.mirrorHash, embedModel);
703
-
704
- const fallbackRow =
705
- vectorRow ??
706
- db
707
- .query<VectorRow, [string, string]>(
708
- "SELECT embedding FROM content_vectors WHERE mirror_hash = ? AND model = ? ORDER BY seq LIMIT 1"
709
- )
710
- .get(doc.mirrorHash, embedModel);
711
-
712
- if (!fallbackRow) {
590
+ // Stored vector of the document's first chunk, from the active partition
591
+ const {
592
+ readStoredDocumentVectors,
593
+ resolveStoredVectorSource,
594
+ similarityHitDocuments,
595
+ storedVectorSearchOptions,
596
+ } = await import("../../store/vector/stored-vectors.js");
597
+ const source = resolveStoredVectorSource(db, modelPreset.embed);
598
+ const [embedding] =
599
+ readStoredDocumentVectors(
600
+ db,
601
+ source,
602
+ [{ id: doc.id, mirrorHash: doc.mirrorHash }],
603
+ { firstChunkOnly: true }
604
+ ).get(doc.id) ?? [];
605
+
606
+ if (!embedding) {
713
607
  return {
714
608
  success: false,
715
609
  error: "Document has no embeddings. Run: gno embed",
@@ -718,9 +612,6 @@ export async function similar(
718
612
  }
719
613
 
720
614
  // Normalize embedding for cosine similarity
721
- const { decodeEmbedding } =
722
- await import("../../store/vector/sqlite-vec.js");
723
- const embedding = decodeEmbedding(fallbackRow.embedding);
724
615
  const dimensions = embedding.length;
725
616
  let norm = 0;
726
617
  for (let i = 0; i < dimensions; i++) {
@@ -738,7 +629,7 @@ export async function similar(
738
629
  const { createVectorIndexPort } =
739
630
  await import("../../store/vector/sqlite-vec.js");
740
631
  const vecResult = await createVectorIndexPort(db, {
741
- model: embedModel,
632
+ model: modelPreset.embed,
742
633
  dimensions,
743
634
  });
744
635
  if (!vecResult.ok) {
@@ -760,13 +651,13 @@ export async function similar(
760
651
  const searchResult = await vectorIndex.searchNearest(
761
652
  embedding,
762
653
  candidateLimit,
763
- {}
654
+ storedVectorSearchOptions(source)
764
655
  );
765
656
  if (!searchResult.ok) {
766
657
  return { success: false, error: searchResult.error.message };
767
658
  }
768
659
 
769
- // Build mirrorHash -> doc map from a single listDocuments call
660
+ // Candidate documents from a single listDocuments call
770
661
  const docsResult = crossCollection
771
662
  ? await store.listDocuments()
772
663
  : await store.listDocuments(doc.collection);
@@ -775,28 +666,18 @@ export async function similar(
775
666
  return { success: false, error: docsResult.error.message };
776
667
  }
777
668
 
778
- const docsByHash = new Map<string, DocumentRow>();
779
- for (const d of docsResult.value) {
780
- if (d.active && d.mirrorHash) {
781
- // Only keep first doc per hash (they have same content)
782
- if (!docsByHash.has(d.mirrorHash)) {
783
- docsByHash.set(d.mirrorHash, d);
784
- }
785
- }
786
- }
787
-
788
- // Map results to documents, excluding self
669
+ // Map hits to their owning documents, excluding self
789
670
  const similarItems: SimilarItem[] = [];
790
671
  const seenDocids = new Set<string>();
791
672
 
792
- for (const vec of searchResult.value) {
673
+ for (const { document: d, distance } of similarityHitDocuments(
674
+ searchResult.value,
675
+ docsResult.value.filter((d) => d.active && d.mirrorHash)
676
+ )) {
793
677
  if (similarItems.length >= limit) {
794
678
  break;
795
679
  }
796
680
 
797
- const d = docsByHash.get(vec.mirrorHash);
798
- if (!d) continue;
799
-
800
681
  // Exclude self
801
682
  if (d.docid === doc.docid) continue;
802
683
 
@@ -805,7 +686,7 @@ export async function similar(
805
686
 
806
687
  // Compute similarity score from cosine distance
807
688
  // sqlite-vec with cosine metric returns distance where similarity = 1 - distance
808
- const score = Math.max(0, Math.min(1, 1 - vec.distance));
689
+ const score = Math.max(0, Math.min(1, 1 - distance));
809
690
 
810
691
  if (score < threshold) continue;
811
692
 
@@ -178,6 +178,13 @@ export function formatSyncResultLines(
178
178
  `Rechunked ${syncResult.rechunkedMirrors} cached mirrors. Run gno embed if embedding was skipped.`
179
179
  );
180
180
  }
181
+ if (syncResult.graphRebuild) {
182
+ lines.push(
183
+ syncResult.graphRebuild === "resolver-upgrade"
184
+ ? "Link graph rebuilt: link resolution was upgraded."
185
+ : "Link graph rebuilt: collection settings or link workspace membership changed."
186
+ );
187
+ }
181
188
 
182
189
  for (const c of syncResult.collections) {
183
190
  lines.push(`${c.collection}:`);
@@ -21,6 +21,7 @@ import {
21
21
  import { isConnectorActivationComplete } from "../../core/activation-connector-health";
22
22
  import { buildActivationStatus } from "../../core/activation-status";
23
23
  import { formatChunkingStatus } from "../../core/chunking-status";
24
+ import { formatLinkWorkspace } from "../../core/link-workspace";
24
25
  import {
25
26
  buildMemoryStatus,
26
27
  formatMemoryStatusLines,
@@ -140,6 +141,8 @@ function formatTerminal(
140
141
  ` ${c.name}: ${c.activeDocuments} docs, ${c.totalChunks} chunks` +
141
142
  (c.embeddedChunks > 0 ? `, ${c.embeddedChunks} embedded` : "")
142
143
  );
144
+ const workspace = formatLinkWorkspace(c);
145
+ if (workspace) lines.push(` Link workspace: ${workspace}`);
143
146
  }
144
147
  }
145
148
 
@@ -336,6 +339,7 @@ export async function status(
336
339
  const statusResult = await store.getStatus({
337
340
  embedModel: resolveModelUri(config, "embed"),
338
341
  chunking: config.chunking ?? {},
342
+ configuredCollections: config.collections.map(({ name }) => name),
339
343
  });
340
344
  if (!statusResult.ok) {
341
345
  return { success: false, error: statusResult.error.message };
@@ -395,6 +399,8 @@ export function formatStatus(
395
399
  collections: s.collections.map((c) => ({
396
400
  name: c.name,
397
401
  path: c.path,
402
+ ...(c.workspaceRoot ? { workspaceRoot: c.workspaceRoot } : {}),
403
+ workspaceSource: c.workspaceSource ?? "none",
398
404
  documentCount: c.activeDocuments,
399
405
  chunkCount: c.totalChunks,
400
406
  embeddedCount: c.embeddedChunks,
@@ -1694,7 +1694,10 @@ function wireOnboardingCommands(program: Command): void {
1694
1694
  collectRepeatableValue,
1695
1695
  []
1696
1696
  )
1697
- .option("--max-findings <count>", "maximum returned findings", Number)
1697
+ .option(
1698
+ "--max-findings <count>",
1699
+ "maximum returned findings (1-100000, or all)"
1700
+ )
1698
1701
  .option("--max-age-days <days>", "explicit age review signal", Number)
1699
1702
  .option(
1700
1703
  "--orphan-root <uri>",
@@ -1736,7 +1739,7 @@ function wireOnboardingCommands(program: Command): void {
1736
1739
  collections: cmdOpts.collection as string[],
1737
1740
  paths: cmdOpts.path as string[],
1738
1741
  tags: cmdOpts.tag as string[],
1739
- maxFindings: cmdOpts.maxFindings as number | undefined,
1742
+ maxFindings: cmdOpts.maxFindings as string | undefined,
1740
1743
  maxAgeDays: cmdOpts.maxAgeDays as number | undefined,
1741
1744
  orphanRoots: cmdOpts.orphanRoot as string[],
1742
1745
  orphanIgnorePrefixes: cmdOpts.orphanIgnorePrefix as string[],
@@ -4749,6 +4752,12 @@ function wireKnowledgeDeltaCommands(program: Command): void {
4749
4752
  program
4750
4753
  .command("impact <doc>")
4751
4754
  .description("Find bounded inbound knowledge dependencies")
4755
+ .option(
4756
+ "-c, --collection <name>",
4757
+ "only traverse these collections (repeatable; default all)",
4758
+ collectRepeatableValue,
4759
+ []
4760
+ )
4752
4761
  .option("--max-depth <n>", "maximum dependency depth", "3")
4753
4762
  .option("--max-nodes <n>", "maximum returned nodes", "100")
4754
4763
  .option("--max-edges <n>", "maximum traversed evidence edges", "250")
@@ -4764,6 +4773,7 @@ function wireKnowledgeDeltaCommands(program: Command): void {
4764
4773
  const result = await impact(
4765
4774
  doc,
4766
4775
  {
4776
+ collections: cmdOpts.collection as string[],
4767
4777
  maxDepth: parsePositiveInt("max-depth", cmdOpts.maxDepth),
4768
4778
  maxNodes: parsePositiveInt("max-nodes", cmdOpts.maxNodes),
4769
4779
  maxEdges: parsePositiveInt("max-edges", cmdOpts.maxEdges),
@@ -7,6 +7,10 @@
7
7
 
8
8
  import type { ZodError } from "zod";
9
9
 
10
+ import {
11
+ validateWorkspaceRootSetting,
12
+ workspaceRootSettingMessage,
13
+ } from "../core/link-workspace";
10
14
  import {
11
15
  normalizeConfigContentTypes,
12
16
  type ConfigWarning,
@@ -132,6 +136,18 @@ export async function loadConfigFromPath(
132
136
  };
133
137
  }
134
138
 
139
+ const workspaceIssues = validateCollectionWorkspaceRoots(result.data);
140
+ if (workspaceIssues.length > 0) {
141
+ return {
142
+ ok: false,
143
+ error: {
144
+ code: "VALIDATION_ERROR",
145
+ message: `Config validation failed: ${workspaceIssues.map((issue) => issue.message).join("; ")}`,
146
+ issues: workspaceIssues,
147
+ },
148
+ };
149
+ }
150
+
135
151
  const normalized = normalizeConfigContentTypes(result.data);
136
152
  return {
137
153
  ok: true,
@@ -140,6 +156,33 @@ export async function loadConfigFromPath(
140
156
  };
141
157
  }
142
158
 
159
+ /**
160
+ * Validate explicit `workspaceRoot` settings against the filesystem: each must
161
+ * be absolute, exist, and contain its collection root. Messages name the
162
+ * collection so the error is actionable.
163
+ */
164
+ export function validateCollectionWorkspaceRoots(
165
+ config: Config
166
+ ): ZodError["issues"] {
167
+ const issues: ZodError["issues"] = [];
168
+ for (const [index, collection] of config.collections.entries()) {
169
+ if (typeof collection.workspaceRoot !== "string") continue;
170
+ const error = validateWorkspaceRootSetting(
171
+ collection.path,
172
+ collection.workspaceRoot
173
+ );
174
+ if (error) {
175
+ issues.push({
176
+ code: "custom",
177
+ message: workspaceRootSettingMessage(collection.name, error),
178
+ path: ["collections", index, "workspaceRoot"],
179
+ input: collection.workspaceRoot,
180
+ });
181
+ }
182
+ }
183
+ return issues;
184
+ }
185
+
143
186
  /**
144
187
  * Load config, returning null if not found (convenience wrapper).
145
188
  * Throws on parse/validation errors.
@@ -166,6 +166,14 @@ export const CollectionSchema = z.object({
166
166
  */
167
167
  sourceAvailability: SourceAvailabilitySchema.optional(),
168
168
 
169
+ /**
170
+ * Link workspace for plain wiki links. Omitted: auto-detect the nearest
171
+ * `.obsidian/` ancestor-or-self. An absolute path joins the collection to
172
+ * that workspace root (it must contain the collection root); `false` keeps
173
+ * the collection's links collection-scoped.
174
+ */
175
+ workspaceRoot: z.union([z.string().min(1), z.literal(false)]).optional(),
176
+
169
177
  /**
170
178
  * Declares the collection as a GNO-managed memory substrate: `remember`
171
179
  * writes fact files here and refuses every collection without the flag.