@gmickel/gno 2.10.1 → 2.12.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 (112) hide show
  1. package/README.md +11 -11
  2. package/assets/skill/SKILL.md +27 -15
  3. package/assets/skill/cli-reference.md +6 -5
  4. package/assets/skill/examples.md +16 -12
  5. package/assets/skill/mcp-reference.md +2 -1
  6. package/assets/spa-production.json.gz +0 -0
  7. package/browser-extension/artifacts/{gno-browser-clipper-v2.10.1.zip → gno-browser-clipper-v2.12.0.zip} +0 -0
  8. package/browser-extension/artifacts/gno-browser-clipper-v2.12.0.zip.sha256 +1 -0
  9. package/browser-extension/dist/manifest.json +1 -1
  10. package/package.json +10 -5
  11. package/spec/cli.md +127 -35
  12. package/spec/evals-agentic.md +3 -1
  13. package/spec/mcp.md +36 -6
  14. package/spec/output-schemas/ask.schema.json +8 -0
  15. package/spec/output-schemas/claim-verification.schema.json +8 -2
  16. package/spec/output-schemas/context-capsule-v1.schema.json +6 -1
  17. package/spec/output-schemas/context-capsule-verification.schema.json +4 -2
  18. package/spec/output-schemas/get.schema.json +4 -0
  19. package/spec/output-schemas/metadata-summary.schema.json +60 -0
  20. package/spec/output-schemas/multi-get.schema.json +4 -0
  21. package/spec/output-schemas/search-result.schema.json +4 -0
  22. package/spec/output-schemas/search-results.schema.json +4 -0
  23. package/spec/output-schemas/status.schema.json +28 -0
  24. package/src/app/constants.ts +16 -0
  25. package/src/app/context-agent-projection.ts +10 -1
  26. package/src/app/context-format.ts +1 -0
  27. package/src/app/verified-ask.ts +2 -0
  28. package/src/cli/commands/agents/block.ts +10 -9
  29. package/src/cli/commands/agents/commands.ts +6 -5
  30. package/src/cli/commands/agents/engine.ts +15 -2
  31. package/src/cli/commands/agents/harnesses.ts +21 -14
  32. package/src/cli/commands/ask-format.ts +18 -1
  33. package/src/cli/commands/ask.ts +4 -5
  34. package/src/cli/commands/cleanup.ts +46 -6
  35. package/src/cli/commands/collection/remove.ts +1 -0
  36. package/src/cli/commands/collection/rename.ts +1 -0
  37. package/src/cli/commands/daemon.ts +5 -1
  38. package/src/cli/commands/doctor.ts +37 -0
  39. package/src/cli/commands/get.ts +11 -1
  40. package/src/cli/commands/mcp/paths.ts +2 -1
  41. package/src/cli/commands/metadata.ts +79 -0
  42. package/src/cli/commands/multi-get.ts +13 -0
  43. package/src/cli/commands/setup-semantic.ts +6 -1
  44. package/src/cli/commands/shared.ts +35 -8
  45. package/src/cli/commands/skill/paths.ts +20 -2
  46. package/src/cli/commands/status.ts +89 -15
  47. package/src/cli/format/search-results.ts +11 -0
  48. package/src/cli/options.ts +2 -0
  49. package/src/cli/program.ts +64 -3
  50. package/src/core/activation-probe-plan.ts +0 -1
  51. package/src/core/config-mutation.ts +72 -55
  52. package/src/core/context-capsule-schema.ts +2 -0
  53. package/src/core/context-capsule-verification.ts +2 -0
  54. package/src/core/context-compiler.ts +2 -5
  55. package/src/core/context-evidence.ts +1 -0
  56. package/src/core/context-verifier.ts +14 -1
  57. package/src/core/memory-remember.ts +0 -1
  58. package/src/core/metadata-discovery.ts +169 -0
  59. package/src/core/module-path.ts +33 -0
  60. package/src/core/runtime-entrypoint.ts +3 -1
  61. package/src/ingestion/file-processor.ts +6 -3
  62. package/src/ingestion/prepare-file.ts +14 -4
  63. package/src/ingestion/source-availability/root.ts +138 -0
  64. package/src/ingestion/sync.ts +35 -1
  65. package/src/ingestion/walker.ts +11 -3
  66. package/src/llm/native-worker/client.ts +8 -4
  67. package/src/llm/native-worker/errors.ts +26 -3
  68. package/src/mcp/http-egress.ts +1 -0
  69. package/src/mcp/http-transport.ts +7 -2
  70. package/src/mcp/server.ts +3 -0
  71. package/src/mcp/tool-descriptions-core.ts +3 -3
  72. package/src/mcp/tool-profile.ts +16 -1
  73. package/src/mcp/tools/get.ts +10 -0
  74. package/src/mcp/tools/index.ts +68 -4
  75. package/src/mcp/tools/multi-get.ts +15 -0
  76. package/src/mcp/tools/query.ts +5 -3
  77. package/src/mcp/tools/search.ts +5 -3
  78. package/src/mcp/tools/status.ts +17 -2
  79. package/src/mcp/tools/vsearch.ts +5 -3
  80. package/src/mcp/tools/workspace-write.ts +42 -34
  81. package/src/pipeline/answer-prompt.ts +15 -2
  82. package/src/pipeline/answer.ts +3 -0
  83. package/src/pipeline/claim-verification-schema.ts +6 -1
  84. package/src/pipeline/claim-verification.ts +54 -1
  85. package/src/pipeline/explain.ts +1 -1
  86. package/src/pipeline/fusion.ts +24 -4
  87. package/src/pipeline/hybrid.ts +5 -1
  88. package/src/pipeline/query-snippet.ts +266 -0
  89. package/src/pipeline/result-context.ts +29 -0
  90. package/src/pipeline/search.ts +154 -20
  91. package/src/pipeline/types.ts +4 -0
  92. package/src/pipeline/vsearch.ts +5 -1
  93. package/src/sdk/client.ts +46 -6
  94. package/src/sdk/documents.ts +5 -0
  95. package/src/sdk/types.ts +11 -1
  96. package/src/serve/public/components/ThoroughnessSelector.tsx +1 -1
  97. package/src/serve/public/globals.built.css +1 -1
  98. package/src/serve/public/pages/Collections.tsx +8 -0
  99. package/src/serve/public/pages/Search.tsx +9 -1
  100. package/src/serve/resident-request.ts +8 -0
  101. package/src/serve/resident-runtime.ts +25 -3
  102. package/src/serve/routes/api.ts +29 -1
  103. package/src/serve/server.ts +12 -0
  104. package/src/serve/spa-production-build.ts +7 -3
  105. package/src/serve/status-model.ts +2 -0
  106. package/src/serve/status.ts +29 -0
  107. package/src/sessions/config-refresh.ts +6 -0
  108. package/src/sessions/import-child.ts +4 -1
  109. package/src/store/sqlite/adapter.ts +241 -20
  110. package/src/store/types.ts +61 -7
  111. package/src/store/vector/variant-search.ts +112 -74
  112. package/browser-extension/artifacts/gno-browser-clipper-v2.10.1.zip.sha256 +0 -1
