@gmickel/gno 2.7.1 → 2.8.1

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 (96) hide show
  1. package/README.md +3 -2
  2. package/assets/skill/SKILL.md +11 -1
  3. package/assets/skill/cli-reference.md +8 -1
  4. package/assets/skill/examples.md +2 -1
  5. package/assets/skill/mcp-reference.md +3 -1
  6. package/assets/skill/recipes/memory-scoped-recall.md +10 -5
  7. package/assets/spa-production.json.gz +0 -0
  8. package/browser-extension/artifacts/{gno-browser-clipper-v2.7.1.zip → gno-browser-clipper-v2.8.1.zip} +0 -0
  9. package/browser-extension/artifacts/gno-browser-clipper-v2.8.1.zip.sha256 +1 -0
  10. package/browser-extension/dist/manifest.json +1 -1
  11. package/package.json +1 -1
  12. package/spec/cli.md +86 -9
  13. package/spec/db/schema.sql +0 -1
  14. package/spec/mcp.md +26 -7
  15. package/spec/output-schemas/audit-report.schema.json +18 -4
  16. package/spec/output-schemas/backlinks.schema.json +4 -0
  17. package/spec/output-schemas/collection-list.schema.json +13 -0
  18. package/spec/output-schemas/graph.schema.json +2 -0
  19. package/spec/output-schemas/links-list.schema.json +4 -0
  20. package/spec/output-schemas/memory-recall.schema.json +1 -1
  21. package/spec/output-schemas/status.schema.json +18 -3
  22. package/src/cli/commands/audit.ts +23 -4
  23. package/src/cli/commands/collection/list.ts +39 -5
  24. package/src/cli/commands/embed.ts +3 -3
  25. package/src/cli/commands/graph.ts +3 -1
  26. package/src/cli/commands/links.ts +61 -180
  27. package/src/cli/commands/shared.ts +7 -0
  28. package/src/cli/commands/status.ts +6 -0
  29. package/src/cli/program.ts +12 -2
  30. package/src/config/loader.ts +43 -0
  31. package/src/config/types.ts +8 -0
  32. package/src/core/audit-contract.ts +16 -4
  33. package/src/core/audit-freshness.ts +11 -1
  34. package/src/core/audit-links.ts +197 -25
  35. package/src/core/audit-outside-index.ts +215 -0
  36. package/src/core/audit-provenance.ts +11 -4
  37. package/src/core/audit-workspace.ts +30 -9
  38. package/src/core/audit.ts +76 -16
  39. package/src/core/context-compiler.ts +3 -0
  40. package/src/core/context-evidence.ts +11 -0
  41. package/src/core/graph-edge-confidence.ts +23 -1
  42. package/src/core/host-paths.ts +1 -0
  43. package/src/core/knowledge-impact.ts +28 -0
  44. package/src/core/link-inventory-markdown.ts +2 -3
  45. package/src/core/link-workspace.ts +324 -0
  46. package/src/core/links.ts +40 -17
  47. package/src/core/memory-recall.ts +254 -15
  48. package/src/core/memory-types.ts +12 -0
  49. package/src/core/memory.ts +2 -0
  50. package/src/core/retrieval-replay-candidate.ts +6 -0
  51. package/src/core/retrieval-trace-request.ts +3 -0
  52. package/src/index.ts +14 -1
  53. package/src/ingestion/graph-reconciliation.ts +77 -15
  54. package/src/ingestion/source-availability/darwin-path.ts +9 -3
  55. package/src/ingestion/sync.ts +27 -4
  56. package/src/ingestion/types.ts +14 -0
  57. package/src/llm/inference-scope.ts +4 -3
  58. package/src/mcp/http-egress.ts +42 -3
  59. package/src/mcp/tools/audit.ts +11 -2
  60. package/src/mcp/tools/changes.ts +1 -0
  61. package/src/mcp/tools/links.ts +74 -93
  62. package/src/mcp/tools/sessions.ts +33 -4
  63. package/src/mcp/tools/status.ts +4 -0
  64. package/src/pipeline/expansion.ts +19 -31
  65. package/src/pipeline/graph-retrieval.ts +22 -2
  66. package/src/pipeline/hybrid.ts +1 -1
  67. package/src/pipeline/search.ts +2 -0
  68. package/src/pipeline/types.ts +10 -3
  69. package/src/sdk/client.ts +1 -0
  70. package/src/serve/findings-pass.ts +1 -1
  71. package/src/serve/public/components/editor/MarkdownPreview.tsx +5 -3
  72. package/src/serve/public/pages/GraphView.tsx +2 -0
  73. package/src/serve/routes/changes.ts +6 -1
  74. package/src/serve/routes/graph.ts +3 -1
  75. package/src/serve/routes/links.ts +45 -50
  76. package/src/serve/routes/sessions.ts +41 -53
  77. package/src/serve/server.ts +2 -1
  78. package/src/serve/status.ts +1 -0
  79. package/src/sessions/config-refresh.ts +111 -0
  80. package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
  81. package/src/store/migrations/034-collection-link-workspace.ts +47 -0
  82. package/src/store/migrations/index.ts +4 -0
  83. package/src/store/sqlite/adapter.ts +477 -337
  84. package/src/store/sqlite/eligibility.ts +8 -2
  85. package/src/store/sqlite/graph-link-resolver.ts +259 -5
  86. package/src/store/sqlite/graph-neighbors.ts +147 -40
  87. package/src/store/sqlite/graph-reference-state.ts +13 -2
  88. package/src/store/sqlite/graph-similarity.ts +96 -0
  89. package/src/store/sqlite/workspace-link-resolver.ts +742 -0
  90. package/src/store/types.ts +64 -5
  91. package/src/store/vector/stats.ts +1 -1
  92. package/src/store/vector/status.ts +27 -0
  93. package/src/store/vector/stored-vectors.ts +158 -0
  94. package/src/store/vector/types.ts +6 -0
  95. package/src/store/vector/variant-search.ts +30 -14
  96. package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +0 -1
