@gmickel/gno 1.17.0 → 1.19.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 (167) hide show
  1. package/README.md +18 -4
  2. package/assets/skill/SKILL.md +44 -1
  3. package/assets/skill/mcp-reference.md +21 -0
  4. package/package.json +3 -2
  5. package/spec/AGENTS.md +83 -0
  6. package/spec/CLAUDE.md +83 -0
  7. package/spec/bench-fixture.schema.json +137 -0
  8. package/spec/cli.md +2894 -0
  9. package/spec/db/schema.sql +442 -0
  10. package/spec/evals-agentic.md +510 -0
  11. package/spec/evals.md +1106 -0
  12. package/spec/mcp.md +2229 -0
  13. package/spec/output-schemas/activation-verification.schema.json +515 -0
  14. package/spec/output-schemas/ask.schema.json +366 -0
  15. package/spec/output-schemas/backlinks.schema.json +131 -0
  16. package/spec/output-schemas/bench-result.schema.json +120 -0
  17. package/spec/output-schemas/capture-receipt.schema.json +143 -0
  18. package/spec/output-schemas/collection-list.schema.json +45 -0
  19. package/spec/output-schemas/context-capsule-v1.schema.json +691 -0
  20. package/spec/output-schemas/context-capsule-verification.schema.json +1338 -0
  21. package/spec/output-schemas/context-list.schema.json +21 -0
  22. package/spec/output-schemas/doctor.schema.json +313 -0
  23. package/spec/output-schemas/error.schema.json +30 -0
  24. package/spec/output-schemas/expansion.schema.json +37 -0
  25. package/spec/output-schemas/get.schema.json +140 -0
  26. package/spec/output-schemas/graph-query.schema.json +99 -0
  27. package/spec/output-schemas/graph.schema.json +371 -0
  28. package/spec/output-schemas/links-list.schema.json +186 -0
  29. package/spec/output-schemas/mcp-add-collection-result.schema.json +23 -0
  30. package/spec/output-schemas/mcp-capture-result.schema.json +152 -0
  31. package/spec/output-schemas/mcp-http-error.schema.json +30 -0
  32. package/spec/output-schemas/mcp-job-list.schema.json +58 -0
  33. package/spec/output-schemas/mcp-job-status.schema.json +224 -0
  34. package/spec/output-schemas/mcp-remove-result.schema.json +39 -0
  35. package/spec/output-schemas/mcp-sync-result.schema.json +41 -0
  36. package/spec/output-schemas/mcp-tag-result.schema.json +33 -0
  37. package/spec/output-schemas/models-list.schema.json +93 -0
  38. package/spec/output-schemas/multi-get.schema.json +103 -0
  39. package/spec/output-schemas/process-status.schema.json +119 -0
  40. package/spec/output-schemas/query-diagnose.schema.json +123 -0
  41. package/spec/output-schemas/resident-status.schema.json +154 -0
  42. package/spec/output-schemas/retrieval-trace-common.schema.json +492 -0
  43. package/spec/output-schemas/retrieval-trace-delete.schema.json +16 -0
  44. package/spec/output-schemas/retrieval-trace-export.schema.json +61 -0
  45. package/spec/output-schemas/retrieval-trace-filters.schema.json +139 -0
  46. package/spec/output-schemas/retrieval-trace-judgment.schema.json +15 -0
  47. package/spec/output-schemas/retrieval-trace-list.schema.json +18 -0
  48. package/spec/output-schemas/retrieval-trace-payloads.schema.json +178 -0
  49. package/spec/output-schemas/retrieval-trace-purge.schema.json +31 -0
  50. package/spec/output-schemas/retrieval-trace-qrels.schema.json +303 -0
  51. package/spec/output-schemas/retrieval-trace-replay.schema.json +286 -0
  52. package/spec/output-schemas/retrieval-trace-show.schema.json +69 -0
  53. package/spec/output-schemas/retrieval-trace-summary.schema.json +65 -0
  54. package/spec/output-schemas/search-result.schema.json +154 -0
  55. package/spec/output-schemas/search-results.schema.json +338 -0
  56. package/spec/output-schemas/similar.schema.json +84 -0
  57. package/spec/output-schemas/status.schema.json +676 -0
  58. package/spec/output-schemas/tags-list.schema.json +48 -0
  59. package/src/app/context-runtime-types.ts +3 -0
  60. package/src/app/context-runtime.ts +15 -1
  61. package/src/cli/commands/ask.ts +106 -36
  62. package/src/cli/commands/context-build.ts +56 -9
  63. package/src/cli/commands/daemon.ts +69 -2
  64. package/src/cli/commands/get.ts +64 -3
  65. package/src/cli/commands/models/pull.ts +13 -3
  66. package/src/cli/commands/query.ts +62 -23
  67. package/src/cli/commands/replay.ts +140 -0
  68. package/src/cli/commands/search.ts +48 -3
  69. package/src/cli/commands/shared.ts +3 -1
  70. package/src/cli/commands/status.ts +2 -0
  71. package/src/cli/commands/trace.ts +200 -0
  72. package/src/cli/commands/vsearch.ts +75 -53
  73. package/src/cli/detach.ts +37 -20
  74. package/src/cli/program.ts +329 -27
  75. package/src/config/index.ts +12 -0
  76. package/src/config/retrieval-traces.ts +56 -0
  77. package/src/config/types.ts +41 -0
  78. package/src/core/context-compiler.ts +11 -4
  79. package/src/core/job-manager.ts +19 -0
  80. package/src/core/mutation-generations.ts +33 -0
  81. package/src/core/retrieval-qrels.ts +405 -0
  82. package/src/core/retrieval-replay-candidate.ts +368 -0
  83. package/src/core/retrieval-replay-types.ts +109 -0
  84. package/src/core/retrieval-replay-validation.ts +89 -0
  85. package/src/core/retrieval-replay.ts +441 -0
  86. package/src/core/retrieval-trace-evidence-origin.ts +175 -0
  87. package/src/core/retrieval-trace-export.ts +113 -0
  88. package/src/core/retrieval-trace-filter-normalization.ts +27 -0
  89. package/src/core/retrieval-trace-filters.ts +19 -0
  90. package/src/core/retrieval-trace-management-helpers.ts +247 -0
  91. package/src/core/retrieval-trace-management-types.ts +132 -0
  92. package/src/core/retrieval-trace-management.ts +422 -0
  93. package/src/core/retrieval-trace-request.ts +141 -0
  94. package/src/core/retrieval-trace-session.ts +494 -0
  95. package/src/core/retrieval-trace.ts +472 -0
  96. package/src/llm/cache.ts +13 -3
  97. package/src/llm/nodeLlamaCpp/adapter.ts +10 -1
  98. package/src/llm/nodeLlamaCpp/lifecycle.ts +71 -0
  99. package/src/mcp/context.ts +161 -0
  100. package/src/mcp/http-security.ts +477 -0
  101. package/src/mcp/http-session.ts +272 -0
  102. package/src/mcp/http-transport.ts +370 -0
  103. package/src/mcp/resources/index.ts +141 -134
  104. package/src/mcp/server.ts +19 -79
  105. package/src/mcp/tools/add-collection.ts +3 -1
  106. package/src/mcp/tools/capture.ts +3 -0
  107. package/src/mcp/tools/clear-collection-embeddings.ts +2 -0
  108. package/src/mcp/tools/context.ts +68 -16
  109. package/src/mcp/tools/embed.ts +62 -52
  110. package/src/mcp/tools/get.ts +35 -1
  111. package/src/mcp/tools/index-cmd.ts +88 -74
  112. package/src/mcp/tools/index.ts +96 -2
  113. package/src/mcp/tools/query.ts +95 -64
  114. package/src/mcp/tools/remove-collection.ts +2 -0
  115. package/src/mcp/tools/search.ts +36 -13
  116. package/src/mcp/tools/status.ts +11 -0
  117. package/src/mcp/tools/sync.ts +16 -14
  118. package/src/mcp/tools/trace.ts +143 -0
  119. package/src/mcp/tools/vsearch.ts +71 -38
  120. package/src/mcp/tools/workspace-write.ts +7 -3
  121. package/src/pipeline/answer.ts +167 -26
  122. package/src/pipeline/graph-retrieval.ts +15 -1
  123. package/src/pipeline/hybrid.ts +151 -43
  124. package/src/pipeline/search.ts +36 -3
  125. package/src/pipeline/trace-metadata.ts +47 -0
  126. package/src/pipeline/types.ts +43 -0
  127. package/src/pipeline/vsearch.ts +101 -38
  128. package/src/sdk/client.ts +380 -71
  129. package/src/sdk/documents.ts +48 -1
  130. package/src/sdk/index.ts +17 -0
  131. package/src/sdk/types.ts +28 -0
  132. package/src/serve/background-runtime.ts +12 -212
  133. package/src/serve/context-capsule.ts +67 -8
  134. package/src/serve/embed-scheduler.ts +74 -43
  135. package/src/serve/index.ts +9 -0
  136. package/src/serve/jobs.ts +78 -80
  137. package/src/serve/public/app.tsx +12 -1
  138. package/src/serve/public/components/HealthCenter.tsx +74 -1
  139. package/src/serve/public/globals.built.css +1 -1
  140. package/src/serve/public/lib/workspace-tabs.ts +2 -0
  141. package/src/serve/public/pages/Dashboard.tsx +11 -0
  142. package/src/serve/public/pages/TraceHistory.tsx +478 -0
  143. package/src/serve/public/pages/trace-history-detail.tsx +224 -0
  144. package/src/serve/resident-admission.ts +159 -0
  145. package/src/serve/resident-background-work.ts +39 -0
  146. package/src/serve/resident-request.ts +55 -0
  147. package/src/serve/resident-runtime.ts +490 -0
  148. package/src/serve/resident-status.ts +96 -0
  149. package/src/serve/retrieval-trace.ts +28 -0
  150. package/src/serve/routes/api.ts +629 -239
  151. package/src/serve/routes/mcp.ts +69 -0
  152. package/src/serve/routes/traces.ts +156 -0
  153. package/src/serve/server.ts +276 -37
  154. package/src/serve/status-model.ts +51 -0
  155. package/src/serve/status.ts +5 -0
  156. package/src/store/index.ts +31 -0
  157. package/src/store/migrations/014-retrieval-traces.ts +303 -0
  158. package/src/store/migrations/index.ts +2 -0
  159. package/src/store/retrieval-trace-codec.ts +384 -0
  160. package/src/store/sqlite/adapter.ts +179 -10
  161. package/src/store/sqlite/retrieval-trace-management-store.ts +341 -0
  162. package/src/store/sqlite/retrieval-trace-retention.ts +349 -0
  163. package/src/store/sqlite/retrieval-trace-rows.ts +267 -0
  164. package/src/store/sqlite/retrieval-trace-store.ts +515 -0
  165. package/src/store/types.ts +297 -0
  166. package/src/store/vector/sqlite-vec.ts +76 -1
  167. package/src/store/vector/types.ts +1 -1
