@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
package/README.md CHANGED
@@ -139,7 +139,7 @@ See the [guide](docs/COMPILED-CONTEXT.md).
139
139
 
140
140
  <!-- public-truth:current-version -->
141
141
 
142
- > Current source version: **v2.8.0**. See [CHANGELOG.md](./CHANGELOG.md).
142
+ > Current source version: **v2.8.1**. See [CHANGELOG.md](./CHANGELOG.md).
143
143
 
144
144
  <!-- /public-truth -->
145
145
 
@@ -355,8 +355,11 @@ rewrite, persist findings, judge factual truth, or replace retrieval.
355
355
  `--max-findings` accepts `all` (MCP `maxFindings: "all"`) to export every
356
356
  finding. Link findings carry `referenceKind`, `resolutionStatus`, and
357
357
  `resolvedScope` in their evidence detail; ambiguous vault links list the tied
358
- `candidates`. Report `truncation.snapshotTruncated` as "totals cover the
359
- bounded snapshot, not the whole index".
358
+ `candidates`. `links.outside-index` `info` findings are link targets that
359
+ exist in the vault but are not indexed (attachments, excluded or unindexed
360
+ folders); Obsidian resolves them, so do not report them as broken links.
361
+ Report `truncation.snapshotTruncated` as "totals cover the bounded snapshot,
362
+ not the whole index".
360
363
 
361
364
  Treat exit `4` as a complete report with findings. Exit `5` or report status
362
365
  `partial`/`changed_during_audit` means evidence is unavailable, inconclusive,
@@ -32,11 +32,16 @@ the flag.
32
32
  Superseded records never appear.
33
33
  - `budget.omitted` > 0: facts matched but did not fit. Narrow the query
34
34
  or raise the budget.
35
- - Empty `facts` plus a `hint`: nothing is stored in scope yet. Say so;
36
- the hint names `gno remember` for when the user wants to store one.
37
- - `retrieval.mode: "lexical"`: the collection has no cached embeddings, so
38
- the query matched every term. Rephrase to the fact's own words, or embed
39
- the collection (`gno embed <collection>`) for question-shaped queries.
35
+ - Empty `facts` plus a `hint`: the hint says why. "No memories in scope
36
+ yet" means nothing is stored there; "No memories in scope matched this
37
+ query" means facts exist but none shares a word with the question, so
38
+ retry with other words before concluding memory is silent. The hints
39
+ name `gno remember` for when the user wants to store one.
40
+ - `retrieval.mode: "lexical"`: the collection has no cached embeddings.
41
+ Questions still work (question words are dropped and facts sharing a
42
+ content word are returned), but a paraphrase with no word in common
43
+ misses; embed the collection (`gno embed <collection>`) to match by
44
+ meaning.
40
45
 
41
46
  3. Answer from the facts and cite each by its `gno://` URI. When memory is
42
47
  silent or stale, fall through to `gno search` / `gno query` on the
Binary file
@@ -0,0 +1 @@
1
+ 3f23832d1ed8b19e24eef29d6ea888a8c84009e39c61981d0b665c95cd7d792e gno-browser-clipper-v2.8.1.zip
@@ -21,5 +21,5 @@
21
21
  "content_security_policy": {
22
22
  "extension_pages": "script-src 'self'; object-src 'none'; connect-src http://127.0.0.1:*"
23
23
  },
24
- "version": "2.8.0"
24
+ "version": "2.8.1"
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "2.8.0",
3
+ "version": "2.8.1",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
package/spec/cli.md CHANGED
@@ -240,7 +240,12 @@ the model with `id`, `model`, `dimensions`, `state` (`active`|`shadow`),
240
240
  `provenance` (building runtime, e.g. `CUDA, Bun 1.4.2`),
241
241
  `compatibleRuntimes` (runtimes that read it) and `incompatibleRuntimes`.
242
242
  Terminal output prints a `Vector partitions:` block
