@gmickel/gno 2.7.1 → 2.8.0

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 (76) hide show
  1. package/README.md +3 -2
  2. package/assets/skill/SKILL.md +8 -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/spa-production.json.gz +0 -0
  7. package/browser-extension/artifacts/{gno-browser-clipper-v2.7.1.zip → gno-browser-clipper-v2.8.0.zip} +0 -0
  8. package/browser-extension/artifacts/gno-browser-clipper-v2.8.0.zip.sha256 +1 -0
  9. package/browser-extension/dist/manifest.json +1 -1
  10. package/package.json +1 -1
  11. package/spec/cli.md +48 -6
  12. package/spec/db/schema.sql +0 -1
  13. package/spec/mcp.md +18 -3
  14. package/spec/output-schemas/audit-report.schema.json +18 -4
  15. package/spec/output-schemas/backlinks.schema.json +4 -0
  16. package/spec/output-schemas/collection-list.schema.json +13 -0
  17. package/spec/output-schemas/graph.schema.json +2 -0
  18. package/spec/output-schemas/links-list.schema.json +4 -0
  19. package/spec/output-schemas/status.schema.json +15 -0
  20. package/src/cli/commands/audit.ts +23 -4
  21. package/src/cli/commands/collection/list.ts +39 -5
  22. package/src/cli/commands/embed.ts +3 -3
  23. package/src/cli/commands/links.ts +34 -131
  24. package/src/cli/commands/shared.ts +7 -0
  25. package/src/cli/commands/status.ts +5 -0
  26. package/src/cli/program.ts +12 -2
  27. package/src/config/loader.ts +43 -0
  28. package/src/config/types.ts +8 -0
  29. package/src/core/audit-contract.ts +16 -4
  30. package/src/core/audit-freshness.ts +11 -1
  31. package/src/core/audit-links.ts +145 -25
  32. package/src/core/audit-provenance.ts +11 -4
  33. package/src/core/audit-workspace.ts +19 -4
  34. package/src/core/audit.ts +67 -15
  35. package/src/core/context-compiler.ts +3 -0
  36. package/src/core/context-evidence.ts +11 -0
  37. package/src/core/graph-edge-confidence.ts +23 -1
  38. package/src/core/host-paths.ts +1 -0
  39. package/src/core/knowledge-impact.ts +28 -0
  40. package/src/core/link-workspace.ts +324 -0
  41. package/src/core/retrieval-replay-candidate.ts +6 -0
  42. package/src/core/retrieval-trace-request.ts +3 -0
  43. package/src/index.ts +14 -1
  44. package/src/ingestion/graph-reconciliation.ts +77 -15
  45. package/src/ingestion/source-availability/darwin-path.ts +9 -3
  46. package/src/ingestion/sync.ts +22 -1
  47. package/src/ingestion/types.ts +14 -0
  48. package/src/llm/inference-scope.ts +4 -3
  49. package/src/mcp/http-egress.ts +42 -3
  50. package/src/mcp/tools/audit.ts +11 -2
  51. package/src/mcp/tools/changes.ts +1 -0
  52. package/src/mcp/tools/links.ts +3 -0
  53. package/src/mcp/tools/sessions.ts +33 -4
  54. package/src/mcp/tools/status.ts +3 -0
  55. package/src/pipeline/expansion.ts +19 -31
  56. package/src/pipeline/graph-retrieval.ts +22 -2
  57. package/src/pipeline/hybrid.ts +1 -1
  58. package/src/pipeline/types.ts +6 -3
  59. package/src/serve/findings-pass.ts +1 -1
  60. package/src/serve/public/pages/GraphView.tsx +2 -0
  61. package/src/serve/routes/changes.ts +6 -1
  62. package/src/serve/routes/links.ts +13 -0
  63. package/src/serve/routes/sessions.ts +41 -53
  64. package/src/sessions/config-refresh.ts +111 -0
  65. package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
  66. package/src/store/migrations/034-collection-link-workspace.ts +47 -0
  67. package/src/store/migrations/index.ts +4 -0
  68. package/src/store/sqlite/adapter.ts +390 -231
  69. package/src/store/sqlite/eligibility.ts +8 -2
  70. package/src/store/sqlite/graph-link-resolver.ts +252 -5
  71. package/src/store/sqlite/graph-neighbors.ts +147 -40
  72. package/src/store/sqlite/graph-reference-state.ts +13 -2
  73. package/src/store/sqlite/workspace-link-resolver.ts +654 -0
  74. package/src/store/types.ts +49 -3
  75. package/src/store/vector/stats.ts +1 -1
  76. 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
