@gmickel/gno 2.7.0 → 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 (99) hide show
  1. package/README.md +26 -30
  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.0.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 +2 -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 +15 -2
  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 +27 -16
  20. package/src/cli/commands/ask.ts +31 -12
  21. package/src/cli/commands/audit.ts +23 -4
  22. package/src/cli/commands/collection/list.ts +39 -5
  23. package/src/cli/commands/embed.ts +3 -3
  24. package/src/cli/commands/links.ts +34 -131
  25. package/src/cli/commands/ls.ts +6 -1
  26. package/src/cli/commands/shared.ts +7 -0
  27. package/src/cli/commands/status.ts +5 -0
  28. package/src/cli/program.ts +12 -2
  29. package/src/config/loader.ts +43 -0
  30. package/src/config/types.ts +8 -0
  31. package/src/core/audit-contract.ts +16 -4
  32. package/src/core/audit-freshness.ts +11 -1
  33. package/src/core/audit-links.ts +145 -25
  34. package/src/core/audit-provenance.ts +11 -4
  35. package/src/core/audit-workspace.ts +19 -4
  36. package/src/core/audit.ts +67 -15
  37. package/src/core/context-compiler.ts +3 -0
  38. package/src/core/context-evidence.ts +11 -0
  39. package/src/core/graph-edge-confidence.ts +23 -1
  40. package/src/core/host-paths.ts +24 -5
  41. package/src/core/knowledge-impact.ts +28 -0
  42. package/src/core/link-workspace.ts +324 -0
  43. package/src/core/request-receipts.ts +63 -9
  44. package/src/core/retrieval-replay-candidate.ts +6 -0
  45. package/src/core/retrieval-trace-request.ts +3 -0
  46. package/src/core/windows-private-path.ts +136 -1
  47. package/src/embed/backlog.ts +22 -10
  48. package/src/embed/variant-backlog.ts +34 -9
  49. package/src/index.ts +14 -1
  50. package/src/ingestion/graph-reconciliation.ts +77 -15
  51. package/src/ingestion/source-availability/darwin-path.ts +9 -3
  52. package/src/ingestion/sync.ts +22 -1
  53. package/src/ingestion/types.ts +14 -0
  54. package/src/llm/inference-scope.ts +19 -0
  55. package/src/mcp/http-egress.ts +42 -3
  56. package/src/mcp/tools/audit.ts +11 -2
  57. package/src/mcp/tools/changes.ts +1 -0
  58. package/src/mcp/tools/links.ts +3 -0
  59. package/src/mcp/tools/sessions.ts +33 -4
  60. package/src/mcp/tools/status.ts +22 -6
  61. package/src/pipeline/expansion.ts +19 -31
  62. package/src/pipeline/graph-retrieval.ts +22 -2
  63. package/src/pipeline/hybrid.ts +1 -1
  64. package/src/pipeline/types.ts +6 -3
  65. package/src/serve/embed-scheduler.ts +2 -2
  66. package/src/serve/findings-pass.ts +1 -1
  67. package/src/serve/host-path-redaction.ts +51 -14
  68. package/src/serve/public/components/BootstrapStatus.tsx +5 -3
  69. package/src/serve/public/components/CaptureModal.tsx +1 -1
  70. package/src/serve/public/components/CollectionModelDialog.tsx +16 -13
  71. package/src/serve/public/components/CollectionsEmptyState.tsx +5 -3
  72. package/src/serve/public/components/FirstRunWizard.tsx +4 -2
  73. package/src/serve/public/components/sessions/SourcesPanel.tsx +77 -60
  74. package/src/serve/public/pages/Collections.tsx +16 -13
  75. package/src/serve/public/pages/Connectors.tsx +7 -4
  76. package/src/serve/public/pages/Dashboard.tsx +8 -6
  77. package/src/serve/public/pages/GraphView.tsx +2 -0
  78. package/src/serve/resident-runtime.ts +7 -0
  79. package/src/serve/routes/changes.ts +6 -1
  80. package/src/serve/routes/links.ts +13 -0
  81. package/src/serve/routes/sessions.ts +81 -37
  82. package/src/serve/server.ts +5 -3
  83. package/src/serve/status-model.ts +10 -6
  84. package/src/serve/status.ts +2 -7
  85. package/src/sessions/config-refresh.ts +111 -0
  86. package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
  87. package/src/store/migrations/034-collection-link-workspace.ts +47 -0
  88. package/src/store/migrations/index.ts +4 -0
  89. package/src/store/sqlite/adapter.ts +392 -233
  90. package/src/store/sqlite/change-journal-store.ts +1 -1
  91. package/src/store/sqlite/eligibility.ts +8 -2
  92. package/src/store/sqlite/graph-link-resolver.ts +252 -5
  93. package/src/store/sqlite/graph-neighbors.ts +147 -40
  94. package/src/store/sqlite/graph-reference-state.ts +13 -2
  95. package/src/store/sqlite/legacy-vector-ownership.ts +2 -1
  96. package/src/store/sqlite/workspace-link-resolver.ts +654 -0
  97. package/src/store/types.ts +49 -3
  98. package/src/store/vector/stats.ts +1 -1
  99. package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +0 -1