243
- unless there is exactly one healthy partition. Per-collection chunk totals remain deduplicated by canonical chunk;
243
+ unless there is exactly one healthy partition. `collections`, `totalDocuments`
244
+ and `totalChunks` cover configured collections only: a collection removed from
245
+ config (for example by `gno collection remove`) is not reported even while its
246
+ rows remain in the index until the next `gno update`. `totalChunks` counts the
247
+ distinct chunks of active documents; chunks left by deleted documents do not
248
+ count. Per-collection chunk totals remain deduplicated by canonical chunk;
244
249
  embedded counts require matching current inputs for every active owner within
245
250
  that collection. Status reads persisted identity and coverage without loading
246
251
  models. Legacy storage remains the fallback before variant authority; ambiguous
@@ -949,9 +954,9 @@ healthy.
949
954
  Link findings resolve with the workspace-aware resolver (see
950
955
  docs/ARCHITECTURE.md "Resolution"). Their evidence `detail` JSON carries
951
956
  separate `referenceKind` (`wiki-name`, `wiki-path`, `explicit-collection`,
952
- `markdown`), `resolutionStatus` (`unresolved`, `ambiguous`) and
953
- `resolvedScope` (`same-collection`, `cross-collection`, `explicit-collection`,
954
- or null when unresolved). Ambiguous workspace links add `candidateCount` and
957
+ `markdown`), `resolutionStatus` (`unresolved`, `ambiguous`, `outside-index`)
958
+ and `resolvedScope` (`same-collection`, `cross-collection`,
959
+ `explicit-collection`, or null when unresolved). Ambiguous workspace links add `candidateCount` and
955
960
  `candidates` (tied candidate URIs in canonical path order); in a
956
961
  collection-scoped audit, candidates outside the requested collections are
957
962
  counted in `candidatesWithheld` and never named, and a list longer than the
@@ -960,6 +965,27 @@ Orphans stay "no incoming or outgoing resolved links", with connectivity drawn
960
965
  from the whole index even when the audited documents are scoped; a tied link
961
966
  connects nothing.
962
967
 
968
+ A plain wiki link or embed from a document in a link workspace whose target
969
+ is not an indexed document but exists as a file inside the workspace root (a
970
+ non-Markdown attachment, a note in an unindexed folder, a note excluded by a
971
+ collection pattern) is not reported by `links.local-targets`. It is an `info`
972
+ finding of `links.outside-index` (evidence kind `outside-index-target`,
973
+ `resolutionStatus: "outside-index"`). That rule stays `pass`, so these
974
+ findings never change the exit code. The check is existence-only: each
975
+ involved workspace root is listed once per run (hidden files and folders
976
+ skipped, symlinked folders not descended, a symlink counted only when it
977
+ resolves to a regular file inside the workspace, at most 200,000 files), no
978
+ file is opened, and no graph edge is created. Targets match files by the workspace
979
+ link rules; a non-Markdown target needs its extension, `.md` is optional. A
980
+ target missing from the listing stays unresolved; an unreadable folder or the
981
+ file bound makes the listing incomplete, which the `links.outside-index` rule
982
+ message states once.
983
+
984
+ The wiki link parser reads Obsidian's table-escaped alias `[[Note\|Alias]]`
985
+ as target `Note` with alias `Alias`. Markdown link text may contain balanced
986
+ square brackets (`[see [1]](note.md)`); a destination containing a square
987
+ bracket is not a link.
988
+
963
989
  The JSON contract is versioned as `gno://schemas/audit-report@1.0`. Finding IDs
964
990
  are stable SHA-256 identities derived from rule, normalized subject/location,
965
991
  and load-bearing evidence. Wall-clock timing and traversal order do not affect
@@ -2014,6 +2040,12 @@ gno recall <query> --scope <scope> [--scope <scope>...] [--collection <name>] [-
2014
2040
  `retrieval.semanticUnavailable` explaining why). Query expansion, graph
