@gmickel/gno 2.8.2 → 2.8.4
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 +1 -1
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.8.2.zip → gno-browser-clipper-v2.8.4.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.4.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +9 -1
- package/spec/cli.md +77 -6
- package/spec/mcp.md +5 -6
- package/spec/output-schemas/doctor.schema.json +10 -4
- package/spec/output-schemas/status.schema.json +4 -2
- package/src/cli/commands/daemon.ts +7 -2
- package/src/cli/commands/doctor-activation.ts +38 -11
- package/src/cli/commands/doctor.ts +46 -25
- package/src/cli/commands/index-cmd.ts +10 -2
- package/src/cli/commands/links.ts +5 -22
- package/src/cli/commands/shared.ts +26 -1
- package/src/cli/commands/status.ts +76 -4
- package/src/cli/commands/update.ts +6 -1
- package/src/cli/commands/vec.ts +42 -2
- package/src/cli/program.ts +2 -2
- package/src/config/types.ts +19 -0
- package/src/converters/adapters/markitdownTs/adapter.ts +1 -52
- package/src/converters/adapters/shared/ooxml-protection.ts +58 -0
- package/src/converters/adapters/xlsx/adapter.ts +132 -0
- package/src/converters/adapters/xlsx/sheet-markdown.ts +312 -0
- package/src/converters/registry.ts +62 -6
- package/src/converters/versions.ts +2 -0
- package/src/core/activation-empty.ts +16 -0
- package/src/core/activation-status.ts +23 -5
- package/src/core/audit-freshness.ts +1 -1
- package/src/core/audit-outside-index.ts +48 -3
- package/src/core/change-diff.ts +16 -4
- package/src/core/link-destination-parse.ts +21 -4
- package/src/core/link-inventory-markdown.ts +23 -3
- package/src/core/links.ts +127 -31
- package/src/core/network-boundary-inventory.ts +18 -0
- package/src/index.ts +10 -0
- package/src/ingestion/document-metadata.ts +256 -0
- package/src/ingestion/file-child-env.ts +9 -0
- package/src/ingestion/file-child.ts +106 -0
- package/src/ingestion/file-processor.ts +546 -0
- package/src/ingestion/frontmatter.ts +6 -2
- package/src/ingestion/index.ts +1 -0
- package/src/ingestion/prepare-convert.ts +32 -0
- package/src/ingestion/prepare-file.ts +272 -0
- package/src/ingestion/prepare-worker.ts +43 -0
- package/src/ingestion/record-container.ts +7 -2
- package/src/ingestion/strip.ts +136 -31
- package/src/ingestion/sync-options.ts +7 -1
- package/src/ingestion/sync.ts +207 -400
- package/src/ingestion/types.ts +18 -0
- package/src/llm/native-worker/embedding-identity.ts +15 -3
- package/src/mcp/http-transport.ts +11 -0
- package/src/mcp/tools/links.ts +10 -38
- package/src/serve/activation-health.ts +25 -7
- package/src/serve/public/components/BootstrapStatus.tsx +9 -4
- package/src/serve/resident-request.ts +19 -0
- package/src/serve/resident-runtime.ts +84 -8
- package/src/serve/routes/links.ts +6 -24
- package/src/serve/server.ts +3 -2
- package/src/serve/status.ts +27 -8
- package/src/sessions/config-refresh.ts +3 -2
- package/src/store/sqlite/graph-similarity.ts +5 -13
- package/src/store/vector/partition-index.ts +100 -0
- package/src/store/vector/stored-vectors.ts +20 -15
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.2.zip.sha256 +0 -1
package/src/ingestion/types.ts
CHANGED
|
@@ -185,9 +185,18 @@ export interface SyncOptions {
|
|
|
185
185
|
/** Conversion limits override */
|
|
186
186
|
limits?: {
|
|
187
187
|
maxBytes?: number;
|
|
188
|
+
/** Per-file conversion wall-clock budget (config `conversion.timeoutMs`). */
|
|
188
189
|
timeoutMs?: number;
|
|
189
190
|
maxOutputChars?: number;
|
|
191
|
+
/** Process memory ceiling while a file converts (`conversion.maxMemoryMb`). */
|
|
192
|
+
maxMemoryMb?: number;
|
|
190
193
|
};
|
|
194
|
+
/**
|
|
195
|
+
* Internal: called once when one file's conversion has run longer than
|
|
196
|
+
* `SLOW_CONVERSION_NOTICE_MS` (or half its budget, if sooner), naming the
|
|
197
|
+
* file while it is still running.
|
|
198
|
+
*/
|
|
199
|
+
onSlowConversion?: (event: SlowConversionEvent) => void;
|
|
191
200
|
/**
|
|
192
201
|
* Max concurrent file processing (default: 1).
|
|
193
202
|
* Higher values improve throughput but increase memory pressure.
|
|
@@ -207,6 +216,15 @@ export interface SyncOptions {
|
|
|
207
216
|
sourceAvailability?: "any" | "local";
|
|
208
217
|
}
|
|
209
218
|
|
|
219
|
+
/** A file whose conversion is taking longer than the notice threshold. */
|
|
220
|
+
export interface SlowConversionEvent {
|
|
221
|
+
collection: string;
|
|
222
|
+
/** Collection-relative path of the file being converted. */
|
|
223
|
+
relPath: string;
|
|
224
|
+
elapsedMs: number;
|
|
225
|
+
budgetMs: number;
|
|
226
|
+
}
|
|
227
|
+
|
|
210
228
|
export type ContentTypeSource =
|
|
211
229
|
| "frontmatter-type"
|
|
212
230
|
| "prefix"
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
// Bun has no inode/ctime identity API. Check replacement during hashing/loading.
|
|
2
2
|
import { stat } from "node:fs/promises";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
/**
|
|
5
|
+
* node-llama-cpp's version, part of the embedding runtime identity. A
|
|
6
|
+
* standalone compiled executable ships no node-llama-cpp package (it cannot
|
|
7
|
+
* run local inference), so the lookup must not stop it from loading.
|
|
8
|
+
*/
|
|
9
|
+
const readNativePackage = async (): Promise<{ version: string }> => {
|
|
10
|
+
try {
|
|
11
|
+
return (await Bun.file(
|
|
12
|
+
new URL("../package.json", import.meta.resolve("node-llama-cpp"))
|
|
13
|
+
).json()) as { version: string };
|
|
14
|
+
} catch {
|
|
15
|
+
return { version: "unavailable" };
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const nativePackage = await readNativePackage();
|
|
7
19
|
|
|
8
20
|
export async function fileIdentity(path: string): Promise<string> {
|
|
9
21
|
const info = await stat(path, { bigint: true });
|
|
@@ -17,6 +17,7 @@ import { MCP_SERVER_NAME, VERSION } from "../app/constants";
|
|
|
17
17
|
import { EgressDeniedError } from "../core/egress-enforcement";
|
|
18
18
|
import { httpMcpRequestNamespace } from "../core/request-receipts";
|
|
19
19
|
import { withInferenceScope } from "../llm/inference-scope";
|
|
20
|
+
import { remoteSafeSessionsError } from "../sessions/types";
|
|
20
21
|
import { createMcpServerSurface, type ToolContext } from "./context";
|
|
21
22
|
import {
|
|
22
23
|
enforceHttpMcpEgress,
|
|
@@ -44,6 +45,8 @@ const POLICY_CHANGED_SSE = new TextEncoder().encode(
|
|
|
44
45
|
export interface HttpMcpTransportRuntime extends HttpMcpSessionRuntime {
|
|
45
46
|
readonly authorizationEpoch?: string;
|
|
46
47
|
readonly isShuttingDown: boolean;
|
|
48
|
+
/** Adopt config-file collection edits; see ResidentRuntime.refreshConfig. */
|
|
49
|
+
refreshConfig?(): Promise<void>;
|
|
47
50
|
admitRequest(signal?: AbortSignal): ResidentRequestHandle | null;
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -281,6 +284,14 @@ export class HttpMcpTransport {
|
|
|
281
284
|
if (!MCP_HTTP_METHODS.has(request.method)) return methodNotAllowed();
|
|
282
285
|
if (this.#closed || this.#runtime.isShuttingDown)
|
|
283
286
|
return jsonRpcError(503, -32_000, "Resident runtime is unavailable");
|
|
287
|
+
// Before admission: adopting config-file collection edits moves the
|
|
288
|
+
// policy epoch, which would void a request already admitted.
|
|
289
|
+
try {
|
|
290
|
+
await this.#runtime.refreshConfig?.();
|
|
291
|
+
} catch (error) {
|
|
292
|
+
const typed = remoteSafeSessionsError(error);
|
|
293
|
+
return jsonRpcError(500, -32_000, `${typed.code}: ${typed.message}`);
|
|
294
|
+
}
|
|
284
295
|
if (!(await this.#acquireCapacity(request.signal))) {
|
|
285
296
|
if (this.#closed || this.#runtime.isShuttingDown)
|
|
286
297
|
return jsonRpcError(503, -32_000, "Resident runtime is unavailable");
|
package/src/mcp/tools/links.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { normalizeCollectionName } from "../../core/validation";
|
|
|
26
26
|
import { getActivePreset } from "../../llm/registry";
|
|
27
27
|
import { createVectorIndexPort } from "../../store/vector";
|
|
28
28
|
import {
|
|
29
|
-
|
|
29
|
+
readSimilaritySourceVectors,
|
|
30
30
|
resolveStoredVectorSource,
|
|
31
31
|
similarityHitDocuments,
|
|
32
32
|
storedVectorSearchOptions,
|
|
@@ -415,55 +415,27 @@ export function handleSimilar(
|
|
|
415
415
|
const preset = getActivePreset(ctx.config);
|
|
416
416
|
const modelUri = preset.embed;
|
|
417
417
|
|
|
418
|
-
//
|
|
418
|
+
// Similarity source vector (first chunk, unit length) from the active
|
|
419
|
+
// partition (NO model loading required)
|
|
419
420
|
const db = ctx.store.getRawDb();
|
|
420
421
|
const source = resolveStoredVectorSource(db, modelUri);
|
|
421
422
|
|
|
422
|
-
let
|
|
423
|
+
let embedding: Float32Array | undefined;
|
|
423
424
|
try {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
]).get(doc.id) ?? [];
|
|
425
|
+
embedding = readSimilaritySourceVectors(db, source, [
|
|
426
|
+
{ id: doc.id, mirrorHash: doc.mirrorHash },
|
|
427
|
+
]).get(doc.id);
|
|
428
428
|
} catch (e) {
|
|
429
429
|
throw new Error(
|
|
430
430
|
`Invalid stored embedding data: ${e instanceof Error ? e.message : String(e)}`
|
|
431
431
|
);
|
|
432
432
|
}
|
|
433
|
-
|
|
434
|
-
if (!first) {
|
|
433
|
+
if (!embedding) {
|
|
435
434
|
throw new Error(
|
|
436
435
|
`${MCP_ERRORS.NOT_FOUND.code}: Document has no embeddings. Run: gno embed`
|
|
437
436
|
);
|
|
438
437
|
}
|
|
439
|
-
|
|
440
|
-
// Compute average embedding from all chunks
|
|
441
|
-
const dimensions = first.length;
|
|
442
|
-
const avgEmbedding = new Float32Array(dimensions);
|
|
443
|
-
for (const embedding of vectors) {
|
|
444
|
-
if (embedding.length !== dimensions) {
|
|
445
|
-
throw new Error(
|
|
446
|
-
`Invalid stored embedding data: Inconsistent embedding dimensions: expected ${dimensions}, got ${embedding.length}`
|
|
447
|
-
);
|
|
448
|
-
}
|
|
449
|
-
for (let i = 0; i < dimensions; i++) {
|
|
450
|
-
const current = avgEmbedding[i] ?? 0;
|
|
451
|
-
avgEmbedding[i] = current + (embedding[i] ?? 0) / vectors.length;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Normalize the average embedding for cosine similarity
|
|
456
|
-
let norm = 0;
|
|
457
|
-
for (let i = 0; i < dimensions; i++) {
|
|
458
|
-
const val = avgEmbedding[i] ?? 0;
|
|
459
|
-
norm += val * val;
|
|
460
|
-
}
|
|
461
|
-
norm = Math.sqrt(norm);
|
|
462
|
-
if (norm > 0) {
|
|
463
|
-
for (let i = 0; i < dimensions; i++) {
|
|
464
|
-
avgEmbedding[i] = (avgEmbedding[i] ?? 0) / norm;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
438
|
+
const dimensions = embedding.length;
|
|
467
439
|
|
|
468
440
|
// Create vector index for search
|
|
469
441
|
const vectorResult = await createVectorIndexPort(db, {
|
|
@@ -496,7 +468,7 @@ export function handleSimilar(
|
|
|
496
468
|
// Search for similar documents (larger pool for filtering)
|
|
497
469
|
const candidateLimit = Math.min(limit * 20, 200);
|
|
498
470
|
const searchResult = await vectorIndex.searchNearest(
|
|
499
|
-
|
|
471
|
+
embedding,
|
|
500
472
|
candidateLimit,
|
|
501
473
|
storedVectorSearchOptions(source)
|
|
502
474
|
);
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import type { ActivationStatus } from "../core/activation-status";
|
|
4
4
|
import type { HealthCheck } from "./status-model";
|
|
5
5
|
|
|
6
|
+
import { isEmptyActivationCollection } from "../core/activation-empty";
|
|
7
|
+
|
|
6
8
|
function countLabel(count: number, singular: string): string {
|
|
7
9
|
return `${count} ${count === 1 ? singular : `${singular}s`}`;
|
|
8
10
|
}
|
|
@@ -10,26 +12,42 @@ function countLabel(count: number, singular: string): string {
|
|
|
10
12
|
export function buildActivationCheck(
|
|
11
13
|
activation: ActivationStatus
|
|
12
14
|
): HealthCheck {
|
|
15
|
+
const ready = activation.collections.filter((collection) => collection.ready);
|
|
16
|
+
const empty = activation.collections.filter(isEmptyActivationCollection);
|
|
17
|
+
const emptyDetail =
|
|
18
|
+
empty.length > 0
|
|
19
|
+
? `${countLabel(empty.length, "folder")} ${empty.length === 1 ? "has" : "have"} no documents yet (${empty.map(({ collection }) => collection).join(", ")}): add files, then run update.`
|
|
20
|
+
: null;
|
|
13
21
|
if (activation.healthy) {
|
|
14
22
|
const semanticReasons = [
|
|
15
23
|
...new Set(
|
|
16
|
-
|
|
17
|
-
({ semanticAvailability }) => semanticAvailability.code
|
|
18
|
-
)
|
|
24
|
+
ready.map(({ semanticAvailability }) => semanticAvailability.code)
|
|
19
25
|
),
|
|
20
26
|
];
|
|
27
|
+
const semanticDetail =
|
|
28
|
+
ready.length > 0
|
|
29
|
+
? `Lexical search is proven. Semantic availability is separate (${semanticReasons.join(", ")}).`
|
|
30
|
+
: null;
|
|
21
31
|
return {
|
|
22
32
|
id: "retrieval-activation",
|
|
23
33
|
title: "Retrieval proof",
|
|
24
34
|
status: "ok",
|
|
25
|
-
summary:
|
|
26
|
-
|
|
35
|
+
summary:
|
|
36
|
+
ready.length === 0
|
|
37
|
+
? `No documents indexed yet in ${countLabel(empty.length, "folder")}`
|
|
38
|
+
: empty.length > 0
|
|
39
|
+
? `${countLabel(ready.length, "folder")} passed lexical retrieval; ${countLabel(empty.length, "folder")} with no documents yet`
|
|
40
|
+
: `${countLabel(ready.length, "folder")} passed lexical retrieval`,
|
|
41
|
+
detail: [semanticDetail, emptyDetail].filter(Boolean).join(" "),
|
|
27
42
|
actionLabel: "Run update",
|
|
28
43
|
actionKind: "sync",
|
|
29
44
|
};
|
|
30
45
|
}
|
|
31
46
|
|
|
32
|
-
const failed = activation.collections.filter(
|
|
47
|
+
const failed = activation.collections.filter(
|
|
48
|
+
(collection) =>
|
|
49
|
+
!collection.ready && !isEmptyActivationCollection(collection)
|
|
50
|
+
);
|
|
33
51
|
const first = failed[0];
|
|
34
52
|
const detail = first?.remediation
|
|
35
53
|
? `${first.collection}: ${first.remediation.stage}/${first.remediation.code}. Run: ${first.remediation.command}`
|
|
@@ -41,7 +59,7 @@ export function buildActivationCheck(
|
|
|
41
59
|
summary: activation.usable
|
|
42
60
|
? `${countLabel(failed.length, "folder")} failed lexical retrieval`
|
|
43
61
|
: "No folder passed lexical retrieval",
|
|
44
|
-
detail,
|
|
62
|
+
detail: [detail, emptyDetail].filter(Boolean).join(" "),
|
|
45
63
|
actionLabel: "Run update",
|
|
46
64
|
actionKind: "sync",
|
|
47
65
|
};
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
|
|
10
10
|
import type { AppStatusResponse } from "../../status-model";
|
|
11
11
|
|
|
12
|
+
import { isEmptyActivationCollection } from "../../../core/activation-empty";
|
|
12
13
|
import { buildConnectorActivationCheck } from "../../activation-health";
|
|
13
14
|
import { Button } from "./ui/button";
|
|
14
15
|
import {
|
|
@@ -147,9 +148,9 @@ export function BootstrapStatus({
|
|
|
147
148
|
<Card className="border-border/60 bg-card/70">
|
|
148
149
|
<CardHeader className="pb-3">
|
|
149
150
|
<div className="flex items-center gap-2">
|
|
150
|
-
{activation.healthy && connectorsHealthy ? (
|
|
151
|
+
{activation.healthy && activation.usable && connectorsHealthy ? (
|
|
151
152
|
<CheckCircle2Icon className="size-4 text-emerald-500" />
|
|
152
|
-
) : activation.usable ? (
|
|
153
|
+
) : activation.usable || activation.healthy ? (
|
|
153
154
|
<ServerCogIcon className="size-4 text-amber-500" />
|
|
154
155
|
) : (
|
|
155
156
|
<AlertCircleIcon className="size-4 text-destructive" />
|
|
@@ -158,7 +159,9 @@ export function BootstrapStatus({
|
|
|
158
159
|
</div>
|
|
159
160
|
<CardDescription>
|
|
160
161
|
{activation.healthy
|
|
161
|
-
?
|
|
162
|
+
? activation.usable
|
|
163
|
+
? "Lexical retrieval proven"
|
|
164
|
+
: "No documents indexed yet"
|
|
162
165
|
: activation.usable
|
|
163
166
|
? `Search usable in ${activation.collections.filter(({ ready }) => ready).length}/${activation.collections.length} folders`
|
|
164
167
|
: "Retrieval proof failed"}
|
|
@@ -179,7 +182,9 @@ export function BootstrapStatus({
|
|
|
179
182
|
<p className="text-muted-foreground text-xs">
|
|
180
183
|
{collection.ready
|
|
181
184
|
? `Lexical passed; semantic ${collection.semanticAvailability.code}`
|
|
182
|
-
:
|
|
185
|
+
: isEmptyActivationCollection(collection)
|
|
186
|
+
? "No documents yet. Add files, then run update."
|
|
187
|
+
: `${collection.remediation?.stage ?? "index"}/${collection.remediation?.code ?? "index_query_failed"}`}
|
|
183
188
|
</p>
|
|
184
189
|
{collection.remediation && (
|
|
185
190
|
<p className="mt-1 font-mono text-muted-foreground text-xs">
|
|
@@ -2,6 +2,23 @@ import type { ResidentRuntime } from "./resident-runtime";
|
|
|
2
2
|
/** Admission and bounded-reader boundary for resident REST reads. */
|
|
3
3
|
|
|
4
4
|
import { withInferenceScope } from "../llm/inference-scope";
|
|
5
|
+
import { sessionsErrorResponse } from "./routes/sessions";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Adopt config-file collection edits before a request is admitted (adoption
|
|
9
|
+
* moves the policy epoch, which would void an admitted request). Returns the
|
|
10
|
+
* error response when the config cannot be read or is bound elsewhere.
|
|
11
|
+
*/
|
|
12
|
+
export async function refreshResidentConfig(
|
|
13
|
+
runtime: Pick<ResidentRuntime, "refreshConfig">
|
|
14
|
+
): Promise<Response | null> {
|
|
15
|
+
try {
|
|
16
|
+
await runtime.refreshConfig?.();
|
|
17
|
+
return null;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return sessionsErrorResponse(error);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
5
22
|
|
|
6
23
|
function unavailableResponse(): Response {
|
|
7
24
|
return Response.json(
|
|
@@ -87,6 +104,8 @@ export async function handleResidentRead(
|
|
|
87
104
|
request: Request | undefined,
|
|
88
105
|
operation: (signal: AbortSignal) => Promise<Response> | Response
|
|
89
106
|
): Promise<Response> {
|
|
107
|
+
const refreshFailure = await refreshResidentConfig(runtime);
|
|
108
|
+
if (refreshFailure) return refreshFailure;
|
|
90
109
|
const admitted = runtime.admitRequest(request?.signal);
|
|
91
110
|
if (!admitted) return unavailableResponse();
|
|
92
111
|
|
|
@@ -51,10 +51,16 @@ import {
|
|
|
51
51
|
} from "../core/shutdown-budget";
|
|
52
52
|
import { acquireCliWriteLease } from "../core/write-lease";
|
|
53
53
|
import { defaultSyncService, withContentTypeRules } from "../ingestion";
|
|
54
|
+
import { disposeFileProcessor } from "../ingestion/file-processor";
|
|
54
55
|
import { withOwnedInferenceScope } from "../llm/inference-scope";
|
|
55
56
|
import { getActivePreset } from "../llm/registry";
|
|
56
57
|
import { createToolContext, Mutex } from "../mcp/context";
|
|
57
58
|
import { watchedCollections } from "../sessions/config";
|
|
59
|
+
import {
|
|
60
|
+
adoptServedConfig,
|
|
61
|
+
readInstanceConfig,
|
|
62
|
+
} from "../sessions/config-refresh";
|
|
63
|
+
import { remoteSafeSessionsError } from "../sessions/types";
|
|
58
64
|
import { SqliteAdapter } from "../store/sqlite/adapter";
|
|
59
65
|
import {
|
|
60
66
|
createServerContext,
|
|
@@ -78,6 +84,8 @@ import {
|
|
|
78
84
|
import { CollectionWatchService as DefaultCollectionWatchService } from "./watch-service";
|
|
79
85
|
|
|
80
86
|
const OWNER_LOCK_TIMEOUT_MS = 0;
|
|
87
|
+
/** How often an idle resident re-reads its config file for collection edits. */
|
|
88
|
+
const CONFIG_REFRESH_INTERVAL_MS = 2_000;
|
|
81
89
|
|
|
82
90
|
export type ResidentMode = "serve" | "daemon";
|
|
83
91
|
|
|
@@ -146,6 +154,15 @@ export interface ResidentRuntime {
|
|
|
146
154
|
provider: (() => HttpMcpTransportStatus) | null
|
|
147
155
|
): void;
|
|
148
156
|
setPolicySessionInvalidator(invalidator: (() => Promise<void>) | null): void;
|
|
157
|
+
/**
|
|
158
|
+
* Adopt collection (and context) edits made to the config file while the
|
|
159
|
+
* resident runs, with the sessions refresh's binding checks: rejects when
|
|
160
|
+
* the file is unreadable or bound to another index, leaving the served
|
|
161
|
+
* config as it was. Other settings keep their restart behaviour. Callers
|
|
162
|
+
* run it before admitting a request (an adoption moves the policy epoch).
|
|
163
|
+
* Optional only for test doubles; the real runtime always provides it.
|
|
164
|
+
*/
|
|
165
|
+
refreshConfig?(): Promise<void>;
|
|
149
166
|
admitRequest(signal?: AbortSignal): ResidentRequestHandle | null;
|
|
150
167
|
withModelLease<T>(operation: () => Promise<T>): Promise<T>;
|
|
151
168
|
markContentMutation(): void;
|
|
@@ -472,17 +489,56 @@ export async function startResidentRuntime(
|
|
|
472
489
|
startBackgroundWork: (operation) => backgroundWork.start(operation),
|
|
473
490
|
});
|
|
474
491
|
|
|
492
|
+
const setServedConfig = (config: Config): void => {
|
|
493
|
+
ctxHolder.config = config;
|
|
494
|
+
ctxHolder.current = { ...ctxHolder.current, config };
|
|
495
|
+
ctxHolder.watchService?.updateCollections(
|
|
496
|
+
watchedCollections(config),
|
|
497
|
+
withContentTypeRules({}, config)
|
|
498
|
+
);
|
|
499
|
+
};
|
|
500
|
+
const instance = {
|
|
501
|
+
configPath: actualConfigPath,
|
|
502
|
+
indexName: canonicalizeIndexName(options.index ?? DEFAULT_INDEX_NAME),
|
|
503
|
+
};
|
|
504
|
+
let configRefresh: Promise<void> | undefined;
|
|
505
|
+
const refreshConfig = (): Promise<void> => {
|
|
506
|
+
configRefresh ??= (async () => {
|
|
507
|
+
const file = await readInstanceConfig(
|
|
508
|
+
instance,
|
|
509
|
+
deps.loadConfig ?? loadConfig
|
|
510
|
+
);
|
|
511
|
+
const served = ctxHolder.config;
|
|
512
|
+
const next: Config = {
|
|
513
|
+
...served,
|
|
514
|
+
collections: file.collections,
|
|
515
|
+
contexts: file.contexts,
|
|
516
|
+
};
|
|
517
|
+
if (Bun.deepEquals(next, served)) return;
|
|
518
|
+
await adoptServedConfig(
|
|
519
|
+
{
|
|
520
|
+
...instance,
|
|
521
|
+
store,
|
|
522
|
+
config: served,
|
|
523
|
+
setConfig: setServedConfig,
|
|
524
|
+
invalidateEgressPolicy: async () => {
|
|
525
|
+
await ctxHolder.invalidateEgressPolicy?.();
|
|
526
|
+
},
|
|
527
|
+
markContentMutation: () => ctxHolder.markContentMutation?.(),
|
|
528
|
+
markIndexMutation: () => ctxHolder.markIndexMutation?.(),
|
|
529
|
+
},
|
|
530
|
+
next
|
|
531
|
+
);
|
|
532
|
+
})().finally(() => {
|
|
533
|
+
configRefresh = undefined;
|
|
534
|
+
});
|
|
535
|
+
return configRefresh;
|
|
536
|
+
};
|
|
537
|
+
|
|
475
538
|
const mcpContext = createToolContext({
|
|
476
539
|
store,
|
|
477
540
|
getConfig: () => ctxHolder.config,
|
|
478
|
-
setConfig:
|
|
479
|
-
ctxHolder.config = config;
|
|
480
|
-
ctxHolder.current = { ...ctxHolder.current, config };
|
|
481
|
-
ctxHolder.watchService?.updateCollections(
|
|
482
|
-
watchedCollections(config),
|
|
483
|
-
withContentTypeRules({}, config)
|
|
484
|
-
);
|
|
485
|
-
},
|
|
541
|
+
setConfig: setServedConfig,
|
|
486
542
|
actualConfigPath,
|
|
487
543
|
indexName: canonicalizeIndexName(options.index ?? DEFAULT_INDEX_NAME),
|
|
488
544
|
toolMutex,
|
|
@@ -633,6 +689,7 @@ export async function startResidentRuntime(
|
|
|
633
689
|
setPolicySessionInvalidator(invalidator) {
|
|
634
690
|
policySessionInvalidator = invalidator;
|
|
635
691
|
},
|
|
692
|
+
refreshConfig,
|
|
636
693
|
async syncAll(syncOptions = {}) {
|
|
637
694
|
const request = admission.admit();
|
|
638
695
|
if (!request) throw new Error("Resident runtime is shutting down");
|
|
@@ -674,8 +731,11 @@ export async function startResidentRuntime(
|
|
|
674
731
|
dispose(closeSurface) {
|
|
675
732
|
if (disposal) return disposal;
|
|
676
733
|
disposed = true;
|
|
734
|
+
clearInterval(configRefreshTimer);
|
|
677
735
|
admission.stop();
|
|
678
736
|
jobManager.stop();
|
|
737
|
+
// A file mid-preparation fails now instead of holding shutdown.
|
|
738
|
+
disposeFileProcessor();
|
|
679
739
|
admissionState = "draining";
|
|
680
740
|
shutdownState = "graceful";
|
|
681
741
|
disposal = disposeResidentResources({
|
|
@@ -725,5 +785,21 @@ export async function startResidentRuntime(
|
|
|
725
785
|
};
|
|
726
786
|
};
|
|
727
787
|
mcpContext.getResidentStatus = () => runtime.getStatus();
|
|
788
|
+
// Without requests (a headless daemon) the watcher still follows the file.
|
|
789
|
+
let lastRefreshError: string | undefined;
|
|
790
|
+
const configRefreshTimer = setInterval(() => {
|
|
791
|
+
backgroundWork.start(async () => {
|
|
792
|
+
try {
|
|
793
|
+
await refreshConfig();
|
|
794
|
+
lastRefreshError = undefined;
|
|
795
|
+
} catch (error) {
|
|
796
|
+
const message = remoteSafeSessionsError(error).message;
|
|
797
|
+
if (message !== lastRefreshError)
|
|
798
|
+
console.warn(`gno ${mode}: config not reloaded: ${message}`);
|
|
799
|
+
lastRefreshError = message;
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
}, CONFIG_REFRESH_INTERVAL_MS);
|
|
803
|
+
configRefreshTimer.unref?.();
|
|
728
804
|
return { success: true, runtime };
|
|
729
805
|
}
|
|
@@ -8,7 +8,7 @@ import type { SqliteAdapter } from "../../store/sqlite/adapter";
|
|
|
8
8
|
import type { ServerContext } from "../context";
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
-
|
|
11
|
+
readSimilaritySourceVectors,
|
|
12
12
|
resolveStoredVectorSource,
|
|
13
13
|
similarityHitDocuments,
|
|
14
14
|
storedVectorSearchOptions,
|
|
@@ -313,7 +313,7 @@ export async function handleDocBacklinks(
|
|
|
313
313
|
* ?threshold=0.5 (min similarity score 0-1, default 0.5)
|
|
314
314
|
* ?crossCollection=true (search across all collections, default false)
|
|
315
315
|
*
|
|
316
|
-
* Algorithm:
|
|
316
|
+
* Algorithm: first-chunk source vector (readSimilaritySourceVectors) -> vector search -> exclude self
|
|
317
317
|
*/
|
|
318
318
|
export async function handleDocSimilar(
|
|
319
319
|
ctx: ServerContext,
|
|
@@ -378,18 +378,14 @@ export async function handleDocSimilar(
|
|
|
378
378
|
} satisfies SimilarDocResponse);
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
-
//
|
|
381
|
+
// Similarity source vector (first chunk, unit length), active partition
|
|
382
382
|
const db = store.getRawDb();
|
|
383
383
|
const source = resolveStoredVectorSource(db, ctx.vectorIndex.model);
|
|
384
384
|
let embedding: Float32Array | undefined;
|
|
385
385
|
try {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
source,
|
|
390
|
-
[{ id: doc.id, mirrorHash: doc.mirrorHash }],
|
|
391
|
-
{ firstChunkOnly: true }
|
|
392
|
-
).get(doc.id) ?? [];
|
|
386
|
+
embedding = readSimilaritySourceVectors(db, source, [
|
|
387
|
+
{ id: doc.id, mirrorHash: doc.mirrorHash },
|
|
388
|
+
]).get(doc.id);
|
|
393
389
|
} catch (e) {
|
|
394
390
|
return errorResponse(
|
|
395
391
|
"RUNTIME",
|
|
@@ -410,20 +406,6 @@ export async function handleDocSimilar(
|
|
|
410
406
|
},
|
|
411
407
|
} satisfies SimilarDocResponse);
|
|
412
408
|
}
|
|
413
|
-
const dimensions = embedding.length;
|
|
414
|
-
|
|
415
|
-
// Normalize embedding for cosine similarity
|
|
416
|
-
let norm = 0;
|
|
417
|
-
for (let i = 0; i < dimensions; i++) {
|
|
418
|
-
const val = embedding[i] ?? 0;
|
|
419
|
-
norm += val * val;
|
|
420
|
-
}
|
|
421
|
-
norm = Math.sqrt(norm);
|
|
422
|
-
if (norm > 0) {
|
|
423
|
-
for (let i = 0; i < dimensions; i++) {
|
|
424
|
-
embedding[i] = (embedding[i] ?? 0) / norm;
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
409
|
|
|
428
410
|
// Search for similar docs (request extra to account for self-exclusion, filtering)
|
|
429
411
|
const candidateLimit = Math.min(limit * 20, 200);
|
package/src/serve/server.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { withRemoteHostPathRedaction } from "./host-path-redaction";
|
|
|
28
28
|
import { PDFJS_ASSET_CACHE_CONTROL } from "./pdfjs-assets";
|
|
29
29
|
// HTML import - Bun handles bundling TSX/CSS automatically via routes
|
|
30
30
|
import homepage from "./public/index.html";
|
|
31
|
-
import { handleResidentRead } from "./resident-request";
|
|
31
|
+
import { handleResidentRead, refreshResidentConfig } from "./resident-request";
|
|
32
32
|
import {
|
|
33
33
|
handleActiveJob,
|
|
34
34
|
handleAsk,
|
|
@@ -702,7 +702,8 @@ export async function startServer(
|
|
|
702
702
|
"/api/collections": {
|
|
703
703
|
GET: async () =>
|
|
704
704
|
withSecurityHeaders(
|
|
705
|
-
await
|
|
705
|
+
(await refreshResidentConfig(runtime as ResidentRuntime)) ??
|
|
706
|
+
(await handleCollections(ctxHolder.config)),
|
|
706
707
|
isDev
|
|
707
708
|
),
|
|
708
709
|
POST: async (req: Request) => {
|
package/src/serve/status.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
|
|
15
15
|
import { getModelsCachePath } from "../app/constants";
|
|
16
16
|
import { buildContentTypeBoostStatus } from "../config/content-types";
|
|
17
|
+
import { isEmptyActivationCollection } from "../core/activation-empty";
|
|
17
18
|
import {
|
|
18
19
|
type ActivationStatus,
|
|
19
20
|
buildActivationStatus,
|
|
@@ -555,7 +556,13 @@ function buildOnboarding(
|
|
|
555
556
|
): AppStatusResponse["onboarding"] {
|
|
556
557
|
const foldersReady = activation.collections.length > 0;
|
|
557
558
|
const modelsReady = modelCheck.status === "ok";
|
|
558
|
-
|
|
559
|
+
// An empty folder is not a failure, but a workspace with no documents yet
|
|
560
|
+
// has not finished its first proof.
|
|
561
|
+
const indexedReady = activation.healthy && activation.usable;
|
|
562
|
+
const readyCount = activation.collections.filter(({ ready }) => ready).length;
|
|
563
|
+
const emptyCount = activation.collections.filter(
|
|
564
|
+
isEmptyActivationCollection
|
|
565
|
+
).length;
|
|
559
566
|
const semanticStates = [
|
|
560
567
|
...new Set(
|
|
561
568
|
activation.collections.map(
|
|
@@ -563,10 +570,22 @@ function buildOnboarding(
|
|
|
563
570
|
)
|
|
564
571
|
),
|
|
565
572
|
];
|
|
566
|
-
const failedActivation =
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
573
|
+
const failedActivation =
|
|
574
|
+
activation.collections.find(
|
|
575
|
+
(collection) =>
|
|
576
|
+
!collection.ready && !isEmptyActivationCollection(collection)
|
|
577
|
+
) ?? activation.collections.find(({ ready }) => !ready);
|
|
578
|
+
const activationDetail = !failedActivation?.remediation
|
|
579
|
+
? "Run the first sync to populate a searchable lexical index."
|
|
580
|
+
: isEmptyActivationCollection(failedActivation)
|
|
581
|
+
? `${failedActivation.collection} has no documents yet. Add files, then run: ${failedActivation.remediation.command}`
|
|
582
|
+
: `${failedActivation.collection}: ${failedActivation.remediation.stage}/${failedActivation.remediation.code}. Run: ${failedActivation.remediation.command}`;
|
|
583
|
+
const emptyNote =
|
|
584
|
+
emptyCount > 0
|
|
585
|
+
? ` ${summarizeCount(emptyCount, "folder")} with no documents yet.`
|
|
586
|
+
: "";
|
|
587
|
+
const passedFolders =
|
|
588
|
+
emptyCount > 0 ? "Every folder with documents" : "Every folder";
|
|
570
589
|
|
|
571
590
|
const steps = [
|
|
572
591
|
{
|
|
@@ -600,7 +619,7 @@ function buildOnboarding(
|
|
|
600
619
|
title: "Prove lexical retrieval",
|
|
601
620
|
status: indexedReady ? "complete" : foldersReady ? "current" : "upcoming",
|
|
602
621
|
detail: indexedReady
|
|
603
|
-
? `${summarizeCount(
|
|
622
|
+
? `${summarizeCount(readyCount, "folder")} passed a corpus-derived lexical proof.${emptyNote}`
|
|
604
623
|
: activationDetail,
|
|
605
624
|
action: "sync",
|
|
606
625
|
},
|
|
@@ -636,8 +655,8 @@ function buildOnboarding(
|
|
|
636
655
|
stage: "ready",
|
|
637
656
|
headline: "Workspace ready",
|
|
638
657
|
detail: modelsReady
|
|
639
|
-
?
|
|
640
|
-
:
|
|
658
|
+
? `${passedFolders} passed lexical retrieval.${emptyNote} Local model files are cached; semantic retrieval remains separate (${semanticStates.join(", ")}).`
|
|
659
|
+
: `${passedFolders} passed lexical retrieval.${emptyNote} Semantic retrieval remains an optional next step (${semanticStates.join(", ")}).`,
|
|
641
660
|
suggestedCollections: suggestions,
|
|
642
661
|
steps,
|
|
643
662
|
};
|
|
@@ -53,9 +53,10 @@ export async function assertInstanceBinding(
|
|
|
53
53
|
* before it can touch this one.
|
|
54
54
|
*/
|
|
55
55
|
export async function readInstanceConfig(
|
|
56
|
-
instance: SessionsInstance
|
|
56
|
+
instance: SessionsInstance,
|
|
57
|
+
load: typeof loadConfig = loadConfig
|
|
57
58
|
): Promise<Config> {
|
|
58
|
-
const loaded = await
|
|
59
|
+
const loaded = await load(instance.configPath);
|
|
59
60
|
if (!loaded.ok) {
|
|
60
61
|
throw new SessionsError(
|
|
61
62
|
"SESSIONS_RUNTIME_FAILURE",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { Database } from "bun:sqlite";
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
readSimilaritySourceVectors,
|
|
10
10
|
resolveStoredVectorSource,
|
|
11
11
|
} from "../vector/stored-vectors";
|
|
12
12
|
|
|
@@ -27,13 +27,6 @@ export interface StoredSimilarityEdge {
|
|
|
27
27
|
score: number;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function unit(vector: Float32Array): Float32Array {
|
|
31
|
-
let norm = 0;
|
|
32
|
-
for (const value of vector) norm += value * value;
|
|
33
|
-
norm = Math.sqrt(norm);
|
|
34
|
-
return norm > 0 ? vector.map((value) => value / norm) : vector;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
30
|
function dot(a: Float32Array, b: Float32Array): number {
|
|
38
31
|
let sum = 0;
|
|
39
32
|
for (let i = 0; i < a.length; i++) sum += (a[i] ?? 0) * (b[i] ?? 0);
|
|
@@ -61,15 +54,14 @@ export function storedSimilarityEdges(
|
|
|
61
54
|
ORDER BY docid
|
|
62
55
|
`)
|
|
63
56
|
.all(JSON.stringify(docids));
|
|
64
|
-
const vectors =
|
|
57
|
+
const vectors = readSimilaritySourceVectors(
|
|
65
58
|
db,
|
|
66
59
|
resolveStoredVectorSource(db, model),
|
|
67
|
-
documents
|
|
68
|
-
{ firstChunkOnly: true }
|
|
60
|
+
documents
|
|
69
61
|
);
|
|
70
62
|
const nodes = documents.flatMap((document) => {
|
|
71
|
-
const vector = vectors.get(document.id)
|
|
72
|
-
return vector ? [{ ...document, vector
|
|
63
|
+
const vector = vectors.get(document.id);
|
|
64
|
+
return vector ? [{ ...document, vector }] : [];
|
|
73
65
|
});
|
|
74
66
|
const edges: StoredSimilarityEdge[] = [];
|
|
75
67
|
for (const node of nodes) {
|