@@ -40,6 +40,7 @@ import {
40
40
  import { planContextEvidence } from "./context-compiler";
41
41
  import { projectContextEvidenceMetadata } from "./context-evidence-metadata";
42
42
  import { createEgressLineage, resolveEgressLineage } from "./egress-provenance";
43
+ import { linkResolutionFingerprintInput } from "./link-workspace";
43
44
  import { projectRecordEvidenceMetadata } from "./record-metadata";
44
45
  import {
45
46
  extractInclusiveLines,
@@ -205,6 +206,13 @@ const canonicalIndexSnapshot = (
205
206
  ),
206
207
  });
207
208
 
209
+ const linkResolutionEntry = (
210
+ rows: Parameters<typeof linkResolutionFingerprintInput>[0]
211
+ ): { linkResolution?: ReturnType<typeof linkResolutionFingerprintInput> } => {
212
+ const linkResolution = linkResolutionFingerprintInput(rows);
213
+ return linkResolution ? { linkResolution } : {};
214
+ };
215
+
208
216
  /** Capture one strict, content-free index/context snapshot before or after work. */
209
217
  export const captureContextEvidenceSnapshot = async (
210
218
  store: ContextEvidenceStore,
@@ -284,6 +292,9 @@ export const captureContextEvidenceSnapshot = async (
284
292
  indexFingerprint: hashJson({
285
293
  snapshots: indexSnapshots,
286
294
  egressLineage,
295
+ // Link workspace membership decides graph neighbours; a membership or
296
+ // resolver change is index drift. Absent without any workspace.
297
+ ...linkResolutionEntry(collectionRows),
287
298
  }),
288
299
  };
289
300
  };
