@gmickel/gno 2.7.0 → 2.8.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.
Files changed (99) hide show
  1. package/README.md +26 -30
  2. package/assets/skill/SKILL.md +8 -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/spa-production.json.gz +0 -0
  7. package/browser-extension/artifacts/{gno-browser-clipper-v2.7.0.zip → gno-browser-clipper-v2.8.0.zip} +0 -0
  8. package/browser-extension/artifacts/gno-browser-clipper-v2.8.0.zip.sha256 +1 -0
  9. package/browser-extension/dist/manifest.json +1 -1
  10. package/package.json +2 -1
  11. package/spec/cli.md +48 -6
  12. package/spec/db/schema.sql +0 -1
  13. package/spec/mcp.md +18 -3
  14. package/spec/output-schemas/audit-report.schema.json +18 -4
  15. package/spec/output-schemas/backlinks.schema.json +4 -0
  16. package/spec/output-schemas/collection-list.schema.json +15 -2
  17. package/spec/output-schemas/graph.schema.json +2 -0
  18. package/spec/output-schemas/links-list.schema.json +4 -0
  19. package/spec/output-schemas/status.schema.json +27 -16
  20. package/src/cli/commands/ask.ts +31 -12
  21. package/src/cli/commands/audit.ts +23 -4
  22. package/src/cli/commands/collection/list.ts +39 -5
  23. package/src/cli/commands/embed.ts +3 -3
  24. package/src/cli/commands/links.ts +34 -131
  25. package/src/cli/commands/ls.ts +6 -1
  26. package/src/cli/commands/shared.ts +7 -0
  27. package/src/cli/commands/status.ts +5 -0
  28. package/src/cli/program.ts +12 -2
  29. package/src/config/loader.ts +43 -0
  30. package/src/config/types.ts +8 -0
  31. package/src/core/audit-contract.ts +16 -4
  32. package/src/core/audit-freshness.ts +11 -1
  33. package/src/core/audit-links.ts +145 -25
  34. package/src/core/audit-provenance.ts +11 -4
  35. package/src/core/audit-workspace.ts +19 -4
  36. package/src/core/audit.ts +67 -15
  37. package/src/core/context-compiler.ts +3 -0
  38. package/src/core/context-evidence.ts +11 -0
  39. package/src/core/graph-edge-confidence.ts +23 -1
  40. package/src/core/host-paths.ts +24 -5
  41. package/src/core/knowledge-impact.ts +28 -0
  42. package/src/core/link-workspace.ts +324 -0
  43. package/src/core/request-receipts.ts +63 -9
  44. package/src/core/retrieval-replay-candidate.ts +6 -0
  45. package/src/core/retrieval-trace-request.ts +3 -0
  46. package/src/core/windows-private-path.ts +136 -1
  47. package/src/embed/backlog.ts +22 -10
  48. package/src/embed/variant-backlog.ts +34 -9
  49. package/src/index.ts +14 -1
  50. package/src/ingestion/graph-reconciliation.ts +77 -15
  51. package/src/ingestion/source-availability/darwin-path.ts +9 -3
  52. package/src/ingestion/sync.ts +22 -1
  53. package/src/ingestion/types.ts +14 -0
  54. package/src/llm/inference-scope.ts +19 -0
  55. package/src/mcp/http-egress.ts +42 -3
  56. package/src/mcp/tools/audit.ts +11 -2
  57. package/src/mcp/tools/changes.ts +1 -0
  58. package/src/mcp/tools/links.ts +3 -0
  59. package/src/mcp/tools/sessions.ts +33 -4
  60. package/src/mcp/tools/status.ts +22 -6
  61. package/src/pipeline/expansion.ts +19 -31
  62. package/src/pipeline/graph-retrieval.ts +22 -2
  63. package/src/pipeline/hybrid.ts +1 -1
  64. package/src/pipeline/types.ts +6 -3
  65. package/src/serve/embed-scheduler.ts +2 -2
  66. package/src/serve/findings-pass.ts +1 -1
  67. package/src/serve/host-path-redaction.ts +51 -14
  68. package/src/serve/public/components/BootstrapStatus.tsx +5 -3
  69. package/src/serve/public/components/CaptureModal.tsx +1 -1
  70. package/src/serve/public/components/CollectionModelDialog.tsx +16 -13
  71. package/src/serve/public/components/CollectionsEmptyState.tsx +5 -3
  72. package/src/serve/public/components/FirstRunWizard.tsx +4 -2
  73. package/src/serve/public/components/sessions/SourcesPanel.tsx +77 -60
  74. package/src/serve/public/pages/Collections.tsx +16 -13
  75. package/src/serve/public/pages/Connectors.tsx +7 -4
  76. package/src/serve/public/pages/Dashboard.tsx +8 -6
  77. package/src/serve/public/pages/GraphView.tsx +2 -0
  78. package/src/serve/resident-runtime.ts +7 -0
  79. package/src/serve/routes/changes.ts +6 -1
  80. package/src/serve/routes/links.ts +13 -0
  81. package/src/serve/routes/sessions.ts +81 -37
  82. package/src/serve/server.ts +5 -3
  83. package/src/serve/status-model.ts +10 -6
  84. package/src/serve/status.ts +2 -7
  85. package/src/sessions/config-refresh.ts +111 -0
  86. package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
  87. package/src/store/migrations/034-collection-link-workspace.ts +47 -0
  88. package/src/store/migrations/index.ts +4 -0
  89. package/src/store/sqlite/adapter.ts +392 -233
  90. package/src/store/sqlite/change-journal-store.ts +1 -1
  91. package/src/store/sqlite/eligibility.ts +8 -2
  92. package/src/store/sqlite/graph-link-resolver.ts +252 -5
  93. package/src/store/sqlite/graph-neighbors.ts +147 -40
  94. package/src/store/sqlite/graph-reference-state.ts +13 -2
  95. package/src/store/sqlite/legacy-vector-ownership.ts +2 -1
  96. package/src/store/sqlite/workspace-link-resolver.ts +654 -0
  97. package/src/store/types.ts +49 -3
  98. package/src/store/vector/stats.ts +1 -1
  99. package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +0 -1