2015
2041
  expansion, and reranking are disabled. Scope and supersession filtering run
2016
2042
  inside the retrieval query; superseded facts are never returned.
2043
+ - The lexical leg searches the query's content words (question and function
2044
+ words dropped; quoted phrases and `-term` exclusions kept). Facts containing
2045
+ every content word are returned first; when none does, facts sharing any
2046
+ content word are returned in BM25 order, dropping those scoring below 0.1
2047
+ of the best match.
2048
+ - The budget is filled in retrieval-rank order.
2017
2049
  - Budget: at most `--max-facts` facts (default 8) under `--max-tokens`
2018
2050
  (default 512). Both must be positive integers. Recall never downloads a
2019
2051
  model.
@@ -2023,8 +2055,11 @@ gno recall <query> --scope <scope> [--scope <scope>...] [--collection <name>] [-
2023
2055
  (`caller`, `session`, `issuedAt`, `memoryIds`, `spanHashes`, `digest`) plus
2024
2056
  `budget` and `retrieval`. Derived output inherits the strictest source
2025
2057
  egress policy (`egressLineage`).
2026
- - Empty recall prints the self-teaching line naming `gno remember`
2027
- (`hint` in JSON) and exits 0.
2058
+ - Empty recall prints a hint (`hint` in JSON) and exits 0. The hint
2059
+ distinguishes an empty scope (`No memories in scope yet. Store one with:
2060
+ gno remember ...`), a populated scope with no match (`No memories in scope
2061
+ matched this query. ...`, also naming `gno remember`), and matches that did
2062
+ not fit the token budget.
2028
2063
 
2029
2064
  **Output:** `--json` prints the shared `RecallResult`. Terminal output lists
2030
2065
  numbered facts with URI, text, scopes, hash, and identity, then `Budget:`,
package/spec/mcp.md CHANGED
@@ -361,7 +361,10 @@ modifies source files, config, index rows, graph edges, daemon state, or a
361
361
  persisted audit baseline.
362
362
 
363
363
  Rules report `pass`, `fail`, `skip`, `unavailable`, or `inconclusive`; reports
364
- are `complete`, `partial`, `changed_during_audit`, or `failed`. Unavailable or
364
+ are `complete`, `partial`, `changed_during_audit`, or `failed`. A passing rule
365
+ may carry `info` findings (`links.outside-index`: link targets that exist as
366
+ files in the link workspace but are not indexed); only `warning` and `error`
367
+ findings fail a rule. Link semantics match `gno audit` (spec/cli.md). Unavailable or
365
368
  changing evidence never appears healthy. Stable finding IDs are derived from
366
369
  rule, normalized subject/location, and evidence fingerprint. Responses are
367
370
  canonically ordered and bounded while retaining exact pre-truncation totals.
@@ -1459,8 +1462,9 @@ Budgeted, cited, current-state recall from a memory-managed collection
1459
1462
  the vector leg did not run
1460
1463
  - `egressLineage` — strictest source policy across returned facts (absent when
1461
1464
  empty)
1462
- - `hint` — self-teaching line naming `gno remember`, present only when no fact
1463
- was returned
1465
+ - `hint` — present only when no fact was returned; says why: the scope holds
1466
+ no current fact (names `gno remember`), nothing in scope matched the query,
1467
+ or the matches did not fit `maxTokens`
1464
1468
 
1465
1469
  **Identity:** `caller` is the MCP client implementation name from the
1466
1470
  `initialize` handshake (`mcp` when absent); `session` is the Streamable HTTP
@@ -2185,7 +2189,7 @@ Find semantically similar documents using vector embeddings.
2185
2189
  **Algorithm:**
2186
2190
 
2187
2191
  1. Get all chunks for the source document
2188
- 2. Retrieve embeddings for each chunk from content_vectors
2192
+ 2. Retrieve each chunk's stored embedding from the active vector partition (legacy `content_vectors` only before any partition activates); no model is loaded
2189
2193
  3. Compute average embedding across all chunks
2190
2194
  4. Search for nearest neighbors using sqlite-vec
2191
2195
  5. Exclude self and filter by collection if not crossCollection
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "hint": {
40
40
  "type": "string",
41
- "description": "Self-teaching line naming `gno remember`; present only when no fact was returned."
41
+ "description": "Why nothing was returned (empty scope, no match in scope, or matches over the token budget); present only when no fact was returned."
42
42
  }
43
43
  },