+ };
@@ -303,6 +303,12 @@ export const runRetrievalReplayCandidate = async (
303
303
  retrievalScope
304
304
  );
305
305
  options.limit = scope.value.fetchLimit;
306
+ const scopedCollections = scope.value.collections.filter(
307
+ (name): name is string => name !== undefined
308
+ );
309
+ if (scopedCollections.length > 0) {
310
+ options.graphCollections = scopedCollections;
311
+ }
306
312
  const result = await runCandidateOnce(deps, source, candidate, options);
307
313
  if (!result.ok) return result;
308
314
  outputs.push(result.value);
@@ -8,6 +8,7 @@ import type { RetrievalTraceTerminalStatus } from "../store/types";
8
8
 
9
9
  import { canonicalTraceJson } from "../store/retrieval-trace-codec";
10
10
  import { err, ok } from "../store/types";
11
+ import { linkResolutionFingerprintInput } from "./link-workspace";
11
12
  import { RetrievalTraceSession } from "./retrieval-trace-session";
12
13
 
13
14
  export const retrievalTraceFailureStatus = (
@@ -45,6 +46,7 @@ export const buildRetrievalTraceFingerprints = async (input: {
45
46
  }): Promise<RetrievalTraceFingerprints> => {
46
47
  const collections = await input.store.getCollections();
47
48
  if (!collections.ok) throw new Error(collections.error.message);
49
+ const linkResolution = linkResolutionFingerprintInput(collections.value);
48
50
  const snapshots = [];
49
51
  for (const collection of [...collections.value].sort((left, right) =>
50
52
  left.name.localeCompare(right.name)
@@ -70,6 +72,7 @@ export const buildRetrievalTraceFingerprints = async (input: {
70
72
  index: fingerprint({
71
73
  indexName: input.indexName ?? "default",
72
74
  snapshots,
75
+ ...(linkResolution ? { linkResolution } : {}),
73
76
  }),
74
77
  };
75
78
  };
package/src/index.ts CHANGED
@@ -18,14 +18,27 @@ import { IMPORT_CHILD_ENV } from "./sessions/import-child-env";
18
18
  * is the flush that actually waits. A closed consumer (EPIPE) settles
19
19
  * through the callback or the 'error' event.
20
20
  */
21
+ const pendingFlushes = new WeakMap<NodeJS.WriteStream, Promise<void>>();
22
+
23
+ /**
24
+ * Concurrent exit paths (normal completion racing a SIGINT) share one flush:
25
+ * a second caller must wait for the drain the first end() started, not treat
26
+ * the already-ended stream as flushed and exit mid-write.
27
+ */
21
28
  function flushStream(stream: NodeJS.WriteStream): Promise<void> {
29
+ const pending = pendingFlushes.get(stream);
30
+ if (pending) {
31
+ return pending;
32
+ }
22
33
  if (stream.destroyed || stream.writableEnded) {
23
34
  return Promise.resolve();
24
35
  }
25
- return new Promise((resolve) => {
36
+ const flush = new Promise<void>((resolve) => {
26
37
  stream.once("error", () => resolve());
27
38
  stream.end(() => resolve());
28
39
  });
40
+ pendingFlushes.set(stream, flush);
41
+ return flush;
29
42
  }
30
43
 
31
44
  /**
@@ -13,15 +13,25 @@ import {
13
13
  normalizeRelationEdgeType,
14
14
  normalizeRelationTarget,
15
15
  } from "../core/change-diff";
16
+ import { LINK_RESOLVER_VERSION } from "../core/link-workspace";
16
17
  import {
17
18
  normalizeMarkdownPath,
18
19
  normalizeWikiName,
19
20
  parseTargetParts,
20
21
  } from "../core/links";
22
+ import {
23
+ createInMemoryWorkspaceResolver,
24
+ membershipsFromCollectionRows,
25
+ } from "../store/sqlite/workspace-link-resolver";
21
26
  import { parseFrontmatter } from "./frontmatter";
22
27
 
23
- const VERSION = 1;
28
+ /**
29
+ * Projection version. 2: workspace-wide wiki resolution; a stored version
30
+ * mismatch forces one full projection on the first sync after upgrade.
31
+ */
32
+ const VERSION = 2;
24
33
  const EDGE_TYPE = /^[a-z][a-z0-9_]*$/;
34
+ const RELATIVE_REF = /^\.\.?\//;
25
35
  type ProjectionError = { relPath: string; code: string; message: string };
26
36
 
27
37
  function identity(doc: DocumentRow): GraphReferenceDocument {
@@ -38,8 +48,27 @@ function identity(doc: DocumentRow): GraphReferenceDocument {
38
48
  };
39
49
  }
40
50
 
41
- /** Insert once in catalog order: preserves legacy Array.find ambiguity precedence. */
42
- function relationResolver(docs: GraphReferenceDocument[]) {
51
+ /**
52
+ * Insert once in catalog order: preserves legacy Array.find ambiguity precedence.
53
+ * Plain wiki-style targets from a document inside a link workspace use the
54
+ * shared workspace ranking (a tie resolves to nothing); URIs, docids,
55
+ * collection-qualified paths and relative markdown keep their contracts.
56
+ */
57
+ function relationResolver(
58
+ docs: GraphReferenceDocument[],
59
+ memberships: ReturnType<typeof membershipsFromCollectionRows>
60
+ ) {
61
+ const workspace = createInMemoryWorkspaceResolver(
62
+ memberships,
63
+ docs.map((doc) => ({
64
+ id: doc.documentId,
65
+ docid: doc.docid,
66
+ collection: doc.collection,
67
+ relPath: doc.relPath,
68
+ title: doc.title,
69
+ }))
70
+ );
71
+ const byId = new Map(docs.map((doc) => [doc.documentId, doc]));
43
72
  const docids = new Map<string, GraphReferenceDocument>();
44
73
  const uris = new Map<string, GraphReferenceDocument>();
45
74
  const paths = new Map<string, GraphReferenceDocument>();
@@ -84,14 +113,27 @@ function relationResolver(docs: GraphReferenceDocument[]) {
84
113
  localWiki.get(`${parts.collection}\0${key}`)
85
114
  );
86
115
  const relativePath = normalizeMarkdownPath(parts.ref, source.relPath);
87
- return (
88
- (relativePath
89
- ? paths.get(`${source.collection}/${relativePath}`)
90
- : undefined) ??
91
- paths.get(parts.ref) ??
92
- localWiki.get(`${source.collection}\0${key}`) ??
93
- wiki.get(key)
94
- );
116
+ const relativeHit = relativePath
117
+ ? paths.get(`${source.collection}/${relativePath}`)
118
+ : undefined;
119
+ const ranked = workspace(source, key);
120
+ if (ranked === undefined) {
121
+ // Source outside any link workspace: the collection-scoped contract.
122
+ return (
123
+ relativeHit ??
124
+ paths.get(parts.ref) ??
125
+ localWiki.get(`${source.collection}\0${key}`) ??
126
+ wiki.get(key)
127
+ );
128
+ }
129
+ // Inside a workspace only the preserved contracts bypass the shared
130
+ // ranking: a collection-qualified path (`collection/relPath`) and an
131
+ // explicitly relative Markdown path (`./x.md`, `../x.md`). Every other
132
+ // name or path resolves exactly as the shared link resolver does.
133
+ const qualified = paths.get(parts.ref);
134
+ if (qualified) return qualified;
135
+ if (RELATIVE_REF.test(parts.ref) && relativeHit) return relativeHit;
136
+ return ranked?.traversable ? byId.get(ranked.target.id) : undefined;
95
137
  };
96
138
  }
97
139
 
@@ -105,12 +147,23 @@ export async function projectGraph(
105
147
  try {
106
148
  const graph = store.graphReferenceStore?.();
107
149
  let fingerprint = "";
150
+ // Partial ports (tests, adapters without collections) resolve links
151
+ // collection-scoped; the SQLite store always reports memberships.
152
+ const collections =
153
+ typeof store.getCollections === "function"
154
+ ? await store.getCollections()
155
+ : undefined;
156
+ if (collections && !collections.ok)
157
+ throw new Error(collections.error.message);
158
+ const memberships = membershipsFromCollectionRows(collections?.value ?? []);
108
159
  if (graph) {
109
- const collections = await store.getCollections();
110
- if (!collections.ok) throw new Error(collections.error.message);
160
+ if (!collections) throw new Error("Graph projection needs collections");
161
+ // Effective workspace membership (roots, nested vaults, real paths) is
162
+ // part of each collection row, so a membership change re-projects.
111
163
  fingerprint = new Bun.CryptoHasher("sha256")
112
164
  .update(
113
165
  JSON.stringify({
166
+ resolver: LINK_RESOLVER_VERSION,
114
167
  rules: options.contentTypeRules ?? [],
115
168
  collections: collections.value
116
169
  .map(({ syncedAt: _syncedAt, ...config }) => config)
@@ -138,7 +191,15 @@ export async function projectGraph(
138
191
  state.inProgress ||
139
192
  state.version !== VERSION ||
140
193
  state.configFingerprint !== fingerprint;
141
- const resolve = relationResolver(current);
194
+ // An existing graph whose resolver or collection membership changed is
195
+ // rebuilt in full; report it so the fallback is never silent.
196
+ if (!forceFull && state && state.version !== null) {
197
+ if (state.version !== VERSION)
198
+ options.onGraphRebuild?.("resolver-upgrade");
199
+ else if (state.configFingerprint !== fingerprint)
200
+ options.onGraphRebuild?.("collections-changed");
201
+ }
202
+ const resolve = relationResolver(current, memberships);
142
203
  let selected: Set<number> | undefined;
143
204
  if (!full && graph) {
144
205
  selected = new Set(requestedSources);
@@ -165,7 +226,8 @@ export async function projectGraph(
165
226
  ))
166
227
  selected.add(id);
167
228
  const resolveOld = relationResolver(
168
- previous.map((row) => row.document)
229
+ previous.map((row) => row.document),
230
+ memberships
169
231
  );
170
232
  for (const row of previous) {
171
233
  const source = currentById.get(row.document.documentId);
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Metadata-free recognition of the macOS File Provider layouts covered by the
3
- * physical fn-118 evidence. This is intentionally narrower than all paths on
3
+ * physical evidence in research/file-provider/ (fn-118; Google Shared drives in
4
+ * fn-179). This is intentionally narrower than all paths on
4
5
  * Darwin: unknown storage must not inherit a no-materialization guarantee.
5
6
  */
6
7
 
@@ -43,8 +44,13 @@ export function classifyDarwinFileProviderPath(
43
44
  return "unsupported";
44
45
  }
45
46
  const domain = parts[2];
46
- if (domain?.startsWith("GoogleDrive-") && parts[3] === "My Drive") {
47
- return "google-drive";
47
+ if (domain?.startsWith("GoogleDrive-")) {
48
+ const isMyDrive = parts[3] === "My Drive";
49
+ const isSharedDrive =
50
+ parts[3] === "Shared drives" &&
51
+ typeof parts[4] === "string" &&
52
+ parts[4].length > 0;
53
+ return isMyDrive || isSharedDrive ? "google-drive" : "unsupported";
48
54
  }
49
55
  if (
50
56
  domain?.startsWith("OneDrive-") &&
@@ -2069,6 +2069,19 @@ export class SyncService {
2069
2069
  }
2070
2070
  }
2071
2071
 
2072
+ // Nested vaults (their own `.obsidian/`) are discovered from the synced
2073
+ // document directories; a change re-fingerprints the graph projection.
2074
+ const nestedRefresh = await store.refreshCollectionNestedWorkspaces?.(
2075
+ collection.name
2076
+ );
2077
+ if (nestedRefresh && !nestedRefresh.ok) {
2078
+ errors.push({
2079
+ relPath: "(link workspace)",
2080
+ code: nestedRefresh.error.code,
2081
+ message: nestedRefresh.error.message,
2082
+ });
2083
+ }
2084
+
2072
2085
  if (syncOptions.projectTypedEdges !== false) {
2073
2086
  errors.push(...(await this.projectTypedEdges(store, syncOptions)));
2074
2087
  }
@@ -2123,8 +2136,15 @@ export class SyncService {
2123
2136
  results.push(result);
2124
2137
  }
2125
2138
 
2139
+ let graphRebuild: SyncResult["graphRebuild"];
2126
2140
  if (results.length > 0) {
2127
- const projectionErrors = await this.projectTypedEdges(store, options);
2141
+ const projectionErrors = await this.projectTypedEdges(store, {
2142
+ ...options,
2143
+ onGraphRebuild: (reason) => {
2144
+ graphRebuild = reason;
2145
+ options.onGraphRebuild?.(reason);
2146
+ },
2147
+ });
2128
2148
  results.at(-1)?.errors.push(...projectionErrors);
2129
2149
  }
2130
2150
 
@@ -2145,6 +2165,7 @@ export class SyncService {
2145
2165
  ...(prepared.rechunkedMirrors
2146
2166
  ? { rechunkedMirrors: prepared.rechunkedMirrors }
2147
2167
  : {}),
2168
+ ...(graphRebuild ? { graphRebuild } : {}),
2148
2169
  totalDurationMs: Date.now() - startTime,
2149
2170
  totalFilesProcessed: totals.processed,
2150
2171
  totalFilesAdded: totals.added,
@@ -161,7 +161,19 @@ export interface ChunkerPort {
161
161
  // ─────────────────────────────────────────────────────────────────────────────
162
162
 
163
163
  /** Sync options */
164
+ /** Why a sync rebuilt the whole link graph instead of reconciling it. */
165
+ export type GraphRebuildReason =
166
+ /** Link resolution semantics changed (first sync after an upgrade). */
167
+ | "resolver-upgrade"
168
+ /** Collection settings changed, including link workspace membership. */
169
+ | "collections-changed";
170
+
164
171
  export interface SyncOptions {
172
+ /**
173
+ * Internal: called when graph reconciliation cannot proceed incrementally
174
+ * and falls back to a full projection of an existing graph.
175
+ */
176
+ onGraphRebuild?: (reason: GraphRebuildReason) => void;
165
177
  /** Index-wide configured policy; omitted means the existing defaults. */
166
178
  chunking?: Partial<ChunkingParams>;
167
179
  /** Internal token passed from an outer sync; never a public CLI override. */
@@ -298,6 +310,8 @@ export interface CollectionSyncResult {
298
310
  export interface SyncResult {
299
311
  /** Cached layouts updated independently of source-refresh file counters. */
300
312
  rechunkedMirrors?: number;
313
+ /** Present when the link graph was rebuilt in full rather than reconciled. */
314
+ graphRebuild?: GraphRebuildReason;
301
315
  collections: CollectionSyncResult[];
302
316
  totalDurationMs: number;
303
317
  totalFilesProcessed: number;
@@ -169,9 +169,10 @@ export function withOwnedInferenceScope<T>(
169
169
  }
170
170
 
171
171
  /**
172
- * One page of a background pass: an inference deadline inside it fails only
173
- * that page (undefined), leaving the pass scope active for later pages.
174
- * Cancellation, or a deadline of the enclosing scope itself, still throws.
172
+ * Best-effort inference (one page of a background pass, or query expansion):
173
+ * an inference deadline inside it fails only this operation (undefined),
174
+ * leaving the enclosing scope active. Cancellation, or a deadline of the
175
+ * enclosing scope itself, still throws.
175
176
  */
176
177
  export async function withInferencePage<T>(
177
178
  operation: () => Promise<T>