@@ -17,8 +17,30 @@ export const GRAPH_EDGE_CONFIDENCE_RANK: Record<GraphEdgeConfidence, number> = {
17
17
  export function classifyResolvedGraphEdge(
18
18
  linkType: "wiki" | "markdown",
19
19
  matchRank: number | null,
20
- matchCount: number | null
20
+ matchCount: number | null,
21
+ /** Workspace resolution reason; semantic, independent of legacy ranks. */
22
+ reason?: string
21
23
  ): { confidence: GraphEdgeConfidence; audit: GraphEdgeAudit } {
24
+ if (linkType === "wiki" && reason !== undefined && reason !== "title") {
25
+ switch (reason) {
26
+ case "workspace-path":
27
+ case "collection-path":
28
+ return {
29
+ confidence: "explicit",
30
+ audit: { resolution: "exact-path", matchCount: 1 },
31
+ };
32
+ case "exact-name":
33
+ return {
34
+ confidence: "explicit",
35
+ audit: { resolution: "exact-name", matchCount: 1 },
36
+ };
37
+ default:
38
+ return {
39
+ confidence: "inferred",
40
+ audit: { resolution: "tie-break", matchCount: 1 },
41
+ };
42
+ }
43
+ }
22
44
  if (linkType === "markdown") {
23
45
  return {
24
46
  confidence: "explicit",
@@ -6,26 +6,45 @@
6
6
  * peek receipts). A remote caller identifies documents by `uri` and
7
7
  * collection-relative `relPath` instead, so every `absPath` key is removed
8
8
  * before a payload leaves a remote-reachable surface.
9
+ *
10
+ * Status, collection, and connector payloads also name owner configuration
11
+ * locations: `configPath`, `dbPath`, and `path` (collection roots, suggested
12
+ * folders, model cache and model files, connector targets). A remote caller
13
+ * identifies a collection by `name`, so those keys are removed from those
14
+ * payloads too.
9
15
  */
10
16
 
11
- const HOST_PATH_FIELD = "absPath";
17
+ /** Document host path key of result payloads. */
18
+ export const HOST_PATH_FIELDS: ReadonlySet<string> = new Set(["absPath"]);
19
+
20
+ /** Owner configuration path keys of status, collection, and connector payloads. */
21
+ export const OWNER_CONFIG_PATH_FIELDS: ReadonlySet<string> = new Set([
22
+ "configPath",
23
+ "dbPath",
24
+ "path",
25
+ "workspaceRoot",
26
+ ]);
12
27
 
13
28
  const isPlainObject = (value: object): boolean => {
14
29
  const proto = Object.getPrototypeOf(value) as object | null;
15
30
  return proto === Object.prototype || proto === null;
16
31
  };
17
32
 
18
- /** Deep copy of a JSON-shaped value with every `absPath` key removed. */
19
- export function withoutHostPaths<T>(value: T): T {
33
+ /** Deep copy of a JSON-shaped value with every key in `fields` removed. */
34
+ export function withoutFields<T>(value: T, fields: ReadonlySet<string>): T {
20
35
  if (Array.isArray(value)) {
21
- return value.map((item: unknown) => withoutHostPaths(item)) as T;
36
+ return value.map((item: unknown) => withoutFields(item, fields)) as T;
22
37
  }
23
38
  if (value === null || typeof value !== "object" || !isPlainObject(value)) {
24
39
  return value;
25
40
  }
26
41
  const copy: Record<string, unknown> = {};
27
42
  for (const [key, entry] of Object.entries(value)) {
28
- if (key !== HOST_PATH_FIELD) copy[key] = withoutHostPaths(entry);
43
+ if (!fields.has(key)) copy[key] = withoutFields(entry, fields);
29
44
  }
30
45
  return copy as T;
31
46
  }
47
+
48
+ /** Deep copy of a JSON-shaped value with every `absPath` key removed. */
49
+ export const withoutHostPaths = <T>(value: T): T =>
50
+ withoutFields(value, HOST_PATH_FIELDS);
@@ -41,6 +41,12 @@ export interface KnowledgeImpactResult {
41
41
  }
42
42
 
43
43
  export interface KnowledgeImpactInput {
44
+ /**
45
+ * Collection scope: only documents in these collections are traversed or
46
+ * returned, and a document outside them is never used as a bridge.
47
+ * Omitted or empty means every indexed collection.
48
+ */
49
+ collections?: string[];
44
50
  maxDepth?: number;
45
51
  maxNodes?: number;
46
52
  maxEdges?: number;
@@ -118,8 +124,30 @@ export async function analyzeKnowledgeImpact(
118
124
  isValidation: true,
119
125
  };
120
126
  }
127
+ const scope = [...new Set(input.collections ?? [])].sort();
128
+ if (scope.length > 0) {
129
+ const known = await store.getCollections();
130
+ if (!known.ok) return { success: false, error: known.error.message };
131
+ const names = new Set(known.value.map((row) => row.name));
132
+ const missing = scope.find((name) => !names.has(name));
133
+ if (missing) {
134
+ return {
135
+ success: false,
136
+ error: `Collection not found: ${missing}`,
137
+ isValidation: true,
138
+ };
139
+ }
140
+ if (!scope.includes(resolved.doc.collection)) {
141
+ return {
142
+ success: false,
143
+ error: `Document is outside the requested collections: ${ref}`,
144
+ isValidation: true,
145
+ };
146
+ }
147
+ }
121
148
  const traversal = await store.queryGraphTraversal(resolved.doc.id, {
122
149
  direction: "in",
150
+ ...(scope.length > 0 ? { collections: scope } : {}),
123
151
  maxDepth: values.maxDepth,
124
152
  maxNodes: values.maxNodes,
125
153
  frontierLimit: values.frontierLimit,
@@ -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
+ };
@@ -11,14 +11,18 @@
11
11
  */
12
12
 
13
13
  import { Database } from "bun:sqlite";
14
- // node:fs/promises chmod/mkdir: filesystem structure ops, no Bun equivalent
15
- import { chmod, mkdir } from "node:fs/promises";
14
+ // node:fs/promises chmod/mkdir/lstat/readdir: filesystem structure ops, no Bun equivalent
15
+ import { chmod, lstat, mkdir, readdir } from "node:fs/promises";
16
16
  // node:path has no Bun path utilities
17
17
  import { basename, dirname, join } from "node:path";
18
18
 
19
19
  import { MCP_ERRORS } from "./errors";
20
20
  import { withWriteLock } from "./file-lock";
21
- import { windowsPrivatePath } from "./windows-private-path";
21
+ import {
22
+ isOwnerOnlyDescriptor,
23
+ windowsDirectoryDescriptor,
24
+ windowsPrivatePath,
25
+ } from "./windows-private-path";
22
26
  import { writeLeasePath } from "./write-lease";
23
27
 
24
28
  /** Committed receipts keep their full outcome this long, then become tombstones. */
@@ -221,25 +225,75 @@ CREATE TABLE IF NOT EXISTS request_receipts (
221
225
  /** Ledger directories whose owner-only Windows DACL this process verified. */
222
226
  const privateLedgerDirs = new Set<string>();
223
227
 
228
+ /** Inside the ledger directory: what the last authoritative check verified. */
229
+ const LEDGER_DIR_MARKER = ".owner-only-verified";
230
+
231
+ export interface PrivateDirAcl {
232
+ /** Authoritative check (spawns PowerShell); `create` sets the owner-only DACL first. */
233
+ verify: (dir: string, create: boolean) => Promise<void>;
234
+ /** In-process owner and DACL bytes, or null when they cannot be read. */
235
+ descriptor: (dir: string) => Uint8Array | null;
236
+ }
237
+
238
+ const WINDOWS_ACL: PrivateDirAcl = {
239
+ verify: windowsPrivatePath,
240
+ descriptor: windowsDirectoryDescriptor,
241
+ };
242
+
243
+ /** Directory identity plus descriptor digest; null unless owner-only. */
244
+ async function ledgerDirStamp(
245
+ dir: string,
246
+ acl: PrivateDirAcl
247
+ ): Promise<string | null> {
248
+ const { dev, ino } = await lstat(dir, { bigint: true });
249
+ const descriptor = acl.descriptor(dir);
250
+ if (!descriptor || !isOwnerOnlyDescriptor(descriptor)) return null;
251
+ const digest = new Bun.CryptoHasher("sha256")
252
+ .update(descriptor)
253
+ .digest("hex");
254
+ return `${dev}:${ino}:${digest}`;
255
+ }
256
+
224
257
  /**
225
258
  * Windows ignores POSIX modes: give a new ledger directory the current-user
226
259
  * DACL before SQLite creates the database or its WAL/SHM (they inherit it),
227
260
  * and refuse an existing one that grants another principal access.
261
+ *
262
+ * The PowerShell check costs a process start, so its success is recorded in a
263
+ * marker inside the directory. A later open skips it only when the directory
264
+ * is the same object and its owner and DACL are byte-identical to the verified
265
+ * ones and still owner-only; reading the marker at all requires access that
266
+ * owner-only DACL grants. Anything else re-runs the authoritative check.
267
+ *
268
+ * An existing but empty directory is secured like a new one: a first open
269
+ * interrupted before its DACL was set leaves exactly that, and nothing inside
270
+ * it could have been exposed.
228
271
  */
229
- async function secureLedgerDir(
272
+ export async function securePrivateLedgerDir(
230
273
  dir: string,
231
- created: string | undefined
274
+ created: boolean,
275
+ acl: PrivateDirAcl = WINDOWS_ACL
232
276
  ): Promise<void> {
233
- if (process.platform !== "win32" || privateLedgerDirs.has(dir)) return;
234
- await windowsPrivatePath(dir, created !== undefined);
235
- privateLedgerDirs.add(dir);
277
+ const marker = Bun.file(join(dir, LEDGER_DIR_MARKER));
278
+ if (!created) {
279
+ const stamp = await ledgerDirStamp(dir, acl);
280
+ if (stamp !== null && stamp === (await marker.text().catch(() => null)))
281
+ return;
282
+ }
283
+ const secure = created || (await readdir(dir)).length === 0;
284
+ await acl.verify(dir, secure);
285
+ const stamp = await ledgerDirStamp(dir, acl);
286
+ if (stamp !== null) await Bun.write(marker, stamp);
236
287
  }
237
288
 
238
289
  async function openLedger(path: string): Promise<Database> {
239
290
  try {
240
291
  const dir = dirname(path);
241
292
  const created = await mkdir(dir, { recursive: true, mode: 0o700 });
242
- await secureLedgerDir(dir, created);
293
+ if (process.platform === "win32" && !privateLedgerDirs.has(dir)) {
294
+ await securePrivateLedgerDir(dir, created !== undefined);
295
+ privateLedgerDirs.add(dir);
296
+ }
243
297
  const db = new Database(path, { create: true, strict: true });
244
298
  try {
245
299
  // POSIX: private before any journal file exists (they inherit this mode).
@@ -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
  };