@gmickel/gno 2.8.0 → 2.8.2

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 (49) hide show
  1. package/README.md +1 -1
  2. package/assets/skill/SKILL.md +7 -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.2.zip} +0 -0
  6. package/browser-extension/artifacts/gno-browser-clipper-v2.8.2.zip.sha256 +1 -0
  7. package/browser-extension/dist/manifest.json +1 -1
  8. package/package.json +1 -1
  9. package/spec/cli.md +52 -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/converters/native/markdown.ts +3 -1
  17. package/src/core/audit-links.ts +57 -4
  18. package/src/core/audit-outside-index.ts +243 -0
  19. package/src/core/audit-workspace.ts +13 -7
  20. package/src/core/audit.ts +9 -1
  21. package/src/core/change-diff.ts +3 -3
  22. package/src/core/link-inventory-markdown.ts +2 -3
  23. package/src/core/link-inventory-opaque.ts +3 -1
  24. package/src/core/links.ts +40 -17
  25. package/src/core/memory-recall.ts +254 -15
  26. package/src/core/memory-types.ts +12 -0
  27. package/src/core/memory.ts +2 -0
  28. package/src/ingestion/strip.ts +89 -136
  29. package/src/ingestion/sync.ts +34 -16
  30. package/src/mcp/tools/links.ts +71 -93
  31. package/src/mcp/tools/status.ts +1 -0
  32. package/src/pipeline/search.ts +2 -0
  33. package/src/pipeline/types.ts +4 -0
  34. package/src/sdk/client.ts +1 -0
  35. package/src/serve/public/components/editor/MarkdownPreview.tsx +5 -3
  36. package/src/serve/routes/graph.ts +3 -1
  37. package/src/serve/routes/links.ts +32 -50
  38. package/src/serve/server.ts +2 -1
  39. package/src/serve/status.ts +1 -0
  40. package/src/store/sqlite/adapter.ts +87 -106
  41. package/src/store/sqlite/graph-link-resolver.ts +7 -0
  42. package/src/store/sqlite/graph-similarity.ts +96 -0
  43. package/src/store/sqlite/workspace-link-resolver.ts +119 -31
  44. package/src/store/types.ts +15 -2
  45. package/src/store/vector/status.ts +27 -0
  46. package/src/store/vector/stored-vectors.ts +158 -0
  47. package/src/store/vector/types.ts +6 -0
  48. package/src/store/vector/variant-search.ts +30 -14
  49. 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.2**. See [CHANGELOG.md](./CHANGELOG.md).
143
143
 
144
144
  <!-- /public-truth -->
145
145
 
@@ -355,8 +355,13 @@ 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, files a Markdown link
360
+ names by path, excluded or unindexed folders); Obsidian resolves them, so do
361
+ not report them as broken links. Links are read only from Markdown notes,
362
+ never from code spans, code blocks, or non-Markdown files.
363
+ Report `truncation.snapshotTruncated` as "totals cover the bounded snapshot,
364
+ not the whole index".
360
365
 
361
366
  Treat exit `4` as a complete report with findings. Exit `5` or report status
362
367
  `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
+ 7bc830276cb923a482b662b980208b0978bf8ddbfbade0873e67d9757cf9c989 gno-browser-clipper-v2.8.2.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.2"
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.2",
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,38 @@ 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
+ Links are parsed only from documents converted by the Markdown converter
969
+ (`native/markdown`); plain text, source code, data files and converted
970
+ documents store no links and produce no link findings, while staying
971
+ searchable. The parser skips frontmatter, HTML comments, and code as a
972
+ CommonMark + GFM parser reads it: inline code spans, fenced code blocks and
973
+ indented code blocks, inside blockquotes and list items too (an indented list
974
+ paragraph is not code). A link outside code on the same line still counts.
975
+
976
+ A plain wiki link, embed, or relative Markdown link from a document in a link
977
+ workspace whose target is not an indexed document but exists as a file inside
978
+ the workspace root (a non-Markdown attachment, a script beside the note, a
979
+ note in an unindexed folder, a note excluded by a collection pattern) is not
980
+ reported by `links.local-targets`. It is an `info`
981
+ finding of `links.outside-index` (evidence kind `outside-index-target`,
982
+ `resolutionStatus: "outside-index"`). That rule stays `pass`, so these
983
+ findings never change the exit code. The check is existence-only: each
984
+ involved workspace root is listed once per run (hidden files and folders
985
+ skipped, symlinked folders not descended, a symlink counted only when it
986
+ resolves to a regular file inside the workspace, at most 200,000 files), no
987
+ file is opened, and no graph edge is created. Wiki targets match files by the workspace
988
+ link rules; a non-Markdown target needs its extension, `.md` is optional. A
989
+ Markdown link matches only the file at its resolved path (NFC,
990
+ case-insensitive). A
991
+ target missing from the listing stays unresolved; an unreadable folder or the
992
+ file bound makes the listing incomplete, which the `links.outside-index` rule
993
+ message states once.
994
+
995
+ The wiki link parser reads Obsidian's table-escaped alias `[[Note\|Alias]]`
996
+ as target `Note` with alias `Alias`. Markdown link text may contain balanced
997
+ square brackets (`[see [1]](note.md)`); a destination containing a square
998
+ bracket is not a link.
999
+
963
1000
  The JSON contract is versioned as `gno://schemas/audit-report@1.0`. Finding IDs
