@gmickel/gno 2.7.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +3 -2
  2. package/assets/skill/SKILL.md +8 -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/spa-production.json.gz +0 -0
  7. package/browser-extension/artifacts/{gno-browser-clipper-v2.7.1.zip → gno-browser-clipper-v2.8.0.zip} +0 -0
  8. package/browser-extension/artifacts/gno-browser-clipper-v2.8.0.zip.sha256 +1 -0
  9. package/browser-extension/dist/manifest.json +1 -1
  10. package/package.json +1 -1
  11. package/spec/cli.md +48 -6
  12. package/spec/db/schema.sql +0 -1
  13. package/spec/mcp.md +18 -3
  14. package/spec/output-schemas/audit-report.schema.json +18 -4
  15. package/spec/output-schemas/backlinks.schema.json +4 -0
  16. package/spec/output-schemas/collection-list.schema.json +13 -0
  17. package/spec/output-schemas/graph.schema.json +2 -0
  18. package/spec/output-schemas/links-list.schema.json +4 -0
  19. package/spec/output-schemas/status.schema.json +15 -0
  20. package/src/cli/commands/audit.ts +23 -4
  21. package/src/cli/commands/collection/list.ts +39 -5
  22. package/src/cli/commands/embed.ts +3 -3
  23. package/src/cli/commands/links.ts +34 -131
  24. package/src/cli/commands/shared.ts +7 -0
  25. package/src/cli/commands/status.ts +5 -0
  26. package/src/cli/program.ts +12 -2
  27. package/src/config/loader.ts +43 -0
  28. package/src/config/types.ts +8 -0
  29. package/src/core/audit-contract.ts +16 -4
  30. package/src/core/audit-freshness.ts +11 -1
  31. package/src/core/audit-links.ts +145 -25
  32. package/src/core/audit-provenance.ts +11 -4
  33. package/src/core/audit-workspace.ts +19 -4
  34. package/src/core/audit.ts +67 -15
  35. package/src/core/context-compiler.ts +3 -0
  36. package/src/core/context-evidence.ts +11 -0
  37. package/src/core/graph-edge-confidence.ts +23 -1
  38. package/src/core/host-paths.ts +1 -0
  39. package/src/core/knowledge-impact.ts +28 -0
  40. package/src/core/link-workspace.ts +324 -0
  41. package/src/core/retrieval-replay-candidate.ts +6 -0
  42. package/src/core/retrieval-trace-request.ts +3 -0
  43. package/src/index.ts +14 -1
  44. package/src/ingestion/graph-reconciliation.ts +77 -15
  45. package/src/ingestion/source-availability/darwin-path.ts +9 -3
  46. package/src/ingestion/sync.ts +22 -1
  47. package/src/ingestion/types.ts +14 -0
  48. package/src/llm/inference-scope.ts +4 -3
  49. package/src/mcp/http-egress.ts +42 -3
  50. package/src/mcp/tools/audit.ts +11 -2
  51. package/src/mcp/tools/changes.ts +1 -0
  52. package/src/mcp/tools/links.ts +3 -0
  53. package/src/mcp/tools/sessions.ts +33 -4
  54. package/src/mcp/tools/status.ts +3 -0
  55. package/src/pipeline/expansion.ts +19 -31
  56. package/src/pipeline/graph-retrieval.ts +22 -2
  57. package/src/pipeline/hybrid.ts +1 -1
  58. package/src/pipeline/types.ts +6 -3
  59. package/src/serve/findings-pass.ts +1 -1
  60. package/src/serve/public/pages/GraphView.tsx +2 -0
  61. package/src/serve/routes/changes.ts +6 -1
  62. package/src/serve/routes/links.ts +13 -0
  63. package/src/serve/routes/sessions.ts +41 -53
  64. package/src/sessions/config-refresh.ts +111 -0
  65. package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
  66. package/src/store/migrations/034-collection-link-workspace.ts +47 -0
  67. package/src/store/migrations/index.ts +4 -0
  68. package/src/store/sqlite/adapter.ts +390 -231
  69. package/src/store/sqlite/eligibility.ts +8 -2
  70. package/src/store/sqlite/graph-link-resolver.ts +252 -5
  71. package/src/store/sqlite/graph-neighbors.ts +147 -40
  72. package/src/store/sqlite/graph-reference-state.ts +13 -2
  73. package/src/store/sqlite/workspace-link-resolver.ts +654 -0
  74. package/src/store/types.ts +49 -3
  75. package/src/store/vector/stats.ts +1 -1
  76. package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +0 -1
