@gmickel/gno 1.9.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/assets/skill/SKILL.md +28 -16
  2. package/assets/skill/cli-reference.md +30 -0
  3. package/assets/skill/examples.md +20 -0
  4. package/assets/skill/mcp-reference.md +7 -1
  5. package/package.json +1 -1
  6. package/src/cli/commands/graph.ts +89 -2
  7. package/src/cli/commands/links.ts +237 -54
  8. package/src/cli/commands/query.ts +212 -0
  9. package/src/cli/commands/ref-parser.ts +8 -103
  10. package/src/cli/options.ts +4 -0
  11. package/src/cli/program.ts +176 -9
  12. package/src/config/content-types.ts +14 -0
  13. package/src/core/file-lock.ts +38 -3
  14. package/src/core/graph-query.ts +137 -0
  15. package/src/core/graph-resolver.ts +117 -0
  16. package/src/core/ref-parser.ts +145 -0
  17. package/src/ingestion/frontmatter.ts +95 -2
  18. package/src/ingestion/sync.ts +281 -1
  19. package/src/mcp/tools/get.ts +1 -1
  20. package/src/mcp/tools/index.ts +89 -1
  21. package/src/mcp/tools/links.ts +83 -1
  22. package/src/mcp/tools/multi-get.ts +1 -1
  23. package/src/mcp/tools/query.ts +207 -0
  24. package/src/pipeline/diagnose.ts +302 -0
  25. package/src/pipeline/filters.ts +119 -0
  26. package/src/pipeline/hybrid.ts +90 -17
  27. package/src/pipeline/types.ts +32 -0
  28. package/src/publish/export-service.ts +1 -1
  29. package/src/sdk/documents.ts +2 -2
  30. package/src/serve/routes/api.ts +194 -0
  31. package/src/serve/routes/graph.ts +173 -1
  32. package/src/serve/server.ts +24 -1
  33. package/src/store/migrations/010-typed-edges.ts +67 -0
  34. package/src/store/migrations/011-doc-edge-traversal-indexes.ts +30 -0
  35. package/src/store/migrations/index.ts +4 -0
  36. package/src/store/sqlite/adapter.ts +826 -102
  37. package/src/store/types.ts +141 -0
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Targeted query diagnostics.
3
+ *
4
+ * @module src/pipeline/diagnose
5
+ */
6
+
7
+ import type { NormalizedContentTypeRule } from "../config";
8
+ import type { DocumentRow, StoreResult } from "../store/types";
9
+ import type { HybridSearchDeps } from "./hybrid";
10
+ import type {
11
+ HybridSearchOptions,
12
+ QueryModeSummary,
13
+ QueryDiagnoseStageId,
14
+ QueryDiagnoseTraceCandidate,
15
+ } from "./types";
16
+
17
+ import { fingerprintContentTypeRules } from "../config";
18
+ import { resolveDocRef } from "../core/ref-parser";
19
+ import { err, ok } from "../store/types";
20
+ import { evaluateQueryTargetFilters } from "./filters";
21
+ import { searchHybrid } from "./hybrid";
22
+
23
+ export type QueryDiagnoseTargetStatus =
24
+ | "not_found"
25
+ | "inactive"
26
+ | "no_indexed_content"
27
+ | "filtered_out"
28
+ | "diagnosed";
29
+
30
+ export type QueryDiagnoseDropReason =
31
+ | "not_in_candidate_set"
32
+ | "below_cutoff"
33
+ | "skipped"
34
+ | null;
35
+
36
+ export interface QueryDiagnoseStage {
37
+ id: QueryDiagnoseStageId;
38
+ status: "active" | "skipped";
39
+ sourceCount: number;
40
+ present: boolean;
41
+ rank: number | null;
42
+ score: number | null;
43
+ survived: boolean;
44
+ dropReason: QueryDiagnoseDropReason;
45
+ reason?: string;
46
+ }
47
+
48
+ export interface QueryDiagnoseResult {
49
+ schemaVersion: "1.0";
50
+ query: string;
51
+ target: {
52
+ ref: string;
53
+ status: QueryDiagnoseTargetStatus;
54
+ docid: string | null;
55
+ uri: string | null;
56
+ title: string | null;
57
+ contentType: string | null;
58
+ contentTypeSource: string | null;
59
+ categories: string[];
60
+ graphHints: string[];
61
+ contentTypeRulesFingerprint: string | null;
62
+ contentTypeFingerprintMatches: boolean | null;
63
+ mirrorHash: string | null;
64
+ chunkCount: number;
65
+ filterReasons: string[];
66
+ };
67
+ stages: QueryDiagnoseStage[];
68
+ chunk: {
69
+ seq: number | null;
70
+ startLine: number | null;
71
+ endLine: number | null;
72
+ language: string | null;
73
+ };
74
+ meta: {
75
+ mode: "bm25_only" | "hybrid";
76
+ vectorsUsed: boolean;
77
+ reranked: boolean;
78
+ totalResults: number;
79
+ queryModes?: QueryModeSummary;
80
+ };
81
+ }
82
+
83
+ export type QueryDiagnoseOptions = HybridSearchOptions & {
84
+ target: string;
85
+ contentTypeRules?: NormalizedContentTypeRule[];
86
+ contentTypeRulesFingerprint?: string;
87
+ };
88
+
89
+ function graphHintsForDoc(
90
+ doc: DocumentRow,
91
+ rules: NormalizedContentTypeRule[]
92
+ ): string[] {
93
+ if (!doc.contentType) {
94
+ return [];
95
+ }
96
+ return rules.find((rule) => rule.id === doc.contentType)?.graphHints ?? [];
97
+ }
98
+
99
+ function buildBaseResult(
100
+ query: string,
101
+ targetRef: string,
102
+ status: QueryDiagnoseTargetStatus,
103
+ doc: DocumentRow | null,
104
+ fields: {
105
+ graphHints?: string[];
106
+ chunkCount?: number;
107
+ filterReasons?: string[];
108
+ fingerprint?: string | null;
109
+ fingerprintMatches?: boolean | null;
110
+ } = {}
111
+ ): QueryDiagnoseResult {
112
+ return {
113
+ schemaVersion: "1.0",
114
+ query,
115
+ target: {
116
+ ref: targetRef,
117
+ status,
118
+ docid: doc?.docid ?? null,
119
+ uri: doc?.uri ?? null,
120
+ title: doc?.title ?? null,
121
+ contentType: doc?.contentType ?? null,
122
+ contentTypeSource: doc?.contentTypeSource ?? null,
123
+ categories: doc?.categories ?? [],
124
+ graphHints: fields.graphHints ?? [],
125
+ contentTypeRulesFingerprint: doc?.contentTypeRulesFingerprint ?? null,
126
+ contentTypeFingerprintMatches: fields.fingerprintMatches ?? null,
127
+ mirrorHash: doc?.mirrorHash ?? null,
128
+ chunkCount: fields.chunkCount ?? 0,
129
+ filterReasons: fields.filterReasons ?? [],
130
+ },
131
+ stages: [],
132
+ chunk: {
133
+ seq: null,
134
+ startLine: null,
135
+ endLine: null,
136
+ language: null,
137
+ },
138
+ meta: {
139
+ mode: "bm25_only",
140
+ vectorsUsed: false,
141
+ reranked: false,
142
+ totalResults: 0,
143
+ },
144
+ };
145
+ }
146
+
147
+ function findTargetCandidate(
148
+ candidates: QueryDiagnoseTraceCandidate[],
149
+ mirrorHash: string,
150
+ targetSeqs: Set<number>
151
+ ): QueryDiagnoseTraceCandidate | undefined {
152
+ return candidates.find(
153
+ (candidate) =>
154
+ candidate.mirrorHash === mirrorHash && targetSeqs.has(candidate.seq)
155
+ );
156
+ }
157
+
158
+ export async function diagnoseQueryTarget(
159
+ deps: HybridSearchDeps,
160
+ query: string,
161
+ options: QueryDiagnoseOptions
162
+ ): Promise<StoreResult<QueryDiagnoseResult>> {
163
+ const resolved = await resolveDocRef(deps.store, options.target);
164
+ if ("error" in resolved) {
165
+ return ok(buildBaseResult(query, options.target, "not_found", null));
166
+ }
167
+
168
+ const doc = resolved.doc;
169
+ const rules = options.contentTypeRules ?? [];
170
+ const expectedFingerprint =
171
+ options.contentTypeRulesFingerprint ?? fingerprintContentTypeRules(rules);
172
+ const fingerprintMatches = doc.contentTypeRulesFingerprint
173
+ ? doc.contentTypeRulesFingerprint === expectedFingerprint
174
+ : null;
175
+ const graphHints = graphHintsForDoc(doc, rules);
176
+
177
+ if (!doc.active) {
178
+ return ok(
179
+ buildBaseResult(query, options.target, "inactive", doc, {
180
+ graphHints,
181
+ fingerprintMatches,
182
+ })
183
+ );
184
+ }
185
+ if (!doc.mirrorHash) {
186
+ return ok(
187
+ buildBaseResult(query, options.target, "no_indexed_content", doc, {
188
+ graphHints,
189
+ fingerprintMatches,
190
+ })
191
+ );
192
+ }
193
+
194
+ const chunksResult = await deps.store.getChunks(doc.mirrorHash);
195
+ if (!chunksResult.ok) {
196
+ return err("QUERY_FAILED", chunksResult.error.message);
197
+ }
198
+ const chunks = chunksResult.value;
199
+ if (chunks.length === 0) {
200
+ return ok(
201
+ buildBaseResult(query, options.target, "no_indexed_content", doc, {
202
+ graphHints,
203
+ fingerprintMatches,
204
+ })
205
+ );
206
+ }
207
+
208
+ const filterEval = await evaluateQueryTargetFilters(
209
+ deps.store,
210
+ query,
211
+ doc,
212
+ chunks,
213
+ options
214
+ );
215
+ if (!filterEval.matches) {
216
+ return ok(
217
+ buildBaseResult(query, options.target, "filtered_out", doc, {
218
+ graphHints,
219
+ chunkCount: chunks.length,
220
+ filterReasons: filterEval.reasons,
221
+ fingerprintMatches,
222
+ })
223
+ );
224
+ }
225
+
226
+ const searchResult = await searchHybrid(deps, query, {
227
+ ...options,
228
+ diagnoseTrace: true,
229
+ });
230
+ if (!searchResult.ok) {
231
+ return err(searchResult.error.code, searchResult.error.message);
232
+ }
233
+
234
+ const trace = searchResult.value.meta.trace;
235
+ const targetSeqs = new Set(chunks.map((chunk) => chunk.seq));
236
+ let seenEarlier = false;
237
+ const stages: QueryDiagnoseStage[] =
238
+ trace?.stages.map((stage) => {
239
+ const candidate = findTargetCandidate(
240
+ stage.candidates,
241
+ doc.mirrorHash ?? "",
242
+ targetSeqs
243
+ );
244
+ const present = Boolean(candidate);
245
+ const dropReason: QueryDiagnoseDropReason =
246
+ stage.status === "skipped"
247
+ ? "skipped"
248
+ : present
249
+ ? null
250
+ : seenEarlier
251
+ ? "below_cutoff"
252
+ : "not_in_candidate_set";
253
+ if (present) {
254
+ seenEarlier = true;
255
+ }
256
+ return {
257
+ id: stage.id,
258
+ status: stage.status,
259
+ sourceCount: stage.sourceCount,
260
+ present,
261
+ rank: candidate?.rank ?? null,
262
+ score: candidate?.score ?? null,
263
+ survived: present,
264
+ dropReason,
265
+ reason: stage.reason,
266
+ };
267
+ }) ?? [];
268
+
269
+ const firstMatched = trace?.stages
270
+ .flatMap((stage) => stage.candidates)
271
+ .find(
272
+ (candidate) =>
273
+ candidate.mirrorHash === doc.mirrorHash && targetSeqs.has(candidate.seq)
274
+ );
275
+ const matchedChunk =
276
+ chunks.find((chunk) => chunk.seq === firstMatched?.seq) ??
277
+ chunks[0] ??
278
+ null;
279
+
280
+ return ok({
281
+ ...buildBaseResult(query, options.target, "diagnosed", doc, {
282
+ graphHints,
283
+ chunkCount: chunks.length,
284
+ fingerprintMatches,
285
+ }),
286
+ stages,
287
+ chunk: {
288
+ seq: matchedChunk?.seq ?? null,
289
+ startLine: matchedChunk?.startLine ?? null,
290
+ endLine: matchedChunk?.endLine ?? null,
291
+ language: matchedChunk?.language ?? null,
292
+ },
293
+ meta: {
294
+ mode:
295
+ searchResult.value.meta.mode === "bm25_only" ? "bm25_only" : "hybrid",
296
+ vectorsUsed: searchResult.value.meta.vectorsUsed ?? false,
297
+ reranked: searchResult.value.meta.reranked ?? false,
298
+ totalResults: searchResult.value.meta.totalResults,
299
+ queryModes: searchResult.value.meta.queryModes,
300
+ },
301
+ });
302
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Shared query filter evaluation for live query assembly and diagnostics.
3
+ *
4
+ * @module src/pipeline/filters
5
+ */
6
+
7
+ import type { ChunkRow, DocumentRow, StorePort } from "../store/types";
8
+ import type { HybridSearchOptions } from "./types";
9
+
10
+ import { matchesExcludedChunks, matchesExcludedText } from "./exclude";
11
+ import { isWithinTemporalRange, resolveTemporalRange } from "./temporal";
12
+
13
+ export interface QueryFilterEvaluation {
14
+ matches: boolean;
15
+ reasons: string[];
16
+ }
17
+
18
+ export function evaluateDocumentChunkFilters(
19
+ query: string,
20
+ doc: DocumentRow,
21
+ chunks: ChunkRow[],
22
+ options: HybridSearchOptions
23
+ ): QueryFilterEvaluation {
24
+ const reasons: string[] = [];
25
+ const temporalRange = resolveTemporalRange(
26
+ query,
27
+ options.since,
28
+ options.until
29
+ );
30
+
31
+ if (options.collection && doc.collection !== options.collection) {
32
+ reasons.push("collection");
33
+ }
34
+ if (!isWithinTemporalRange(doc.sourceMtime, temporalRange)) {
35
+ reasons.push("date");
36
+ }
37
+ if (
38
+ options.author &&
39
+ !doc.author?.toLowerCase().includes(options.author.toLowerCase())
40
+ ) {
41
+ reasons.push("author");
42
+ }
43
+ if (options.categories?.length) {
44
+ const allowed = new Set(options.categories.map((c) => c.toLowerCase()));
45
+ const contentTypeMatch = doc.contentType
46
+ ? allowed.has(doc.contentType.toLowerCase())
47
+ : false;
48
+ const categoryMatch = (doc.categories ?? []).some((c) =>
49
+ allowed.has(c.toLowerCase())
50
+ );
51
+ if (!contentTypeMatch && !categoryMatch) {
52
+ reasons.push("category");
53
+ }
54
+ }
55
+ if (
56
+ options.lang &&
57
+ !chunks.some((chunk) => chunk.language === options.lang)
58
+ ) {
59
+ reasons.push("lang");
60
+ }
61
+ if (
62
+ matchesExcludedText(
63
+ [
64
+ doc.title ?? "",
65
+ doc.relPath,
66
+ doc.author ?? "",
67
+ doc.contentType ?? "",
68
+ ...(doc.categories ?? []),
69
+ ],
70
+ options.exclude
71
+ ) ||
72
+ matchesExcludedChunks(chunks, options.exclude)
73
+ ) {
74
+ reasons.push("exclude");
75
+ }
76
+
77
+ return {
78
+ matches: reasons.length === 0,
79
+ reasons,
80
+ };
81
+ }
82
+
83
+ export async function evaluateQueryTargetFilters(
84
+ store: StorePort,
85
+ query: string,
86
+ doc: DocumentRow,
87
+ chunks: ChunkRow[],
88
+ options: HybridSearchOptions
89
+ ): Promise<QueryFilterEvaluation> {
90
+ const reasons = [
91
+ ...evaluateDocumentChunkFilters(query, doc, chunks, options).reasons,
92
+ ];
93
+
94
+ if (options.tagsAll?.length || options.tagsAny?.length) {
95
+ const tagsResult = await store.getTagsForDoc(doc.id);
96
+ if (!tagsResult.ok) {
97
+ reasons.push("tags");
98
+ } else {
99
+ const docTags = new Set(tagsResult.value.map((tag) => tag.tag));
100
+ if (
101
+ options.tagsAll?.length &&
102
+ !options.tagsAll.every((tag) => docTags.has(tag))
103
+ ) {
104
+ reasons.push("tagsAll");
105
+ }
106
+ if (
107
+ options.tagsAny?.length &&
108
+ !options.tagsAny.some((tag) => docTags.has(tag))
109
+ ) {
110
+ reasons.push("tagsAny");
111
+ }
112
+ }
113
+ }
114
+
115
+ return {
116
+ matches: reasons.length === 0,
117
+ reasons,
118
+ };
119
+ }
@@ -14,6 +14,8 @@ import type {
14
14
  ExplainLine,
15
15
  HybridSearchOptions,
16
16
  PipelineConfig,
17
+ QueryDiagnoseTrace,
18
+ QueryDiagnoseTraceCandidate,
17
19
  SearchResult,
18
20
  SearchResults,
19
21
  } from "./types";
@@ -22,7 +24,6 @@ import { embedTextsWithRecovery } from "../embed/batch";
22
24
  import { err, ok } from "../store/types";
23
25
  import { createChunkLookup } from "./chunk-lookup";
24
26
  import { formatQueryForEmbedding } from "./contextual";
25
- import { matchesExcludedChunks, matchesExcludedText } from "./exclude";
26
27
  import { expandQuery } from "./expansion";
27
28
  import {
28
29
  buildExplainResults,
@@ -36,6 +37,7 @@ import {
36
37
  explainTimings,
37
38
  explainVector,
38
39
  } from "./explain";
40
+ import { evaluateDocumentChunkFilters } from "./filters";
39
41
  import { type RankedInput, rrfFuse, toRankedInput } from "./fusion";
40
42
  import { expandGraphCandidates } from "./graph-retrieval";
41
43
  import { selectBestChunkForSteering } from "./intent";
@@ -153,6 +155,7 @@ async function checkBm25Strength(
153
155
  interface ChunkId {
154
156
  mirrorHash: string;
155
157
  seq: number;
158
+ score?: number;
156
159
  }
157
160
 
158
161
  type FtsChunksResult =
@@ -196,6 +199,7 @@ async function searchFtsChunks(
196
199
  chunks: result.value.map((r) => ({
197
200
  mirrorHash: r.mirrorHash,
198
201
  seq: r.seq,
202
+ score: r.score,
199
203
  })),
200
204
  };
201
205
  }
@@ -236,6 +240,32 @@ async function searchVectorChunks(
236
240
  return searchResult.value.map((r) => ({
237
241
  mirrorHash: r.mirrorHash,
238
242
  seq: r.seq,
243
+ score: r.distance,
244
+ }));
245
+ }
246
+
247
+ function toTraceCandidates(chunks: ChunkId[]): QueryDiagnoseTraceCandidate[] {
248
+ return chunks.map((chunk, index) => ({
249
+ mirrorHash: chunk.mirrorHash,
250
+ seq: chunk.seq,
251
+ rank: index + 1,
252
+ score: chunk.score ?? index + 1,
253
+ }));
254
+ }
255
+
256
+ function candidatesToTrace(
257
+ candidates: Array<{
258
+ mirrorHash: string;
259
+ seq: number;
260
+ fusionScore?: number;
261
+ blendedScore?: number;
262
+ }>
263
+ ): QueryDiagnoseTraceCandidate[] {
264
+ return candidates.map((candidate, index) => ({
265
+ mirrorHash: candidate.mirrorHash,
266
+ seq: candidate.seq,
267
+ rank: index + 1,
268
+ score: candidate.blendedScore ?? candidate.fusionScore ?? index + 1,
239
269
  }));
240
270
  }
241
271
 
@@ -377,6 +407,9 @@ export async function searchHybrid(
377
407
  // 2. Parallel retrieval using raw store/vector APIs for correct seq tracking
378
408
  // ─────────────────────────────────────────────────────────────────────────
379
409
  const rankedInputs: RankedInput[] = [];
410
+ const diagnoseTrace: QueryDiagnoseTrace | undefined = options.diagnoseTrace
411
+ ? { stages: [] }
412
+ : undefined;
380
413
 
381
414
  const bm25StartedAt = performance.now();
382
415
 
@@ -401,6 +434,12 @@ export async function searchHybrid(
401
434
 
402
435
  const bm25Chunks = bm25Result.ok ? bm25Result.chunks : [];
403
436
  const bm25Count = bm25Chunks.length;
437
+ diagnoseTrace?.stages.push({
438
+ id: "bm25",
439
+ status: "active",
440
+ sourceCount: 1,
441
+ candidates: toTraceCandidates(bm25Chunks),
442
+ });
404
443
  if (bm25Count > 0) {
405
444
  rankedInputs.push(toRankedInput("bm25", bm25Chunks));
406
445
  }
@@ -446,6 +485,7 @@ export async function searchHybrid(
446
485
  }
447
486
 
448
487
  const vectorStartedAt = performance.now();
488
+ const vectorTraceChunks: ChunkId[] = [];
449
489
 
450
490
  if (vectorAvailable && vectorIndex && embedPort) {
451
491
  const vectorVariantQueries = [
@@ -469,6 +509,7 @@ export async function searchHybrid(
469
509
  );
470
510
 
471
511
  vecCount = vecChunks.length;
512
+ vectorTraceChunks.push(...vecChunks);
472
513
  if (vecCount > 0) {
473
514
  rankedInputs.push(toRankedInput("vector", vecChunks));
474
515
  }
@@ -520,6 +561,7 @@ export async function searchHybrid(
520
561
  if (variant.source === "vector") {
521
562
  vecCount = chunks.length;
522
563
  }
564
+ vectorTraceChunks.push(...chunks);
523
565
  if (chunks.length === 0) {
524
566
  continue;
525
567
  }
@@ -530,6 +572,14 @@ export async function searchHybrid(
530
572
  }
531
573
  timings.vectorMs = performance.now() - vectorStartedAt;
532
574
 
575
+ diagnoseTrace?.stages.push({
576
+ id: "vector",
577
+ status: vectorAvailable ? "active" : "skipped",
578
+ reason: vectorAvailable ? undefined : "vector_unavailable",
579
+ sourceCount: vectorAvailable ? 1 : 0,
580
+ candidates: toTraceCandidates(vectorTraceChunks),
581
+ });
582
+
533
583
  explainLines.push(
534
584
  explainVector(
535
585
  vecCount,
@@ -546,6 +596,12 @@ export async function searchHybrid(
546
596
  const candidateLimit =
547
597
  options.candidateLimit ?? pipelineConfig.rerankCandidates;
548
598
  let fusedCandidates = rrfFuse(rankedInputs, pipelineConfig.rrf);
599
+ diagnoseTrace?.stages.push({
600
+ id: "fusion",
601
+ status: "active",
602
+ sourceCount: rankedInputs.length,
603
+ candidates: candidatesToTrace(fusedCandidates),
604
+ });
549
605
 
550
606
  timings.fusionMs = performance.now() - fusionStartedAt;
551
607
  const graphStartedAt = performance.now();
@@ -570,6 +626,20 @@ export async function searchHybrid(
570
626
  fusedCandidates = rrfFuse(rankedInputs, pipelineConfig.rrf);
571
627
  timings.fusionMs += performance.now() - graphFusionStartedAt;
572
628
  }
629
+ diagnoseTrace?.stages.push({
630
+ id: "graph",
631
+ status: graphExpansion.meta.attempted ? "active" : "skipped",
632
+ reason: graphExpansion.meta.attempted
633
+ ? undefined
634
+ : graphExpansion.meta.fallbackReasons.join(", ") || "disabled",
635
+ sourceCount: graphExpansion.meta.attempted ? 1 : 0,
636
+ candidates: graphExpansion.candidates.map((candidate, index) => ({
637
+ mirrorHash: candidate.mirrorHash,
638
+ seq: candidate.seq,
639
+ rank: index + 1,
640
+ score: index + 1,
641
+ })),
642
+ });
573
643
  if (graphExpansion.meta.fallbackReasons.length > 0) {
574
644
  counters.fallbackEvents.push(...graphExpansion.meta.fallbackReasons);
575
645
  }
@@ -604,6 +674,16 @@ export async function searchHybrid(
604
674
  }
605
675
  timings.rerankMs = performance.now() - rerankStartedAt;
606
676
 
677
+ diagnoseTrace?.stages.push({
678
+ id: "rerank",
679
+ status: rerankResult.reranked ? "active" : "skipped",
680
+ reason: rerankResult.reranked
681
+ ? undefined
682
+ : (rerankResult.fallbackReason ?? "disabled"),
683
+ sourceCount: rerankResult.reranked ? 1 : 0,
684
+ candidates: candidatesToTrace(rerankResult.candidates),
685
+ });
686
+
607
687
  explainLines.push(
608
688
  explainRerank(!options.noRerank && rerankPort !== null, candidateLimit)
609
689
  );
@@ -754,22 +834,14 @@ export async function searchHybrid(
754
834
  continue;
755
835
  }
756
836
 
757
- const excluded =
758
- matchesExcludedText(
759
- [
760
- doc.title ?? "",
761
- doc.relPath,
762
- doc.author ?? "",
763
- doc.contentType ?? "",
764
- ...(doc.categories ?? []),
765
- ],
766
- options.exclude
767
- ) ||
768
- matchesExcludedChunks(
769
- chunksMap.get(candidate.mirrorHash) ?? [],
770
- options.exclude
771
- );
772
- if (excluded) {
837
+ const docChunks = chunksMap.get(candidate.mirrorHash) ?? [];
838
+ const filterEval = evaluateDocumentChunkFilters(
839
+ query,
840
+ doc,
841
+ docChunks,
842
+ options
843
+ );
844
+ if (!filterEval.matches) {
773
845
  continue;
774
846
  }
775
847
 
@@ -935,6 +1007,7 @@ export async function searchHybrid(
935
1007
  queryLanguage,
936
1008
  queryModes: queryModeSummary,
937
1009
  explain: explainData,
1010
+ trace: diagnoseTrace,
938
1011
  },
939
1012
  });
940
1013
  }
@@ -104,6 +104,8 @@ export interface SearchMeta {
104
104
  lines: ExplainLine[];
105
105
  results: ExplainResult[];
106
106
  };
107
+ /** Internal diagnose trace, only populated when diagnoseTrace is enabled */
108
+ trace?: QueryDiagnoseTrace;
107
109
  }
108
110
 
109
111
  /** Complete search results wrapper */
@@ -182,6 +184,8 @@ export type HybridSearchOptions = SearchOptions & {
182
184
  noGraph?: boolean;
183
185
  /** Language hint for prompt selection (does NOT filter retrieval, only affects expansion prompts) */
184
186
  queryLanguageHint?: string;
187
+ /** Internal: capture per-stage candidates for query diagnose */
188
+ diagnoseTrace?: boolean;
185
189
  };
186
190
 
187
191
  /** Options for ask command */
@@ -258,6 +262,34 @@ export interface FusionCandidate {
258
262
  sources: FusionSource[];
259
263
  }
260
264
 
265
+ export type QueryDiagnoseStageId =
266
+ | "bm25"
267
+ | "vector"
268
+ | "fusion"
269
+ | "graph"
270
+ | "rerank";
271
+
272
+ export type QueryDiagnoseStageStatus = "active" | "skipped";
273
+
274
+ export interface QueryDiagnoseTraceCandidate {
275
+ mirrorHash: string;
276
+ seq: number;
277
+ rank: number;
278
+ score: number;
279
+ }
280
+
281
+ export interface QueryDiagnoseTraceStage {
282
+ id: QueryDiagnoseStageId;
283
+ status: QueryDiagnoseStageStatus;
284
+ reason?: string;
285
+ sourceCount: number;
286
+ candidates: QueryDiagnoseTraceCandidate[];
287
+ }
288
+
289
+ export interface QueryDiagnoseTrace {
290
+ stages: QueryDiagnoseTraceStage[];
291
+ }
292
+
261
293
  // ─────────────────────────────────────────────────────────────────────────────
262
294
  // Rerank & Blending Types
263
295
  // ─────────────────────────────────────────────────────────────────────────────
@@ -7,7 +7,7 @@
7
7
  import type { Collection } from "../config/types";
8
8
  import type { DocumentRow, StorePort, TagRow } from "../store/types";
9
9
 
10
- import { parseRef } from "../cli/commands/ref-parser";
10
+ import { parseRef } from "../core/ref-parser";
11
11
  import { parseFrontmatter } from "../ingestion/frontmatter";
12
12
  import { getContentBatch } from "../store/content-batch";
13
13
  import {