@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
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Existence-only classification of unresolved workspace wiki links and
3
+ * relative Markdown links whose target is a file inside the link workspace
4
+ * that is not an indexed document (an attachment, a script beside a note, or
5
+ * a note in an unindexed or excluded folder). Obsidian
6
+ * resolves such links, so the link audit reports them as `outside-index`
7
+ * instead of unresolved. Only file names are listed: no file is opened,
8
+ * indexed, or returned, and no graph edge is created.
9
+ *
10
+ * @module src/core/audit-outside-index
11
+ */
12
+
13
+ import type { Database } from "bun:sqlite";
14
+
15
+ // node:fs/promises readdir/realpath/stat: directory enumeration and symlink
16
+ // resolution; Bun has no equivalent that can skip an unreadable folder
17
+ // instead of failing the scan.
18
+ import { readdir, realpath, stat } from "node:fs/promises";
19
+ // node:path join: platform path algebra; no Bun equivalent.
20
+ import { join } from "node:path";
21
+
22
+ import type { AuditLinkSnapshot } from "../store/sqlite/graph-link-resolver";
23
+
24
+ import {
25
+ createWorkspaceFileMatcher,
26
+ loadLinkWorkspaceMemberships,
27
+ } from "../store/sqlite/workspace-link-resolver";
28
+ import { pathContains, placeDocument } from "./link-workspace";
29
+
30
+ /** Path identity for exact-path matching: NFC, case-insensitive. */
31
+ const pathKey = (path: string): string => path.normalize("NFC").toLowerCase();
32
+
33
+ /** Upper bound of files listed per workspace; beyond it the listing is partial. */
34
+ export const WORKSPACE_FILE_LISTING_MAX_FILES = 200_000;
35
+
36
+ export interface WorkspaceFileListing {
37
+ /** Workspace-relative POSIX paths (NFC) of every non-hidden file. */
38
+ files: string[];
39
+ /** False when a folder could not be read or the file bound was reached. */
40
+ complete: boolean;
41
+ }
42
+
43
+ interface WorkspaceDirectoryEntry {
44
+ name: string;
45
+ isFile(): boolean;
46
+ isDirectory(): boolean;
47
+ isSymbolicLink(): boolean;
48
+ }
49
+
50
+ /** Filesystem reads the listing needs; injectable for tests. */
51
+ export interface WorkspaceFileSystem {
52
+ readDirectory(path: string): Promise<WorkspaceDirectoryEntry[]>;
53
+ realPath(path: string): Promise<string>;
54
+ isRegularFile(path: string): Promise<boolean>;
55
+ }
56
+
57
+ const nodeWorkspaceFileSystem: WorkspaceFileSystem = {
58
+ readDirectory: (path) => readdir(path, { withFileTypes: true }),
59
+ realPath: (path) => realpath(path),
60
+ isRegularFile: async (path) => (await stat(path)).isFile(),
61
+ };
62
+
63
+ /** A symlink target that does not exist (or loops) is not a file, not an error. */
64
+ const MISSING_TARGET_CODES = new Set(["ENOENT", "ENOTDIR", "ELOOP"]);
65
+
66
+ /**
67
+ * A symlink counts as a file only when it resolves to an existing regular
68
+ * file inside the workspace. Dangling links, links to folders and links that
69
+ * leave the workspace are not files. Other errors are reported as such.
70
+ */
71
+ const symlinkIsWorkspaceFile = async (
72
+ fs: WorkspaceFileSystem,
73
+ realRoot: string,
74
+ path: string
75
+ ): Promise<"file" | "not-file" | "error"> => {
76
+ try {
77
+ const target = await fs.realPath(path);
78
+ if (!pathContains(realRoot, target)) return "not-file";
79
+ return (await fs.isRegularFile(target)) ? "file" : "not-file";
80
+ } catch (cause) {
81
+ const code = (cause as NodeJS.ErrnoException | undefined)?.code;
82
+ return code !== undefined && MISSING_TARGET_CODES.has(code)
83
+ ? "not-file"
84
+ : "error";
85
+ }
86
+ };
87
+
88
+ /**
89
+ * List every file below a workspace root once. Hidden files and folders
90
+ * (`.obsidian`, `.trash`, `.git`) are skipped, as Obsidian does; symlinked
91
+ * folders are not descended. Filesystem calls use the names as stored on
92
+ * disk; only the returned paths are NFC-normalized for matching. An
93
+ * unreadable folder is skipped and marks the listing incomplete, so links
94
+ * into it stay unresolved.
95
+ */
96
+ export const listWorkspaceFiles = async (
97
+ root: string,
98
+ options: {
99
+ maxFiles?: number;
100
+ signal?: AbortSignal;
101
+ fileSystem?: WorkspaceFileSystem;
102
+ } = {}
103
+ ): Promise<WorkspaceFileListing> => {
104
+ const maxFiles = options.maxFiles ?? WORKSPACE_FILE_LISTING_MAX_FILES;
105
+ const fs = options.fileSystem ?? nodeWorkspaceFileSystem;
106
+ const files: string[] = [];
107
+ let complete = true;
108
+ let realRoot: string;
109
+ try {
110
+ realRoot = await fs.realPath(root);
111
+ } catch {
112
+ return { files, complete: false };
113
+ }
114
+ // Each folder keeps its on-disk segments; matching uses the NFC path.
115
+ const queue: string[][] = [[]];
116
+ for (let head = 0; head < queue.length; head += 1) {
117
+ options.signal?.throwIfAborted();
118
+ const segments = queue[head] as string[];
119
+ let entries;
120
+ try {
121
+ entries = await fs.readDirectory(join(root, ...segments));
122
+ } catch {
123
+ complete = false;
124
+ continue;
125
+ }
126
+ for (const entry of entries) {
127
+ if (entry.name.startsWith(".")) continue;
128
+ const entrySegments = [...segments, entry.name];
129
+ let isFile = entry.isFile();
130
+ if (entry.isDirectory()) {
131
+ queue.push(entrySegments);
132
+ continue;
133
+ }
134
+ if (entry.isSymbolicLink()) {
135
+ const kind = await symlinkIsWorkspaceFile(
136
+ fs,
137
+ realRoot,
138
+ join(root, ...entrySegments)
139
+ );
140
+ if (kind === "error") complete = false;
141
+ isFile = kind === "file";
142
+ }
143
+ if (!isFile) continue;
144
+ if (files.length >= maxFiles) return { files, complete: false };
145
+ files.push(entrySegments.join("/").normalize("NFC"));
146
+ }
147
+ }
148
+ return { files, complete };
149
+ };
150
+
151
+ /**
152
+ * Mark unresolved plain wiki links and relative Markdown links of audited
153
+ * documents inside a link workspace whose target exists as a workspace file.
154
+ * A wiki link matches under workspace resolution rules; a Markdown link must
155
+ * name the file's exact path (NFC, case-insensitive). Each involved workspace
156
+ * is listed once per call. A failed or partial listing never marks a link
157
+ * that is not in it; the snapshot then carries one diagnostic.
158
+ */
159
+ export const markOutsideIndexLinks = async (
160
+ db: Database,
161
+ snapshot: AuditLinkSnapshot,
162
+ options: { maxFiles?: number; signal?: AbortSignal } = {}
163
+ ): Promise<AuditLinkSnapshot> => {
164
+ const audited = snapshot.auditedDocumentIds
165
+ ? new Set(snapshot.auditedDocumentIds)
166
+ : null;
167
+ const candidates = snapshot.links
168
+ .map((link, index) => ({ link, index }))
169
+ .filter(
170
+ ({ link }) =>
171
+ link.resolved === null &&
172
+ (link.linkType === "markdown" ||
173
+ (link.linkType === "wiki" && link.explicitCollection !== true)) &&
174
+ (audited === null || audited.has(link.sourceId))
175
+ );
176
+ if (candidates.length === 0) return snapshot;
177
+ const memberships = loadLinkWorkspaceMemberships(db);
178
+ const documentRelPaths = new Map(
179
+ snapshot.documents.map((document) => [document.id, document.relPath])
180
+ );
181
+ type PlacedLink = {
182
+ link: AuditLinkSnapshot["links"][number];
183
+ index: number;
184
+ key: string;
185
+ } & ({ sourcePath: string } | { targetPath: string });
186
+ const placed = candidates.flatMap(({ link, index }): PlacedLink[] => {
187
+ const membership = memberships.get(link.sourceCollection);
188
+ const placement = placeDocument(
189
+ membership,
190
+ documentRelPaths.get(link.sourceId) ?? link.sourceRelPath
191
+ );
192
+ if (placement.key === null) return [];
193
+ if (link.linkType === "wiki") {
194
+ return [{ link, index, key: placement.key, sourcePath: placement.path }];
195
+ }
196
+ // A Markdown target is stored collection-relative; place it the same way.
197
+ const target = placeDocument(membership, link.targetRefNorm);
198
+ return target.key === placement.key
199
+ ? [{ link, index, key: placement.key, targetPath: target.path }]
200
+ : [];
201
+ });
202
+ if (placed.length === 0) return snapshot;
203
+ const listings = new Map<
204
+ string,
205
+ {
206
+ matches: ReturnType<typeof createWorkspaceFileMatcher>;
207
+ paths: Set<string>;
208
+ }
209
+ >();
210
+ const maxFiles = options.maxFiles ?? WORKSPACE_FILE_LISTING_MAX_FILES;
211
+ let incomplete = 0;
212
+ for (const key of new Set(placed.map((entry) => entry.key))) {
213
+ const listing = await listWorkspaceFiles(key, {
214
+ maxFiles,
215
+ signal: options.signal,
216
+ });
217
+ if (!listing.complete) incomplete += 1;
218
+ listings.set(key, {
219
+ matches: createWorkspaceFileMatcher(listing.files),
220
+ paths: new Set(listing.files.map(pathKey)),
221
+ });
222
+ }
223
+ const links = [...snapshot.links];
224
+ for (const entry of placed) {
225
+ const listing = listings.get(entry.key);
226
+ const exists =
227
+ "targetPath" in entry
228
+ ? listing?.paths.has(pathKey(entry.targetPath)) === true
229
+ : listing?.matches(entry.link.targetRefNorm, entry.sourcePath) === true;
230
+ if (exists) {
231
+ links[entry.index] = { ...entry.link, outsideIndex: true };
232
+ }
233
+ }
234
+ return {
235
+ ...snapshot,
236
+ links,
237
+ ...(incomplete > 0
238
+ ? {
239
+ outsideIndexDiagnostic: `The file listing of ${incomplete} link workspace${incomplete === 1 ? " was" : "s were"} incomplete (an unreadable folder or link, or more than ${maxFiles} files); links to files it missed stay unresolved`,
240
+ }
241
+ : {}),
242
+ };
243
+ };
@@ -32,6 +32,7 @@ import {
32
32
  } from "./audit";