@@ -81,6 +81,36 @@ export type AskCommandResult =
81
81
  }
82
82
  | { success: false; error: string };
83
83
 
84
+ /**
85
+ * Give every document URI `?index=` on a named index, like search and
86
+ * Capsule results: results, citations and the answer-context selection.
87
+ */
88
+ export function decorateAskUris(
89
+ result: AskResult,
90
+ indexName: string | undefined
91
+ ): AskResult {
92
+ const decorate = <T extends { uri: string }>(item: T): T => ({
93
+ ...item,
94
+ uri: decorateUriForIndex(item.uri, indexName),
95
+ });
96
+ const answerContext = result.meta.answerContext;
97
+ return {
98
+ ...result,
99
+ results: result.results.map(decorate),
100
+ citations: result.citations?.map(decorate),
101
+ meta: answerContext
102
+ ? {
103
+ ...result.meta,
104
+ answerContext: {
105
+ ...answerContext,
106
+ selected: answerContext.selected.map(decorate),
107
+ dropped: answerContext.dropped.map(decorate),
108
+ },
109
+ }
110
+ : result.meta,
111
+ };
112
+ }
113
+
84
114
  // ─────────────────────────────────────────────────────────────────────────────
85
115
  // Command Implementation
86
116
  // ─────────────────────────────────────────────────────────────────────────────
@@ -435,20 +465,9 @@ export async function ask(
435
465
  return { success: false, error: finalized.error.message };
436
466
  }
437
467
  }
438
- // URIs carry `?index=` like search and Capsule results on a named index.
439
468
  return {
440
469
  success: true,
441
- data: {
442
- ...askResult,
443
- results: askResult.results.map((result) => ({
444
- ...result,
445
- uri: decorateUriForIndex(result.uri, globals.index),
446
- })),
447
- citations: askResult.citations?.map((citation) => ({
448
- ...citation,
449
- uri: decorateUriForIndex(citation.uri, globals.index),
450
- })),
451
- },
470
+ data: decorateAskUris(askResult, globals.index),
452
471
  metadata: traceSession?.metadata(),
453
472
  };
