@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.
- package/README.md +26 -30
- 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.0.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 +2 -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 +15 -2
- 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 +27 -16
- package/src/cli/commands/ask.ts +31 -12
- 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/ls.ts +6 -1
- 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 +24 -5
- package/src/core/knowledge-impact.ts +28 -0
- package/src/core/link-workspace.ts +324 -0
- package/src/core/request-receipts.ts +63 -9
- package/src/core/retrieval-replay-candidate.ts +6 -0
- package/src/core/retrieval-trace-request.ts +3 -0
- package/src/core/windows-private-path.ts +136 -1
- package/src/embed/backlog.ts +22 -10
- package/src/embed/variant-backlog.ts +34 -9
- 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 +19 -0
- 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 +22 -6
- 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/embed-scheduler.ts +2 -2
- package/src/serve/findings-pass.ts +1 -1
- package/src/serve/host-path-redaction.ts +51 -14
- package/src/serve/public/components/BootstrapStatus.tsx +5 -3
- package/src/serve/public/components/CaptureModal.tsx +1 -1
- package/src/serve/public/components/CollectionModelDialog.tsx +16 -13
- package/src/serve/public/components/CollectionsEmptyState.tsx +5 -3
- package/src/serve/public/components/FirstRunWizard.tsx +4 -2
- package/src/serve/public/components/sessions/SourcesPanel.tsx +77 -60
- package/src/serve/public/pages/Collections.tsx +16 -13
- package/src/serve/public/pages/Connectors.tsx +7 -4
- package/src/serve/public/pages/Dashboard.tsx +8 -6
- package/src/serve/public/pages/GraphView.tsx +2 -0
- package/src/serve/resident-runtime.ts +7 -0
- package/src/serve/routes/changes.ts +6 -1
- package/src/serve/routes/links.ts +13 -0
- package/src/serve/routes/sessions.ts +81 -37
- package/src/serve/server.ts +5 -3
- package/src/serve/status-model.ts +10 -6
- package/src/serve/status.ts +2 -7
- 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 +392 -233
- package/src/store/sqlite/change-journal-store.ts +1 -1
- 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/legacy-vector-ownership.ts +2 -1
- 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.0.zip.sha256 +0 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/** Windows owner-only evidence storage. Paths are data, never interpolated script.
|
|
2
2
|
* Fresh Windows objects may use the token default Owner instead of its User.
|
|
3
3
|
* Only those exact owner SIDs are accepted; allowed DACL entries remain User-only. */
|
|
4
|
+
// bun:ffi — reading a security descriptor in-process needs advapi32; Bun has no ACL API
|
|
5
|
+
import { dlopen, FFIType } from "bun:ffi";
|
|
6
|
+
|
|
4
7
|
// Use framework APIs directly: module auto-discovery depends on profile paths
|
|
5
8
|
// deliberately absent from isolated native workers.
|
|
6
9
|
const ACL = `
|
|
@@ -65,7 +68,8 @@ export async function windowsPrivatePath(
|
|
|
65
68
|
// ACL checks return no data; do not allocate an unused stdout pipe.
|
|
66
69
|
stdout: "ignore",
|
|
67
70
|
stderr: "pipe",
|
|
68
|
-
|
|
71
|
+
// A cold PowerShell start on a loaded host can take over 10s.
|
|
72
|
+
timeout: 30000,
|
|
69
73
|
}
|
|
70
74
|
);
|
|
71
75
|
const reader = child.stderr.getReader();
|
|
@@ -94,3 +98,134 @@ export async function windowsPrivatePath(
|
|
|
94
98
|
await child.exited;
|
|
95
99
|
}
|
|
96
100
|
}
|
|
101
|
+
|
|
102
|
+
const OWNER_AND_DACL = 0x1 | 0x4; // OWNER_ | DACL_SECURITY_INFORMATION
|
|
103
|
+
const FILE_ATTRIBUTE_DIRECTORY = 0x10;
|
|
104
|
+
const FILE_ATTRIBUTE_REPARSE_POINT = 0x4_00;
|
|
105
|
+
const INVALID_FILE_ATTRIBUTES = 0xff_ff_ff_ff;
|
|
106
|
+
const SECURITY_DESCRIPTOR_MAX_BYTES = 65_536;
|
|
107
|
+
|
|
108
|
+
interface Win32Security {
|
|
109
|
+
getFileAttributes: (path: Uint8Array) => number;
|
|
110
|
+
getFileSecurity: (
|
|
111
|
+
path: Uint8Array,
|
|
112
|
+
info: number,
|
|
113
|
+
descriptor: Uint8Array,
|
|
114
|
+
length: number,
|
|
115
|
+
needed: Uint32Array
|
|
116
|
+
) => number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let win32Security: Win32Security | null | undefined;
|
|
120
|
+
|
|
121
|
+
function loadWin32Security(): Win32Security | null {
|
|
122
|
+
if (win32Security !== undefined) return win32Security;
|
|
123
|
+
try {
|
|
124
|
+
const kernel32 = dlopen("kernel32.dll", {
|
|
125
|
+
GetFileAttributesW: { args: [FFIType.ptr], returns: FFIType.u32 },
|
|
126
|
+
});
|
|
127
|
+
const advapi32 = dlopen("advapi32.dll", {
|
|
128
|
+
GetFileSecurityW: {
|
|
129
|
+
args: [FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr],
|
|
130
|
+
returns: FFIType.i32,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
win32Security = {
|
|
134
|
+
getFileAttributes: kernel32.symbols.GetFileAttributesW,
|
|
135
|
+
getFileSecurity: advapi32.symbols.GetFileSecurityW,
|
|
136
|
+
};
|
|
137
|
+
} catch {
|
|
138
|
+
win32Security = null;
|
|
139
|
+
}
|
|
140
|
+
return win32Security;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Owner and DACL of a directory that is not a reparse point, read in-process
|
|
145
|
+
* (no PowerShell). Null when unavailable: callers treat that as unverified.
|
|
146
|
+
*/
|
|
147
|
+
export function windowsDirectoryDescriptor(path: string): Uint8Array | null {
|
|
148
|
+
if (process.platform !== "win32") return null;
|
|
149
|
+
const win32 = loadWin32Security();
|
|
150
|
+
if (!win32) return null;
|
|
151
|
+
const widePath = Buffer.from(`${path}\0`, "utf16le");
|
|
152
|
+
const attributes = win32.getFileAttributes(widePath);
|
|
153
|
+
if (
|
|
154
|
+
attributes === INVALID_FILE_ATTRIBUTES ||
|
|
155
|
+
(attributes & FILE_ATTRIBUTE_DIRECTORY) === 0 ||
|
|
156
|
+
(attributes & FILE_ATTRIBUTE_REPARSE_POINT) !== 0
|
|
157
|
+
) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const descriptor = new Uint8Array(SECURITY_DESCRIPTOR_MAX_BYTES);
|
|
161
|
+
const needed = new Uint32Array(1);
|
|
162
|
+
const ok = win32.getFileSecurity(
|
|
163
|
+
widePath,
|
|
164
|
+
OWNER_AND_DACL,
|
|
165
|
+
descriptor,
|
|
166
|
+
descriptor.length,
|
|
167
|
+
needed
|
|
168
|
+
);
|
|
169
|
+
const length = needed[0] ?? 0;
|
|
170
|
+
if (ok === 0 || length === 0 || length > descriptor.length) return null;
|
|
171
|
+
return descriptor.slice(0, length);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const SE_DACL_PRESENT = 0x4;
|
|
175
|
+
const SE_SELF_RELATIVE = 0x80_00;
|
|
176
|
+
const ACCESS_ALLOWED_ACE_TYPE = 0;
|
|
177
|
+
const ACCESS_DENIED_ACE_TYPE = 1;
|
|
178
|
+
const FILE_ALL_ACCESS = 0x1f_01_ff;
|
|
179
|
+
|
|
180
|
+
function sidAt(view: DataView, offset: number): Uint8Array | null {
|
|
181
|
+
if (offset === 0 || offset + 8 > view.byteLength) return null;
|
|
182
|
+
const end = offset + 8 + 4 * view.getUint8(offset + 1);
|
|
183
|
+
if (end > view.byteLength) return null;
|
|
184
|
+
return new Uint8Array(view.buffer, view.byteOffset + offset, end - offset);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
188
|
+
return left.length === right.length && left.every((b, i) => b === right[i]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Self-relative descriptor whose owner is the only principal any allow entry
|
|
193
|
+
* names, with full control among them. Stricter than the PowerShell policy
|
|
194
|
+
* (which also accepts a token default owner): a false here only means the
|
|
195
|
+
* authoritative check runs.
|
|
196
|
+
*/
|
|
197
|
+
export function isOwnerOnlyDescriptor(descriptor: Uint8Array): boolean {
|
|
198
|
+
const view = new DataView(
|
|
199
|
+
descriptor.buffer,
|
|
200
|
+
descriptor.byteOffset,
|
|
201
|
+
descriptor.byteLength
|
|
202
|
+
);
|
|
203
|
+
if (view.byteLength < 20 || view.getUint8(0) !== 1) return false;
|
|
204
|
+
const control = view.getUint16(2, true);
|
|
205
|
+
if ((control & SE_SELF_RELATIVE) === 0 || (control & SE_DACL_PRESENT) === 0)
|
|
206
|
+
return false;
|
|
207
|
+
const owner = sidAt(view, view.getUint32(4, true));
|
|
208
|
+
const dacl = view.getUint32(16, true);
|
|
209
|
+
if (!owner || dacl === 0 || dacl + 8 > view.byteLength) return false;
|
|
210
|
+
const aclEnd = dacl + view.getUint16(dacl + 2, true);
|
|
211
|
+
if (aclEnd > view.byteLength) return false;
|
|
212
|
+
let offset = dacl + 8;
|
|
213
|
+
let fullControl = false;
|
|
214
|
+
for (let ace = view.getUint16(dacl + 4, true); ace > 0; ace--) {
|
|
215
|
+
if (offset + 8 > aclEnd) return false;
|
|
216
|
+
const type = view.getUint8(offset);
|
|
217
|
+
const size = view.getUint16(offset + 2, true);
|
|
218
|
+
if (size < 8 || offset + size > aclEnd) return false;
|
|
219
|
+
if (type === ACCESS_ALLOWED_ACE_TYPE) {
|
|
220
|
+
const sid = sidAt(view, offset + 8);
|
|
221
|
+
if (!sid || offset + 8 + sid.length > offset + size) return false;
|
|
222
|
+
if (!sameBytes(sid, owner)) return false;
|
|
223
|
+
const mask = view.getUint32(offset + 4, true);
|
|
224
|
+
if ((mask & FILE_ALL_ACCESS) === FILE_ALL_ACCESS) fullControl = true;
|
|
225
|
+
} else if (type !== ACCESS_DENIED_ACE_TYPE) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
offset += size;
|
|
229
|
+
}
|
|
230
|
+
return fullControl;
|
|
231
|
+
}
|
package/src/embed/backlog.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { AcquireWriteTurn } from "./retry";
|
|
|
19
19
|
import {
|
|
20
20
|
assertInferenceActive,
|
|
21
21
|
isBackgroundInference,
|
|
22
|
+
withInferencePage,
|
|
22
23
|
} from "../llm/inference-scope";
|
|
23
24
|
import { formatDocForEmbedding } from "../pipeline/contextual";
|
|
24
25
|
import { err, ok } from "../store/types";
|
|
@@ -221,16 +222,27 @@ export async function embedBacklog(
|
|
|
221
222
|
}
|
|
222
223
|
|
|
223
224
|
const beforeEmbedded = embedded;
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
225
|
+
const storePage = () =>
|
|
226
|
+
embedAndStoreBatch({
|
|
227
|
+
embedPort,
|
|
228
|
+
vectorIndex,
|
|
229
|
+
items: batch,
|
|
230
|
+
modelUri,
|
|
231
|
+
embedFingerprint,
|
|
232
|
+
identityStillCurrent: deps.identityStillCurrent,
|
|
233
|
+
statsPort,
|
|
234
|
+
acquireWriteTurn: deps.acquireWriteTurn,
|
|
235
|
+
});
|
|
236
|
+
const batchStoreResult = background
|
|
237
|
+
? await withInferencePage(storePage)
|
|
238
|
+
: await storePage();
|
|
239
|
+
if (!batchStoreResult) {
|
|
240
|
+
// Past its deadline: this page stays pending for the next pass.
|
|
241
|
+
errors += batch.length;
|
|
242
|
+
deps.onProgress?.(embedded, errors);
|
|
243
|
+
await Bun.sleep(0);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
234
246
|
if (batchStoreResult.deferred)
|
|
235
247
|
return ok({ embedded, errors, contentionErrors, deferred: true });
|
|
236
248
|
embedded += batchStoreResult.embedded;
|
|
@@ -5,6 +5,7 @@ import type { EmbedBacklogDeps, EmbedBacklogResult } from "./backlog";
|
|
|
5
5
|
import {
|
|
6
6
|
assertInferenceActive,
|
|
7
7
|
isBackgroundInference,
|
|
8
|
+
withInferencePage,
|
|
8
9
|
} from "../llm/inference-scope";
|
|
9
10
|
import { err, ok } from "../store/types";
|
|
10
11
|
import { getVectorStatsDatabase } from "../store/vector/stats";
|
|
@@ -38,7 +39,20 @@ export async function embedVariantBacklog(
|
|
|
38
39
|
const total = { embedded: 0, errors: 0, contentionErrors: 0 };
|
|
39
40
|
let after: { documentId: number; seq: number } | undefined;
|
|
40
41
|
try {
|
|
41
|
-
while (
|
|
42
|
+
while (true) {
|
|
43
|
+
// A failed request drops the port's cached identity (a timed-out native
|
|
44
|
+
// worker is retired); reload it so one failed page cannot end the pass.
|
|
45
|
+
if (background) {
|
|
46
|
+
const reloaded = await withInferencePage(() => deps.embedPort.init());
|
|
47
|
+
// Without an identity the pass cannot continue. Failing it schedules
|
|
48
|
+
// the scheduler's bounded retry instead of ending with pages pending.
|
|
49
|
+
if (!reloaded?.ok)
|
|
50
|
+
throw new Error(
|
|
51
|
+
reloaded?.error.message ??
|
|
52
|
+
"Embedding model reload exceeded its inference deadline"
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (!identityStillCurrent()) break;
|
|
42
56
|
assertInferenceActive();
|
|
43
57
|
const pending = variantBacklogPage(deps, store, batchSize, after);
|
|
44
58
|
if (!pending.length) break;
|
|
@@ -54,14 +68,25 @@ export async function embedVariantBacklog(
|
|
|
54
68
|
.get(owner.documentId, deps.collection!)
|
|
55
69
|
)
|
|
56
70
|
: pending;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
71
|
+
const embedPage = () =>
|
|
72
|
+
embedVariantBatch({
|
|
73
|
+
store,
|
|
74
|
+
embedPort: deps.embedPort,
|
|
75
|
+
owners,
|
|
76
|
+
identityStillCurrent,
|
|
77
|
+
force: deps.force,
|
|
78
|
+
acquireWriteTurn: deps.acquireWriteTurn,
|
|
79
|
+
});
|
|
80
|
+
let result = background
|
|
81
|
+
? await withInferencePage(embedPage)
|
|
82
|
+
: await embedPage();
|
|
83
|
+
if (!result) {
|
|
84
|
+
// Past its deadline: this page stays pending for the next pass.
|
|
85
|
+
total.errors += owners.length;
|
|
86
|
+
deps.onProgress?.(total.embedded, total.errors);
|
|
87
|
+
await Bun.sleep(0);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
65
90
|
if (result.deferred) return ok({ ...total, deferred: true });
|
|
66
91
|
total.embedded += result.embedded;
|
|
67
92
|
total.errors += result.errors;
|
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;
|
|
@@ -168,6 +168,25 @@ export function withOwnedInferenceScope<T>(
|
|
|
168
168
|
return scope.run({}, () => withInferenceScope(options, operation));
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
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.
|
|
176
|
+
*/
|
|
177
|
+
export async function withInferencePage<T>(
|
|
178
|
+
operation: () => Promise<T>
|
|
179
|
+
): Promise<T | undefined> {
|
|
180
|
+
try {
|
|
181
|
+
return await withOwnedInferenceScope(inferenceOptions(), operation);
|
|
182
|
+
} catch (cause) {
|
|
183
|
+
if (!(cause instanceof DOMException && cause.name === "TimeoutError"))
|
|
184
|
+
throw cause;
|
|
185
|
+
assertInferenceActive();
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
171
190
|
/** A cancelled queued reader releases only its eventual grant, never another caller's. */
|
|
172
191
|
export async function acquireInferencePermit(
|
|
173
192
|
acquire: () => Promise<() => void>
|
package/src/mcp/http-egress.ts
CHANGED
|
@@ -124,6 +124,21 @@ const collectionFromRef = (value: unknown): string | null => {
|
|
|
124
124
|
return value.slice(0, slash).trim().toLowerCase() || null;
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Tools whose results follow resolved graph edges. Links resolve across a
|
|
129
|
+
* link workspace, so a ref's own collection does not bound the result: the
|
|
130
|
+
* scope is the explicit collection argument (else every collection) plus the
|
|
131
|
+
* collection of every referenced document.
|
|
132
|
+
*/
|
|
133
|
+
const GRAPH_RESULT_TOOLS = new Set([
|
|
134
|
+
"gno_backlinks",
|
|
135
|
+
"gno_graph",
|
|
136
|
+
"gno_graph_neighbors",
|
|
137
|
+
"gno_graph_path",
|
|
138
|
+
"gno_graph_query",
|
|
139
|
+
"gno_impact",
|
|
140
|
+
]);
|
|
141
|
+
|
|
127
142
|
const requestedCollections = (
|
|
128
143
|
params: unknown,
|
|
129
144
|
collections: readonly Collection[]
|
|
@@ -134,17 +149,41 @@ const requestedCollections = (
|
|
|
134
149
|
|
|
135
150
|
const names = new Set<string>();
|
|
136
151
|
const direct = args.collection;
|
|
137
|
-
|
|
138
|
-
|
|
152
|
+
// Handlers treat a blank collection as omitted, so it must not count as a
|
|
153
|
+
// scope here either (a graph call with one is authorized as unscoped).
|
|
154
|
+
if (typeof direct === "string" && direct.trim())
|
|
155
|
+
names.add(direct.trim().toLowerCase());
|
|
156
|
+
if (
|
|
157
|
+
(record?.name === "gno_audit" || record?.name === "gno_impact") &&
|
|
158
|
+
Array.isArray(args.collections)
|
|
159
|
+
) {
|
|
139
160
|
for (const value of args.collections) {
|
|
140
161
|
if (typeof value !== "string") continue;
|
|
141
162
|
const normalized = value.trim().toLowerCase();
|
|
142
163
|
if (normalized) names.add(normalized);
|
|
143
164
|
}
|
|
144
165
|
}
|
|
166
|
+
const graphTool =
|
|
167
|
+
typeof record?.name === "string" && GRAPH_RESULT_TOOLS.has(record.name);
|
|
168
|
+
// A graph result spans every collection a link resolves into: without an
|
|
169
|
+
// explicit scope, authorize them all.
|
|
170
|
+
// `gno_similar` with crossCollection returns documents from every collection.
|
|
171
|
+
const crossCollectionSimilar =
|
|
172
|
+
record?.name === "gno_similar" && args.crossCollection === true;
|
|
173
|
+
if ((graphTool && names.size === 0) || crossCollectionSimilar) {
|
|
174
|
+
for (const { name } of collections) names.add(name);
|
|
175
|
+
}
|
|
176
|
+
// The referenced documents' own collections are always authorized too:
|
|
177
|
+
// graph tools serialize the target's metadata even when the result scope
|
|
178
|
+
// is narrower. A graph-tool ref whose collection cannot be read without
|
|
179
|
+
// the index (a docid) authorizes every collection (fail closed).
|
|
145
180
|
for (const key of ["ref", "target", "from", "to", "root", "uri"]) {
|
|
146
|
-
const
|
|
181
|
+
const value = args[key];
|
|
182
|
+
const collection = collectionFromRef(value);
|
|
147
183
|
if (collection) names.add(collection);
|
|
184
|
+
else if (graphTool && typeof value === "string" && value.trim()) {
|
|
185
|
+
for (const { name } of collections) names.add(name);
|
|
186
|
+
}
|
|
148
187
|
}
|
|
149
188
|
if (Array.isArray(args.refs)) {
|
|
150
189
|
for (const ref of args.refs) {
|
package/src/mcp/tools/audit.ts
CHANGED
|
@@ -5,7 +5,11 @@ import { z } from "zod";
|
|
|
5
5
|
import type { AuditCategory, AuditReport } from "../../core/audit";
|
|
6
6
|
import type { ToolContext } from "../server";
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
AUDIT_CATEGORIES,
|
|
10
|
+
AUDIT_MAX_FINDINGS_ALL,
|
|
11
|
+
AUDIT_MAX_FINDINGS_LIMIT,
|
|
12
|
+
} from "../../core/audit";
|
|
9
13
|
import { runWorkspaceAudit } from "../../core/audit-workspace";
|
|
10
14
|
import { normalizeTag, validateTag } from "../../core/tags";
|
|
11
15
|
import { normalizeCollectionName } from "../../core/validation";
|
|
@@ -19,7 +23,12 @@ export const auditInputSchema = z
|
|
|
19
23
|
collections: z.array(z.string().min(1)).max(256).default([]),
|
|
20
24
|
paths: z.array(z.string().min(1)).max(256).default([]),
|
|
21
25
|
tags: z.array(z.string().min(1)).max(256).default([]),
|
|
22
|
-
maxFindings: z
|
|
26
|
+
maxFindings: z
|
|
27
|
+
.union([
|
|
28
|
+
z.number().int().min(1).max(AUDIT_MAX_FINDINGS_LIMIT),
|
|
29
|
+
z.literal(AUDIT_MAX_FINDINGS_ALL),
|
|
30
|
+
])
|
|
31
|
+
.default(100),
|
|
23
32
|
maxAgeDays: z.number().int().min(1).optional(),
|
|
24
33
|
orphanRoots: z.array(z.string().min(1)).max(256).default([]),
|
|
25
34
|
orphanIgnorePrefixes: z.array(z.string().min(1)).max(256).default([]),
|
package/src/mcp/tools/changes.ts
CHANGED
|
@@ -24,6 +24,7 @@ export const diffInputSchema = z.object({
|
|
|
24
24
|
|
|
25
25
|
export const impactInputSchema = z.object({
|
|
26
26
|
ref: z.string().trim().min(1).max(4096),
|
|
27
|
+
collections: z.array(z.string().trim().min(1)).max(256).optional(),
|
|
27
28
|
maxDepth: z.number().int().min(1).max(6).default(3),
|
|
28
29
|
maxNodes: z.number().int().min(1).max(1000).default(100),
|
|
29
30
|
maxEdges: z.number().int().min(1).max(5000).default(250),
|