@@ -9,7 +9,7 @@ import type { ContentTypeBoostStatus } from "../../config/content-types";
9
9
  import type { ActivationStatus } from "../../core/activation-status";
10
10
  import type { MemoryStatus } from "../../core/memory-diagnostics";
11
11
  import type { BackgroundIssue } from "../../serve/status-model";
12
- import type { IndexStatus } from "../../store/types";
12
+ import type { IndexStatus, StaleCollection } from "../../store/types";
13
13
 
14
14
  import { getIndexDbPath, getModelsCachePath } from "../../app/constants";
15
15
  import {
@@ -33,6 +33,10 @@ import {
33
33
  } from "../../core/memory-diagnostics";
34
34
  import { formatVectorPartitionLines } from "../../core/vector-partition-status";
35
35
  import { BUDGET_ERROR_CODES } from "../../ingestion/file-processor";
36
+ import {
37
+ checkCollectionRoots,
38
+ type CollectionRootError,
39
+ } from "../../ingestion/source-availability/root";
36
40
  import { ModelCache } from "../../llm/cache";
37
41
  import { getActivePreset, resolveModelUri } from "../../llm/registry";
38
42
  import { getConnectorVerificationTargets } from "../../serve/connectors";
@@ -80,6 +84,10 @@ export type StatusResult =
80
84
  runtimeIssues?: RuntimeIssue[];
81
85
  /** Files whose latest conversion stopped at the per-file budget. */
82
86
  budgetStops: BudgetStop[];
87
+ /** Indexed collections this config does not list (kept until cleanup). */
88
+ staleCollections?: StaleCollection[];
89
+ /** Configured collections whose root is missing or unreadable now. */
90
+ rootErrors?: Record<string, CollectionRootError>;
83
91
  }
84
92
  | { success: false; error: string };
85
93
 
@@ -91,6 +99,18 @@ export interface BudgetStop {
91
99
  message: string;
92
100
  }
93
101
 
102
+ /** Indexed collections the config omits; an unreadable list is not fatal. */
103
+ async function collectStaleCollections(
104
+ store: SqliteAdapter,
105
+ configured: string[]
106
+ ): Promise<StaleCollection[]> {
107
+ const stale = await store.listStaleCollections(configured);
108
+ return stale.ok ? stale.value.filter((c) => c.documents > 0) : [];
109
+ }
110
+
111
+ const STALE_COLLECTIONS_HINT =
112
+ 'not in this config; add them back, or run "gno cleanup" to delete their index data';
113
+
94
114
  /** Most recent ingest errors inspected for budget stops. */