@@ -191,6 +191,12 @@ export interface SearchOptions extends InferenceOptions {
191
191
  minScore?: number;
192
192
  /** Filter by collection */
193
193
  collection?: string;
194
+ /**
195
+ * Internal graph allowlist for a request partitioned into one retrieval per
196
+ * collection (Context Capsules, replay): graph neighbours may come from any
197
+ * of these collections, never from others. Defaults to `[collection]`.
198
+ */
199
+ graphCollections?: string[];
194
200
  /** Internal exact corpus scope used by deterministic retrieval replay. */
195
201
  retrievalScope?: {
196
202
  relPathPrefix?: string;
@@ -226,6 +232,10 @@ export interface SearchOptions extends InferenceOptions {
226
232
  scopes: string[];
227
233
  excludeSuperseded: boolean;
228
234
  };
235
+ /** Internal: match any positive lexical term (OR) instead of all (AND). */
236
+ anyTerm?: boolean;
237
+ /** Internal: drop hits below this fraction of the best raw BM25 score. */
238
+ minRelativeScore?: number;
229
239
  }
230
240
 
231
241
  /** Structured query mode identifier */
@@ -406,8 +416,6 @@ export type RerankedCandidate = FusionCandidate & {
406
416
 
407
417
  /** Search pipeline configuration */
408
418
  export interface PipelineConfig {
409
- /** Expansion timeout in ms */
410
- expansionTimeout: number;
411
419
  /** Max candidates to rerank */
412
420
  rerankCandidates: number;
413
421
  /** RRF configuration */
@@ -418,7 +426,6 @@ export interface PipelineConfig {
418
426
 
419
427
  /** Default pipeline configuration */
420
428
  export const DEFAULT_PIPELINE_CONFIG: PipelineConfig = {
421
- expansionTimeout: 5000,
422
429
  rerankCandidates: 20,
423
430
  rrf: DEFAULT_RRF_CONFIG,
424
431
  blendingSchedule: DEFAULT_BLENDING_SCHEDULE,
package/src/sdk/client.ts CHANGED
@@ -1509,6 +1509,7 @@ class GnoClientImpl implements GnoClient {
1509
1509
  await this.store.getStatus({
1510
1510
  embedModel: resolveModelUri(this.config, "embed"),
1511
1511
  chunking: this.config.chunking ?? {},
1512
+ configuredCollections: this.config.collections.map(({ name }) => name),
1512
1513
  })
1513
1514
  );
1514
1515
  return {
@@ -30,7 +30,7 @@ import {
30
30
  } from "../core/findings-run-state";
31
31
  import { acquireCliWriteLease } from "../core/write-lease";
32
32
 
33
- /** Audit report cap; matches the audit schema ceiling. */
33
+ /** Audit report cap for the daemon findings pass. */
34
34
  const FINDINGS_AUDIT_MAX_FINDINGS = 1000;
35
35
  const LEASE_HOLDER_COMMAND = "gno daemon (findings pass)";
36
36
  const CONTROL_CHARS = /\p{Cc}/gu;
@@ -16,6 +16,7 @@ import remarkGfm from "remark-gfm";
16
16
  import {
17
17
  normalizeWikiName,
18
18
  parseTargetParts,
19
+ splitWikiLinkContent,
19
20
  stripWikiMdExt,
20
21
  } from "../../../../core/links";
21
22
  import { slugifySectionTitle } from "../../../../core/sections";
@@ -103,9 +104,10 @@ function renderMarkdownWithWikiLinks(
103
104
  }
104
105
 
105
106
  return content.replace(WIKI_LINK_REGEX, (match, rawContent: string) => {
106
- const [rawTarget, rawAlias] = rawContent.split("|");
107
- const displayText = rawAlias?.trim() || rawTarget?.trim() || match;
108
- const parsed = parseTargetParts(rawTarget ?? "");
107
+ const { target: rawTarget, alias: rawAlias } =
108
+ splitWikiLinkContent(rawContent);
109
+ const displayText = rawAlias?.trim() || rawTarget.trim() || match;
110
+ const parsed = parseTargetParts(rawTarget);
109
111
  const targetCollection = parsed.collection || collection || "";
110
112
  const targetRefKey = normalizeWikiName(stripWikiMdExt(parsed.ref));
111
113
  const targetAnchorKey = (parsed.anchor ?? "").trim().toLowerCase();
@@ -83,6 +83,8 @@ interface GraphLink {
83
83
  resolution:
84
84
  | "exact-title"
85
85
  | "exact-path"
86
+ | "exact-name"
87
+ | "tie-break"
86
88
  | "path-fallback"
87
89
  | "ambiguous-fallback"
88
90
  | "similarity";
@@ -79,7 +79,12 @@ export async function handleImpact(
79
79
  ): Promise<Response> {
80
80
  const ref = url.searchParams.get("ref")?.trim();
81
81
  if (!ref) return errorResponse("VALIDATION", "ref is required");
82
- const input: KnowledgeImpactInput = {};
82
+ const collections = url.searchParams
83
+ .getAll("collection")
84
+ .map((value) => value.trim())
85
+ .filter(Boolean);
86
+ const input: KnowledgeImpactInput =
87
+ collections.length > 0 ? { collections } : {};
83
88
  for (const [queryName, inputName] of [
84
89
  ["maxDepth", "maxDepth"],
85
90
  ["maxNodes", "maxNodes"],
@@ -141,7 +141,8 @@ function parseBoolean(value: string | null, defaultValue: boolean): boolean {
141
141
  */
142
142
  export async function handleGraph(
143
143
  store: SqliteAdapter,
144
- url: URL
144
+ url: URL,
145
+ embedModel?: string
145
146
  ): Promise<Response> {
146
147
  // Parse query params
147
148
  const collection = url.searchParams.get("collection") || undefined;
@@ -197,6 +198,7 @@ export async function handleGraph(
197
198
  threshold: thresholdResult.value,
198
199
  linkedOnly,
199
200
  similarTopK: similarTopKResult.value,
201
+ embedModel,
200
202
  };
201
203
 
202
204
  const result = await store.getGraph(options);
@@ -7,7 +7,12 @@
7
7
  import type { SqliteAdapter } from "../../store/sqlite/adapter";
8
8
  import type { ServerContext } from "../context";
9
9
 
10
- import { decodeEmbedding } from "../../store/vector/sqlite-vec";
10
+ import {
11
+ readStoredDocumentVectors,
12
+ resolveStoredVectorSource,
13
+ similarityHitDocuments,
14
+ storedVectorSearchOptions,
15
+ } from "../../store/vector/stored-vectors";
11
16
 
12
17
  // ─────────────────────────────────────────────────────────────────────────────
13
18
  // Types
@@ -34,6 +39,8 @@ export interface LinkResponse {
34
39
  resolvedUri?: string;
35
40
  /** Resolved target title (if found) */
36
41
  resolvedTitle?: string;
42
+ /** Collection of the resolved target (may differ from the source's) */
43
+ resolvedCollection?: string;
37
44
  }>;
38
45
  meta: {
39
46
  docid: string;
@@ -49,6 +56,8 @@ export interface BacklinkResponse {
49
56
  sourceDocid: string;
50
57
  sourceUri: string;
51
58
  sourceTitle?: string;
59
+ /** Collection of the linking document */
60
+ sourceCollection?: string;
52
61
  linkText?: string;
53
62
  startLine: number;
54
63
  startCol: number;
@@ -189,6 +198,11 @@ export async function handleDocLinks(
189
198
  targetRefNorm: l.targetRefNorm,
190
199
  targetCollection: l.targetCollection || doc.collection,
191
200
  linkType: l.linkType,
201
+ source: {
202
+ collection: doc.collection,
203
+ relPath: doc.relPath,
204
+ explicit: Boolean(l.targetCollection),
205
+ },
192
206
  }))
193
207
  );
194
208
  const resolutionAvailable = resolvedResult.ok;
@@ -217,6 +231,9 @@ export async function handleDocLinks(
217
231
  resolvedDocid: resolved.docid,
218
232
  resolvedUri: resolved.uri,
219
233
  resolvedTitle: resolved.title ?? undefined,
234
+ ...(resolved.collection && {
235
+ resolvedCollection: resolved.collection,
236
+ }),
220
237
  }),
221
238
  }),
222
239
  };
@@ -273,6 +290,7 @@ export async function handleDocBacklinks(
273
290
  backlinks: backlinks.map((b) => ({
274
291
  sourceDocid: b.sourceDocid,
275
292
  sourceUri: b.sourceDocUri,
293
+ ...(b.sourceCollection && { sourceCollection: b.sourceCollection }),
276
294
  ...(b.sourceDocTitle && { sourceTitle: b.sourceDocTitle }),
277
295
  ...(b.linkText && { linkText: b.linkText }),
278
296
  startLine: b.startLine,
@@ -360,31 +378,27 @@ export async function handleDocSimilar(
360
378
  } satisfies SimilarDocResponse);
361
379
  }
362
380
 
363
- // Get embedding model from context
364
- const embedModel = ctx.vectorIndex.model;
365
-
366
- // Get document embedding from content_vectors (prefer seq=0)
381
+ // Stored vector of the document's first chunk, from the active partition
367
382
  const db = store.getRawDb();
368
-
369
- interface VectorRow {
370
- embedding: Uint8Array;
383
+ const source = resolveStoredVectorSource(db, ctx.vectorIndex.model);
384
+ let embedding: Float32Array | undefined;
385
+ try {
386
+ [embedding] =
387
+ readStoredDocumentVectors(
388
+ db,
389
+ source,
390
+ [{ id: doc.id, mirrorHash: doc.mirrorHash }],
391
+ { firstChunkOnly: true }
392
+ ).get(doc.id) ?? [];
393
+ } catch (e) {
394
+ return errorResponse(
395
+ "RUNTIME",
396
+ `Invalid stored embedding data: ${e instanceof Error ? e.message : String(e)}`,
397
+ 500
398
+ );
371
399
  }
372
400
 
373
- const vectorRow = db
374
- .query<VectorRow, [string, string]>(
375
- "SELECT embedding FROM content_vectors WHERE mirror_hash = ? AND model = ? AND seq = 0 LIMIT 1"
376
- )
377
- .get(doc.mirrorHash, embedModel);
378
-
379
- const fallbackRow =
380
- vectorRow ??
381
- db
382
- .query<VectorRow, [string, string]>(
383
- "SELECT embedding FROM content_vectors WHERE mirror_hash = ? AND model = ? ORDER BY seq LIMIT 1"
384
- )
385
- .get(doc.mirrorHash, embedModel);
386
-
387
- if (!fallbackRow) {
401
+ if (!embedding) {
388
402
  return jsonResponse({
389
403
  similar: [],
390
404
  meta: {
@@ -396,20 +410,7 @@ export async function handleDocSimilar(
396
410
  },
397
411
  } satisfies SimilarDocResponse);
398
412
  }
399
-
400
- let dimensions: number;
401
- let embedding: Float32Array;
402
-
403
- try {
404
- embedding = decodeEmbedding(fallbackRow.embedding);
405
- dimensions = embedding.length;
406
- } catch (e) {
407
- return errorResponse(
408
- "RUNTIME",
409
- `Invalid stored embedding data: ${e instanceof Error ? e.message : String(e)}`,
410
- 500
411
- );
412
- }
413
+ const dimensions = embedding.length;
413
414
 
414
415
  // Normalize embedding for cosine similarity
415
416
  let norm = 0;
@@ -429,7 +430,7 @@ export async function handleDocSimilar(
429
430
  const searchResult = await ctx.vectorIndex.searchNearest(
430
431
  embedding,
431
432
  candidateLimit,
432
- {}
433
+ storedVectorSearchOptions(source)
433
434
  );
434
435
 
435
436
  if (!searchResult.ok) {
@@ -444,22 +445,16 @@ export async function handleDocSimilar(
444
445
  return errorResponse("RUNTIME", docsResult.error.message, 500);
445
446
  }
446
447
 
447
- const docsByHash = new Map(
448
- docsResult.value
449
- .filter((d) => d.mirrorHash && d.active)
450
- .map((d) => [d.mirrorHash!, d])
451
- );
452
-
453
- // Build similar docs list, excluding self
448
+ // Build similar docs list from each hit's owning documents, excluding self
454
449
  const similar: SimilarDocResponse["similar"] = [];
455
450
  const seenDocids = new Set<string>();
456
451
 
457
- for (const vec of searchResult.value) {
452
+ for (const { document: similarDoc, distance } of similarityHitDocuments(
453
+ searchResult.value,
454
+ docsResult.value.filter((d) => d.mirrorHash && d.active)
455
+ )) {
458
456
  if (similar.length >= limit) break;
459
457
 
460
- const similarDoc = docsByHash.get(vec.mirrorHash);
461
- if (!similarDoc) continue;
462
-
463
458
  // Exclude self
464
459
  if (similarDoc.docid === doc.docid) continue;
465
460
 
@@ -468,7 +463,7 @@ export async function handleDocSimilar(
468
463
 
469
464
  // Compute similarity score from cosine distance
470
465
  // sqlite-vec with cosine metric returns distance where similarity = 1 - distance
471
- const score = Math.max(0, Math.min(1, 1 - vec.distance));
466
+ const score = Math.max(0, Math.min(1, 1 - distance));
472
467
  if (score < threshold) continue;
473
468
 
474
469
  similar.push({
@@ -16,7 +16,6 @@ import type { SqliteAdapter } from "../../store/sqlite/adapter";
16
16
  import type { RequestPeerServer } from "../request-locality";
17
17
  import type { ContextHolder } from "./api";
18
18
 
19
- import { getIndexDbPath } from "../../app/constants";
20
19
  import { getConfigPaths, loadConfig } from "../../config";
21
20
  import { withContentTypeRules } from "../../ingestion";
22
21
  import {
@@ -28,8 +27,14 @@ import {
28
27
  runAutomationProfile,
29
28
  setAutomationProfile,
30
29
  } from "../../sessions/automation";
31
- import { assertSessionBinding } from "../../sessions/binding";
32
30
  import { SessionSourceSchema, watchedCollections } from "../../sessions/config";
31
+ import {
32
+ adoptServedConfig,
33
+ assertInstanceBinding as assertConfigBinding,
34
+ readInstanceConfig,
35
+ refreshServedConfig,
36
+ type ServedSessionsConfig,
37
+ } from "../../sessions/config-refresh";
33
38
  import { importInChildProcess } from "../../sessions/import-child";
34
39
  import { SessionsService } from "../../sessions/service";
35
40
  import {
@@ -186,18 +191,11 @@ function instanceIdentity(ctxHolder: ContextHolder): {
186
191
  }
187
192
 
188
193
  /** Refuse an archive config opened against a different index (and vice versa). */
189
- async function assertInstanceBinding(
194
+ function assertInstanceBinding(
190
195
  ctxHolder: ContextHolder,
191
196
  config: Config = ctxHolder.config
192
197
  ): Promise<void> {
193
- const { configPath, indexName } = instanceIdentity(ctxHolder);
194
- if (!config.sessions) return;
195
- await assertSessionBinding({
196
- config,
197
- configPath,
198
- indexName,
199
- dbPath: getIndexDbPath(indexName),
200
- });
198
+ return assertConfigBinding(instanceIdentity(ctxHolder), config);
201
199
  }
202
200
 
203
201
  /** Service over the instance's own config/index pair, binding checked. */
@@ -215,55 +213,47 @@ async function archiveService(
215
213
  });
216
214
  }
217
215
 
216
+ /** This instance's served config, as the shared config refresh sees it. */
217
+ function servedConfig(
218
+ ctxHolder: ContextHolder,
219
+ store: SqliteAdapter
220
+ ): ServedSessionsConfig {
221
+ return {
222
+ ...instanceIdentity(ctxHolder),
223
+ store,
224
+ config: ctxHolder.config,
225
+ setConfig: (config) => {
226
+ ctxHolder.config = config;
227
+ ctxHolder.current = { ...ctxHolder.current, config };
228
+ ctxHolder.watchService?.updateCollections(
229
+ watchedCollections(config),
230
+ withContentTypeRules({}, config)
231
+ );
232
+ },
233
+ invalidateEgressPolicy: async () => {
234
+ await ctxHolder.invalidateEgressPolicy?.();
235
+ },
236
+ markContentMutation: () => ctxHolder.markContentMutation?.(),
237
+ markIndexMutation: () => ctxHolder.markIndexMutation?.(),
238
+ };
239
+ }
240
+
218
241
  /**
219
242
  * Adopt a config the sessions service already persisted: project collections
220
243
  * and contexts into the open store, swap the in-memory context, and refresh
221
- * the watcher, egress policy and mutation generations (same sequence as the
222
- * config-sync route helpers).
244
+ * the watcher, egress policy and mutation generations.
223
245
  */
224
- async function adoptConfig(
246
+ function adoptConfig(
225
247
  ctxHolder: ContextHolder,
226
248
  store: SqliteAdapter,
227
249
  config: Config
228
250
  ): Promise<void> {
229
- const collections = await store.syncCollections(config.collections);
230
- if (!collections.ok) {
231
- throw new Error(
232
- `Config saved but collection sync failed: ${collections.error.message}`
233
- );
234
- }
235
- const contexts = await store.syncContexts(config.contexts ?? []);
236
- if (!contexts.ok) {
237
- throw new Error(
238
- `Config saved but context sync failed: ${contexts.error.message}`
239
- );
240
- }
241
- ctxHolder.config = config;
242
- ctxHolder.current = { ...ctxHolder.current, config };
243
- ctxHolder.watchService?.updateCollections(
244
- watchedCollections(config),
245
- withContentTypeRules({}, config)
246
- );
247
- await ctxHolder.invalidateEgressPolicy?.();
248
- ctxHolder.markContentMutation?.();
249
- ctxHolder.markIndexMutation?.();
251
+ return adoptServedConfig(servedConfig(ctxHolder, store), config);
250
252
  }
251
253
 
252
- /**
253
- * Read this instance's config file, binding checked: unreadable is an error,
254
- * never served stale, and a config rebound to another index is refused
255
- * before it can touch this one.
256
- */
257
- async function readConfigFile(ctxHolder: ContextHolder): Promise<Config> {
258
- const loaded = await loadConfig(instanceIdentity(ctxHolder).configPath);
259
- if (!loaded.ok) {
260
- throw new SessionsError(
261
- "SESSIONS_RUNTIME_FAILURE",
262
- "The server could not read its config file; fix the file (gno doctor shows the error) and reload."
263
- );
264
- }
265
- await assertInstanceBinding(ctxHolder, loaded.value);
266
- return loaded.value;
254
+ /** Read this instance's config file, binding checked (see readInstanceConfig). */
255
+ function readConfigFile(ctxHolder: ContextHolder): Promise<Config> {
256
+ return readInstanceConfig(instanceIdentity(ctxHolder));
267
257
  }
268
258
 
269
259
  // ─────────────────────────────────────────────────────────────────────────────
@@ -282,9 +272,7 @@ export async function refreshSessionsConfig(
282
272
  store: SqliteAdapter
283
273
  ): Promise<Response | null> {
284
274
  try {
285
- const config = await readConfigFile(ctxHolder);
286
- if (Bun.deepEquals(config, ctxHolder.config)) return null;
287
- await adoptConfig(ctxHolder, store, config);
275
+ await refreshServedConfig(servedConfig(ctxHolder, store));
288
276
  return null;
289
277
  } catch (error) {
290
278
  return sessionsErrorResponse(error);
@@ -3,6 +3,7 @@ import type { RequestPeerServer } from "./request-locality";
3
3
  import type { ResidentRuntime } from "./resident-runtime";
4
4
  import type { ContextHolder } from "./routes/api";
5
5
 
6
+ import { getActivePreset } from "../llm/registry";
6
7
  import {
7
8
  isHttpGatewayLoopbackBind,
8
9
  resolveHttpGatewayConfig,
@@ -1694,7 +1695,7 @@ export async function startServer(
1694
1695
  const url = new URL(req.url);
1695
1696
  return withSecurityHeaders(
1696
1697
  await handleResidentRead(runtime as ResidentRuntime, req, () =>
1697
- handleGraph(store, url)
1698
+ handleGraph(store, url, getActivePreset(ctxHolder.config).embed)
1698
1699
  ),
1699
1700
  isDev
1700
1701
  );
@@ -650,6 +650,7 @@ export async function buildAppStatus(
650
650
  const result = await ctx.store.getStatus({
651
651
  embedModel: resolveModelUri(ctx.config, "embed"),
652
652
  chunking: ctx.config.chunking ?? {},
653
+ configuredCollections: ctx.config.collections.map(({ name }) => name),
653
654
  });
654
655
  if (!result.ok) {
655
656
  throw result.error;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Keep a running server's session-archive config current with its config
3
+ * file. `gno sessions source add/remove` and automation changes rewrite the
4
+ * file from another process; the REST routes and the MCP session tools both
5
+ * re-read it here, binding checked, and adopt it only when it changed.
6
+ *
7
+ * @module src/sessions/config-refresh
8
+ */
9
+
10
+ import type { Config } from "../config/types";
11
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
12
+
13
+ import { getIndexDbPath } from "../app/constants";
14
+ import { loadConfig } from "../config";
15
+ import { collectionEgressPolicyEpoch } from "../core/collection-egress-policy-service";
16
+ import { assertSessionBinding } from "./binding";
17
+ import { SessionsError } from "./types";
18
+
19
+ /** The config/index pair a server instance was opened on. */
20
+ export interface SessionsInstance {
21
+ configPath: string;
22
+ indexName: string;
23
+ }
24
+
25
+ /** A running server's served config and the hooks that follow a swap. */
26
+ export interface ServedSessionsConfig extends SessionsInstance {
27
+ store: SqliteAdapter;
28
+ config: Config;
29
+ /** Swap the served config in memory (and anything derived from it). */
30
+ setConfig: (config: Config) => void;
31
+ invalidateEgressPolicy?: () => Promise<unknown>;
32
+ markContentMutation?: () => void;
33
+ markIndexMutation?: () => void;
34
+ }
35
+
36
+ /** Refuse an archive config opened against a different index (and vice versa). */
37
+ export async function assertInstanceBinding(
38
+ instance: SessionsInstance,
39
+ config: Config
40
+ ): Promise<void> {
41
+ if (!config.sessions) return;
42
+ await assertSessionBinding({
43
+ config,
44
+ configPath: instance.configPath,
45
+ indexName: instance.indexName,
46
+ dbPath: getIndexDbPath(instance.indexName),
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Read this instance's config file, binding checked: unreadable is an error,
52
+ * never served stale, and a config rebound to another index is refused
53
+ * before it can touch this one.
54
+ */
55
+ export async function readInstanceConfig(
56
+ instance: SessionsInstance
57
+ ): Promise<Config> {
58
+ const loaded = await loadConfig(instance.configPath);
59
+ if (!loaded.ok) {
60
+ throw new SessionsError(
61
+ "SESSIONS_RUNTIME_FAILURE",
62
+ "The server could not read its config file; fix the file (gno doctor shows the error) and reload."
63
+ );
64
+ }
65
+ await assertInstanceBinding(instance, loaded.value);
66
+ return loaded.value;
67
+ }
68
+
69
+ /**
70
+ * Adopt a config already persisted to the file: project collections and
71
+ * contexts into the open store, swap the served config, then refresh the
72
+ * egress policy (only when it changed) and mutation generations.
73
+ */
74
+ export async function adoptServedConfig(
75
+ served: ServedSessionsConfig,
76
+ config: Config
77
+ ): Promise<void> {
78
+ const collections = await served.store.syncCollections(config.collections);
79
+ if (!collections.ok) {
80
+ throw new Error(
81
+ `Config saved but collection sync failed: ${collections.error.message}`
82
+ );
83
+ }
84
+ const contexts = await served.store.syncContexts(config.contexts ?? []);
85
+ if (!contexts.ok) {
86
+ throw new Error(
87
+ `Config saved but context sync failed: ${contexts.error.message}`
88
+ );
89
+ }
90
+ const policyChanged =
91
+ collectionEgressPolicyEpoch(config) !==
92
+ collectionEgressPolicyEpoch(served.config);
93
+ served.setConfig(config);
94
+ if (policyChanged) await served.invalidateEgressPolicy?.();
95
+ served.markContentMutation?.();
96
+ served.markIndexMutation?.();
97
+ }
98
+
99
+ /**
100
+ * Adopt the config file when it changed underneath the running server.
101
+ * Returns the config to serve; throws a `SessionsError` when the file is
102
+ * unreadable or bound to another index, leaving the served config as it was.
103
+ */
104
+ export async function refreshServedConfig(
105
+ served: ServedSessionsConfig
106
+ ): Promise<Config> {
107
+ const config = await readInstanceConfig(served);
108
+ if (Bun.deepEquals(config, served.config)) return served.config;
109
+ await adoptServedConfig(served, config);
110
+ return config;
111
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Migration: drop the low-selectivity documents(active) index.
3
+ *
4
+ * GNO keeps no planner statistics, so SQLite treated `idx_documents_active`
5
+ * (matching nearly every row) as a peer of selective indexes and chose it
6
+ * for `<column> = ? AND active = 1` lookups, turning point lookups and
7
+ * per-chunk EXISTS probes into whole-table walks. No query filters on
8
+ * inactive documents, so the index serves no lookup.
9
+ *
10
+ * @module src/store/migrations/033-drop-documents-active-index
11
+ */
12
+
13
+ import type { Database } from "bun:sqlite";
14
+
15
+ import type { Migration } from "./runner";
16
+
17
+ export const migration: Migration = {
18
+ version: 33,
19
+ name: "drop_documents_active_index",
20
+
21
+ up(db: Database): void {
22
+ db.exec("DROP INDEX IF EXISTS idx_documents_active");
23
+ },
24
+
25
+ down(db: Database): void {
26
+ db.exec(
27
+ "CREATE INDEX IF NOT EXISTS idx_documents_active ON documents(active)"
28
+ );
29
+ },
30
+ };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Store each collection's effective link workspace (derived from the
3
+ * filesystem at config sync) so query-time link resolution, status output
4
+ * and graph projection fingerprints read one consistent membership.
5
+ */
6
+ import type { Migration } from "./runner";
7
+
8
+ const COLUMNS: Array<[string, string]> = [
9
+ ["real_path", "TEXT"],
10
+ ["workspace_root", "TEXT"],
11
+ [
12
+ "workspace_source",
13
+ "TEXT NOT NULL DEFAULT 'none' CHECK (workspace_source IN ('none', 'detected', 'configured', 'disabled', 'unavailable'))",
14
+ ],
15
+ ["workspace_nested", "TEXT"],
16
+ ];
17
+
18
+ const existingColumns = (db: Parameters<Migration["up"]>[0]): Set<string> =>
19
+ new Set(
20
+ db
21
+ .query<{ name: string }, []>("PRAGMA table_info(collections)")
22
+ .all()
23
+ .map((row) => row.name)
24
+ );
25
+
26
+ export const migration: Migration = {
27
+ version: 34,
28
+ name: "collection_link_workspace",
29
+
30
+ up(db): void {
31
+ const columns = existingColumns(db);
32
+ for (const [name, definition] of COLUMNS) {
33
+ if (!columns.has(name)) {
34
+ db.exec(`ALTER TABLE collections ADD COLUMN ${name} ${definition}`);
35
+ }
36
+ }
37
+ },
38
+
39
+ down(db): void {
40
+ const columns = existingColumns(db);
41
+ for (const [name] of [...COLUMNS].reverse()) {
42
+ if (columns.has(name)) {
43
+ db.exec(`ALTER TABLE collections DROP COLUMN ${name}`);
44
+ }
45
+ }
46
+ },
47
+ };
@@ -46,6 +46,8 @@ import { migration as m029 } from "./029-graph-reference-state";
46
46
  import { migration as m030 } from "./030-typed-metadata";
47
47
  import { migration as m031 } from "./031-runtime-independent-vectors";
48
48
  import { migration as m032 } from "./032-vector-runtime-callers";
49
+ import { migration as m033 } from "./033-drop-documents-active-index";
50
+ import { migration as m034 } from "./034-collection-link-workspace";
49
51
 
50
52
  /** All migrations in order */
51
53
  export const migrations = [
@@ -81,4 +83,6 @@ export const migrations = [
81
83
  m030,
82
84
  m031,
83
85
  m032,
86
+ m033,
87
+ m034,
84
88
  ];