964
1001
  are stable SHA-256 identities derived from rule, normalized subject/location,
965
1002
  and load-bearing evidence. Wall-clock timing and traversal order do not affect
@@ -2014,6 +2051,12 @@ gno recall <query> --scope <scope> [--scope <scope>...] [--collection <name>] [-
2014
2051
  `retrieval.semanticUnavailable` explaining why). Query expansion, graph
2015
2052
  expansion, and reranking are disabled. Scope and supersession filtering run
2016
2053
  inside the retrieval query; superseded facts are never returned.
2054
+ - The lexical leg searches the query's content words (question and function
2055
+ words dropped; quoted phrases and `-term` exclusions kept). Facts containing
2056
+ every content word are returned first; when none does, facts sharing any
2057
+ content word are returned in BM25 order, dropping those scoring below 0.1
2058
+ of the best match.
2059
+ - The budget is filled in retrieval-rank order.
2017
2060
  - Budget: at most `--max-facts` facts (default 8) under `--max-tokens`
2018
2061
  (default 512). Both must be positive integers. Recall never downloads a
2019
2062
  model.
@@ -2023,8 +2066,11 @@ gno recall <query> --scope <scope> [--scope <scope>...] [--collection <name>] [-
2023
2066
  (`caller`, `session`, `issuedAt`, `memoryIds`, `spanHashes`, `digest`) plus
2024
2067
  `budget` and `retrieval`. Derived output inherits the strictest source
2025
2068
  egress policy (`egressLineage`).
2026
- - Empty recall prints the self-teaching line naming `gno remember`
2027
- (`hint` in JSON) and exits 0.
2069
+ - Empty recall prints a hint (`hint` in JSON) and exits 0. The hint
2070
+ distinguishes an empty scope (`No memories in scope yet. Store one with:
2071
+ gno remember ...`), a populated scope with no match (`No memories in scope
2072
+ matched this query. ...`, also naming `gno remember`), and matches that did
2073
+ not fit the token budget.
2028
2074
 
2029
2075
  **Output:** `--json` prints the shared `RecallResult`. Terminal output lists
2030
2076
  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 };
@@ -8,7 +8,9 @@ import type { Converter, ConvertInput, ConvertResult } from "../types";
8
8
  import { basenameWithoutExt } from "../path";
9
9
  import { NATIVE_VERSIONS } from "../versions";
10
10
 
11
- const CONVERTER_ID = "native/markdown" as const;
11
+ /** Converter id of Markdown sources, the only documents with link edges. */
12
+ export const MARKDOWN_CONVERTER_ID = "native/markdown" as const;
13
+ const CONVERTER_ID = MARKDOWN_CONVERTER_ID;
12
14
  const CONVERTER_VERSION = NATIVE_VERSIONS.markdown;
13
15
 
14
16
  /** UTF-8 BOM character */
@@ -13,8 +13,12 @@ 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
+ * 1.3: relative Markdown links to such files are `outside-index` too.
20
+ */
21
+ export const LINK_AUDIT_RULE_VERSION = "1.3" as const;
18
22
  /** Default per-rule cap; runs pass the effective `maxFindings` instead. */
19
23
  export const LINK_AUDIT_MAX_FINDINGS_PER_RULE = 1000;
20
24
 