95
115
  const BUDGET_STOP_SCAN_LIMIT = 100;
96
116
  /** Most budget stops listed in status output. */
@@ -164,12 +184,14 @@ function connectorProjectionLine(activation: ActivationStatus): string | null {
164
184
 
165
185
  function isStatusHealthy(
166
186
  indexStatus: IndexStatus,
167
- activation: ActivationStatus
187
+ activation: ActivationStatus,
188
+ rootErrors: Record<string, CollectionRootError> = {}
168
189
  ): boolean {
169
190
  return (
170
191
  indexStatus.healthy &&
171
192
  activation.healthy &&
172
- isConnectorActivationComplete(activation)
193
+ isConnectorActivationComplete(activation) &&
194
+ Object.keys(rootErrors).length === 0
173
195
  );
174
196
  }
175
197
 
@@ -180,7 +202,8 @@ function formatTerminal(
180
202
  indexStatus: IndexStatus,
181
203
  activation: ActivationStatus,
182
204
  contentTypeBoost: ContentTypeBoostStatus,
183
- memory: MemoryStatus
205
+ memory: MemoryStatus,
206
+ rootErrors: Record<string, CollectionRootError> = {}
184
207
  ): string {
185
208
  const lines: string[] = [];
186
209
 
@@ -203,6 +226,11 @@ function formatTerminal(
203
226
  ` ${c.name}: ${c.activeDocuments} docs, ${c.totalChunks} chunks` +
204
227
  (c.embeddedChunks > 0 ? `, ${c.embeddedChunks} embedded` : "")
205
228
  );
229
+ const rootError = rootErrors[c.name];
230
+ if (rootError) {
231
+ lines.push(` Root unavailable: ${c.path}`);
232
+ lines.push(` [${rootError.code}] ${rootError.message}`);
233
+ }
206
234
  const workspace = formatLinkWorkspace(c);
207
235
  if (workspace) lines.push(` Link workspace: ${workspace}`);
208
236
  }
@@ -240,7 +268,7 @@ function formatTerminal(
240
268
  lines.push(`Ranking fingerprint: ${contentTypeBoost.rulesFingerprint}`);
241
269
 
242
270
  lines.push(
243
- `Health: ${isStatusHealthy(indexStatus, activation) ? "OK" : "DEGRADED"}`
271
+ `Health: ${isStatusHealthy(indexStatus, activation, rootErrors) ? "OK" : "DEGRADED"}`
244
272
  );
245
273
  lines.push("");
246
274
  const activationState = activation.healthy
@@ -290,7 +318,8 @@ function formatMarkdown(
290
318
  indexStatus: IndexStatus,
291
319
  activation: ActivationStatus,
292
320
  contentTypeBoost: ContentTypeBoostStatus,
293
- memory: MemoryStatus
321
+ memory: MemoryStatus,
322
+ rootErrors: Record<string, CollectionRootError> = {}
294
323
  ): string {
295
324
  const lines: string[] = [];
296
325
 
@@ -299,7 +328,7 @@ function formatMarkdown(
299
328
  lines.push(`- **Config**: ${indexStatus.configPath}`);
300
329
  lines.push(`- **Database**: ${indexStatus.dbPath}`);
301
330
  lines.push(
302
- `- **Health**: ${isStatusHealthy(indexStatus, activation) ? "✓ OK" : "⚠ DEGRADED"}`
331
+ `- **Health**: ${isStatusHealthy(indexStatus, activation, rootErrors) ? "✓ OK" : "⚠ DEGRADED"}`
303
332
  );
304
333
  lines.push("");
305
334
 
@@ -314,6 +343,15 @@ function formatMarkdown(
314
343
  );
315
344
  }
316
345
  lines.push("");
346
+ for (const c of indexStatus.collections) {
347
+ const rootError = rootErrors[c.name];
348
+ if (rootError) {
349
+ lines.push(
350
+ `- **Root unavailable**: ${c.name} (${c.path}) [${rootError.code}] ${rootError.message}`
351
+ );
352
+ }
353
+ }
354
+ if (Object.keys(rootErrors).length > 0) lines.push("");
317
355
  }
318
356
 
319
357
  lines.push("## Summary");
@@ -390,6 +428,8 @@ export async function status(
390
428
  return { success: false, error: configResult.error.message };
391
429
  }
392
430
  const config = configResult.value;
431
+ // Read-time root check, overlapping the index reads below; it never rejects.
432
+ const rootErrors = checkCollectionRoots(config.collections);
393
433
 
394
434
  // Open database
395
435
  const store = new SqliteAdapter();
@@ -446,6 +486,11 @@ export async function status(
446
486
  ...(await checkResidentRuntimes()),
447
487
  ],
448
488
  budgetStops: await collectBudgetStops(store),
489
+ staleCollections: await collectStaleCollections(
490
+ store,
491
+ config.collections.map(({ name }) => name)
492
+ ),
493
+ rootErrors: await rootErrors,
449
494
  };
450
495
  } finally {
451
496
  await store.close();
@@ -482,6 +527,9 @@ export function formatStatus(
482
527
  documentCount: c.activeDocuments,
483
528
  chunkCount: c.totalChunks,
484
529
  embeddedCount: c.embeddedChunks,
530
+ ...(result.rootErrors?.[c.name]
531
+ ? { rootError: result.rootErrors[c.name] }
532
+ : {}),
485
533
  })),