@@ -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),
@@ -219,6 +219,8 @@ interface BacklinksInput {
219
219
  interface BacklinkOutput {
220
220
  sourceDocUri: string;
221
221
  sourceDocTitle?: string;
222
+ /** Collection of the linking document. */
223
+ sourceCollection?: string;
222
224
  linkText?: string;
223
225
  position: { startLine: number; startCol: number };
224
226
  }
@@ -308,6 +310,7 @@ export function handleBacklinks(
308
310
  (b: BacklinkRow) => ({
309
311
  sourceDocUri: b.sourceDocUri,
310
312
  ...(b.sourceDocTitle && { sourceDocTitle: b.sourceDocTitle }),
313
+ ...(b.sourceCollection && { sourceCollection: b.sourceCollection }),
311
314
  ...(b.linkText && { linkText: b.linkText }),
312
315
  position: { startLine: b.startLine, startCol: b.startCol },
313
316
  })
@@ -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
 
@@ -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,
@@ -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;
@@ -406,8 +412,6 @@ export type RerankedCandidate = FusionCandidate & {
406
412
 
407
413
  /** Search pipeline configuration */
408
414
  export interface PipelineConfig {
409
- /** Expansion timeout in ms */
410
- expansionTimeout: number;
411
415
  /** Max candidates to rerank */
412
416
  rerankCandidates: number;
413
417
  /** RRF configuration */
@@ -418,7 +422,6 @@ export interface PipelineConfig {
418
422
 
419
423
  /** Default pipeline configuration */
420
424
  export const DEFAULT_PIPELINE_CONFIG: PipelineConfig = {
421
- expansionTimeout: 5000,
422
425
  rerankCandidates: 20,
423
426
  rrf: DEFAULT_RRF_CONFIG,
424
427
  blendingSchedule: DEFAULT_BLENDING_SCHEDULE,
@@ -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;
@@ -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"],
@@ -34,6 +34,8 @@ export interface LinkResponse {
34
34
  resolvedUri?: string;
35
35
  /** Resolved target title (if found) */
36
36
  resolvedTitle?: string;
37
+ /** Collection of the resolved target (may differ from the source's) */
38
+ resolvedCollection?: string;
37
39
  }>;
38
40
  meta: {
39
41
  docid: string;
@@ -49,6 +51,8 @@ export interface BacklinkResponse {
49
51
  sourceDocid: string;
50
52
  sourceUri: string;
51
53
  sourceTitle?: string;
54
+ /** Collection of the linking document */
55
+ sourceCollection?: string;
52
56
  linkText?: string;
53
57
  startLine: number;
54
58
  startCol: number;
@@ -189,6 +193,11 @@ export async function handleDocLinks(
189
193
  targetRefNorm: l.targetRefNorm,
190
194
  targetCollection: l.targetCollection || doc.collection,
191
195
  linkType: l.linkType,
196
+ source: {
197
+ collection: doc.collection,
198
+ relPath: doc.relPath,
199
+ explicit: Boolean(l.targetCollection),
200
+ },
192
201
  }))
193
202
  );
194
203
  const resolutionAvailable = resolvedResult.ok;
@@ -217,6 +226,9 @@ export async function handleDocLinks(
217
226
  resolvedDocid: resolved.docid,
218
227
  resolvedUri: resolved.uri,
219
228
  resolvedTitle: resolved.title ?? undefined,
229
+ ...(resolved.collection && {
230
+ resolvedCollection: resolved.collection,
231
+ }),
220
232
  }),
221
233
  }),
222
234
  };
@@ -273,6 +285,7 @@ export async function handleDocBacklinks(
273
285
  backlinks: backlinks.map((b) => ({
274
286
  sourceDocid: b.sourceDocid,
275
287
  sourceUri: b.sourceDocUri,
288
+ ...(b.sourceCollection && { sourceCollection: b.sourceCollection }),
276
289
  ...(b.sourceDocTitle && { sourceTitle: b.sourceDocTitle }),
277
290
  ...(b.linkText && { linkText: b.linkText }),
278
291
  startLine: b.startLine,
@@ -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);