@@ -70,6 +74,7 @@ const linkFinding = (
70
74
  onEvidenceTruncated: () => void
71
75
  ): AuditFindingDraft => {
72
76
  const ambiguous = (link.resolved?.matchCount ?? 0) > 1;
77
+ const outsideIndex = !ambiguous && link.outsideIndex === true;
73
78
  const target = `${link.targetCollection}:${link.targetRef}`;
74
79
  const kind = referenceKind(link);
75
80
  const tied = (link.resolved?.candidates ?? []).map((candidate) =>
@@ -101,7 +106,11 @@ const linkFinding = (
101
106
  matchRank: link.resolved?.matchRank ?? null,
102
107
  normalizedTarget: link.targetRefNorm,
103
108
  referenceKind: kind,
104
- resolutionStatus: ambiguous ? "ambiguous" : "unresolved",
109
+ resolutionStatus: ambiguous
110
+ ? "ambiguous"
111
+ : outsideIndex
112
+ ? "outside-index"
113
+ : "unresolved",
105
114
  resolvedScope,
106
115
  };
107
116
  // Candidate URIs are added while the detail stays within the evidence
@@ -140,6 +149,26 @@ const linkFinding = (
140
149
  candidates.uris.pop();
141
150
  }
142
151
  if (candidates.truncated) onEvidenceTruncated();
152
+ if (outsideIndex) {
153
+ return {
154
+ subject: link.sourceUri,
155
+ location: lineLocation(link.startLine, link.startCol),
156
+ severity: "info",
157
+ message: `Link target exists outside the index: ${target}`,
158
+ evidence: [
159
+ {
160
+ kind: "outside-index-target",
161
+ summary: target,
162
+ uri: link.sourceUri,
163
+ path: link.sourceRelPath,
164
+ detail: render(),
165
+ },
166
+ ],
167
+ guidance: [
168
+ "No action needed; index the target's folder to add it to the link graph",
169
+ ],
170
+ };
171
+ }
143
172
  return {
144
173
  subject: link.sourceUri,
145
174
  location: lineLocation(link.startLine, link.startCol),
@@ -210,6 +239,7 @@ export const evaluateLinkAudit = (
210
239
  policy: AuditOrphanPolicy
211
240
  ): AuditRuleContribution[] => {
212
241
  const unresolved: AuditFindingDraft[] = [];
242
+ const outsideIndex: AuditFindingDraft[] = [];
213
243
  const ambiguous: AuditFindingDraft[] = [];
214
244
  const connected = new Set<number>();
215
245
  const auditedDocumentIds = new Set(
@@ -229,7 +259,11 @@ export const evaluateLinkAudit = (
229
259
  for (const link of snapshot.links) {
230
260
  const sourceAudited = auditedDocumentIds.has(link.sourceId);
231
261
  if (!link.resolved) {
232
- if (sourceAudited) unresolved.push(finding(link));
262
+ if (sourceAudited) {
263
+ (link.outsideIndex === true ? outsideIndex : unresolved).push(
264
+ finding(link)
265
+ );
266
+ }
233
267
  continue;
234
268
  }
235
269
  // A tied workspace link is not a resolved link: it connects nothing.
@@ -327,6 +361,25 @@ export const evaluateLinkAudit = (
327
361
  findingCount: orphanFindings.length,
328
362
  skipReason: partial ? "snapshot_truncated" : null,
329
363
  },
364
+ {
365
+ // Informational: Obsidian resolves these links, so they never fail
366
+ // the audit. Info findings keep the rule passing.
367
+ ...common,
368
+ ruleId: "links.outside-index",
369
+ category: "links",
370
+ status: partial ? ("inconclusive" as const) : ("pass" as const),
371
+ message: [
372
+ partial
373
+ ? "Outside-index scan was truncated"
374
+ : `${outsideIndex.length} links point to existing files outside the index`,
375
+ ...(snapshot.outsideIndexDiagnostic
376
+ ? [snapshot.outsideIndexDiagnostic]
377
+ : []),
378
+ ].join("; "),
379
+ findings: boundedFindings(outsideIndex, maxFindingsPerRule),
380
+ findingCount: outsideIndex.length,
381
+ skipReason: partial ? "snapshot_truncated" : null,
382
+ },
330
383
  {
331
384
  ...common,
332
385
  ruleId: "links.parser-boundary",