486
534
  totalDocuments: s.activeDocuments,
487
535
  totalChunks: s.totalChunks,
@@ -491,7 +539,7 @@ export function formatStatus(
491
539
  typedMetadata: s.typedMetadata,
492
540
  chunking: s.chunking,
493
541
  lastUpdated: s.lastUpdatedAt,
494
- healthy: isStatusHealthy(s, result.activation),
542
+ healthy: isStatusHealthy(s, result.activation, result.rootErrors),
495
543
  contentTypeBoost: result.contentTypeBoost,
496
544
  activation: result.activation,
497
545
  memory: result.memory,
@@ -501,19 +549,37 @@ export function formatStatus(
501
549
  ...(result.runtimeIssues?.length
502
550
  ? { runtimeIssues: result.runtimeIssues }
503
551
  : {}),
552
+ ...(result.staleCollections?.length
553
+ ? { staleCollections: result.staleCollections }
554
+ : {}),
504
555
  },
505
556
  null,
506
557
  2
507
558
  );
508
559
  }
509
560
 
561
+ const staleLines = (result.staleCollections ?? []).map(
562
+ (c) =>
563
+ `${c.name}: ${c.documents} ${c.documents === 1 ? "document" : "documents"}`
564
+ );
510
565
  if (options.md) {
511
- return formatMarkdown(
512
- result.status,
513
- result.activation,
514
- result.contentTypeBoost,
515
- result.memory
516
- );
566
+ return [
567
+ formatMarkdown(
568
+ result.status,
569
+ result.activation,
570
+ result.contentTypeBoost,
571
+ result.memory,
572
+ result.rootErrors
573
+ ),
574
+ ...(staleLines.length
575
+ ? [
576
+ "",
577
+ `## Collections ${STALE_COLLECTIONS_HINT}`,
578
+ "",
579
+ ...staleLines.map((line) => `- ${line}`),
580
+ ]
581
+ : []),
582
+ ].join("\n");
517
583
  }
518
584
 
