@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.
- package/README.md +3 -2
- package/assets/skill/SKILL.md +8 -1
- package/assets/skill/cli-reference.md +8 -1
- package/assets/skill/examples.md +2 -1
- package/assets/skill/mcp-reference.md +3 -1
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.7.1.zip → gno-browser-clipper-v2.8.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +48 -6
- package/spec/db/schema.sql +0 -1
- package/spec/mcp.md +18 -3
- package/spec/output-schemas/audit-report.schema.json +18 -4
- package/spec/output-schemas/backlinks.schema.json +4 -0
- package/spec/output-schemas/collection-list.schema.json +13 -0
- package/spec/output-schemas/graph.schema.json +2 -0
- package/spec/output-schemas/links-list.schema.json +4 -0
- package/spec/output-schemas/status.schema.json +15 -0
- package/src/cli/commands/audit.ts +23 -4
- package/src/cli/commands/collection/list.ts +39 -5
- package/src/cli/commands/embed.ts +3 -3
- package/src/cli/commands/links.ts +34 -131
- package/src/cli/commands/shared.ts +7 -0
- package/src/cli/commands/status.ts +5 -0
- package/src/cli/program.ts +12 -2
- package/src/config/loader.ts +43 -0
- package/src/config/types.ts +8 -0
- package/src/core/audit-contract.ts +16 -4
- package/src/core/audit-freshness.ts +11 -1
- package/src/core/audit-links.ts +145 -25
- package/src/core/audit-provenance.ts +11 -4
- package/src/core/audit-workspace.ts +19 -4
- package/src/core/audit.ts +67 -15
- package/src/core/context-compiler.ts +3 -0
- package/src/core/context-evidence.ts +11 -0
- package/src/core/graph-edge-confidence.ts +23 -1
- package/src/core/host-paths.ts +1 -0
- package/src/core/knowledge-impact.ts +28 -0
- package/src/core/link-workspace.ts +324 -0
- package/src/core/retrieval-replay-candidate.ts +6 -0
- package/src/core/retrieval-trace-request.ts +3 -0
- package/src/index.ts +14 -1
- package/src/ingestion/graph-reconciliation.ts +77 -15
- package/src/ingestion/source-availability/darwin-path.ts +9 -3
- package/src/ingestion/sync.ts +22 -1
- package/src/ingestion/types.ts +14 -0
- package/src/llm/inference-scope.ts +4 -3
- package/src/mcp/http-egress.ts +42 -3
- package/src/mcp/tools/audit.ts +11 -2
- package/src/mcp/tools/changes.ts +1 -0
- package/src/mcp/tools/links.ts +3 -0
- package/src/mcp/tools/sessions.ts +33 -4
- package/src/mcp/tools/status.ts +3 -0
- package/src/pipeline/expansion.ts +19 -31
- package/src/pipeline/graph-retrieval.ts +22 -2
- package/src/pipeline/hybrid.ts +1 -1
- package/src/pipeline/types.ts +6 -3
- package/src/serve/findings-pass.ts +1 -1
- package/src/serve/public/pages/GraphView.tsx +2 -0
- package/src/serve/routes/changes.ts +6 -1
- package/src/serve/routes/links.ts +13 -0
- package/src/serve/routes/sessions.ts +41 -53
- package/src/sessions/config-refresh.ts +111 -0
- package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
- package/src/store/migrations/034-collection-link-workspace.ts +47 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +390 -231
- package/src/store/sqlite/eligibility.ts +8 -2
- package/src/store/sqlite/graph-link-resolver.ts +252 -5
- package/src/store/sqlite/graph-neighbors.ts +147 -40
- package/src/store/sqlite/graph-reference-state.ts +13 -2
- package/src/store/sqlite/workspace-link-resolver.ts +654 -0
- package/src/store/types.ts +49 -3
- package/src/store/vector/stats.ts +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
42
|
-
|
|
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
|
-
|
|
88
|
-
(relativePath
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
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-")
|
|
47
|
-
|
|
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-") &&
|
package/src/ingestion/sync.ts
CHANGED
|
@@ -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,
|
|
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,
|
package/src/ingestion/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* Cancellation, or a deadline of the
|
|
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>
|