@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,742 @@
1
+ /**
2
+ * Workspace-wide wiki link resolution (Obsidian semantics) for plain
3
+ * `[[Target]]` links whose source document belongs to a link workspace.
4
+ *
5
+ * One ranking function serves both candidate strategies: a SQL basename
6
+ * prefilter for small target sets and a bulk load of every workspace member
7
+ * for large ones. Both feed identical rows into the same in-memory index, so
8
+ * results never depend on the strategy or on insertion order.
9
+ *
10
+ * Document-side case folding mirrors SQLite `lower()` (ASCII only), the
11
+ * documented caveat shared with collection-scoped resolution.
12
+ *
13
+ * @module src/store/sqlite/workspace-link-resolver
14
+ */
15
+
16
+ import type { Database } from "bun:sqlite";
17
+
18
+ // node:path/posix: POSIX path algebra for workspace-relative paths; no Bun equivalent.
19
+ import { posix as pathPosix } from "node:path";
20
+
21
+ import type {
22
+ CollectionWorkspaceMembership,
23
+ LinkWorkspaceSource,
24
+ } from "../../core/link-workspace";
25
+
26
+ import { placeDocument } from "../../core/link-workspace";
27
+ import { stripWikiMdExt } from "../../core/links";
28
+
29
+ /** Why a workspace link resolved to its target. */
30
+ export type WorkspaceResolutionReason =
31
+ /** Exact path relative to the workspace root. */
32
+ | "workspace-path"
33
+ /** Exact path relative to the source's own collection root. */
34
+ | "collection-path"
35
+ /** The only file (or path suffix) with that name in the workspace. */
36
+ | "exact-name"
37
+ /** Won over other same-named files by folder or depth tie-break. */
38
+ | "tie-break"
39
+ /** No file matched; frontmatter title inside the source collection. */
40
+ | "title";
41
+
42
+ export interface WorkspaceCandidate {
43
+ id: number;
44
+ docid: string;
45
+ collection: string;
46
+ /** Workspace-relative path (NFC, original case). */
47
+ path: string;
48
+ }
49
+
50
+ export interface WorkspaceLinkSource {
51
+ collection: string;
52
+ relPath: string;
53
+ }
54
+
55
+ export interface WorkspaceTargetResolution {
56
+ target: WorkspaceCandidate;
57
+ reason: WorkspaceResolutionReason;
58
+ /** False when candidates stay tied: audit-only, never a graph edge. */
59
+ traversable: boolean;
60
+ /** Distinct equally ranked candidates (length > 1 only when tied). */
61
+ tied: WorkspaceCandidate[];
62
+ /** Legacy-compatible rank for title fallback confidence (1-4), else null. */
63
+ titleRank: number | null;
64
+ }
65
+
66
+ interface CatalogDoc extends WorkspaceCandidate {
67
+ wsKey: string;
68
+ /** Workspace-relative path, ASCII-lowered like SQLite lower(). */
69
+ wsNorm: string;
70
+ wsFolderNorm: string;
71
+ depth: number;
72
+ /** Collection-relative path, ASCII-lowered. */
73
+ relNorm: string;
74
+ baseNorm: string;
75
+ titleNorm: string | null;
76
+ }
77
+
78
+ interface CatalogRow {
79
+ id: number;
80
+ docid: string;
81
+ collection: string;
82
+ rel_path: string;
83
+ title_norm: string | null;
84
+ }
85
+
86
+ const RELATIVE_TARGET = /^\.\.?\//;
87
+
88
+ /** SQLite `lower()` folds ASCII letters only; mirror it exactly. */
89
+ const asciiLower = (value: string): string =>
90
+ value.replace(/[A-Z]+/g, (match) => match.toLowerCase());
91
+
92
+ const lastSegment = (value: string): string =>
93
+ value.slice(value.lastIndexOf("/") + 1);
94
+
95
+ const folderOf = (value: string): string =>
96
+ value.includes("/") ? value.slice(0, value.lastIndexOf("/")) : "";
97
+
98
+ const compareCodeUnits = (left: string, right: string): number =>
99
+ left < right ? -1 : left > right ? 1 : 0;
100
+
101
+ /** Read every collection's stored link workspace membership. */
102
+ export const loadLinkWorkspaceMemberships = (
103
+ db: Database
104
+ ): Map<string, CollectionWorkspaceMembership> => {
105
+ const rows = db
106
+ .query<
107
+ {
108
+ name: string;
109
+ real_path: string | null;
110
+ workspace_root: string | null;
111
+ workspace_source: LinkWorkspaceSource | null;
112
+ workspace_nested: string | null;
113
+ },
114
+ []
115
+ >(
116
+ "SELECT name, real_path, workspace_root, workspace_source, workspace_nested FROM collections"
117
+ )
118
+ .all();
119
+ const memberships = new Map<string, CollectionWorkspaceMembership>();
120
+ for (const row of rows) {
121
+ let nested: string[] = [];
122
+ if (row.workspace_nested) {
123
+ try {
124
+ const parsed: unknown = JSON.parse(row.workspace_nested);
125
+ if (Array.isArray(parsed)) {
126
+ nested = parsed.filter(
127
+ (value): value is string => typeof value === "string"
128
+ );
129
+ }
130
+ } catch {
131
+ nested = [];
132
+ }
133
+ }
134
+ memberships.set(row.name, {
135
+ collection: row.name,
136
+ realPath: row.real_path,
137
+ root: row.workspace_root,
138
+ source: row.workspace_source ?? "none",
139
+ nested,
140
+ });
141
+ }
142
+ return memberships;
143
+ };
144
+
145
+ /** Workspace identity of a source document, or null for legacy resolution. */
146
+ export const workspaceKeyForDocument = (
147
+ memberships: ReadonlyMap<string, CollectionWorkspaceMembership>,
148
+ collection: string,
149
+ relPath: string
150
+ ): string | null => placeDocument(memberships.get(collection), relPath).key;
151
+
152
+ /** Collections that can hold documents of the given workspace. */
153
+ export const workspaceMemberCollections = (
154
+ memberships: ReadonlyMap<string, CollectionWorkspaceMembership>,
155
+ wsKeys: ReadonlySet<string>
156
+ ): string[] => {
157
+ const members: string[] = [];
158
+ for (const membership of memberships.values()) {
159
+ const placement = placeDocument(membership, "x");
160
+ const nestedKeys = membership.nested.map(
161
+ (prefix) => placeDocument(membership, `${prefix}/x`).key
162
+ );
163
+ if (
164
+ (placement.key !== null && wsKeys.has(placement.key)) ||
165
+ nestedKeys.some((key) => key !== null && wsKeys.has(key))
166
+ ) {
167
+ members.push(membership.collection);
168
+ }
169
+ }
170
+ return members.sort();
171
+ };
172
+
173
+ /**
174
+ * Normalize a lowercased wiki target for workspace matching. `./` and `../`
175
+ * resolve against the source folder; `..` never escapes the workspace root.
176
+ * Returns null for an escaping target.
177
+ */
178
+ export const normalizeWorkspaceTarget = (
179
+ targetRefNorm: string,
180
+ sourceFolderNorm: string
181
+ ): string | null => {
182
+ let value = targetRefNorm.trim().replace(/^\/+/, "");
183
+ if (value.startsWith("./") || value.startsWith("../")) {
184
+ value = sourceFolderNorm ? `${sourceFolderNorm}/${value}` : value;
185
+ }
186
+ if (value.includes("/") || value === "..") {
187
+ value = pathPosix.normalize(value).replace(/\/+$/, "");
188
+ }
189
+ if (value === ".." || value.startsWith("../") || value === ".") return null;
190
+ return value;
191
+ };
192
+
193
+ /** Keys a document's lowered basename must equal for a target's last segment. */
194
+ const basenameKeys = (normalizedTarget: string): string[] => {
195
+ const base = stripWikiMdExt(lastSegment(normalizedTarget));
196
+ return [base, `${base}.md`];
197
+ };
198
+
199
+ /** Legacy title lookup values (ranks 1-4 of the collection-scoped resolver). */
200
+ const titleLookups = (targetRefNorm: string): Array<[number, string[]]> => {
201
+ const baseRef = stripWikiMdExt(targetRefNorm);
202
+ const baseRefMd = `${baseRef}.md`;
203
+ const suffixes = (value: string): string[] => {
204
+ const output = [value];
205
+ let separator = value.indexOf("/");
206
+ while (separator >= 0) {
207
+ const suffix = value.slice(separator + 1);
208
+ if (suffix) output.push(suffix);
209
+ separator = value.indexOf("/", separator + 1);
210
+ }
211
+ return output;
212
+ };
213
+ return [
214
+ [1, [baseRef]],
215
+ [2, [baseRefMd]],
216
+ [3, suffixes(baseRef)],
217
+ [
218
+ 4,
219
+ suffixes(baseRefMd)
220
+ .filter((value) => value.endsWith(".md"))
221
+ .map((value) => value.slice(0, -3)),
222
+ ],
223
+ ];
224
+ };
225
+
226
+ class WorkspaceCatalog {
227
+ private readonly byBase = new Map<string, CatalogDoc[]>();
228
+ private readonly byTitle = new Map<string, CatalogDoc[]>();
229
+
230
+ constructor(
231
+ private readonly memberships: ReadonlyMap<
232
+ string,
233
+ CollectionWorkspaceMembership
234
+ >
235
+ ) {}
236
+
237
+ add(row: CatalogRow): void {
238
+ const placement = placeDocument(
239
+ this.memberships.get(row.collection),
240
+ row.rel_path
241
+ );
242
+ if (placement.key === null) return;
243
+ const wsNorm = asciiLower(placement.path);
244
+ const doc: CatalogDoc = {
245
+ id: row.id,
246
+ docid: row.docid,
247
+ collection: row.collection,
248
+ path: placement.path,
249
+ wsKey: placement.key,
250
+ wsNorm,
251
+ wsFolderNorm: folderOf(wsNorm),
252
+ depth: wsNorm.split("/").length,
253
+ relNorm: asciiLower(row.rel_path.normalize("NFC")),
254
+ baseNorm: lastSegment(wsNorm),
255
+ titleNorm: row.title_norm,
256
+ };
257
+ const baseKey = `${doc.wsKey}\0${doc.baseNorm}`;
258
+ const bases = this.byBase.get(baseKey) ?? [];
259
+ bases.push(doc);
260
+ this.byBase.set(baseKey, bases);
261
+ if (doc.titleNorm !== null) {
262
+ const titleKey = `${doc.collection}\0${doc.titleNorm}`;
263
+ const titles = this.byTitle.get(titleKey) ?? [];
264
+ titles.push(doc);
265
+ this.byTitle.set(titleKey, titles);
266
+ }
267
+ }
268
+
269
+ byBasename(wsKey: string, keys: readonly string[]): CatalogDoc[] {
270
+ const lists = keys
271
+ .map((key) => this.byBase.get(`${wsKey}\0${key}`))
272
+ .filter((list): list is CatalogDoc[] => list !== undefined);
273
+ if (lists.length <= 1) return lists[0] ?? [];
274
+ const found = new Map<number, CatalogDoc>();
275
+ for (const list of lists) {
276
+ for (const doc of list) found.set(doc.id, doc);
277
+ }
278
+ return [...found.values()];
279
+ }
280
+
281
+ byTitleIn(
282
+ collection: string,
283
+ wsKey: string,
284
+ keys: readonly string[]
285
+ ): CatalogDoc[] {
286
+ const found = new Map<number, CatalogDoc>();
287
+ for (const key of keys) {
288
+ for (const doc of this.byTitle.get(`${collection}\0${key}`) ?? []) {
289
+ if (doc.wsKey === wsKey) found.set(doc.id, doc);
290
+ }
291
+ }
292
+ return [...found.values()];
293
+ }
294
+ }
295
+
296
+ /** A normalized wiki target, ready to be matched against workspace paths. */
297
+ interface WorkspaceTargetShape {
298
+ normalized: string;
299
+ base: string;
300
+ exact: Set<string>;
301
+ relative: boolean;
302
+ hasPath: boolean;
303
+ }
304
+
305
+ const workspaceTargetShape = (
306
+ targetRefNorm: string,
307
+ sourceWsFolderNorm: string
308
+ ): WorkspaceTargetShape | null => {
309
+ const normalized = normalizeWorkspaceTarget(
310
+ targetRefNorm,
311
+ sourceWsFolderNorm
312
+ );
313
+ if (normalized === null || normalized.length === 0) return null;
314
+ const base = stripWikiMdExt(normalized);
315
+ // A relative target stays a path even when it normalizes to a root name.
316
+ const relative = RELATIVE_TARGET.test(targetRefNorm.trim());
317
+ return {
318
+ normalized,
319
+ base,
320
+ exact: new Set([base, `${base}.md`]),
321
+ relative,
322
+ hasPath: relative || normalized.includes("/"),
323
+ };
324
+ };
325
+
326
+ /**
327
+ * Match class of one same-basename file for a target, or null when it does
328
+ * not match: 0 exact workspace path, 1 exact path relative to the source's
329
+ * collection, 2 same folder as the source, 3 any other name or path-suffix
330
+ * match. Exact path classes apply to path targets only; a plain name ranks
331
+ * by folder and depth like any other same-named file. A relative target is
332
+ * already a full workspace path: only the exact workspace path matches.
333
+ */
334
+ const workspaceMatchClass = (
335
+ shape: WorkspaceTargetShape,
336
+ file: {
337
+ wsNorm: string;
338
+ wsFolderNorm: string;
339
+ /** Path relative to the source's collection root, when it is in it. */
340
+ sourceCollectionRelNorm: string | null;
341
+ sourceWsFolderNorm: string;
342
+ }
343
+ ): number | null => {
344
+ if (shape.hasPath && shape.exact.has(file.wsNorm)) return 0;
345
+ if (shape.relative) return null;
346
+ if (
347
+ shape.hasPath &&
348
+ file.sourceCollectionRelNorm !== null &&
349
+ shape.exact.has(file.sourceCollectionRelNorm)
350
+ ) {
351
+ return 1;
352
+ }
353
+ if (
354
+ !shape.hasPath ||
355
+ file.wsNorm.endsWith(`/${shape.base}`) ||
356
+ file.wsNorm.endsWith(`/${shape.base}.md`)
357
+ ) {
358
+ return file.wsFolderNorm === file.sourceWsFolderNorm ? 2 : 3;
359
+ }
360
+ return null;
361
+ };
362
+
363
+ interface RankedCandidate {
364
+ doc: CatalogDoc;
365
+ klass: number;
366
+ }
367
+
368
+ /**
369
+ * Collapse rows of one physical source (the same workspace path indexed by
370
+ * overlapping collections). Source identity is the exact, case-preserving
371
+ * workspace path: `Note.md` and `note.md` on a case-sensitive filesystem
372
+ * are two files and stay two candidates (a tie), never one. The
373
+ * representative keeps the best class, then the source's own collection,
374
+ * then the lexicographically first collection.
375
+ */
376
+ const distinctSources = (
377
+ ranked: RankedCandidate[],
378
+ sourceCollection: string
379
+ ): RankedCandidate[] => {
380
+ const preference = (left: RankedCandidate, right: RankedCandidate): number =>
381
+ left.klass - right.klass ||
382
+ Number(left.doc.collection !== sourceCollection) -
383
+ Number(right.doc.collection !== sourceCollection) ||
384
+ compareCodeUnits(left.doc.collection, right.doc.collection);
385
+ const bySource = new Map<string, RankedCandidate>();
386
+ for (const candidate of ranked) {
387
+ const key = `${candidate.doc.wsKey}\0${candidate.doc.path}`;
388
+ const existing = bySource.get(key);
389
+ if (!existing || preference(candidate, existing) < 0) {
390
+ bySource.set(key, candidate);
391
+ }
392
+ }
393
+ return [...bySource.values()];
394
+ };
395
+
396
+ const toCandidate = (doc: CatalogDoc): WorkspaceCandidate => ({
397
+ id: doc.id,
398
+ docid: doc.docid,
399
+ collection: doc.collection,
400
+ path: doc.path,
401
+ });
402
+
403
+ /**
404
+ * Rank one plain wiki target inside its source's workspace. Ranking tuple:
405
+ * workspace-path exact, collection-path exact, same-folder name, other name
406
+ * or path-suffix match; then fewest path segments; candidates still equal
407
+ * are tied (listed in canonical path order, no edge).
408
+ */
409
+ const rankTarget = (
410
+ catalog: WorkspaceCatalog,
411
+ wsKey: string,
412
+ source: { collection: string; wsFolderNorm: string },
413
+ targetRefNorm: string
414
+ ): WorkspaceTargetResolution | null => {
415
+ const shape = workspaceTargetShape(targetRefNorm, source.wsFolderNorm);
416
+ if (shape === null) return null;
417
+ const matches: RankedCandidate[] = [];
418
+ for (const doc of catalog.byBasename(wsKey, basenameKeys(shape.normalized))) {
419
+ const klass = workspaceMatchClass(shape, {
420
+ wsNorm: doc.wsNorm,
421
+ wsFolderNorm: doc.wsFolderNorm,
422
+ sourceCollectionRelNorm:
423
+ doc.collection === source.collection ? doc.relNorm : null,
424
+ sourceWsFolderNorm: source.wsFolderNorm,
425
+ });
426
+ if (klass !== null) matches.push({ doc, klass });
427
+ }
428
+ const distinct = distinctSources(matches, source.collection);
429
+ if (distinct.length > 0) {
430
+ distinct.sort(
431
+ (left, right) =>
432
+ left.klass - right.klass ||
433
+ left.doc.depth - right.doc.depth ||
434
+ compareCodeUnits(left.doc.wsNorm, right.doc.wsNorm) ||
435
+ compareCodeUnits(left.doc.path, right.doc.path)
436
+ );
437
+ const best = distinct[0]!;
438
+ const tied = distinct.filter(
439
+ (candidate) =>
440
+ candidate.klass === best.klass && candidate.doc.depth === best.doc.depth
441
+ );
442
+ const traversable = tied.length === 1;
443
+ const reason: WorkspaceResolutionReason =
444
+ best.klass === 0
445
+ ? "workspace-path"
446
+ : best.klass === 1
447
+ ? "collection-path"
448
+ : distinct.length === 1
449
+ ? "exact-name"
450
+ : "tie-break";
451
+ return {
452
+ target: toCandidate(best.doc),
453
+ reason,
454
+ traversable,
455
+ tied: traversable ? [] : tied.map(({ doc }) => toCandidate(doc)),
456
+ titleRank: null,
457
+ };
458
+ }
459
+ // Fallback: frontmatter title inside the source collection only; relative
460
+ // targets are paths and never fall back to titles.
461
+ if (shape.relative) return null;
462
+ for (const [rank, keys] of titleLookups(targetRefNorm)) {
463
+ const docs = catalog
464
+ .byTitleIn(source.collection, wsKey, keys)
465
+ .sort(
466
+ (left, right) =>
467
+ left.depth - right.depth ||
468
+ compareCodeUnits(left.wsNorm, right.wsNorm)
469
+ );
470
+ if (docs.length === 0) continue;
471
+ return {
472
+ target: toCandidate(docs[0]!),
473
+ reason: "title",
474
+ traversable: docs.length === 1,
475
+ tied: docs.length === 1 ? [] : docs.map(toCandidate),
476
+ titleRank: rank,
477
+ };
478
+ }
479
+ return null;
480
+ };
481
+
482
+ /** Membership map from collection rows (as returned by getCollections). */
483
+ export const membershipsFromCollectionRows = (
484
+ rows: ReadonlyArray<{
485
+ name: string;
486
+ realPath?: string | null;
487
+ workspaceRoot?: string | null;
488
+ workspaceSource?: LinkWorkspaceSource;
489
+ workspaceNested?: string[];
490
+ }>
491
+ ): Map<string, CollectionWorkspaceMembership> =>
492
+ new Map(
493
+ rows.map((row) => [
494
+ row.name,
495
+ {
496
+ collection: row.name,
497
+ realPath: row.realPath ?? null,
498
+ root: row.workspaceRoot ?? null,
499
+ source: row.workspaceSource ?? "none",
500
+ nested: row.workspaceNested ?? [],
501
+ },
502
+ ])
503
+ );
504
+
505
+ /**
506
+ * Same ranking over an in-memory document list (graph projection). Returns
507
+ * `undefined` when the source is not in a link workspace (caller keeps its
508
+ * collection-scoped contract), `null` when nothing matched.
509
+ */
510
+ export const createInMemoryWorkspaceResolver = (
511
+ memberships: ReadonlyMap<string, CollectionWorkspaceMembership>,
512
+ documents: ReadonlyArray<{
513
+ id: number;
514
+ docid: string;
515
+ collection: string;
516
+ relPath: string;
517
+ title: string | null;
518
+ }>
519
+ ): ((
520
+ source: WorkspaceLinkSource,
521
+ targetRefNorm: string
522
+ ) => WorkspaceTargetResolution | null | undefined) => {
523
+ const anyWorkspace = [...memberships.values()].some(
524
+ (membership) =>
525
+ membership.realPath !== null &&
526
+ membership.source !== "disabled" &&
527
+ membership.source !== "unavailable" &&
528
+ (membership.root !== null || membership.nested.length > 0)
529
+ );
530
+ if (!anyWorkspace) return () => undefined;
531
+ const catalog = new WorkspaceCatalog(memberships);
532
+ for (const document of documents) {
533
+ catalog.add({
534
+ id: document.id,
535
+ docid: document.docid,
536
+ collection: document.collection,
537
+ rel_path: document.relPath,
538
+ // Mirrors SQLite lower(trim(title)) used by the SQL candidate paths.
539
+ title_norm:
540
+ typeof document.title === "string"
541
+ ? asciiLower(document.title.trim())
542
+ : null,
543
+ });
544
+ }
545
+ return (source, targetRefNorm) => {
546
+ const placement = placeDocument(
547
+ memberships.get(source.collection),
548
+ source.relPath
549
+ );
550
+ if (placement.key === null) return undefined;
551
+ return rankTarget(
552
+ catalog,
553
+ placement.key,
554
+ {
555
+ collection: source.collection,
556
+ wsFolderNorm: folderOf(asciiLower(placement.path)),
557
+ },
558
+ targetRefNorm
559
+ );
560
+ };
561
+ };
562
+
563
+ /**
564
+ * Existence matcher over workspace files that are not indexed documents
565
+ * (attachments, notes in unindexed or excluded folders). A target matches a
566
+ * file under the same rules as an indexed note: basename with `.md` optional
567
+ * (so a non-Markdown target needs its extension), exact or suffix path for
568
+ * path targets, exact workspace path for `./` and `../` targets. The path
569
+ * relative to the source's collection needs no separate check here: that
570
+ * file's workspace path equals the target or ends with `/target`. File names
571
+ * fold case fully, as Obsidian matches link targets case-insensitively.
572
+ */
573
+ export const createWorkspaceFileMatcher = (
574
+ workspacePaths: Iterable<string>
575
+ ): ((targetRefNorm: string, sourceWsPath: string) => boolean) => {
576
+ const byBase = new Map<string, Array<{ wsNorm: string; folder: string }>>();
577
+ for (const path of workspacePaths) {
578
+ const wsNorm = path.normalize("NFC").toLowerCase();
579
+ const base = lastSegment(wsNorm);
580
+ const files = byBase.get(base) ?? [];
581
+ files.push({ wsNorm, folder: folderOf(wsNorm) });
582
+ byBase.set(base, files);
583
+ }
584
+ return (targetRefNorm, sourceWsPath) => {
585
+ const sourceWsFolderNorm = folderOf(
586
+ sourceWsPath.normalize("NFC").toLowerCase()
587
+ );
588
+ const shape = workspaceTargetShape(targetRefNorm, sourceWsFolderNorm);
589
+ if (shape === null) return false;
590
+ return basenameKeys(shape.normalized).some((key) =>
591
+ (byBase.get(key) ?? []).some(
592
+ (file) =>
593
+ workspaceMatchClass(shape, {
594
+ wsNorm: file.wsNorm,
595
+ wsFolderNorm: file.folder,
596
+ sourceCollectionRelNorm: null,
597
+ sourceWsFolderNorm,
598
+ }) !== null
599
+ )
600
+ );
601
+ };
602
+ };
603
+
604
+ export interface WorkspaceTargetInput {
605
+ targetRefNorm: string;
606
+ source: WorkspaceLinkSource;
607
+ wsKey: string;
608
+ }
609
+
610
+ const MAX_SQL_PARAMS = 900;
611
+ const CATALOG_COLUMNS =
612
+ "id, docid, collection, rel_path, lower(trim(title)) AS title_norm";
613
+ /** SQLite basename of rel_path (text after the last '/'). */
614
+ const BASENAME_SQL =
615
+ "substr(rel_path, length(rtrim(rel_path, replace(rel_path, '/', ''))) + 1)";
616
+
617
+ const chunk = <T>(items: readonly T[], size: number): T[][] => {
618
+ const chunks: T[][] = [];
619
+ for (let offset = 0; offset < items.length; offset += size) {
620
+ chunks.push(items.slice(offset, offset + size));
621
+ }
622
+ return chunks;
623
+ };
624
+
625
+ const loadRows = (
626
+ db: Database,
627
+ collections: readonly string[],
628
+ filter: { sql: string; values: string[] } | null
629
+ ): CatalogRow[] => {
630
+ if (collections.length === 0) return [];
631
+ const rows: CatalogRow[] = [];
632
+ const collectionPlaceholders = collections.map(() => "?").join(",");
633
+ const valueChunks = filter
634
+ ? chunk(filter.values, Math.max(1, MAX_SQL_PARAMS - collections.length))
635
+ : [[]];
636
+ for (const values of valueChunks) {
637
+ if (filter && values.length === 0) continue;
638
+ rows.push(
639
+ ...db
640
+ .query<CatalogRow, string[]>(
641
+ `SELECT ${CATALOG_COLUMNS} FROM documents
642
+ WHERE active = 1 AND collection IN (${collectionPlaceholders})
643
+ ${filter ? `AND ${filter.sql} IN (${values.map(() => "?").join(",")})` : ""}
644
+ ORDER BY id`
645
+ )
646
+ .all(...collections, ...values)
647
+ );
648
+ }
649
+ return rows;
650
+ };
651
+
652
+ /** Both Unicode forms so a NFD file name still reaches the NFC ranking. */
653
+ const withUnicodeForms = (values: Iterable<string>): string[] => {
654
+ const output = new Set<string>();
655
+ for (const value of values) {
656
+ output.add(value);
657
+ output.add(value.normalize("NFD"));
658
+ }
659
+ return [...output].sort();
660
+ };
661
+
662
+ /**
663
+ * Resolve workspace targets. `bulk` loads every document of the involved
664
+ * workspaces once; otherwise a SQL basename/title prefilter fetches only
665
+ * potential candidates. Both paths rank identical rows identically.
666
+ */
667
+ export const resolveWorkspaceTargets = (
668
+ db: Database,
669
+ memberships: ReadonlyMap<string, CollectionWorkspaceMembership>,
670
+ targets: readonly WorkspaceTargetInput[],
671
+ options: { bulk: boolean }
672
+ ): Array<WorkspaceTargetResolution | null> => {
673
+ if (targets.length === 0) return [];
674
+ const wsKeys = new Set(targets.map((target) => target.wsKey));
675
+ const collections = workspaceMemberCollections(memberships, wsKeys);
676
+ const catalog = new WorkspaceCatalog(memberships);
677
+ const seen = new Set<number>();
678
+ const addRows = (rows: CatalogRow[]) => {
679
+ for (const row of rows) {
680
+ if (seen.has(row.id)) continue;
681
+ seen.add(row.id);
682
+ catalog.add(row);
683
+ }
684
+ };
685
+ if (options.bulk) {
686
+ addRows(loadRows(db, collections, null));
687
+ } else {
688
+ const bases = new Set<string>();
689
+ const titles = new Set<string>();
690
+ for (const target of targets) {
691
+ const normalized = normalizeWorkspaceTarget(target.targetRefNorm, "");
692
+ const raw = target.targetRefNorm.trim();
693
+ for (const value of [normalized, raw]) {
694
+ if (!value) continue;
695
+ for (const key of basenameKeys(value)) bases.add(key);
696
+ }
697
+ for (const [, keys] of titleLookups(target.targetRefNorm)) {
698
+ for (const key of keys) titles.add(key);
699
+ }
700
+ }
701
+ addRows(
702
+ loadRows(db, collections, {
703
+ sql: `lower(${BASENAME_SQL})`,
704
+ values: withUnicodeForms(bases),
705
+ })
706
+ );
707
+ addRows(
708
+ loadRows(db, collections, {
709
+ sql: "lower(trim(title))",
710
+ values: [...titles].sort(),
711
+ })
712
+ );
713
+ }
714
+ const cache = new Map<string, WorkspaceTargetResolution | null>();
715
+ const folders = new Map<string, string>();
716
+ return targets.map((target) => {
717
+ const sourceKey = `${target.source.collection}\0${target.source.relPath}`;
718
+ let wsFolderNorm = folders.get(sourceKey);
719
+ if (wsFolderNorm === undefined) {
720
+ wsFolderNorm = folderOf(
721
+ asciiLower(
722
+ placeDocument(
723
+ memberships.get(target.source.collection),
724
+ target.source.relPath
725
+ ).path
726
+ )
727
+ );
728
+ folders.set(sourceKey, wsFolderNorm);
729
+ }
730
+ // Resolution depends on workspace, source collection and folder only.
731
+ const cacheKey = `${target.wsKey}\0${target.source.collection}\0${wsFolderNorm}\0${target.targetRefNorm}`;
732
+ if (cache.has(cacheKey)) return cache.get(cacheKey) ?? null;
733
+ const result = rankTarget(
734
+ catalog,
735
+ target.wsKey,
736
+ { collection: target.source.collection, wsFolderNorm },
737
+ target.targetRefNorm
738
+ );
739
+ cache.set(cacheKey, result);
740
+ return result;
741
+ });
742
+ };