519
585
  const issueLines = result.backgroundIssues.map(
@@ -529,8 +595,16 @@ export function formatStatus(
529
595
  result.status,
530
596
  result.activation,
531
597
  result.contentTypeBoost,
532
- result.memory
598
+ result.memory,
599
+ result.rootErrors
533
600
  ),
601
+ ...(staleLines.length
602
+ ? [
603
+ "",
604
+ `Collections ${STALE_COLLECTIONS_HINT}:`,
605
+ ...staleLines.map((line) => ` ${line}`),
606
+ ]
607
+ : []),
534
608
  ...(stopLines.length
535
609
  ? [
536
610
  "",
@@ -88,6 +88,9 @@ function formatTerminal(data: SearchResults, options: FormatOptions): string {
88
88
  if (r.title) {
89
89
  lines.push(` ${r.title}`);
90
90
  }
91
+ if (r.supersededBy) {
92
+ lines.push(` Superseded by: ${r.supersededBy}`);
93
+ }
91
94
  if (r.snippet) {
92
95
  const content = options.full
93
96
  ? r.snippet
@@ -175,6 +178,9 @@ function formatMarkdown(data: SearchResults, options: FormatOptions): string {
175
178
  lines.push(`- **URI**: \`${r.uri}\``);
176
179
  lines.push(`- **Score**: ${r.score.toFixed(2)}`);
177
180
  lines.push(`- **DocID**: \`${r.docid}\``);
181
+ if (r.supersededBy) {
182
+ lines.push(`- **Superseded by**: \`${r.supersededBy}\``);
183
+ }
178
184
  if (r.snippet) {
179
185
  const content = options.full
180
186
  ? r.snippet
@@ -223,6 +229,11 @@ function formatXml(data: SearchResults, options: FormatOptions): string {
223
229
  lines.push(` <title>${escapeXml(r.title)}</title>`);
224
230
  }
225
231
  lines.push(` <relPath>${escapeXml(r.source.relPath)}</relPath>`);
232
+ if (r.supersededBy) {
233
+ lines.push(
234
+ ` <supersededBy>${escapeXml(r.supersededBy)}</supersededBy>`
235
+ );
236
+ }
226
237
  if (r.snippet) {
227
238
  const content = options.full
228
239
  ? r.snippet
@@ -82,6 +82,7 @@ export const CMD = {
82
82
  contextSaved: "context.saved",
83
83
  modelsList: "models.list",
84
84
  tagsList: "tags.list",
85
+ metadataList: "metadata",
85
86
  linksList: "links.list",
86
87
  backlinks: "backlinks",
87
88
  similar: "similar",
@@ -118,6 +119,7 @@ const FORMAT_SUPPORT: Record<CommandId, OutputFormat[]> = {
118
119
  [CMD.contextSaved]: ["terminal", "json"],
119
120
  [CMD.modelsList]: ["terminal", "json"],
120
121
  [CMD.tagsList]: ["terminal", "json", "md"],
122
+ [CMD.metadataList]: ["terminal", "json", "md"],
121
123
  [CMD.linksList]: ["terminal", "json", "md"],
122
124
  [CMD.backlinks]: ["terminal", "json", "md"],
123
125
  [CMD.similar]: ["terminal", "json", "md"],
@@ -2481,6 +2481,11 @@ function wireRetrievalCommands(program: Command): void {
2481
2481
  );
2482
2482
  }
2483
2483
 
2484
+ // Terminal output is the raw content; the mark goes to stderr so
2485
+ // piping the document stays byte-exact.
2486
+ if (format === "terminal" && result.data.supersededBy) {
2487
+ process.stderr.write(`Superseded by: ${result.data.supersededBy}\n`);
2488
+ }
2484
2489
  process.stdout.write(
2485
2490
  `${formatGet(result, {
2486
2491
  lineNumbers: Boolean(cmdOpts.lineNumbers),
@@ -3646,9 +3651,14 @@ function wireManagementCommands(program: Command): void {
3646
3651
 
3647
3652
  // cleanup - Clean stale data
3648
3653
  addWriteLeaseFlags(
3649
- program.command("cleanup").description("Clean orphaned data from index")
3654
+ program
3655
+ .command("cleanup")
3656
+ .description("Clean orphaned data from index")
3657
+ .option("--json", "JSON output")
3650
3658
  ).action(async (cmdOpts: Record<string, unknown>) => {
3651
- const { cleanup, formatCleanup } = await import("./commands/cleanup");
3659
+ const { cleanup, formatCleanup, formatDroppingCollections } =
3660
+ await import("./commands/cleanup");
3661
+ const json = Boolean(cmdOpts.json) || getGlobals().json;
3652
3662
  const lease = parseWriteLeaseFlags(cmdOpts);
3653
3663
  const result = await withCliWriteLease(
3654
3664
  {
@@ -3660,6 +3670,13 @@ function wireManagementCommands(program: Command): void {
3660
3670
  cleanup({
3661
3671
  configPath: getGlobals().config,
3662
3672
  indexName: getGlobals().index,
3673
+ // Name what is about to be deleted before deleting it.
3674
+ onDropping: (collections) => {
3675
+ const plan = json
3676
+ ? JSON.stringify({ deleting: collections })
3677
+ : formatDroppingCollections(collections);
3678
+ process.stderr.write(`${plan}\n`);
3679
+ },
3663
3680
  })
3664
3681
  );
3665
3682
  throwIfWriteLeaseBusy(result, false);
@@ -3667,7 +3684,11 @@ function wireManagementCommands(program: Command): void {
3667
3684
  if (!result.success) {
3668
3685
  throw new CliError("RUNTIME", result.error ?? "Cleanup failed");
3669
3686
  }
3670
- process.stdout.write(`${formatCleanup(result)}\n`);
3687
+ process.stdout.write(
3688
+ json
3689
+ ? `${JSON.stringify({ droppedCollections: result.droppedCollections, ...result.stats }, null, 2)}\n`
3690
+ : `${formatCleanup(result)}\n`
3691
+ );
3671
3692
  });
3672
3693
 
3673
3694
  // reset - Reset GNO to fresh state
@@ -4184,6 +4205,46 @@ function wireAgentsCommands(program: Command): void {
4184
4205
  // ─────────────────────────────────────────────────────────────────────────────
4185
4206
 
4186
4207
  function wireTagsCommands(program: Command): void {
4208
+ program
4209
+ .command("metadata")
4210
+ .description(
4211
+ "List typed metadata keys with their types, document counts and common values"
4212
+ )
4213
+ .option("-c, --collection <name>", "only documents in this collection")
4214
+ .option(
4215
+ "--values <n>",
4216
+ "common values listed per key (1-100, default 10)",
4217
+ (value) => Number(value)
4218
+ )
4219
+ .option("--json", "JSON output")
4220
+ .option("--md", "Markdown output")
4221
+ .action(async (cmdOpts: Record<string, unknown>) => {
4222
+ const format = getFormat(cmdOpts);
4223
+ assertFormatSupported(CMD.metadataList, format);
4224
+ const globals = getGlobals();
4225
+ const { metadataList, formatMetadataList } =
4226
+ await import("./commands/metadata");
4227
+ const result = await metadataList({
4228
+ configPath: globals.config,
4229
+ indexName: globals.index,
4230
+ collection: cmdOpts.collection as string | undefined,
4231
+ values: cmdOpts.values as number | undefined,
4232
+ });
4233
+ if (!result.success) {
4234
+ throw new CliError(
4235
+ result.isValidation ? "VALIDATION" : "RUNTIME",
4236
+ result.error
4237
+ );
4238
+ }
4239
+ await writeOutput(
4240
+ formatMetadataList(result.data, {
4241
+ json: format === "json",
4242
+ md: format === "md",
4243
+ }),
4244
+ format
4245
+ );
4246
+ });
4247
+
4187
4248
  const tagsCmd = program.command("tags").description("Manage document tags");
4188
4249
 
4189
4250
  // tags list
@@ -281,7 +281,6 @@ export async function findEphemeralActivationProbeMatch(
281
281
  const search = await store.searchFts(candidate.term, {
282
282
  collection: plan.collection,
283
283
  limit: PROBE_RESULT_LIMIT,
284
- snippet: false,
285
284
  });
286
285
  if (!search.ok) {
287
286
  return err(search.error.code, search.error.message, search.error.cause);
@@ -60,7 +60,9 @@ export interface ConfigMutationState {
60
60
  }
61
61
 
62
62
  type PersistedConfigHook = (
63
- config: Config
63
+ config: Config,
64
+ /** Collection names before the mutation (mutators may edit in place). */
65
+ previousCollections: readonly string[]
64
66
  ) => Promise<{ ok: true } | { ok: false; error: string; code: string }>;
65
67
 
66
68
  async function applySerializedConfigChange<T>(
@@ -102,6 +104,9 @@ async function applySerializedConfigChange<T>(
102
104
  code: "LOAD_ERROR",
103
105
  };
104
106
  }
107
+ const previousCollections = currentConfig.collections.map(
108
+ ({ name }) => name
109
+ );
105
110
  const mutationResult = await mutate(currentConfig, {
106
111
  created: !loadResult.ok,
107
112
  });
@@ -129,7 +134,7 @@ async function applySerializedConfigChange<T>(
129
134
  }
130
135
  }
131
136
 
132
- const persisted = await afterPersist?.(newConfig);
137
+ const persisted = await afterPersist?.(newConfig, previousCollections);
133
138
  if (persisted && !persisted.ok) return persisted;
134
139
  ctx.onConfigUpdated?.(newConfig);
135
140
 
@@ -165,62 +170,74 @@ export async function applyConfigChange<T = void>(
165
170
  state: ConfigMutationState
166
171
  ) => Promise<MutationResult<T>> | MutationResult<T>
167
172
  ): Promise<ApplyConfigResult<T>> {
168
- return applySerializedConfigChange(ctx, mutate, async (newConfig) => {
169
- await ctx.afterConfigSaved?.(newConfig);
170
-
171
- if (ctx.projectStore) {
172
- const projection = await ctx.projectStore(ctx.store, newConfig);
173
- if (!projection.ok) {
174
- console.warn(`Config saved but DB sync failed: ${projection.error}`);
175
- return {
176
- ok: false,
177
- error: `DB sync failed: ${projection.error}`,
178
- code: "SYNC_ERROR",
179
- };
180
- }
181
- } else {
182
- const syncProjection = async (): Promise<void> => {
183
- const syncCollResult = await ctx.store.syncCollections(
184
- newConfig.collections
185
- );
186
- if (!syncCollResult.ok) {
187
- throw new Error(
188
- `Collection sync failed: ${syncCollResult.error.message}`
189
- );
173
+ return applySerializedConfigChange(
174
+ ctx,
175
+ mutate,
176
+ async (newConfig, previousCollections) => {
177
+ await ctx.afterConfigSaved?.(newConfig);
178
+
179
+ if (ctx.projectStore) {
180
+ const projection = await ctx.projectStore(ctx.store, newConfig);
181
+ if (!projection.ok) {
182
+ console.warn(`Config saved but DB sync failed: ${projection.error}`);
183
+ return {
184
+ ok: false,
185
+ error: `DB sync failed: ${projection.error}`,
186
+ code: "SYNC_ERROR",
187
+ };
190
188
  }
191
- const syncCtxResult = await ctx.store.syncContexts(
192
- newConfig.contexts ?? []
193
- );
194
- if (!syncCtxResult.ok) {
195
- throw new Error(
196
- `Context sync failed: ${syncCtxResult.error.message}`
189
+ } else {
190
+ const syncProjection = async (): Promise<void> => {
191
+ // Collections this change took out of the config are explicitly
192
+ // removed; other indexed collections the config omits stay as they
193
+ // are (the next ordinary open reports them).
194
+ const kept = new Set(newConfig.collections.map(({ name }) => name));
195
+ const syncCollResult = await ctx.store.syncCollections(
196
+ newConfig.collections,
197
+ {
198
+ remove: previousCollections.filter((name) => !kept.has(name)),
199
+ keepStale: true,
200
+ }
197
201
  );
198
- }
199
- };
200
- const transaction = ctx.store.withTransaction
201
- ? await ctx.store.withTransaction(syncProjection)
202
- : await syncProjection()
203
- .then(() => ({ ok: true as const, value: undefined }))
204
- .catch((cause: unknown) => ({
205
- ok: false as const,
206
- error: {
207
- message:
208
- cause instanceof Error ? cause.message : "DB sync failed",
209
- },
210
- }));
211
- if (!transaction.ok) {
212
- console.warn(
213
- `Config saved but DB sync failed: ${transaction.error.message}`
214
- );
215
- return {
216
- ok: false,
217
- error: `DB sync failed: ${transaction.error.message}`,
218
- code: "SYNC_ERROR",
202
+ if (!syncCollResult.ok) {
203
+ throw new Error(
204
+ `Collection sync failed: ${syncCollResult.error.message}`
205
+ );
206
+ }
207
+ const syncCtxResult = await ctx.store.syncContexts(
208
+ newConfig.contexts ?? []
209
+ );
210
+ if (!syncCtxResult.ok) {
211
+ throw new Error(
212
+ `Context sync failed: ${syncCtxResult.error.message}`
213
+ );
214
+ }
219
215
  };
216
+ const transaction = ctx.store.withTransaction
217
+ ? await ctx.store.withTransaction(syncProjection)
218
+ : await syncProjection()
219
+ .then(() => ({ ok: true as const, value: undefined }))
220
+ .catch((cause: unknown) => ({
221
+ ok: false as const,
222
+ error: {
223
+ message:
224
+ cause instanceof Error ? cause.message : "DB sync failed",
225
+ },
226
+ }));
227
+ if (!transaction.ok) {
228
+ console.warn(
229
+ `Config saved but DB sync failed: ${transaction.error.message}`
230
+ );
231
+ return {
232
+ ok: false,
233
+ error: `DB sync failed: ${transaction.error.message}`,
234
+ code: "SYNC_ERROR",
235
+ };
236
+ }
220
237
  }
221
- }
222
238
 
223
- await ctx.afterStoreSynced?.(newConfig);
224
- return { ok: true };
225
- });
239
+ await ctx.afterStoreSynced?.(newConfig);
240
+ return { ok: true };
241
+ }
242
+ );
226
243
  }
@@ -365,6 +365,8 @@ export const contextCapsuleEvidenceSchema = z
365
365
  ]),
366
366
  egressLineage: egressLineageSchema.optional(),
367
367
  record: recordEvidenceMetadataSchema.optional(),
368
+ /** Current successor when a newer memory fact superseded this one. */
369
+ supersededBy: contextCapsuleGnoUriSchema.optional(),
368
370
  })
369
371
  .strict()
370
372
  .superRefine((value, context) => {
@@ -51,6 +51,7 @@ export const contextCapsuleVerificationEvidenceSchema = z
51
51
  "chunk_corrupt",
52
52
  "source_missing",
53
53
  "mirror_missing",
54
+ "superseded",
54
55
  ]),
55
56
  rankingStatus: z.enum(["unchanged", "reranked", "unavailable"]),
56
57
  rankingCode: z.enum([
@@ -82,6 +83,7 @@ export const contextCapsuleVerificationEvidenceSchema = z
82
83
  "passage_stale",
83
84
  "chunk_missing",
84
85
  "chunk_corrupt",
86
+ "superseded",
85
87
  ].includes(value.contentCode) &&
86
88
  hashes.every((hash) => hash !== null)) ||
87
89
  (value.contentStatus === "missing" &&
@@ -28,7 +28,7 @@ import type {
28
28
  import type { ContextConfiguredGuidance } from "./context-guidance";
29
29
  import type { MetadataPredicate } from "./typed-metadata";
30
30
 
31
- import { decorateUriForIndex } from "../app/constants";
31
+ import { decorateResultUris, decorateUriForIndex } from "../app/constants";
32
32
  import { canonicalizeIndexName } from "../app/index-name";
33
33
  import { resolveTemporalRange } from "../pipeline/temporal";
34
34
  import { attachSearchResultPlannerMetadata } from "../pipeline/trace-metadata";
@@ -383,10 +383,7 @@ export const planContextEvidence = async <T, P>(
383
383
  const decoratedResults = responses
384
384
  .flatMap((response) => response.results)
385
385
  .map((result) => {
386
- const decorated = {
387
- ...result,
388
- uri: decorateUriForIndex(result.uri, indexName),
389
- };
386
+ const decorated = decorateResultUris(result, indexName);
390
387
  const metadata = result[SEARCH_RESULT_PLANNER_METADATA];
391
388
  return metadata
392
389
  ? attachSearchResultPlannerMetadata(decorated, metadata)
@@ -502,6 +502,7 @@ export const materializeContextEvidenceCandidates = async (
502
502
  ...(projectRecordEvidenceMetadata(document)
503
503
  ? { record: projectRecordEvidenceMetadata(document) }
504
504
  : {}),
505
+ ...(result.supersededBy ? { supersededBy: result.supersededBy } : {}),
505
506
  },
506
507
  },
507
508
  };
@@ -11,6 +11,7 @@ import type { ContextCapsuleVerification } from "./context-capsule-verification"
11
11
 
12
12
  import { decorateUriForIndex, deriveDocid } from "../app/constants";
13
13
  import { chunkMatchesCanonicalContent } from "../pipeline/chunk-lookup";
14
+ import { ok } from "../store/types";
14
15
  import { type ContextCapsuleCreateOptions } from "./context-capsule";
15
16
  import { sha256Text } from "./context-capsule-validation";
16
17
  import {
@@ -35,6 +36,7 @@ type ContextVerifierStore = Pick<
35
36
  | "getCollections"
36
37
  | "getContexts"
37
38
  | "getDocumentsByDocids"
39
+ | "getSupersedingUris"
38
40
  > &
39
41
  Required<Pick<StorePort, "getContentBatch">>;
40
42
 
@@ -127,6 +129,8 @@ interface LoadedEvidence {
127
129
  chunksByHash: Map<string, ChunkRow[]>;
128
130
  documents: DocumentRow[];
129
131
  snapshot: ContextEvidenceSnapshot;
132
+ /** Current successor URI of evidence whose memory fact was superseded. */
133
+ successors: ReadonlyMap<string, string>;
130
134
  }
131
135
 
132
136
  const missingReceipt = (evidence: Evidence): EvidenceReceipt => ({
@@ -229,6 +233,8 @@ const verifyContent = (
229
233
  contentCode = "chunk_missing";
230
234
  } else if (!chunkValid) {
231
235
  contentCode = "chunk_corrupt";
236
+ } else if (loaded.successors.has(evidence.uri)) {
237
+ contentCode = "superseded";
232
238
  }
233
239
 
234
240
  return {
@@ -374,16 +380,23 @@ export const verifyContextCapsule = async (
374
380
  )
375
381
  ),
376
382
  ];
377
- const [documentResult, contentResult, chunkResult, rankResult] =
383
+ const [documentResult, contentResult, chunkResult, rankResult, successors] =
378
384
  await Promise.all([
379
385
  deps.store.getDocumentsByDocids(docids, { activeOnly: true }),
380
386
  deps.store.getContentBatch(mirrorHashes),
381
387
  deps.store.getChunksBatch(mirrorHashes),
382
388
  deps.resolveCurrentRanks?.(structuredClone(capsule)).catch(() => null) ??
383
389
  Promise.resolve(null),
390
+ deps.store.getSupersedingUris?.([...evidenceUris]) ??
391
+ Promise.resolve(ok(new Map<string, string>())),
384
392
  ]);
385
393
  const loaded: LoadedEvidence = {
386
394
  snapshot: before,
395
+ successors: unwrapStore(
396
+ successors,
397
+ "document_load_failed",
398
+ "Failed to load supersession of verification evidence"
399
+ ),
387
400
  documents: unwrapStore(
388
401
  documentResult,
389
402
  "document_load_failed",
@@ -90,7 +90,6 @@ async function loadCandidatePool(
90
90
  memoryScopesAny: input.scopes,
91
91
  excludeSuperseded: true,
92
92
  anyTerm: true,
93
- snippet: false,
94
93
  });
95
94
  // INVALID_INPUT means the text has no searchable terms: an empty pool.
96
95
  if (!ftsResult.ok && ftsResult.error.code !== "INVALID_INPUT") {