33
33
  import { evaluateFreshnessAudit } from "./audit-freshness";
34
34
  import { evaluateLinkAudit } from "./audit-links";
35
+ import { markOutsideIndexLinks } from "./audit-outside-index";
35
36
  import { evaluateProvenanceAudit } from "./audit-provenance";
36
37
  import {
37
38
  extractCaptureSourceFromFrontmatter,
@@ -429,15 +430,20 @@ const loadWorkspaceSnapshot = async (
429
430
  ? captureAuditLinkSnapshot(options.store.getRawDb())
430
431
  : emptyLinkSnapshot();
431
432
  const selectedIds = new Set(selected.documents.map(({ id }) => id));
433
+ const links = filterLinkSnapshot(
434
+ rawLinks,
435
+ selectedIds,
436
+ selected.documents,
437
+ selected.truncated,
438
+ filters.collections
439
+ );
432
440
  return {
433
441
  documents: observed,
434
- links: filterLinkSnapshot(
435
- rawLinks,
436
- selectedIds,
437
- selected.documents,
438
- selected.truncated,
439
- filters.collections
440
- ),
442
+ links: options.categories.includes("links")
443
+ ? await markOutsideIndexLinks(options.store.getRawDb(), links, {
444
+ signal: options.signal,
445
+ })
446
+ : links,
441
447
  truncated: selected.truncated,
442
448
  };
443
449
  };
package/src/core/audit.ts CHANGED
@@ -226,8 +226,16 @@ const materializeRule = (
226
226
  )
227
227
  .sort(compareAuditFindings);
228
228
 
229
+ // Only warning/error findings fail a passing rule; info findings of an
230
+ // informational rule (links.outside-index) leave it passing.
229
231
  let status = contribution.status;
230
- if (findings.length > 0 && status === "pass") {
232
+ if (
233
+ status === "pass" &&
234
+ findings.some(
235
+ (finding) =>
236
+ finding.severity === "error" || finding.severity === "warning"
237
+ )
238
+ ) {
231
239
  status = "fail";
232
240
  }
233
241
 
@@ -12,7 +12,7 @@ import type {
12
12
 
13
13
  import { parseFrontmatter } from "../ingestion/frontmatter";
14
14
  import { buildLineOffsets } from "../ingestion/position";
15
- import { getExcludedRanges } from "../ingestion/strip";
15
+ import { type ExcludedRange, getExcludedRanges } from "../ingestion/strip";
16
16
  import { normalizeMarkdownPath, normalizeWikiName, parseLinks } from "./links";
17
17
  import { extractSections } from "./sections";
18
18
 
@@ -107,9 +107,9 @@ const extractTypedEdges = (markdown: string): string[] => {
107
107
  export const extractDocumentStructure = (
108
108
  markdown: string,
109
109
  relPath: string,
110
- dateFields: Readonly<Record<string, string>> | null | undefined
110
+ dateFields: Readonly<Record<string, string>> | null | undefined,
111
+ excludedRanges: ExcludedRange[] = getExcludedRanges(markdown)
111
112
  ): DocumentStructureSnapshot => {
112
- const excludedRanges = getExcludedRanges(markdown);
113
113
  const links = parseLinks(markdown, buildLineOffsets(markdown), excludedRanges)
114
114
  .map((link) => linkSummary(link, relPath))
115
115
  .filter((value): value is string => value !== null);
@@ -23,7 +23,7 @@ import {
23
23
  pushInventoryToken,
24
24
  } from "./link-inventory-opaque";
25
25
  import { isRelevantDestination } from "./link-relevance";
26
- import { parseTargetParts } from "./links";
26
+ import { parseTargetParts, splitWikiLinkContent } from "./links";
27
27
 
28
28
  const EXTERNAL_URL_REGEX = /^[a-z][a-z0-9+.-]*:/i;
29
29
 
@@ -51,8 +51,7 @@ export function inventoryWikiLinks(
51
51
  }
52
52
 
53
53
  const content = match[1] ?? "";
54
- const pipeIndex = content.indexOf("|");
55
- const targetPart = pipeIndex >= 0 ? content.slice(0, pipeIndex) : content;
54
+ const targetPart = splitWikiLinkContent(content).target;
56
55
  const trimmedTarget = targetPart.trim();
57
56
  if (!trimmedTarget) continue;
58
57
  const leadingWs = targetPart.length - targetPart.trimStart().length;
@@ -81,7 +81,9 @@ export function findExcludedKind(
81
81
  export function codeContextReason(
82
82
  kind: ExcludedRange["kind"] | null
83
83
  ): "code_fence_context" | "inline_code_context" | null {
84
- if (kind === "fenced_code") return "code_fence_context";
84
+ if (kind === "fenced_code" || kind === "indented_code") {
85
+ return "code_fence_context";
86
+ }
85
87
  if (kind === "inline_code") return "inline_code_context";
86
88
  return null;
87
89
  }
package/src/core/links.ts CHANGED
@@ -88,6 +88,8 @@ const LOGSEQ_EMBED_REGEX =
88
88
  * Markdown inline link: [text](url)
89
89
  * Captures: 1=text, 2=url (path and optional anchor)
90
90
  * Negative lookbehind to avoid image links ![]()
91
+ * Link text may contain balanced square brackets one level deep
92
+ * (`[see [1]](note.md)`), as CommonMark allows.
91
93
  *
92
94
  * SCOPE LIMITATIONS:
93
95
  * - Only matches simple inline links [text](url)
@@ -95,7 +97,10 @@ const LOGSEQ_EMBED_REGEX =
95
97
  * - Does NOT match autolinks <url> or bare URLs
96
98
  * - Parens in URLs not supported (use %28 %29 encoding)
97
99
  */
98
- const MARKDOWN_LINK_REGEX = /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g;
100
+ const MARKDOWN_LINK_REGEX = /(?<!!)\[((?:[^[\]]|\[[^[\]]*\])*)\]\(([^)]+)\)/g;
101
+
102
+ /** Square brackets in a destination mean the text was split, not a path. */
103
+ const BRACKET_IN_DESTINATION_REGEX = /[[\]]/;
99
104
 
100
105
  /** External URL pattern (http:// https:// mailto: etc.) */
101
106
  const EXTERNAL_URL_REGEX = /^[a-z][a-z0-9+.-]*:/i;
@@ -144,6 +149,26 @@ export function extractWikiBasename(ref: string): string {
144
149
  return stripWikiMdExt(base);
145
150
  }
146
151
 
152
+ /**
153
+ * Split the content of a wiki link into target and alias. Obsidian also reads
154
+ * `\|` (the pipe escaped inside a Markdown table) as the alias separator,
155
+ * so `[[Note\|Alias]]` targets `Note`, not `Note\`.
156
+ */
157
+ export function splitWikiLinkContent(content: string): {
158
+ target: string;
159
+ alias?: string;
160
+ } {
161
+ const pipeIndex = content.indexOf("|");
162
+ if (pipeIndex < 0) {
163
+ return { target: content };
164
+ }
165
+ const escaped = pipeIndex > 0 && content[pipeIndex - 1] === "\\";
166
+ return {
167
+ target: content.slice(0, escaped ? pipeIndex - 1 : pipeIndex),
168
+ alias: content.slice(pipeIndex + 1),
169
+ };
170
+ }
171
+
147
172
  // ─────────────────────────────────────────────────────────────────────────────
148
173
  // Path Normalization
149
174
  // ─────────────────────────────────────────────────────────────────────────────
@@ -338,22 +363,14 @@ export function parseLinks(
338
363
  const content = match[1];
339
364
  if (!content) continue;
340
365
 
341
- // Parse [[target|alias]] format
342
- const pipeIndex = content.indexOf("|");
343
- let targetPart: string;
344
- let displayText: string | undefined;
345
-
346
- if (pipeIndex >= 0) {
347
- targetPart = content.slice(0, pipeIndex);
348
- const aliasText = content.slice(pipeIndex + 1);
349
- // Only set displayText if different from target
350
- displayText =
351
- aliasText !== targetPart
352
- ? truncateText(aliasText, MAX_DISPLAY_TEXT_GRAPHEMES)
353
- : undefined;
354
- } else {
355
- targetPart = content;
356
- }
366
+ // Parse [[target|alias]] (and table-escaped [[target\|alias]]) format
367
+ const { target: targetPart, alias: aliasText } =
368
+ splitWikiLinkContent(content);
369
+ // Only set displayText if different from target
370
+ const displayText =
371
+ aliasText !== undefined && aliasText !== targetPart
372
+ ? truncateText(aliasText, MAX_DISPLAY_TEXT_GRAPHEMES)
373
+ : undefined;
357
374
 
358
375
  const trimmedTarget = targetPart.trim();
359
376
  if (!trimmedTarget) {
@@ -427,6 +444,12 @@ export function parseLinks(
427
444
  continue;
428
445
  }
429
446
 
447
+ // Brackets in the destination come from link text split at an
448
+ // unbalanced bracket (`[a [b](c](d)`): unparseable, not a missing target.
449
+ if (BRACKET_IN_DESTINATION_REGEX.test(url)) {
450
+ continue;
451
+ }
452
+
430
453
  // Skip URLs that look like protocol-relative (//example.com)
431
454
  if (url.startsWith("//")) {
432
455
  continue;