@@ -27,6 +27,7 @@ import {
27
27
  planMoveRefactor,
28
28
  planRenameRefactor,
29
29
  } from "../../core/file-refactors";
30
+ import { recordContentMutation } from "../../core/mutation-generations";
30
31
  import { defaultSyncService, withContentTypeRules } from "../../ingestion";
31
32
  import { runTool, type ToolResult } from "./index";
32
33
 
@@ -210,11 +211,12 @@ export function handleRenameNote(
210
211
  const currentPath = join(collection.path, doc.relPath);
211
212
  const nextPath = join(collection.path, plan.nextRelPath);
212
213
  await renameFilePath(currentPath, nextPath);
213
- await defaultSyncService.syncCollection(
214
+ const syncResult = await defaultSyncService.syncCollection(
214
215
  collection,
215
216
  ctx.store,
216
217
  withContentTypeRules({ runUpdateCmd: false }, ctx.config)
217
218
  );
219
+ recordContentMutation(syncResult, ctx.markContentMutation);
218
220
  return {
219
221
  uri: plan.nextUri,
220
222
  relPath: plan.nextRelPath,
@@ -255,11 +257,12 @@ export function handleMoveNote(
255
257
  const nextPath = join(collection.path, plan.nextRelPath);
256
258
  await mkdir(dirname(nextPath), { recursive: true });
257
259
  await renameFilePath(currentPath, nextPath);
258
- await defaultSyncService.syncCollection(
260
+ const syncResult = await defaultSyncService.syncCollection(
259
261
  collection,
260
262
  ctx.store,
261
263
  withContentTypeRules({ runUpdateCmd: false }, ctx.config)
262
264
  );
265
+ recordContentMutation(syncResult, ctx.markContentMutation);
263
266
  return {
264
267
  uri: plan.nextUri,
265
268
  relPath: plan.nextRelPath,
@@ -308,11 +311,12 @@ export function handleDuplicateNote(
308
311
  const nextPath = join(collection.path, plan.nextRelPath);
309
312
  await mkdir(dirname(nextPath), { recursive: true });
310
313
  await copyFilePath(currentPath, nextPath);
311
- await defaultSyncService.syncCollection(
314
+ const syncResult = await defaultSyncService.syncCollection(
312
315
  collection,
313
316
  ctx.store,
314
317
  withContentTypeRules({ runUpdateCmd: false }, ctx.config)
315
318
  );
319
+ recordContentMutation(syncResult, ctx.markContentMutation);
316
320
  return {
317
321
  uri: plan.nextUri,
318
322
  relPath: plan.nextRelPath,
@@ -5,6 +5,7 @@
5
5
  * @module src/pipeline/answer
6
6
  */
7
7
 
8
+ import type { RetrievalTraceSession } from "../core/retrieval-trace-session";
8
9
  import type { GenerationPort } from "../llm/types";
9
10
  import type { StorePort } from "../store/types";
10
11
  import type {
@@ -15,6 +16,11 @@ import type {
15
16
  } from "./types";
16
17
 
17
18
  import { buildAnswerPrompt, type AnswerPromptSource } from "./answer-prompt";
19
+ import { attachCitationTraceMetadata } from "./trace-metadata";
20
+ import {
21
+ CITATION_TRACE_METADATA,
22
+ SEARCH_RESULT_PLANNER_METADATA,
23
+ } from "./types";
18
24
 
19
25
  // ─────────────────────────────────────────────────────────────────────────────
20
26
  // Constants
@@ -24,6 +30,11 @@ import { buildAnswerPrompt, type AnswerPromptSource } from "./answer-prompt";
24
30
  export const ABSTENTION_MESSAGE =
25
31
  "I don't have enough information in the provided sources to answer this question.";
26
32
 
33
+ export const answerTraceTerminalStatus = (
34
+ citations: readonly Citation[] | undefined
35
+ ): "completed" | "partial" =>
36
+ citations && citations.length > 0 ? "completed" : "partial";
37
+
27
38
  /** Max characters per document (~8K tokens) */
28
39
  const MAX_DOC_CHARS = 32_000;
29
40
 
@@ -86,6 +97,65 @@ interface SelectedSource {
86
97
  reason: string;
87
98
  }
88
99
 
100
+ interface ExactAnswerPassage {
101
+ text: string;
102
+ startLine: number;
103
+ endLine: number;
104
+ passageHash: string;
105
+ }
106
+
107
+ const sha256 = (value: string): string =>
108
+ new Bun.CryptoHasher("sha256").update(value).digest("hex");
109
+
110
+ const completeLinePrefix = (
111
+ content: string,
112
+ startLine: number,
113
+ maxChars: number
114
+ ): ExactAnswerPassage | null => {
115
+ const lines = content.split("\n");
116
+ if (lines.length === 0) return null;
117
+ const selected: string[] = [];
118
+ let characters = 0;
119
+ for (const line of lines) {
120
+ const addition = selected.length === 0 ? line.length : line.length + 1;
121
+ if (characters + addition > maxChars) break;
122
+ selected.push(line);
123
+ characters += addition;
124
+ }
125
+ if (selected.length === 0) return null;
126
+ const text = selected.join("\n");
127
+ if (!text.trim()) return null;
128
+ return {
129
+ text,
130
+ startLine,
131
+ endLine: startLine + selected.length - 1,
132
+ passageHash: sha256(text),
133
+ };
134
+ };
135
+
136
+ const anchoredCompleteLines = (
137
+ content: string,
138
+ metadata: SearchResult[typeof SEARCH_RESULT_PLANNER_METADATA]
139
+ ): ExactAnswerPassage | null => {
140
+ if (
141
+ !metadata?.passageHash ||
142
+ metadata.startLine === undefined ||
143
+ metadata.endLine === undefined ||
144
+ metadata.endLine < metadata.startLine
145
+ ) {
146
+ return null;
147
+ }
148
+ const lines = content.split("\n");
149
+ const text = lines.slice(metadata.startLine - 1, metadata.endLine).join("\n");
150
+ if (
151
+ text.split("\n").length !== metadata.endLine - metadata.startLine + 1 ||
152
+ sha256(text) !== metadata.passageHash
153
+ ) {
154
+ return null;
155
+ }
156
+ return completeLinePrefix(text, metadata.startLine, MAX_DOC_CHARS);
157
+ };
158
+
89
159
  // ─────────────────────────────────────────────────────────────────────────────
90
160
  // Citation Processing
91
161
  // ─────────────────────────────────────────────────────────────────────────────
@@ -423,53 +493,78 @@ export async function generateGroundedAnswer(
423
493
  ): Promise<AnswerGenerationResult | null> {
424
494
  const { genPort, store } = deps;
425
495
  const sourceSelection = selectAdaptiveSources(query, results);
496
+ const finalRanks = new Map(
497
+ results.map((result, index) => [result, index + 1] as const)
498
+ );
426
499
  const promptSources: AnswerPromptSource[] = [];
427
500
  const citations: Citation[] = [];
428
501
  let citationIndex = 0;
429
502
 
430
503
  for (const r of sourceSelection.selected) {
431
- let content: string | null = null;
432
- let usedFullContent = false;
504
+ let passage: ExactAnswerPassage | null = null;
505
+ const plannerMetadata = r[SEARCH_RESULT_PLANNER_METADATA];
506
+ const sourceHash = r.source.sourceHash;
507
+ const mirrorHash = r.conversion?.mirrorHash;
433
508
 
434
509
  // Try to fetch full document content if store available
435
- if (store && r.conversion?.mirrorHash) {
436
- const contentResult = await store.getContent(r.conversion.mirrorHash);
510
+ if (store && mirrorHash) {
511
+ const contentResult = await store.getContent(mirrorHash);
437
512
  if (contentResult.ok && contentResult.value) {
438
- content = contentResult.value;
439
- usedFullContent = true;
440
- // Truncate to max doc chars
441
- if (content.length > MAX_DOC_CHARS) {
442
- content = `${content.slice(0, MAX_DOC_CHARS)}\n\n[... truncated ...]`;
513
+ if (sha256(contentResult.value) === mirrorHash) {
514
+ passage =
515
+ anchoredCompleteLines(contentResult.value, plannerMetadata) ??
516
+ completeLinePrefix(contentResult.value, 1, MAX_DOC_CHARS);
443
517
  }
444
518
  }
445
519
  }
446
520
 
447
- // Fallback to snippet if full content unavailable
448
- if (!content) {
449
- if (!r.snippet || r.snippet.trim().length === 0) {
450
- continue;
451
- }
452
- content =
453
- r.snippet.length > MAX_SNIPPET_CHARS
454
- ? `${r.snippet.slice(0, MAX_SNIPPET_CHARS)}...`
455
- : r.snippet;
521
+ // Snippet fallback is allowed only when the planner proves it is the exact
522
+ // complete canonical chunk, never an FTS marker/ellipsis presentation.
523
+ if (
524
+ !passage &&
525
+ plannerMetadata?.passageHash &&
526
+ plannerMetadata.startLine !== undefined &&
527
+ plannerMetadata.endLine !== undefined &&
528
+ sha256(r.snippet) === plannerMetadata.passageHash &&
529
+ r.snippet.split("\n").length ===
530
+ plannerMetadata.endLine - plannerMetadata.startLine + 1
531
+ ) {
532
+ passage = completeLinePrefix(
533
+ r.snippet,
534
+ plannerMetadata.startLine,
535
+ MAX_SNIPPET_CHARS
536
+ );
456
537
  }
538
+ if (!(passage && sourceHash && mirrorHash)) continue;
457
539
 
458
540
  citationIndex += 1;
459
541
  promptSources.push({
460
542
  index: citationIndex,
461
543
  docid: r.docid,
462
544
  uri: r.uri,
463
- content,
545
+ content: passage.text,
464
546
  guidance: r.context,
465
547
  });
466
- // Clear line range when citing full content (not a specific snippet)
467
- citations.push({
468
- docid: r.docid,
469
- uri: r.uri,
470
- startLine: usedFullContent ? undefined : r.snippetRange?.startLine,
471
- endLine: usedFullContent ? undefined : r.snippetRange?.endLine,
472
- });
548
+ citations.push(
549
+ attachCitationTraceMetadata(
550
+ {
551
+ docid: r.docid,
552
+ uri: r.uri,
553
+ startLine: passage.startLine,
554
+ endLine: passage.endLine,
555
+ },
556
+ {
557
+ sourceHash,
558
+ mirrorHash,
559
+ passageHash: passage.passageHash,
560
+ seq: plannerMetadata?.seq,
561
+ rank: finalRanks.get(r) ?? 1,
562
+ plannerRank: plannerMetadata?.retrievalRank,
563
+ sources: plannerMetadata?.sources,
564
+ graphExpanded: plannerMetadata?.graphExpanded,
565
+ }
566
+ )
567
+ );
473
568
  }
474
569
 
475
570
  if (promptSources.length === 0) {
@@ -528,3 +623,49 @@ export function processAnswerResult(rawResult: AnswerGenerationResult): {
528
623
  answerContext: rawResult.answerContext,
529
624
  };
530
625
  }
626
+
627
+ /** Process first, then record only citations retained in the final answer. */
628
+ export async function processAnswerResultWithTrace(
629
+ rawResult: AnswerGenerationResult,
630
+ traceSession?: RetrievalTraceSession
631
+ ): Promise<ReturnType<typeof processAnswerResult>> {
632
+ const processed = processAnswerResult(rawResult);
633
+ if (!traceSession || processed.citations.length === 0) return processed;
634
+ const evidence = processed.citations.flatMap((citation) => {
635
+ const metadata = citation[CITATION_TRACE_METADATA];
636
+ if (
637
+ !metadata ||
638
+ citation.startLine === undefined ||
639
+ citation.endLine === undefined
640
+ ) {
641
+ return [];
642
+ }
643
+ return [
644
+ {
645
+ docid: citation.docid,
646
+ uri: citation.uri,
647
+ sourceHash: metadata.sourceHash,
648
+ mirrorHash: metadata.mirrorHash,
649
+ passageHash: metadata.passageHash,
650
+ startLine: citation.startLine,
651
+ endLine: citation.endLine,
652
+ rank: metadata.rank,
653
+ ...(metadata.seq === undefined ? {} : { seq: metadata.seq }),
654
+ ...(metadata.plannerRank === undefined
655
+ ? {}
656
+ : { plannerRank: metadata.plannerRank }),
657
+ ...(metadata.sources === undefined
658
+ ? {}
659
+ : { sources: metadata.sources }),
660
+ ...(metadata.graphExpanded === undefined
661
+ ? {}
662
+ : { graphExpanded: metadata.graphExpanded }),
663
+ },
664
+ ];
665
+ });
666
+ const recorded = await traceSession.recordEvidence("cite", evidence);
667
+ if (!recorded.ok) {
668
+ throw new Error(`Trace recording failed: ${recorded.error.message}`);
669
+ }
670
+ return processed;
671
+ }
@@ -62,6 +62,7 @@ const matchesDocumentFilters = (
62
62
  until?: string;
63
63
  categories?: string[];
64
64
  author?: string;
65
+ relPathPrefix?: string;
65
66
  }
66
67
  ): boolean => {
67
68
  if (
@@ -201,6 +202,7 @@ export async function expandGraphCandidates(
201
202
  until?: string;
202
203
  categories?: string[];
203
204
  author?: string;
205
+ relPathPrefix?: string;
204
206
  } = {}
205
207
  ): Promise<GraphRetrievalResult> {
206
208
  const maxCandidates = Math.max(
@@ -249,6 +251,13 @@ export async function expandGraphCandidates(
249
251
  if (!doc.mirrorHash) {
250
252
  continue;
251
253
  }
254
+ if (
255
+ options.relPathPrefix !== undefined &&
256
+ doc.relPath !== options.relPathPrefix &&
257
+ !doc.relPath.startsWith(`${options.relPathPrefix}/`)
258
+ ) {
259
+ continue;
260
+ }
252
261
  const rank =
253
262
  seedCandidates.findIndex(
254
263
  (candidate) => candidate.mirrorHash === doc.mirrorHash
@@ -325,7 +334,12 @@ export async function expandGraphCandidates(
325
334
  }
326
335
 
327
336
  const metadataFilteredDocs = docsResult.value.filter(
328
- (doc) => doc.mirrorHash && matchesDocumentFilters(doc, options)
337
+ (doc) =>
338
+ doc.mirrorHash &&
339
+ (options.relPathPrefix === undefined ||
340
+ doc.relPath === options.relPathPrefix ||
341
+ doc.relPath.startsWith(`${options.relPathPrefix}/`)) &&
342
+ matchesDocumentFilters(doc, options)
329
343
  );
330
344
  const docs = await filterDocsByTags(store, metadataFilteredDocs, options);
331
345
  const docByDocid = new Map(docs.map((doc) => [doc.docid, doc]));
@@ -55,9 +55,10 @@ import {
55
55
  shouldSortByRecency,
56
56
  } from "./temporal";
57
57
  import {
58
- DEFAULT_PIPELINE_CONFIG,
59
- SEARCH_RESULT_PLANNER_METADATA,
60
- } from "./types";
58
+ attachSearchResultPlannerMetadata,
59
+ attachSearchResultsTraceMetadata,
60
+ } from "./trace-metadata";
61
+ import { DEFAULT_PIPELINE_CONFIG } from "./types";
61
62
 
62
63
  // ─────────────────────────────────────────────────────────────────────────────
63
64
  // Dependencies
@@ -121,11 +122,13 @@ async function checkBm25Strength(
121
122
  until?: string;
122
123
  categories?: string[];
123
124
  author?: string;
125
+ relPathPrefix?: string;
124
126
  }
125
127
  ): Promise<boolean> {
126
128
  const result = await store.searchFts(query, {
127
129
  limit: 5,
128
130
  collection: options?.collection,
131
+ relPathPrefix: options?.relPathPrefix,
129
132
  language: options?.lang,
130
133
  tagsAll: options?.tagsAll,
131
134
  tagsAny: options?.tagsAny,
@@ -179,11 +182,13 @@ async function searchFtsChunks(
179
182
  until?: string;
180
183
  categories?: string[];
181
184
  author?: string;
185
+ relPathPrefix?: string;
182
186
  }
183
187
  ): Promise<FtsChunksResult> {
184
188
  const result = await store.searchFts(query, {
185
189
  limit: options.limit,
186
190
  collection: options.collection,
191
+ relPathPrefix: options.relPathPrefix,
187
192
  language: options.lang,
188
193
  tagsAll: options.tagsAll,
189
194
  tagsAny: options.tagsAny,
@@ -216,7 +221,11 @@ async function searchVectorChunks(
216
221
  vectorIndex: VectorIndexPort,
217
222
  embedPort: EmbeddingPort,
218
223
  query: string,
219
- options: { limit: number; minScore?: number }
224
+ options: {
225
+ limit: number;
226
+ minScore?: number;
227
+ allowedMirrorHashes?: string[];
228
+ }
220
229
  ): Promise<ChunkId[]> {
221
230
  if (!vectorIndex.searchAvailable) {
222
231
  return [];
@@ -234,7 +243,10 @@ async function searchVectorChunks(
234
243
  const searchResult = await vectorIndex.searchNearest(
235
244
  queryEmbedding,
236
245
  options.limit,
237
- { minScore: options.minScore }
246
+ {
247
+ minScore: options.minScore,
248
+ allowedMirrorHashes: options.allowedMirrorHashes,
249
+ }
238
250
  );
239
251
 
240
252
  if (!searchResult.ok) {
@@ -381,6 +393,7 @@ export async function searchHybrid(
381
393
  until: temporalRange.until,
382
394
  categories: options.categories,
383
395
  author: options.author,
396
+ relPathPrefix: options.retrievalScope?.relPathPrefix,
384
397
  });
385
398
 
386
399
  if (hasStrongSignal) {
@@ -397,6 +410,8 @@ export async function searchHybrid(
397
410
  });
398
411
  if (expandResult.ok) {
399
412
  expansion = expandResult.value;
413
+ } else {
414
+ counters.fallbackEvents.push("expansion_error");
400
415
  }
401
416
  }
402
417
  }
@@ -428,6 +443,7 @@ export async function searchHybrid(
428
443
  until: temporalRange.until,
429
444
  categories: options.categories,
430
445
  author: options.author,
446
+ relPathPrefix: options.retrievalScope?.relPathPrefix,
431
447
  });
432
448
 
433
449
  // Propagate FTS syntax errors as INVALID_INPUT
@@ -462,6 +478,7 @@ export async function searchHybrid(
462
478
  until: temporalRange.until,
463
479
  categories: options.categories,
464
480
  author: options.author,
481
+ relPathPrefix: options.retrievalScope?.relPathPrefix,
465
482
  })
466
483
  )
467
484
  );
@@ -509,6 +526,7 @@ export async function searchHybrid(
509
526
  query,
510
527
  {
511
528
  limit: limit * 2 * retrievalMultiplier,
529
+ allowedMirrorHashes: options.retrievalScope?.allowedMirrorHashes,
512
530
  }
513
531
  );
514
532
 
@@ -552,7 +570,10 @@ export async function searchHybrid(
552
570
 
553
571
  const searchResult = await vectorIndex.searchNearest(
554
572
  new Float32Array(embedding),
555
- variant.limit
573
+ variant.limit,
574
+ {
575
+ allowedMirrorHashes: options.retrievalScope?.allowedMirrorHashes,
576
+ }
556
577
  );
557
578
  if (!searchResult.ok || searchResult.value.length === 0) {
558
579
  continue;
@@ -615,6 +636,7 @@ export async function searchHybrid(
615
636
  limit,
616
637
  candidateLimit,
617
638
  disabled: !options.graph || options.noGraph,
639
+ relPathPrefix: options.retrievalScope?.relPathPrefix,
618
640
  lang: options.lang,
619
641
  tagsAll: options.tagsAll,
620
642
  tagsAny: options.tagsAny,
@@ -735,6 +757,14 @@ export async function searchHybrid(
735
757
  const matchesMetadataFilters = (
736
758
  doc: (typeof docsResult.value)[number]
737
759
  ): boolean => {
760
+ const relPathPrefix = options.retrievalScope?.relPathPrefix;
761
+ if (
762
+ relPathPrefix !== undefined &&
763
+ doc.relPath !== relPathPrefix &&
764
+ !doc.relPath.startsWith(`${relPathPrefix}/`)
765
+ ) {
766
+ return false;
767
+ }
738
768
  if (!isWithinTemporalRange(doc.sourceMtime, temporalRange)) {
739
769
  return false;
740
770
  }
@@ -923,42 +953,51 @@ export async function searchHybrid(
923
953
  if (!docidMap.has(docidKey)) docidMap.set(docidKey, doc.docid);
924
954
  const collectionPath = collectionPaths.get(doc.collection);
925
955
  seenDocids.add(doc.docid);
926
- results.push({
927
- docid: doc.docid,
928
- score: candidate.blendedScore,
929
- uri: doc.uri,
930
- title: doc.title ?? undefined,
931
- contentType: doc.contentType ?? undefined,
932
- categories: doc.categories ?? undefined,
933
- line: snippetChunk.startLine,
934
- snippet,
935
- snippetLanguage: chunk.language ?? undefined,
936
- snippetRange,
937
- source: {
938
- relPath: doc.relPath,
939
- absPath: collectionPath
940
- ? `${collectionPath}/${doc.relPath}`
941
- : undefined,
942
- mime: doc.sourceMime,
943
- ext: doc.sourceExt,
944
- modifiedAt: doc.sourceMtime,
945
- documentDate: doc.frontmatterDate ?? undefined,
946
- sizeBytes: doc.sourceSize,
947
- sourceHash: doc.sourceHash,
948
- },
949
- conversion: {
950
- mirrorHash: candidate.mirrorHash,
951
- converterId: doc.converterId ?? undefined,
952
- converterVersion: doc.converterVersion ?? undefined,
953
- },
954
- [SEARCH_RESULT_PLANNER_METADATA]: {
955
- retrievalRank: candidateIndex + 1,
956
- mirrorHash: candidate.mirrorHash,
957
- seq: snippetChunk.seq,
958
- sources: [...candidate.sources].sort(),
959
- graphExpanded: candidate.sources.includes("graph"),
960
- },
961
- });
956
+ results.push(
957
+ attachSearchResultPlannerMetadata(
958
+ {
959
+ docid: doc.docid,
960
+ score: candidate.blendedScore,
961
+ uri: doc.uri,
962
+ title: doc.title ?? undefined,
963
+ contentType: doc.contentType ?? undefined,
964
+ categories: doc.categories ?? undefined,
965
+ line: snippetChunk.startLine,
966
+ snippet,
967
+ snippetLanguage: chunk.language ?? undefined,
968
+ snippetRange,
969
+ source: {
970
+ relPath: doc.relPath,
971
+ absPath: collectionPath
972
+ ? `${collectionPath}/${doc.relPath}`
973
+ : undefined,
974
+ mime: doc.sourceMime,
975
+ ext: doc.sourceExt,
976
+ modifiedAt: doc.sourceMtime,
977
+ documentDate: doc.frontmatterDate ?? undefined,
978
+ sizeBytes: doc.sourceSize,
979
+ sourceHash: doc.sourceHash,
980
+ },
981
+ conversion: {
982
+ mirrorHash: candidate.mirrorHash,
983
+ converterId: doc.converterId ?? undefined,
984
+ converterVersion: doc.converterVersion ?? undefined,
985
+ },
986
+ },
987
+ {
988
+ retrievalRank: candidateIndex + 1,
989
+ mirrorHash: candidate.mirrorHash,
990
+ seq: snippetChunk.seq,
991
+ sources: [...candidate.sources].sort(),
992
+ graphExpanded: candidate.sources.includes("graph"),
993
+ startLine: snippetChunk.startLine,
994
+ endLine: snippetChunk.endLine,
995
+ passageHash: new Bun.CryptoHasher("sha256")
996
+ .update(snippetChunk.text)
997
+ .digest("hex"),
998
+ }
999
+ )
1000
+ );
962
1001
  }
963
1002
  }
964
1003
  timings.assemblyMs = performance.now() - assemblyStartedAt;
@@ -1002,7 +1041,7 @@ export async function searchHybrid(
1002
1041
  const finalResults = results.slice(0, limit);
1003
1042
  await attachSearchResultContexts(store, finalResults);
1004
1043
 
1005
- return ok({
1044
+ const output: SearchResults = {
1006
1045
  results: finalResults,
1007
1046
  meta: {
1008
1047
  query,
@@ -1033,5 +1072,74 @@ export async function searchHybrid(
1033
1072
  explain: explainData,
1034
1073
  trace: diagnoseTrace,
1035
1074
  },
1075
+ };
1076
+ const fallbackCodes = [...new Set(counters.fallbackEvents)].sort();
1077
+ const capabilityOutcomes = [
1078
+ { capability: "lexical_search", status: "used" as const },
1079
+ vectorAvailable
1080
+ ? fallbackCodes.includes("vector_embed_error")
1081
+ ? {
1082
+ capability: "semantic_search",
1083
+ status: "failed" as const,
1084
+ reasonCode: "vector_embed_error",
1085
+ }
1086
+ : { capability: "semantic_search", status: "used" as const }
1087
+ : {
1088
+ capability: "semantic_search",
1089
+ status: "unavailable" as const,
1090
+ reasonCode: "vector_unavailable",
1091
+ },
1092
+ expansion !== null
1093
+ ? { capability: "query_expansion", status: "used" as const }
1094
+ : {
1095
+ capability: "query_expansion",
1096
+ status:
1097
+ expansionStatus === "attempted"
1098
+ ? ("failed" as const)
1099
+ : ("unavailable" as const),
1100
+ reasonCode:
1101
+ expansionStatus === "attempted"
1102
+ ? "expansion_error"
1103
+ : expansionStatus === "skipped_strong"
1104
+ ? "expansion_skipped_strong"
1105
+ : "expansion_disabled",
1106
+ },
1107
+ rerankResult.reranked
1108
+ ? { capability: "reranking", status: "used" as const }
1109
+ : {
1110
+ capability: "reranking",
1111
+ status:
1112
+ rerankResult.fallbackReason === "error"
1113
+ ? ("failed" as const)
1114
+ : ("unavailable" as const),
1115
+ reasonCode:
1116
+ rerankResult.fallbackReason === "error"
1117
+ ? "rerank_error"
1118
+ : "rerank_disabled",
1119
+ },
1120
+ graphExpansion.meta.enabled
1121
+ ? { capability: "graph_expansion", status: "used" as const }
1122
+ : {
1123
+ capability: "graph_expansion",
1124
+ status: "unavailable" as const,
1125
+ reasonCode:
1126
+ graphExpansion.meta.fallbackReasons[0] ?? "graph_disabled",
1127
+ },
1128
+ ];
1129
+ attachSearchResultsTraceMetadata(output, {
1130
+ capabilityOutcomes,
1131
+ fallbackCodes,
1036
1132
  });
1133
+ const traceResult = await options.traceSession?.recordRetrieval(
1134
+ output,
1135
+ timings.totalMs
1136
+ );
1137
+ if (traceResult && !traceResult.ok) {
1138
+ return err(
1139
+ "QUERY_FAILED",
1140
+ `Trace recording failed: ${traceResult.error.message}`,
1141
+ traceResult.error.cause
1142
+ );
1143
+ }
1144
+ return ok(output);
1037
1145
  }