@gmickel/gno 2.7.1 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +3 -2
  2. package/assets/skill/SKILL.md +11 -1
  3. package/assets/skill/cli-reference.md +8 -1
  4. package/assets/skill/examples.md +2 -1
  5. package/assets/skill/mcp-reference.md +3 -1
  6. package/assets/skill/recipes/memory-scoped-recall.md +10 -5
  7. package/assets/spa-production.json.gz +0 -0
  8. package/browser-extension/artifacts/{gno-browser-clipper-v2.7.1.zip → gno-browser-clipper-v2.8.1.zip} +0 -0
  9. package/browser-extension/artifacts/gno-browser-clipper-v2.8.1.zip.sha256 +1 -0
  10. package/browser-extension/dist/manifest.json +1 -1
  11. package/package.json +1 -1
  12. package/spec/cli.md +86 -9
  13. package/spec/db/schema.sql +0 -1
  14. package/spec/mcp.md +26 -7
  15. package/spec/output-schemas/audit-report.schema.json +18 -4
  16. package/spec/output-schemas/backlinks.schema.json +4 -0
  17. package/spec/output-schemas/collection-list.schema.json +13 -0
  18. package/spec/output-schemas/graph.schema.json +2 -0
  19. package/spec/output-schemas/links-list.schema.json +4 -0
  20. package/spec/output-schemas/memory-recall.schema.json +1 -1
  21. package/spec/output-schemas/status.schema.json +18 -3
  22. package/src/cli/commands/audit.ts +23 -4
  23. package/src/cli/commands/collection/list.ts +39 -5
  24. package/src/cli/commands/embed.ts +3 -3
  25. package/src/cli/commands/graph.ts +3 -1
  26. package/src/cli/commands/links.ts +61 -180
  27. package/src/cli/commands/shared.ts +7 -0
  28. package/src/cli/commands/status.ts +6 -0
  29. package/src/cli/program.ts +12 -2
  30. package/src/config/loader.ts +43 -0
  31. package/src/config/types.ts +8 -0
  32. package/src/core/audit-contract.ts +16 -4
  33. package/src/core/audit-freshness.ts +11 -1
  34. package/src/core/audit-links.ts +197 -25
  35. package/src/core/audit-outside-index.ts +215 -0
  36. package/src/core/audit-provenance.ts +11 -4
  37. package/src/core/audit-workspace.ts +30 -9
  38. package/src/core/audit.ts +76 -16
  39. package/src/core/context-compiler.ts +3 -0
  40. package/src/core/context-evidence.ts +11 -0
  41. package/src/core/graph-edge-confidence.ts +23 -1
  42. package/src/core/host-paths.ts +1 -0
  43. package/src/core/knowledge-impact.ts +28 -0
  44. package/src/core/link-inventory-markdown.ts +2 -3
  45. package/src/core/link-workspace.ts +324 -0
  46. package/src/core/links.ts +40 -17
  47. package/src/core/memory-recall.ts +254 -15
  48. package/src/core/memory-types.ts +12 -0
  49. package/src/core/memory.ts +2 -0
  50. package/src/core/retrieval-replay-candidate.ts +6 -0
  51. package/src/core/retrieval-trace-request.ts +3 -0
  52. package/src/index.ts +14 -1
  53. package/src/ingestion/graph-reconciliation.ts +77 -15
  54. package/src/ingestion/source-availability/darwin-path.ts +9 -3
  55. package/src/ingestion/sync.ts +27 -4
  56. package/src/ingestion/types.ts +14 -0
  57. package/src/llm/inference-scope.ts +4 -3
  58. package/src/mcp/http-egress.ts +42 -3
  59. package/src/mcp/tools/audit.ts +11 -2
  60. package/src/mcp/tools/changes.ts +1 -0
  61. package/src/mcp/tools/links.ts +74 -93
  62. package/src/mcp/tools/sessions.ts +33 -4
  63. package/src/mcp/tools/status.ts +4 -0
  64. package/src/pipeline/expansion.ts +19 -31
  65. package/src/pipeline/graph-retrieval.ts +22 -2
  66. package/src/pipeline/hybrid.ts +1 -1
  67. package/src/pipeline/search.ts +2 -0
  68. package/src/pipeline/types.ts +10 -3
  69. package/src/sdk/client.ts +1 -0
  70. package/src/serve/findings-pass.ts +1 -1
  71. package/src/serve/public/components/editor/MarkdownPreview.tsx +5 -3
  72. package/src/serve/public/pages/GraphView.tsx +2 -0
  73. package/src/serve/routes/changes.ts +6 -1
  74. package/src/serve/routes/graph.ts +3 -1
  75. package/src/serve/routes/links.ts +45 -50
  76. package/src/serve/routes/sessions.ts +41 -53
  77. package/src/serve/server.ts +2 -1
  78. package/src/serve/status.ts +1 -0
  79. package/src/sessions/config-refresh.ts +111 -0
  80. package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
  81. package/src/store/migrations/034-collection-link-workspace.ts +47 -0
  82. package/src/store/migrations/index.ts +4 -0
  83. package/src/store/sqlite/adapter.ts +477 -337
  84. package/src/store/sqlite/eligibility.ts +8 -2
  85. package/src/store/sqlite/graph-link-resolver.ts +259 -5
  86. package/src/store/sqlite/graph-neighbors.ts +147 -40
  87. package/src/store/sqlite/graph-reference-state.ts +13 -2
  88. package/src/store/sqlite/graph-similarity.ts +96 -0
  89. package/src/store/sqlite/workspace-link-resolver.ts +742 -0
  90. package/src/store/types.ts +64 -5
  91. package/src/store/vector/stats.ts +1 -1
  92. package/src/store/vector/status.ts +27 -0
  93. package/src/store/vector/stored-vectors.ts +158 -0
  94. package/src/store/vector/types.ts +6 -0
  95. package/src/store/vector/variant-search.ts +30 -14
  96. package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +0 -1
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Link workspaces: collections whose roots share one workspace root resolve
3
+ * plain wiki links across each other (Obsidian vault semantics).
4
+ *
5
+ * Membership is derived from the filesystem, never stored in content: the
6
+ * nearest ancestor-or-self directory containing `.obsidian/`, or an explicit
7
+ * per-collection `workspaceRoot` (absolute path, or `false` to opt out).
8
+ * Ownership is finally decided per document: a nested vault (`.obsidian/`
9
+ * below the collection root) forms its own workspace.
10
+ *
11
+ * @module src/core/link-workspace
12
+ */
13
+
14
+ // node:fs has no Bun equivalent for synchronous realpath/stat of directories.
15
+ import { realpathSync, statSync } from "node:fs";
16
+ // node:path provides platform-correct path algebra; Bun has no path utilities.
17
+ import { dirname, isAbsolute, relative, sep } from "node:path";
18
+
19
+ /** Bump when resolution semantics change; enters projection fingerprints. */
20
+ export const LINK_RESOLVER_VERSION = 2;
21
+
22
+ export const WORKSPACE_MARKER_DIR = ".obsidian";
23
+
24
+ export type LinkWorkspaceSource =
25
+ /** No workspace root: collection-scoped resolution (today's behaviour). */
26
+ | "none"
27
+ /** Nearest `.obsidian/` ancestor-or-self of the collection root. */
28
+ | "detected"
29
+ /** Explicit absolute `workspaceRoot`. */
30
+ | "configured"
31
+ /** Explicit `workspaceRoot: false` opt-out. */
32
+ | "disabled"
33
+ /** Root or ancestors could not be inspected; fails closed to collection scope. */
34
+ | "unavailable";
35
+
36
+ export interface CollectionWorkspaceInfo {
37
+ /** Canonical (real) collection root, or null when it cannot be resolved. */
38
+ realPath: string | null;
39
+ /** Canonical workspace root, or null for collection-scoped resolution. */
40
+ root: string | null;
41
+ source: LinkWorkspaceSource;
42
+ }
43
+
44
+ /** Stored membership of one collection, as read back from the index. */
45
+ export interface CollectionWorkspaceMembership extends CollectionWorkspaceInfo {
46
+ collection: string;
47
+ /** Collection-relative POSIX prefixes that hold their own `.obsidian/`. */
48
+ nested: string[];
49
+ }
50
+
51
+ type DirProbe = "present" | "absent" | "error";
52
+
53
+ const probeMarker = (directory: string): DirProbe => {
54
+ try {
55
+ return statSync(`${directory}${sep}${WORKSPACE_MARKER_DIR}`).isDirectory()
56
+ ? "present"
57
+ : "absent";
58
+ } catch (cause) {
59
+ const code = (cause as NodeJS.ErrnoException | undefined)?.code;
60
+ return code === "ENOENT" || code === "ENOTDIR" ? "absent" : "error";
61
+ }
62
+ };
63
+
64
+ const canonical = (path: string): string | null => {
65
+ try {
66
+ return realpathSync(path).normalize("NFC");
67
+ } catch {
68
+ return null;
69
+ }
70
+ };
71
+
72
+ /** Join a POSIX relative path onto a native absolute root. */
73
+ const nativeJoin = (root: string, posixPath: string): string =>
74
+ `${root}${sep}${posixPath.split("/").join(sep)}`;
75
+
76
+ /** True when `child` equals `parent` or sits inside it (component-wise). */
77
+ export const pathContains = (parent: string, child: string): boolean => {
78
+ const rel = relative(parent, child);
79
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
80
+ };
81
+
82
+ /** POSIX path of `child` relative to `parent` ('' when equal). */
83
+ export const posixRelative = (parent: string, child: string): string =>
84
+ relative(parent, child).split(sep).join("/").normalize("NFC");
85
+
86
+ export type WorkspaceRootSettingError =
87
+ | "not_absolute"
88
+ | "not_found"
89
+ | "not_containing";
90
+
91
+ /** Validate an explicit `workspaceRoot` against the collection root. */
92
+ export const validateWorkspaceRootSetting = (
93
+ collectionPath: string,
94
+ workspaceRoot: string
95
+ ): WorkspaceRootSettingError | null => {
96
+ if (!isAbsolute(workspaceRoot)) return "not_absolute";
97
+ const root = canonical(workspaceRoot);
98
+ if (root === null) return "not_found";
99
+ try {
100
+ if (!statSync(root).isDirectory()) return "not_found";
101
+ } catch {
102
+ return "not_found";
103
+ }
104
+ const collectionRoot = canonical(collectionPath) ?? collectionPath;
105
+ return pathContains(root, collectionRoot) ? null : "not_containing";
106
+ };
107
+
108
+ export const workspaceRootSettingMessage = (
109
+ collection: string,
110
+ error: WorkspaceRootSettingError
111
+ ): string => {
112
+ switch (error) {
113
+ case "not_absolute":
114
+ return `Collection "${collection}": workspaceRoot must be an absolute path`;
115
+ case "not_found":
116
+ return `Collection "${collection}": workspaceRoot does not exist or is not a directory`;
117
+ case "not_containing":
118
+ return `Collection "${collection}": workspaceRoot must contain the collection root`;
119
+ }
120
+ };
121
+
122
+ /**
123
+ * Resolve one collection's workspace from its root. Real paths are used
124
+ * throughout; an unreadable root or ancestor never joins a broader workspace.
125
+ */
126
+ export const detectCollectionWorkspace = (collection: {
127
+ path: string;
128
+ workspaceRoot?: string | false;
129
+ }): CollectionWorkspaceInfo => {
130
+ const realPath = canonical(collection.path);
131
+ if (collection.workspaceRoot === false) {
132
+ return { realPath, root: null, source: "disabled" };
133
+ }
134
+ if (realPath === null) {
135
+ return { realPath: null, root: null, source: "unavailable" };
136
+ }
137
+ if (typeof collection.workspaceRoot === "string") {
138
+ const invalid = validateWorkspaceRootSetting(
139
+ collection.path,
140
+ collection.workspaceRoot
141
+ );
142
+ const root = invalid ? null : canonical(collection.workspaceRoot);
143
+ return root === null
144
+ ? { realPath, root: null, source: "unavailable" }
145
+ : { realPath, root, source: "configured" };
146
+ }
147
+ let current = realPath;
148
+ for (;;) {
149
+ const probe = probeMarker(current);
150
+ if (probe === "present") {
151
+ return { realPath, root: current, source: "detected" };
152
+ }
153
+ if (probe === "error") {
154
+ return { realPath, root: null, source: "unavailable" };
155
+ }
156
+ const parent = dirname(current);
157
+ if (parent === current) {
158
+ return { realPath, root: null, source: "none" };
159
+ }
160
+ current = parent;
161
+ }
162
+ };
163
+
164
+ /**
165
+ * Find nested vaults inside a collection: every ancestor directory of an
166
+ * indexed document (below the collection root) that holds `.obsidian/`.
167
+ * Bounded by distinct directories, never by link count.
168
+ */
169
+ export const detectNestedWorkspacePrefixes = (
170
+ realRoot: string,
171
+ relPaths: Iterable<string>
172
+ ): string[] => {
173
+ const directories = new Set<string>();
174
+ for (const relPath of relPaths) {
175
+ let directory = relPath.includes("/")
176
+ ? relPath.slice(0, relPath.lastIndexOf("/"))
177
+ : "";
178
+ while (directory && !directories.has(directory)) {
179
+ directories.add(directory);
180
+ directory = directory.includes("/")
181
+ ? directory.slice(0, directory.lastIndexOf("/"))
182
+ : "";
183
+ }
184
+ }
185
+ const nested: string[] = [];
186
+ for (const directory of directories) {
187
+ if (probeMarker(nativeJoin(realRoot, directory)) === "present") {
188
+ nested.push(directory.normalize("NFC"));
189
+ }
190
+ }
191
+ return nested.sort();
192
+ };
193
+
194
+ /**
195
+ * Fingerprint input for effective link resolution: resolver version plus the
196
+ * membership of every collection that belongs to (or contains) a workspace.
197
+ * Undefined when no collection is in a workspace, so collection-scoped
198
+ * indexes keep their existing fingerprints.
199
+ */
200
+ export const linkResolutionFingerprintInput = (
201
+ rows: ReadonlyArray<{
202
+ name: string;
203
+ realPath?: string | null;
204
+ workspaceRoot?: string | null;
205
+ workspaceSource?: LinkWorkspaceSource;
206
+ workspaceNested?: string[];
207
+ }>
208
+ ):
209
+ | {
210
+ version: number;
211
+ workspaces: Array<{
212
+ collection: string;
213
+ realPath: string | null;
214
+ root: string | null;
215
+ source: LinkWorkspaceSource;
216
+ nested: string[];
217
+ }>;
218
+ }
219
+ | undefined => {
220
+ const workspaces = rows
221
+ .filter(
222
+ (row) =>
223
+ (row.workspaceRoot ?? null) !== null ||
224
+ (row.workspaceNested?.length ?? 0) > 0
225
+ )
226
+ .map((row) => ({
227
+ collection: row.name,
228
+ realPath: row.realPath ?? null,
229
+ root: row.workspaceRoot ?? null,
230
+ source: row.workspaceSource ?? "none",
231
+ nested: [...(row.workspaceNested ?? [])].sort(),
232
+ }))
233
+ .sort((left, right) =>
234
+ left.collection < right.collection
235
+ ? -1
236
+ : left.collection > right.collection
237
+ ? 1
238
+ : 0
239
+ );
240
+ return workspaces.length > 0
241
+ ? { version: LINK_RESOLVER_VERSION, workspaces }
242
+ : undefined;
243
+ };
244
+
245
+ /**
246
+ * One-line link workspace description for status output, or null for a
247
+ * collection that is not in a workspace. A redacted root prints no path.
248
+ */
249
+ export const formatLinkWorkspace = (collection: {
250
+ workspaceRoot?: string | null;
251
+ workspaceSource?: string;
252
+ }): string | null => {
253
+ switch (collection.workspaceSource) {
254
+ case "detected":
255
+ case "configured":
256
+ return collection.workspaceRoot
257
+ ? `${collection.workspaceRoot} (${collection.workspaceSource})`
258
+ : collection.workspaceSource;
259
+ case "disabled":
260
+ return "off (links stay inside this collection)";
261
+ case "unavailable":
262
+ return "unavailable (links stay inside this collection)";
263
+ default:
264
+ return null;
265
+ }
266
+ };
267
+
268
+ /** Workspace identity and workspace-relative path of one document. */
269
+ export interface DocumentWorkspacePlacement {
270
+ /** Canonical workspace root (the workspace identity), or null. */
271
+ key: string | null;
272
+ /** Document path relative to the workspace root (POSIX, NFC). */
273
+ path: string;
274
+ }
275
+
276
+ /** Collection root relative to its workspace root, per membership object. */
277
+ const workspacePrefixes = new WeakMap<CollectionWorkspaceMembership, string>();
278
+
279
+ /**
280
+ * Place a document into its workspace: the deepest nested vault containing
281
+ * it, else its collection's workspace. Collection-scoped collections (none,
282
+ * disabled, unavailable) never place documents into a workspace, except that
283
+ * nested vaults still own their documents when the collection root is known
284
+ * and not opted out.
285
+ */
286
+ export const placeDocument = (
287
+ membership: CollectionWorkspaceMembership | undefined,
288
+ relPath: string
289
+ ): DocumentWorkspacePlacement => {
290
+ const normalized = relPath.normalize("NFC");
291
+ if (
292
+ !membership ||
293
+ membership.realPath === null ||
294
+ membership.source === "disabled" ||
295
+ membership.source === "unavailable"
296
+ ) {
297
+ return { key: null, path: normalized };
298
+ }
299
+ let nestedPrefix: string | null = null;
300
+ for (const prefix of membership.nested) {
301
+ if (
302
+ normalized.startsWith(`${prefix}/`) &&
303
+ (nestedPrefix === null || prefix.length > nestedPrefix.length)
304
+ ) {
305
+ nestedPrefix = prefix;
306
+ }
307
+ }
308
+ if (nestedPrefix !== null) {
309
+ return {
310
+ key: nativeJoin(membership.realPath, nestedPrefix),
311
+ path: normalized.slice(nestedPrefix.length + 1),
312
+ };
313
+ }
314
+ if (membership.root === null) return { key: null, path: normalized };
315
+ let prefix = workspacePrefixes.get(membership);
316
+ if (prefix === undefined) {
317
+ prefix = posixRelative(membership.root, membership.realPath);
318
+ workspacePrefixes.set(membership, prefix);
319
+ }
320
+ return {
321
+ key: membership.root,
322
+ path: prefix ? `${prefix}/${normalized}` : normalized,
323
+ };
324
+ };
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;