@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
@@ -169,9 +169,10 @@ export function withOwnedInferenceScope<T>(
169
169
  }
170
170
 
171
171
  /**
172
- * One page of a background pass: an inference deadline inside it fails only
173
- * that page (undefined), leaving the pass scope active for later pages.
174
- * Cancellation, or a deadline of the enclosing scope itself, still throws.
172
+ * Best-effort inference (one page of a background pass, or query expansion):
173
+ * an inference deadline inside it fails only this operation (undefined),
174
+ * leaving the enclosing scope active. Cancellation, or a deadline of the
175
+ * enclosing scope itself, still throws.
175
176
  */
176
177
  export async function withInferencePage<T>(
177
178
  operation: () => Promise<T>
@@ -124,6 +124,21 @@ const collectionFromRef = (value: unknown): string | null => {
124
124
  return value.slice(0, slash).trim().toLowerCase() || null;
125
125
  };
126
126
 
127
+ /**
128
+ * Tools whose results follow resolved graph edges. Links resolve across a
129
+ * link workspace, so a ref's own collection does not bound the result: the
130
+ * scope is the explicit collection argument (else every collection) plus the
131
+ * collection of every referenced document.
132
+ */
133
+ const GRAPH_RESULT_TOOLS = new Set([
134
+ "gno_backlinks",
135
+ "gno_graph",
136
+ "gno_graph_neighbors",
137
+ "gno_graph_path",
138
+ "gno_graph_query",
139
+ "gno_impact",
140
+ ]);
141
+
127
142
  const requestedCollections = (
128
143
  params: unknown,
129
144
  collections: readonly Collection[]
@@ -134,17 +149,41 @@ const requestedCollections = (
134
149
 
135
150
  const names = new Set<string>();
136
151
  const direct = args.collection;
137
- if (typeof direct === "string") names.add(direct.trim().toLowerCase());
138
- if (record?.name === "gno_audit" && Array.isArray(args.collections)) {
152
+ // Handlers treat a blank collection as omitted, so it must not count as a
153
+ // scope here either (a graph call with one is authorized as unscoped).
154
+ if (typeof direct === "string" && direct.trim())
155
+ names.add(direct.trim().toLowerCase());
156
+ if (
157
+ (record?.name === "gno_audit" || record?.name === "gno_impact") &&
158
+ Array.isArray(args.collections)
159
+ ) {
139
160
  for (const value of args.collections) {
140
161
  if (typeof value !== "string") continue;
141
162
  const normalized = value.trim().toLowerCase();
142
163
  if (normalized) names.add(normalized);
143
164
  }
144
165
  }
166
+ const graphTool =
167
+ typeof record?.name === "string" && GRAPH_RESULT_TOOLS.has(record.name);
168
+ // A graph result spans every collection a link resolves into: without an
169
+ // explicit scope, authorize them all.
170
+ // `gno_similar` with crossCollection returns documents from every collection.
171
+ const crossCollectionSimilar =
172
+ record?.name === "gno_similar" && args.crossCollection === true;
173
+ if ((graphTool && names.size === 0) || crossCollectionSimilar) {
174
+ for (const { name } of collections) names.add(name);
175
+ }
176
+ // The referenced documents' own collections are always authorized too:
177
+ // graph tools serialize the target's metadata even when the result scope
178
+ // is narrower. A graph-tool ref whose collection cannot be read without
179
+ // the index (a docid) authorizes every collection (fail closed).
145
180
  for (const key of ["ref", "target", "from", "to", "root", "uri"]) {
146
- const collection = collectionFromRef(args[key]);
181
+ const value = args[key];
182
+ const collection = collectionFromRef(value);
147
183
  if (collection) names.add(collection);
184
+ else if (graphTool && typeof value === "string" && value.trim()) {
185
+ for (const { name } of collections) names.add(name);
186
+ }
148
187
  }
149
188
  if (Array.isArray(args.refs)) {
150
189
  for (const ref of args.refs) {
@@ -5,7 +5,11 @@ import { z } from "zod";
5
5
  import type { AuditCategory, AuditReport } from "../../core/audit";
6
6
  import type { ToolContext } from "../server";
7
7
 
8
- import { AUDIT_CATEGORIES } from "../../core/audit";
8
+ import {
9
+ AUDIT_CATEGORIES,
10
+ AUDIT_MAX_FINDINGS_ALL,
11
+ AUDIT_MAX_FINDINGS_LIMIT,
12
+ } from "../../core/audit";
9
13
  import { runWorkspaceAudit } from "../../core/audit-workspace";
10
14
  import { normalizeTag, validateTag } from "../../core/tags";
11
15
  import { normalizeCollectionName } from "../../core/validation";
@@ -19,7 +23,12 @@ export const auditInputSchema = z
19
23
  collections: z.array(z.string().min(1)).max(256).default([]),
20
24
  paths: z.array(z.string().min(1)).max(256).default([]),
21
25
  tags: z.array(z.string().min(1)).max(256).default([]),
22
- maxFindings: z.number().int().min(1).max(1000).default(100),
26
+ maxFindings: z
27
+ .union([
28
+ z.number().int().min(1).max(AUDIT_MAX_FINDINGS_LIMIT),
29
+ z.literal(AUDIT_MAX_FINDINGS_ALL),
30
+ ])
31
+ .default(100),
23
32
  maxAgeDays: z.number().int().min(1).optional(),
24
33
  orphanRoots: z.array(z.string().min(1)).max(256).default([]),
25
34
  orphanIgnorePrefixes: z.array(z.string().min(1)).max(256).default([]),
@@ -24,6 +24,7 @@ export const diffInputSchema = z.object({
24
24
 
25
25
  export const impactInputSchema = z.object({
26
26
  ref: z.string().trim().min(1).max(4096),
27
+ collections: z.array(z.string().trim().min(1)).max(256).optional(),
27
28
  maxDepth: z.number().int().min(1).max(6).default(3),
28
29
  maxNodes: z.number().int().min(1).max(1000).default(100),
29
30
  maxEdges: z.number().int().min(1).max(5000).default(250),
@@ -25,6 +25,12 @@ import { parseRef } from "../../core/ref-parser";
25
25
  import { normalizeCollectionName } from "../../core/validation";
26
26
  import { getActivePreset } from "../../llm/registry";
27
27
  import { createVectorIndexPort } from "../../store/vector";
28
+ import {
29
+ readStoredDocumentVectors,
30
+ resolveStoredVectorSource,
31
+ similarityHitDocuments,
32
+ storedVectorSearchOptions,
33
+ } from "../../store/vector/stored-vectors";
28
34
  import { runTool, type ToolResult } from "./index";
29
35
 
30
36
  // ─────────────────────────────────────────────────────────────────────────────
@@ -219,6 +225,8 @@ interface BacklinksInput {
219
225
  interface BacklinkOutput {
220
226
  sourceDocUri: string;
221
227
  sourceDocTitle?: string;
228
+ /** Collection of the linking document. */
229
+ sourceCollection?: string;
222
230
  linkText?: string;
223
231
  position: { startLine: number; startCol: number };
224
232
  }
@@ -308,6 +316,7 @@ export function handleBacklinks(
308
316
  (b: BacklinkRow) => ({
309
317
  sourceDocUri: b.sourceDocUri,
310
318
  ...(b.sourceDocTitle && { sourceDocTitle: b.sourceDocTitle }),
319
+ ...(b.sourceCollection && { sourceCollection: b.sourceCollection }),
311
320
  ...(b.linkText && { linkText: b.linkText }),
312
321
  position: { startLine: b.startLine, startCol: b.startCol },
313
322
  })
@@ -406,62 +415,41 @@ export function handleSimilar(
406
415
  const preset = getActivePreset(ctx.config);
407
416
  const modelUri = preset.embed;
408
417
 
409
- // Get stored embeddings from content_vectors (NO model loading required)
418
+ // Stored chunk vectors from the active partition (NO model loading required)
410
419
  const db = ctx.store.getRawDb();
420
+ const source = resolveStoredVectorSource(db, modelUri);
411
421
 
412
- interface VectorRow {
413
- embedding: Uint8Array;
422
+ let vectors: Float32Array[];
423
+ try {
424
+ vectors =
425
+ readStoredDocumentVectors(db, source, [
426
+ { id: doc.id, mirrorHash: doc.mirrorHash },
427
+ ]).get(doc.id) ?? [];
428
+ } catch (e) {
429
+ throw new Error(
430
+ `Invalid stored embedding data: ${e instanceof Error ? e.message : String(e)}`
431
+ );
414
432
  }
415
-
416
- const vectorRows = db
417
- .query<VectorRow, [string, string]>(
418
- `SELECT embedding FROM content_vectors
419
- WHERE mirror_hash = ? AND model = ?
420
- ORDER BY seq`
421
- )
422
- .all(doc.mirrorHash, modelUri);
423
-
424
- if (vectorRows.length === 0) {
433
+ const first = vectors[0];
434
+ if (!first) {
425
435
  throw new Error(
426
436
  `${MCP_ERRORS.NOT_FOUND.code}: Document has no embeddings. Run: gno embed`
427
437
  );
428
438
  }
429
439
 
430
440
  // Compute average embedding from all chunks
431
- const firstBlob = vectorRows[0]?.embedding;
432
- if (!firstBlob) {
433
- throw new Error("No embedding data available");
434
- }
435
-
436
- let dimensions: number;
437
- let avgEmbedding: Float32Array;
438
-
439
- try {
440
- dimensions = firstBlob.byteLength / 4;
441
- avgEmbedding = new Float32Array(dimensions);
442
-
443
- for (const row of vectorRows) {
444
- const blob = new Uint8Array(row.embedding);
445
- const embeddingDims = blob.byteLength / 4;
446
- if (embeddingDims !== dimensions) {
447
- throw new Error(
448
- `Inconsistent embedding dimensions: expected ${dimensions}, got ${embeddingDims}`
449
- );
450
- }
451
- const embedding = new Float32Array(
452
- blob.buffer,
453
- blob.byteOffset,
454
- embeddingDims
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}`
455
447
  );
456
- for (let i = 0; i < dimensions; i++) {
457
- const current = avgEmbedding[i] ?? 0;
458
- avgEmbedding[i] = current + (embedding[i] ?? 0) / vectorRows.length;
459
- }
460
448
  }
461
- } catch (e) {
462
- throw new Error(
463
- `Invalid stored embedding data: ${e instanceof Error ? e.message : String(e)}`
464
- );
449
+ for (let i = 0; i < dimensions; i++) {
450
+ const current = avgEmbedding[i] ?? 0;
451
+ avgEmbedding[i] = current + (embedding[i] ?? 0) / vectors.length;
452
+ }
465
453
  }
466
454
 
467
455
  // Normalize the average embedding for cosine similarity
@@ -510,73 +498,62 @@ export function handleSimilar(
510
498
  const searchResult = await vectorIndex.searchNearest(
511
499
  avgEmbedding,
512
500
  candidateLimit,
513
- {}
501
+ storedVectorSearchOptions(source)
514
502
  );
515
503
 
516
504
  if (!searchResult.ok) {
517
505
  throw new Error(searchResult.error.message);
518
506
  }
519
507
 
520
- // Get unique mirrorHashes, excluding self
521
- const mirrorHashes = [
522
- ...new Set(
523
- searchResult.value
524
- .filter((r) => r.mirrorHash !== doc.mirrorHash)
525
- .map((r) => r.mirrorHash)
526
- ),
527
- ];
508
+ // Legacy hits (no owners) exclude the source's content, as before
509
+ const hits = searchResult.value.filter(
510
+ (r) => r.documentIds !== undefined || r.mirrorHash !== doc.mirrorHash
511
+ );
528
512
 
529
- // Batch query documents by mirrorHash (avoid N+1)
513
+ // Batch query the hits' documents: exact owners for partition hits,
514
+ // documents sharing the content for legacy hits (avoid N+1)
530
515
  interface DocRow {
516
+ id: number;
531
517
  docid: string;
532
518
  uri: string;
533
519
  title: string | null;
534
520
  collection: string;
535
521
  rel_path: string;
536
- mirror_hash: string;
522
+ mirrorHash: string;
537
523
  }
538
524
 
539
- const placeholders = mirrorHashes.map(() => "?").join(",");
540
- const docRows =
541
- mirrorHashes.length > 0
542
- ? (db
543
- .query<DocRow, string[]>(
544
- `SELECT docid, uri, title, collection, rel_path, mirror_hash
545
- FROM documents WHERE mirror_hash IN (${placeholders}) AND active = 1`
546
- )
547
- .all(...mirrorHashes) as DocRow[])
548
- : [];
549
-
550
- // Build lookup map
551
- const docsByHash = new Map<string, DocRow>();
552
- for (const row of docRows) {
553
- if (!docsByHash.has(row.mirror_hash)) {
554
- docsByHash.set(row.mirror_hash, row);
555
- }
556
- }
557
-
558
- // Build best score per mirrorHash from search results
559
- const scoresByHash = new Map<string, number>();
560
- for (const r of searchResult.value) {
561
- if (r.mirrorHash === doc.mirrorHash) continue;
562
- // Compute similarity score from cosine distance
563
- // sqlite-vec with cosine metric returns distance where similarity = 1 - distance
564
- const score = Math.max(0, Math.min(1, 1 - r.distance));
565
- const existing = scoresByHash.get(r.mirrorHash) ?? 0;
566
- if (score > existing) {
567
- scoresByHash.set(r.mirrorHash, score);
568
- }
569
- }
525
+ const docRows = db
526
+ .query<DocRow, [string, string]>(
527
+ `SELECT id, docid, uri, title, collection, rel_path, mirror_hash AS mirrorHash
528
+ FROM documents WHERE active = 1
529
+ AND (id IN (SELECT value FROM json_each(?))
530
+ OR mirror_hash IN (SELECT value FROM json_each(?)))
531
+ ORDER BY id`
532
+ )
533
+ .all(
534
+ JSON.stringify([
535
+ ...new Set(hits.flatMap((r) => r.documentIds ?? [])),
536
+ ]),
537
+ JSON.stringify([
538
+ ...new Set(
539
+ hits.filter((r) => !r.documentIds).map((r) => r.mirrorHash)
540
+ ),
541
+ ])
542
+ );
570
543
 
571
- // Build similar docs list
544
+ // Build similar docs list; hits are nearest first, so a document's
545
+ // first hit carries its best score
572
546
  const similar: SimilarDocOutput[] = [];
547
+ const seenIds = new Set<number>();
573
548
  const docCollection = doc.collection.toLowerCase();
574
549
 
575
- for (const mirrorHash of mirrorHashes) {
550
+ for (const { document: docRow, distance } of similarityHitDocuments(
551
+ hits,
552
+ docRows
553
+ )) {
576
554
  if (similar.length >= limit) break;
577
-
578
- const docRow = docsByHash.get(mirrorHash);
579
- if (!docRow) continue;
555
+ if (docRow.id === doc.id || seenIds.has(docRow.id)) continue;
556
+ seenIds.add(docRow.id);
580
557
 
581
558
  // Filter by collection if not crossCollection (case-insensitive)
582
559
  if (
@@ -586,7 +563,9 @@ export function handleSimilar(
586
563
  continue;
587
564
  }
588
565
 
589
- const score = scoresByHash.get(mirrorHash) ?? 0;
566
+ // Compute similarity score from cosine distance
567
+ // sqlite-vec with cosine metric returns distance where similarity = 1 - distance
568
+ const score = Math.max(0, Math.min(1, 1 - distance));
590
569
  if (score < threshold) continue;
591
570
 
592
571
  // Get absPath (case-insensitive collection lookup)
@@ -796,6 +775,7 @@ export function handleGraph(
796
775
  threshold: args.threshold ?? 0.7,
797
776
  linkedOnly: args.linkedOnly ?? true,
798
777
  similarTopK: args.similarTopK ?? 5,
778
+ embedModel: getActivePreset(ctx.config).embed,
799
779
  });
800
780
 
801
781
  if (!result.ok) {
@@ -833,6 +813,7 @@ async function getValidatedGraph(
833
813
  threshold: args.threshold ?? 0.7,
834
814
  linkedOnly: args.linkedOnly ?? true,
835
815
  similarTopK: args.similarTopK ?? 5,
816
+ embedModel: getActivePreset(ctx.config).embed,
836
817
  });
837
818
 
838
819
  if (!result.ok) {
@@ -13,9 +13,11 @@
13
13
 
14
14
  import { z } from "zod";
15
15
 
16
+ import type { Config } from "../../config/types";
16
17
  import type { ToolContext } from "../server";
17
18
 
18
19
  import { runAutomationProfile } from "../../sessions/automation";
20
+ import { refreshServedConfig } from "../../sessions/config-refresh";
19
21
  import {
20
22
  formatAutomationRunText,
21
23
  formatImportReceiptText,
@@ -99,9 +101,36 @@ export const SESSIONS_IMPORT_MCP_ANNOTATIONS = {
99
101
  openWorldHint: false,
100
102
  } as const;
101
103
 
102
- function service(ctx: ToolContext): SessionsService {
103
- return new SessionsService({
104
+ /**
105
+ * The config file as it is now, adopted when the CLI changed it while this
106
+ * server runs (same binding checks and errors as the REST routes). Inside a
107
+ * request `ctx.config` stays the snapshot taken at its start, so callers use
108
+ * the returned config. An adoption moves the egress policy epoch; this
109
+ * request advances with it rather than voiding itself.
110
+ */
111
+ function currentConfig(ctx: ToolContext): Promise<Config> {
112
+ return refreshServedConfig({
113
+ configPath: ctx.actualConfigPath,
114
+ indexName: ctx.indexName,
115
+ store: ctx.store,
104
116
  config: ctx.config,
117
+ setConfig: (config) => {
118
+ ctx.config = config;
119
+ },
120
+ invalidateEgressPolicy: async () => {
121
+ const invalidation = await ctx.invalidateEgressPolicy?.();
122
+ if (invalidation) {
123
+ ctx.advanceRequestAuthorizationEpoch?.(invalidation.policyEpoch);
124
+ }
125
+ },
126
+ markContentMutation: () => ctx.markContentMutation?.(),
127
+ markIndexMutation: () => ctx.markIndexMutation?.(),
128
+ });
129
+ }
130
+
131
+ async function service(ctx: ToolContext): Promise<SessionsService> {
132
+ return new SessionsService({
133
+ config: await currentConfig(ctx),
105
134
  configPath: ctx.actualConfigPath,
106
135
  indexName: ctx.indexName,
107
136
  store: ctx.store,
@@ -120,7 +149,7 @@ export function handleSessionsStatus(ctx: ToolContext): Promise<ToolResult> {
120
149
  "gno_sessions_status",
121
150
  async () => {
122
151
  try {
123
- return await service(ctx).status();
152
+ return await (await service(ctx)).status();
124
153
  } catch (error) {
125
154
  return rethrowSessionsError(error);
126
155
  }
@@ -146,7 +175,7 @@ export function handleSessionsImport(
146
175
  try {
147
176
  // A child process keeps this server answering during a long import.
148
177
  receipt = await importInChildProcess({
149
- config: ctx.config,
178
+ config: await currentConfig(ctx),
150
179
  configPath: ctx.actualConfigPath,
151
180
  indexName: ctx.indexName,
152
181
  sourceId: args.sourceId,
@@ -10,6 +10,7 @@ import type { ToolContext } from "../server";
10
10
  import { buildContentTypeBoostStatus } from "../../config/content-types";
11
11
  import { formatChunkingStatus } from "../../core/chunking-status";
12
12
  import { OWNER_CONFIG_PATH_FIELDS, withoutFields } from "../../core/host-paths";
13
+ import { formatLinkWorkspace } from "../../core/link-workspace";
13
14
  import { formatVectorPartitionLines } from "../../core/vector-partition-status";
14
15
  import { resolveModelUri } from "../../llm/registry";
15
16
  import { createStandaloneResidentStatus } from "../../serve/resident-status";
@@ -55,6 +56,8 @@ function formatStatus(status: StatusView): string {
55
56
  ` ${c.name}: ${c.activeDocuments} docs, ${c.totalChunks} chunks` +
56
57
  (c.embeddedChunks > 0 ? `, ${c.embeddedChunks} embedded` : "")
57
58
  );
59
+ const workspace = formatLinkWorkspace(c);
60
+ if (workspace) lines.push(` Link workspace: ${workspace}`);
58
61
  }
59
62
  }
60
63
 
@@ -102,6 +105,7 @@ export function handleStatus(
102
105
  const result = await ctx.store.getStatus({
103
106
  embedModel: resolveModelUri(ctx.config, "embed"),
104
107
  chunking: ctx.config.chunking ?? {},
108
+ configuredCollections: ctx.config.collections.map(({ name }) => name),
105
109
  });
106
110
  if (!result.ok) {
107
111
  throw new Error(result.error.message);
@@ -15,6 +15,7 @@ import {
15
15
  assertInferenceActive,
16
16
  assertInferenceResult,
17
17
  inferenceOptions,
18
+ withInferencePage,
18
19
  } from "../llm/inference-scope";
19
20
  import { ok } from "../store/types";
20
21
 
@@ -23,7 +24,6 @@ import { ok } from "../store/types";
23
24
  // ─────────────────────────────────────────────────────────────────────────────
24
25
 
25
26
  const EXPANSION_PROMPT_VERSION = "v3";
26
- const DEFAULT_TIMEOUT_MS = 5000;
27
27
  // Non-greedy to avoid matching from first { to last } across multiple objects
28
28
  const JSON_EXTRACT_PATTERN = /\{[\s\S]*?\}/;
29
29
  const QUOTED_PHRASE_PATTERN = /"([^"]+)"/g;
@@ -457,8 +457,6 @@ export function parseExpansionOutput(
457
457
  export interface ExpansionOptions extends InferenceOptions {
458
458
  /** Language hint for prompt selection */
459
459
  lang?: string;
460
- /** Timeout in milliseconds */
461
- timeout?: number;
462
460
  /** Optional context that steers expansion for ambiguous queries */
463
461
  intent?: string;
464
462
  /** Optional bounded context size override for expansion generation */
@@ -467,59 +465,49 @@ export interface ExpansionOptions extends InferenceOptions {
467
465
 
468
466
  /**
469
467
  * Expand query using generation model.
470
- * Returns null on timeout or parse failure (graceful degradation).
468
+ * Returns null when generation fails or times out, or its output does not
469
+ * parse (graceful degradation).
471
470
  */
472
471
  export async function expandQuery(
473
472
  genPort: GenerationPort,
474
473
  query: string,
475
474
  options: ExpansionOptions = {}
476
475
  ): Promise<StoreResult<ExpansionResult | null>> {
477
- const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
478
-
479
476
  // Build prompt
480
477
  const prompt = buildExpansionPrompt(query, options);
481
478
 
482
479
  assertInferenceActive(options);
483
480
  const operational = inferenceOptions(options);
484
- const budget = new AbortController();
485
- const expiresAt = performance.now() + timeout;
486
- const timer = setTimeout(() => budget.abort(), timeout);
487
481
  try {
488
- const result = await genPort.generate(
489
- prompt,
490
- {
491
- temperature: 0,
492
- seed: 42,
493
- maxTokens: 512,
494
- contextSize: options.contextSize,
495
- },
496
- {
497
- ...operational,
498
- signal: operational.signal
499
- ? AbortSignal.any([operational.signal, budget.signal])
500
- : budget.signal,
501
- }
482
+ // Expansion is best effort but has no budget of its own: slow hardware
483
+ // (CPU generation takes 15-20s) and a cold model load still expand. A
484
+ // generation that hits models.inferenceTimeout fails only expansion.
485
+ const result = await withInferencePage(() =>
486
+ genPort.generate(
487
+ prompt,
488
+ {
489
+ temperature: 0,
490
+ seed: 42,
491
+ maxTokens: 512,
492
+ contextSize: options.contextSize,
493
+ },
494
+ operational
495
+ )
502
496
  );
503
- // The independent expansion budget preserves its existing fallback. Caller
504
- // cancellation/deadline always wins and cannot become a lexical success.
497
+ // Caller cancellation/deadline always wins and cannot become a lexical success.
505
498
  assertInferenceActive(options);
506
- if (budget.signal.aborted || performance.now() >= expiresAt)
507
- return ok(null);
499
+ if (!result) return ok(null);
508
500
  assertInferenceResult(result);
509
501
  if (!result.ok) return ok(null);
510
502
  return ok(parseExpansionOutput(result.value, query));
511
503
  } catch (cause) {
512
504
  assertInferenceActive(options);
513
- if (budget.signal.aborted || performance.now() >= expiresAt)
514
- return ok(null);
515
505
  if (
516
506
  cause instanceof Error &&
517
507
  ["AbortError", "TimeoutError"].includes(cause.name)
518
508
  )
519
509
  throw cause;
520
510
  return ok(null);
521
- } finally {
522
- clearTimeout(timer);
523
511
  }
524
512
  }
525
513
 
@@ -196,6 +196,7 @@ const loadGraphLinks = async (
196
196
  seedDocumentIds: number[],
197
197
  options: {
198
198
  collection?: string;
199
+ collections?: string[];
199
200
  includeSimilar?: boolean;
200
201
  }
201
202
  ): Promise<
@@ -205,6 +206,7 @@ const loadGraphLinks = async (
205
206
  const neighborsResult = await store.getGraphNeighborsForSeeds({
206
207
  seedDocumentIds,
207
208
  collection: options.collection,
209
+ collections: options.collections,
208
210
  limitEdges: GRAPH_EDGE_LIMIT,
209
211
  });
210
212
  if (!neighborsResult.ok) {
@@ -239,6 +241,11 @@ export async function expandGraphCandidates(
239
241
  fusedCandidates: FusionCandidate[],
240
242
  options: {
241
243
  collection?: string;
244
+ /**
245
+ * Graph allowlist (plural request scope). Neighbours must belong to one of
246
+ * these collections; defaults to `[collection]` when unset.
247
+ */
248
+ collections?: string[];
242
249
  includeSimilar?: boolean;
243
250
  eligibility?: DocumentEligibilityOptions;
244
251
  limit?: number;
@@ -353,8 +360,14 @@ export async function expandGraphCandidates(
353
360
  }
354
361
 
355
362
  const seedDocumentIds = [...seedByDocid.values()].map(({ doc }) => doc.id);
363
+ const graphAllowlist = options.collections
364
+ ? new Set(options.collections)
365
+ : options.collection
366
+ ? new Set([options.collection])
367
+ : undefined;
356
368
  const linksResult = await loadGraphLinks(store, seedDocumentIds, {
357
369
  collection: options.collection,
370
+ collections: options.collections,
358
371
  includeSimilar: options.includeSimilar,
359
372
  });
360
373
  if (!linksResult.ok) {
@@ -400,9 +413,15 @@ export async function expandGraphCandidates(
400
413
  return { candidates: [], meta };
401
414
  }
402
415
 
416
+ // A plural allowlist hydrates across its collections; scope is enforced on
417
+ // each neighbour's own collection below, never widened past the allowlist.
418
+ const plural = options.collections !== undefined;
403
419
  const docsResult = await store.getDocumentsByDocids(rankedNeighborDocids, {
404
- eligibility: options.eligibility,
405
- collection: options.collection,
420
+ eligibility:
421
+ plural && options.eligibility
422
+ ? { ...options.eligibility, collection: undefined }
423
+ : options.eligibility,
424
+ collection: plural ? undefined : options.collection,
406
425
  activeOnly: true,
407
426
  });
408
427
  if (!docsResult.ok) {
@@ -413,6 +432,7 @@ export async function expandGraphCandidates(
413
432
  const metadataFilteredDocs = docsResult.value.filter(
414
433
  (doc) =>
415
434
  doc.mirrorHash &&
435
+ (!graphAllowlist || graphAllowlist.has(doc.collection)) &&
416
436
  (options.relPathPrefix === undefined ||
417
437
  sourceRelPath(doc) === options.relPathPrefix ||
418
438
  sourceRelPath(doc).startsWith(`${options.relPathPrefix}/`)) &&
@@ -520,7 +520,6 @@ async function searchHybridWithHydration(
520
520
  const expandResult = await expandQuery(expandPort, query, {
521
521
  // Use queryLanguage for prompt selection, NOT options.lang (retrieval filter)
522
522
  lang: queryLanguage,
523
- timeout: pipelineConfig.expansionTimeout,
524
523
  intent: options.intent,
525
524
  contextSize: deps.config.models?.expandContextSize,
526
525
  });
@@ -804,6 +803,7 @@ async function searchHybridWithHydration(
804
803
  fusedCandidates,
805
804
  {
806
805
  collection: options.collection,
806
+ collections: options.graphCollections,
807
807
  includeSimilar: vectorAvailable,
808
808
  eligibility: vectorEligibility,
809
809
  limit,
@@ -223,6 +223,8 @@ export async function searchBm25(
223
223
  filter: options.filter,
224
224
  memoryScopesAny: options.memoryFilter?.scopes,
225
225
  excludeSuperseded: options.memoryFilter?.excludeSuperseded,
226
+ anyTerm: options.anyTerm,
227
+ minRelativeScore: options.minRelativeScore,
226
228
  });
227
229
 
228
230
  if (!ftsResult.ok) {