44
44
  "$defs": {
@@ -98,7 +98,7 @@
98
98
  },
99
99
  "collections": {
100
100
  "type": "array",
101
- "description": "Collection statistics",
101
+ "description": "Statistics for configured collections; a collection removed from config is omitted even before its index rows are pruned",
102
102
  "items": {
103
103
  "type": "object",
104
104
  "required": ["name", "documentCount", "chunkCount", "embeddedCount"],
@@ -146,12 +146,12 @@
146
146
  },
147
147
  "totalDocuments": {
148
148
  "type": "integer",
149
- "description": "Total documents across all collections",
149
+ "description": "Active documents across configured collections",
150
150
  "minimum": 0
151
151
  },
152
152
  "totalChunks": {
153
153
  "type": "integer",
154
- "description": "Total chunks across all collections",
154
+ "description": "Distinct chunks of active documents across configured collections",
155
155
  "minimum": 0
156
156
  },
157
157
  "embeddingBacklog": {
@@ -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,12 +5,7 @@
5
5
  * @module src/cli/commands/links
6
6
  */
7
7
 
8
- import type {
9
- DocEdgeRow,
10
- DocLinkRow,
11
- DocumentRow,
12
- StorePort,
13
- } from "../../store/types";
8
+ import type { DocEdgeRow, DocLinkRow, StorePort } from "../../store/types";
14
9
 
15
10
  import { resolveDocRef } from "../../core/ref-parser";
16
11
  import { initStore } from "./shared";
@@ -592,27 +587,23 @@ export async function similar(
592
587
  }
593
588
  const db = store.getRawDb();
594
589
 
595
- // Get document embedding from content_vectors (prefer seq=0)
596
- interface VectorRow {
597
- embedding: Uint8Array;
598
- }
599
-
600
- const embedModel = modelPreset.embed;
601
- const vectorRow = db
602
- .query<VectorRow, [string, string]>(
603
- "SELECT embedding FROM content_vectors WHERE mirror_hash = ? AND model = ? AND seq = 0 LIMIT 1"
604
- )
605
- .get(doc.mirrorHash, embedModel);
606
-
607
- const fallbackRow =
608
- vectorRow ??
609
- db
610
- .query<VectorRow, [string, string]>(
611
- "SELECT embedding FROM content_vectors WHERE mirror_hash = ? AND model = ? ORDER BY seq LIMIT 1"
612
- )
613
- .get(doc.mirrorHash, embedModel);
614
-
615
- 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) {
616
607
  return {
617
608
  success: false,
618
609
  error: "Document has no embeddings. Run: gno embed",
@@ -621,9 +612,6 @@ export async function similar(
621
612
  }
622
613
 
623
614
  // Normalize embedding for cosine similarity
624
- const { decodeEmbedding } =
625
- await import("../../store/vector/sqlite-vec.js");
626
- const embedding = decodeEmbedding(fallbackRow.embedding);
627
615
  const dimensions = embedding.length;
628
616
  let norm = 0;
629
617
  for (let i = 0; i < dimensions; i++) {
@@ -641,7 +629,7 @@ export async function similar(
641
629
  const { createVectorIndexPort } =
642
630
  await import("../../store/vector/sqlite-vec.js");
643
631
  const vecResult = await createVectorIndexPort(db, {
644
- model: embedModel,
632
+ model: modelPreset.embed,
645
633
  dimensions,
646
634
  });
647
635
  if (!vecResult.ok) {
@@ -663,13 +651,13 @@ export async function similar(
663
651
  const searchResult = await vectorIndex.searchNearest(
664
652
  embedding,
665
653
  candidateLimit,
666
- {}
654
+ storedVectorSearchOptions(source)
667
655
  );
668
656
  if (!searchResult.ok) {
669
657
  return { success: false, error: searchResult.error.message };
670
658
  }
671
659
 
672
- // Build mirrorHash -> doc map from a single listDocuments call
660
+ // Candidate documents from a single listDocuments call
673
661
  const docsResult = crossCollection
674
662
  ? await store.listDocuments()
675
663
  : await store.listDocuments(doc.collection);
@@ -678,28 +666,18 @@ export async function similar(
678
666
  return { success: false, error: docsResult.error.message };
679
667
  }
680
668
 
681
- const docsByHash = new Map<string, DocumentRow>();
682
- for (const d of docsResult.value) {
683
- if (d.active && d.mirrorHash) {
684
- // Only keep first doc per hash (they have same content)
685
- if (!docsByHash.has(d.mirrorHash)) {
686
- docsByHash.set(d.mirrorHash, d);
687
- }
688
- }
689
- }
690
-
691
- // Map results to documents, excluding self
669
+ // Map hits to their owning documents, excluding self
692
670
  const similarItems: SimilarItem[] = [];
693
671
  const seenDocids = new Set<string>();
694
672
 
695
- 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
+ )) {
696
677
  if (similarItems.length >= limit) {
697
678
  break;
698
679
  }
699
680
 
700
- const d = docsByHash.get(vec.mirrorHash);
701
- if (!d) continue;
702
-
703
681
  // Exclude self
704
682
  if (d.docid === doc.docid) continue;
705
683
 
@@ -708,7 +686,7 @@ export async function similar(
708
686
 
709
687
  // Compute similarity score from cosine distance
710
688
  // sqlite-vec with cosine metric returns distance where similarity = 1 - distance
711
- const score = Math.max(0, Math.min(1, 1 - vec.distance));
689
+ const score = Math.max(0, Math.min(1, 1 - distance));
712
690
 
713
691
  if (score < threshold) continue;
714
692
 
@@ -339,6 +339,7 @@ export async function status(
339
339
  const statusResult = await store.getStatus({
340
340
  embedModel: resolveModelUri(config, "embed"),
341
341
  chunking: config.chunking ?? {},
342
+ configuredCollections: config.collections.map(({ name }) => name),
342
343
  });
343
344
  if (!statusResult.ok) {
344
345
  return { success: false, error: statusResult.error.message };
@@ -13,8 +13,11 @@ import {
13
13
  compareAuditFindingDrafts,
14
14
  } from "./audit";
15
15
 
16
- /** 1.1: workspace-wide resolution and reference/resolution/scope evidence. */
17
- export const LINK_AUDIT_RULE_VERSION = "1.1" as const;
16
+ /**
17
+ * 1.1: workspace-wide resolution and reference/resolution/scope evidence.
18
+ * 1.2: `links.outside-index` for workspace files that are not indexed.
19
+ */
20
+ export const LINK_AUDIT_RULE_VERSION = "1.2" as const;
18
21
  /** Default per-rule cap; runs pass the effective `maxFindings` instead. */
19
22
  export const LINK_AUDIT_MAX_FINDINGS_PER_RULE = 1000;
20
23
 
@@ -70,6 +73,7 @@ const linkFinding = (
70
73
  onEvidenceTruncated: () => void
71
74
  ): AuditFindingDraft => {
72
75
  const ambiguous = (link.resolved?.matchCount ?? 0) > 1;
76
+ const outsideIndex = !ambiguous && link.outsideIndex === true;
73
77
  const target = `${link.targetCollection}:${link.targetRef}`;
74
78
  const kind = referenceKind(link);
75
79
  const tied = (link.resolved?.candidates ?? []).map((candidate) =>
@@ -101,7 +105,11 @@ const linkFinding = (
101
105
  matchRank: link.resolved?.matchRank ?? null,
102
106
  normalizedTarget: link.targetRefNorm,
103
107
  referenceKind: kind,
104
- resolutionStatus: ambiguous ? "ambiguous" : "unresolved",
108
+ resolutionStatus: ambiguous
109
+ ? "ambiguous"
110
+ : outsideIndex
111
+ ? "outside-index"
112
+ : "unresolved",
105
113
  resolvedScope,
106
114
  };
107
115
  // Candidate URIs are added while the detail stays within the evidence
@@ -140,6 +148,26 @@ const linkFinding = (
140
148
  candidates.uris.pop();
141
149
  }
142
150
  if (candidates.truncated) onEvidenceTruncated();
151
+ if (outsideIndex) {
152
+ return {
153
+ subject: link.sourceUri,
154
+ location: lineLocation(link.startLine, link.startCol),
155
+ severity: "info",
156
+ message: `Link target exists outside the index: ${target}`,
157
+ evidence: [
158
+ {
159
+ kind: "outside-index-target",
160
+ summary: target,
161
+ uri: link.sourceUri,
162
+ path: link.sourceRelPath,
163
+ detail: render(),
164
+ },
165
+ ],
166
+ guidance: [
167
+ "No action needed; index the target's folder to add it to the link graph",
168
+ ],
169
+ };
170
+ }
143
171
  return {
144
172
  subject: link.sourceUri,
145
173
  location: lineLocation(link.startLine, link.startCol),
@@ -210,6 +238,7 @@ export const evaluateLinkAudit = (
210
238
  policy: AuditOrphanPolicy
211
239
  ): AuditRuleContribution[] => {
212
240
  const unresolved: AuditFindingDraft[] = [];
241
+ const outsideIndex: AuditFindingDraft[] = [];
213
242
  const ambiguous: AuditFindingDraft[] = [];
214
243
  const connected = new Set<number>();
215
244
  const auditedDocumentIds = new Set(
@@ -229,7 +258,11 @@ export const evaluateLinkAudit = (
229
258
  for (const link of snapshot.links) {
230
259
  const sourceAudited = auditedDocumentIds.has(link.sourceId);
231
260
  if (!link.resolved) {
232
- if (sourceAudited) unresolved.push(finding(link));
261
+ if (sourceAudited) {
262
+ (link.outsideIndex === true ? outsideIndex : unresolved).push(
263
+ finding(link)
264
+ );
265
+ }
233
266
  continue;
234
267
  }
235
268
  // A tied workspace link is not a resolved link: it connects nothing.
@@ -327,6 +360,25 @@ export const evaluateLinkAudit = (
327
360
  findingCount: orphanFindings.length,
328
361
  skipReason: partial ? "snapshot_truncated" : null,
329
362
  },
363
+ {
364
+ // Informational: Obsidian resolves these links, so they never fail
365
+ // the audit. Info findings keep the rule passing.
366
+ ...common,
367
+ ruleId: "links.outside-index",
368
+ category: "links",
369
+ status: partial ? ("inconclusive" as const) : ("pass" as const),
370
+ message: [
371
+ partial
372
+ ? "Outside-index scan was truncated"
373
+ : `${outsideIndex.length} links point to existing files outside the index`,
374
+ ...(snapshot.outsideIndexDiagnostic
375
+ ? [snapshot.outsideIndexDiagnostic]
376
+ : []),
377
+ ].join("; "),
378
+ findings: boundedFindings(outsideIndex, maxFindingsPerRule),
379
+ findingCount: outsideIndex.length,
380
+ skipReason: partial ? "snapshot_truncated" : null,
381
+ },
330
382
  {
331
383
  ...common,
332
384
  ruleId: "links.parser-boundary",