454
473
  } catch (cause) {
@@ -14,8 +14,11 @@ import type { WorkspaceAuditProgress } from "../../core/audit-workspace";
14
14
  import { getIndexDbPath } from "../../app/constants";
15
15
  import { loadConfig } from "../../config";
16
16
  import {
17
+ AUDIT_MAX_FINDINGS_RANGE_MESSAGE,
17
18
  auditExitCode,
18
19
  AUDIT_CATEGORIES,
20
+ parseAuditMaxFindingsInput,
21
+ resolveAuditMaxFindings,
19
22
  serializeAuditReportCanonical,
20
23
  } from "../../core/audit";
21
24
  import { runWorkspaceAudit } from "../../core/audit-workspace";
@@ -31,7 +34,8 @@ export interface AuditCommandOptions {
31
34
  collections?: string[];
32
35
  paths?: string[];
33
36
  tags?: string[];
34
- maxFindings?: number;
37
+ /** Positive integer, `all`, or the raw CLI string for either. */
38
+ maxFindings?: number | string;
35
39
  maxAgeDays?: number;
36
40
  orphanRoots?: string[];
37
41
  orphanIgnorePrefixes?: string[];
@@ -80,11 +84,18 @@ export const audit = async (
80
84
  error: "category must be links, provenance, freshness, or all",
81
85
  };
82
86
  }
83
- if (invalidPositiveInteger(options.maxFindings)) {
87
+ const maxFindings =
88
+ typeof options.maxFindings === "string"
89
+ ? parseAuditMaxFindingsInput(options.maxFindings)
90
+ : options.maxFindings;
91
+ if (
92
+ (options.maxFindings !== undefined && maxFindings === undefined) ||
93
+ !resolveAuditMaxFindings(maxFindings).ok
94
+ ) {
84
95
  return {
85
96
  success: false,
86
97
  invalid: true,
87
- error: "maxFindings must be a positive integer",
98
+ error: AUDIT_MAX_FINDINGS_RANGE_MESSAGE,
88
99
  };
89
100
  }
90
101
  if (invalidPositiveInteger(options.maxAgeDays)) {
@@ -155,7 +166,7 @@ export const audit = async (
155
166
  collectionFilters: requestedCollections,
156
167
  pathFilters: options.paths,
157
168
  tagFilters: requestedTags,
158
- maxFindings: options.maxFindings,
169
+ maxFindings,
159
170
  agePolicy:
160
171
  options.maxAgeDays === undefined
161
172
  ? undefined
@@ -188,6 +199,14 @@ export const formatAuditReport = (
188
199
  `Categories: ${report.scope.categories.join(", ")}`,
189
200
  `Rules: ${report.counts.rules.total} (${report.counts.rules.fail} failed, ${report.counts.rules.unavailable} unavailable, ${report.counts.rules.inconclusive} inconclusive)`,
190
201
  `Findings: ${report.counts.findings.total}${report.counts.findings.truncated ? ` (${report.counts.findings.returned} shown)` : ""}`,
202
+ ...(report.truncation.snapshotTruncated
203
+ ? [
204
+ "Snapshot: truncated; totals cover the bounded audit snapshot, not the whole index",
205
+ ]
206
+ : []),
207
+ ...(report.truncation.evidenceTruncated
208
+ ? ["Evidence: some finding evidence was shortened"]
209
+ : []),
191
210
  `Examined: ${report.counts.examined.documents} document/rule observations`,
192
211
  `Duration: ${report.durationMs}ms`,
193
212
  ];
@@ -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
@@ -5,8 +5,6 @@
5
5
  * @module src/cli/commands/links
6
6
  */
7
7
 
8
- import { basename } from "node:path";
9
-
10
8
  import type {
11
9
  DocEdgeRow,
12
10
  DocLinkRow,
@@ -14,7 +12,6 @@ import type {
14
12
  StorePort,
15
13
  } from "../../store/types";
16
14
 
17
- import { normalizeWikiName } from "../../core/links";
18
15
  import { resolveDocRef } from "../../core/ref-parser";
19
16
  import { initStore } from "./shared";
20
17
 
@@ -51,6 +48,8 @@ export interface LinkWithResolution {
51
48
  resolved: boolean;
52
49
  resolvedDocid?: string;
53
50
  resolvedUri?: string;
51
+ /** Collection of the resolved target (may differ from the source's). */
52
+ resolvedCollection?: string;
54
53
  }
55
54
 
56
55
  export interface SemanticLinkItem {
@@ -155,6 +154,8 @@ export interface BacklinkItem {
155
154
  sourceDocid: string;
156
155
  sourceUri: string;
157
156
  sourceTitle?: string;
157
+ /** Collection of the linking document. */
158
+ sourceCollection?: string;
158
159
  linkText?: string;
159
160
  startLine: number;
160
161
  startCol: number;
@@ -233,96 +234,13 @@ export type SimilarResult =
233
234
  | { success: true; data: SimilarResponse }
234
235
  | { success: false; error: string; isValidation?: boolean };
235
236
 
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
237
  // ─────────────────────────────────────────────────────────────────────────────
318
238
  // Helper: Map DocLinkRow to output format (avoids null leakage)
319
239
  // ─────────────────────────────────────────────────────────────────────────────
320
240
 
321
241
  function mapLinkToOutput(
322
242
  link: DocLinkRow,
323
- resolved: boolean,
324
- resolvedDocid?: string,
325
- resolvedUri?: string
243
+ resolved: { docid: string; uri: string; collection?: string } | null
326
244
  ): LinkWithResolution {
327
245
  return {
328
246
  targetRef: link.targetRef,
@@ -336,9 +254,12 @@ function mapLinkToOutput(
336
254
  startCol: link.startCol,
337
255
  endLine: link.endLine,
338
256
  endCol: link.endCol,
339
- resolved,
340
- ...(resolvedDocid && { resolvedDocid }),
341
- ...(resolvedUri && { resolvedUri }),
257
+ resolved: resolved !== null,
258
+ ...(resolved && {
259
+ resolvedDocid: resolved.docid,
260
+ resolvedUri: resolved.uri,
261
+ ...(resolved.collection && { resolvedCollection: resolved.collection }),
262
+ }),
342
263
  };
343
264
  }
344
265
 
@@ -443,48 +364,29 @@ export async function linksList(
443
364
  return a.startCol - b.startCol;
444
365
  });
445
366
 
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
- );
367
+ // Resolve with the shared link resolver (workspace-aware for plain wiki
368
+ // links whose source sits in a link workspace).
369
+ const resolvedResult = await store.resolveLinks(
370
+ links.map((link) => ({
371
+ targetRefNorm: link.targetRefNorm || link.targetRef,
372
+ targetCollection: link.targetCollection ?? doc.collection,
373
+ linkType: link.linkType,
374
+ source: {
375
+ collection: doc.collection,
376
+ relPath: doc.relPath,
377
+ explicit: Boolean(link.targetCollection),
378
+ },
379
+ }))
380
+ );
381
+ if (!resolvedResult.ok) {
382
+ return { success: false, error: resolvedResult.error.message };
487
383
  }
384
+ const linksWithResolution: LinkWithResolution[] = links.map(
385
+ (link, index) => {
386
+ const resolved = resolvedResult.value[index] ?? null;
387
+ return mapLinkToOutput(link, resolved);
388
+ }
389
+ );
488
390
 
489
391
  const resolvedCount = linksWithResolution.filter((l) => l.resolved).length;
490
392
 
@@ -606,6 +508,7 @@ export async function backlinks(
606
508
  sourceDocid: bl.sourceDocid,
607
509
  sourceUri: bl.sourceDocUri,
608
510
  ...(bl.sourceDocTitle && { sourceTitle: bl.sourceDocTitle }),
511
+ ...(bl.sourceCollection && { sourceCollection: bl.sourceCollection }),
609
512
  ...(bl.linkText && { linkText: bl.linkText }),
610
513
  startLine: bl.startLine,
611
514
  startCol: bl.startCol,
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { DocumentRow, StorePort, StoreResult } from "../../store/types";
9
9
 
10
+ import { decorateUriForIndex } from "../../app/constants";
10
11
  import { initStore } from "./shared";
11
12
 
12
13
  // ─────────────────────────────────────────────────────────────────────────────
@@ -145,7 +146,11 @@ export async function ls(
145
146
  // Apply offset and limit
146
147
  const offset = options.offset ?? 0;
147
148
  const limit = options.limit ?? 20;
148
- const paged = allActive.slice(offset, offset + limit);
149
+ // Sorted on canonical URIs; the output URIs carry `?index=` like search.
150
+ const paged = allActive.slice(offset, offset + limit).map((doc) => ({
151
+ ...doc,
152
+ uri: decorateUriForIndex(doc.uri, options.indexName),
153
+ }));
149
154
 
150
155
  return {
151
156
  success: true,
@@ -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
 
@@ -395,6 +398,8 @@ export function formatStatus(
395
398
  collections: s.collections.map((c) => ({
396
399
  name: c.name,
397
400
  path: c.path,
401
+ ...(c.workspaceRoot ? { workspaceRoot: c.workspaceRoot } : {}),
402
+ workspaceSource: c.workspaceSource ?? "none",
398
403
  documentCount: c.activeDocuments,
399
404
  chunkCount: c.totalChunks,
400
405
  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.