@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.
Files changed (66) hide show
  1. package/README.md +1 -1
  2. package/assets/spa-production.json.gz +0 -0
  3. package/browser-extension/artifacts/{gno-browser-clipper-v2.8.2.zip → gno-browser-clipper-v2.8.4.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v2.8.4.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +9 -1
  7. package/spec/cli.md +77 -6
  8. package/spec/mcp.md +5 -6
  9. package/spec/output-schemas/doctor.schema.json +10 -4
  10. package/spec/output-schemas/status.schema.json +4 -2
  11. package/src/cli/commands/daemon.ts +7 -2
  12. package/src/cli/commands/doctor-activation.ts +38 -11
  13. package/src/cli/commands/doctor.ts +46 -25
  14. package/src/cli/commands/index-cmd.ts +10 -2
  15. package/src/cli/commands/links.ts +5 -22
  16. package/src/cli/commands/shared.ts +26 -1
  17. package/src/cli/commands/status.ts +76 -4
  18. package/src/cli/commands/update.ts +6 -1
  19. package/src/cli/commands/vec.ts +42 -2
  20. package/src/cli/program.ts +2 -2
  21. package/src/config/types.ts +19 -0
  22. package/src/converters/adapters/markitdownTs/adapter.ts +1 -52
  23. package/src/converters/adapters/shared/ooxml-protection.ts +58 -0
  24. package/src/converters/adapters/xlsx/adapter.ts +132 -0
  25. package/src/converters/adapters/xlsx/sheet-markdown.ts +312 -0
  26. package/src/converters/registry.ts +62 -6
  27. package/src/converters/versions.ts +2 -0
  28. package/src/core/activation-empty.ts +16 -0
  29. package/src/core/activation-status.ts +23 -5
  30. package/src/core/audit-freshness.ts +1 -1
  31. package/src/core/audit-outside-index.ts +48 -3
  32. package/src/core/change-diff.ts +16 -4
  33. package/src/core/link-destination-parse.ts +21 -4
  34. package/src/core/link-inventory-markdown.ts +23 -3
  35. package/src/core/links.ts +127 -31
  36. package/src/core/network-boundary-inventory.ts +18 -0
  37. package/src/index.ts +10 -0
  38. package/src/ingestion/document-metadata.ts +256 -0
  39. package/src/ingestion/file-child-env.ts +9 -0
  40. package/src/ingestion/file-child.ts +106 -0
  41. package/src/ingestion/file-processor.ts +546 -0
  42. package/src/ingestion/frontmatter.ts +6 -2
  43. package/src/ingestion/index.ts +1 -0
  44. package/src/ingestion/prepare-convert.ts +32 -0
  45. package/src/ingestion/prepare-file.ts +272 -0
  46. package/src/ingestion/prepare-worker.ts +43 -0
  47. package/src/ingestion/record-container.ts +7 -2
  48. package/src/ingestion/strip.ts +136 -31
  49. package/src/ingestion/sync-options.ts +7 -1
  50. package/src/ingestion/sync.ts +207 -400
  51. package/src/ingestion/types.ts +18 -0
  52. package/src/llm/native-worker/embedding-identity.ts +15 -3
  53. package/src/mcp/http-transport.ts +11 -0
  54. package/src/mcp/tools/links.ts +10 -38
  55. package/src/serve/activation-health.ts +25 -7
  56. package/src/serve/public/components/BootstrapStatus.tsx +9 -4
  57. package/src/serve/resident-request.ts +19 -0
  58. package/src/serve/resident-runtime.ts +84 -8
  59. package/src/serve/routes/links.ts +6 -24
  60. package/src/serve/server.ts +3 -2
  61. package/src/serve/status.ts +27 -8
  62. package/src/sessions/config-refresh.ts +3 -2
  63. package/src/store/sqlite/graph-similarity.ts +5 -13
  64. package/src/store/vector/partition-index.ts +100 -0
  65. package/src/store/vector/stored-vectors.ts +20 -15
  66. package/browser-extension/artifacts/gno-browser-clipper-v2.8.2.zip.sha256 +0 -1
@@ -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
- const nativePackage: { version: string } = await Bun.file(
5
- new URL("../package.json", import.meta.resolve("node-llama-cpp"))
6
- ).json();
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");
@@ -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
- readStoredDocumentVectors,
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
- // Stored chunk vectors from the active partition (NO model loading required)
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 vectors: Float32Array[];
423
+ let embedding: Float32Array | undefined;
423
424
  try {
424
- vectors =
425
- readStoredDocumentVectors(db, source, [
426
- { id: doc.id, mirrorHash: doc.mirrorHash },
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
- const first = vectors[0];
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
- avgEmbedding,
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
- activation.collections.map(
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: `${countLabel(activation.collections.length, "folder")} passed lexical retrieval`,
26
- detail: `Lexical search is proven. Semantic availability is separate (${semanticReasons.join(", ")}).`,
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(({ ready }) => !ready);
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
- ? "Lexical retrieval proven"
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
- : `${collection.remediation?.stage ?? "index"}/${collection.remediation?.code ?? "index_query_failed"}`}
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: (config) => {
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
- readStoredDocumentVectors,
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: seq=0 embedding (fallback to first available) -> vector search -> exclude self
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
- // Stored vector of the document's first chunk, from the active partition
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
- [embedding] =
387
- readStoredDocumentVectors(
388
- db,
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);
@@ -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 handleCollections(ctxHolder.config),
705
+ (await refreshResidentConfig(runtime as ResidentRuntime)) ??
706
+ (await handleCollections(ctxHolder.config)),
706
707
  isDev
707
708
  ),
708
709
  POST: async (req: Request) => {
@@ -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
- const indexedReady = activation.healthy;
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 = activation.collections.find(({ ready }) => !ready);
567
- const activationDetail = failedActivation?.remediation
568
- ? `${failedActivation.collection}: ${failedActivation.remediation.stage}/${failedActivation.remediation.code}. Run: ${failedActivation.remediation.command}`
569
- : "Run the first sync to populate a searchable lexical index.";
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(activation.collections.length, "folder")} passed a corpus-derived lexical proof.`
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
- ? `Every folder passed lexical retrieval. Local model files are cached; semantic retrieval remains separate (${semanticStates.join(", ")}).`
640
- : `Every folder passed lexical retrieval. Semantic retrieval remains an optional next step (${semanticStates.join(", ")}).`,
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 loadConfig(instance.configPath);
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
- readStoredDocumentVectors,
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 = readStoredDocumentVectors(
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)?.[0];
72
- return vector ? [{ ...document, vector: unit(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) {