@gmickel/gno 1.16.0 → 1.17.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 (46) hide show
  1. package/README.md +20 -16
  2. package/assets/skill/SKILL.md +8 -5
  3. package/package.json +1 -1
  4. package/src/app/context-agent-projection.ts +303 -0
  5. package/src/app/context-format.ts +249 -0
  6. package/src/app/context-runtime-contract.ts +325 -0
  7. package/src/app/context-runtime-input.ts +362 -0
  8. package/src/app/context-runtime-types.ts +65 -0
  9. package/src/app/context-runtime.ts +170 -0
  10. package/src/app/context-surface.ts +145 -0
  11. package/src/cli/commands/context-build.ts +149 -0
  12. package/src/cli/commands/context-verify.ts +90 -0
  13. package/src/cli/options.ts +4 -0
  14. package/src/cli/program.ts +178 -0
  15. package/src/core/context-budget.ts +461 -0
  16. package/src/core/context-capsule-index-schema.ts +15 -0
  17. package/src/core/context-capsule-retrieval-schema.ts +81 -0
  18. package/src/core/context-capsule-schema.ts +473 -0
  19. package/src/core/context-capsule-validation.ts +416 -0
  20. package/src/core/context-capsule-verification.ts +218 -0
  21. package/src/core/context-capsule.ts +439 -0
  22. package/src/core/context-compiler.ts +513 -0
  23. package/src/core/context-evidence-metadata.ts +33 -0
  24. package/src/core/context-evidence.ts +495 -0
  25. package/src/core/context-facets.ts +163 -0
  26. package/src/core/context-guidance.ts +69 -0
  27. package/src/core/context-scope.ts +32 -0
  28. package/src/core/context-verifier-canonical.ts +90 -0
  29. package/src/core/context-verifier-input.ts +66 -0
  30. package/src/core/context-verifier.ts +447 -0
  31. package/src/core/sections.ts +63 -0
  32. package/src/mcp/server.ts +10 -4
  33. package/src/mcp/tools/context.ts +229 -0
  34. package/src/mcp/tools/index.ts +27 -0
  35. package/src/pipeline/chunk-lookup.ts +33 -0
  36. package/src/pipeline/hybrid.ts +79 -57
  37. package/src/pipeline/types.ts +14 -0
  38. package/src/sdk/client.ts +68 -6
  39. package/src/sdk/index.ts +21 -0
  40. package/src/sdk/types.ts +24 -0
  41. package/src/serve/background-runtime.ts +1 -0
  42. package/src/serve/context-capsule.ts +136 -0
  43. package/src/serve/context.ts +10 -1
  44. package/src/serve/routes/api.ts +2 -0
  45. package/src/serve/server.ts +23 -0
  46. package/src/store/sqlite/adapter.ts +38 -20
@@ -14,6 +14,26 @@ export interface DocumentSection {
14
14
  }
15
15
 
16
16
  const HEADING_REGEX = /^(#{1,6})\s+(.+?)\s*#*\s*$/u;
17
+ const FENCE_REGEX = /^ {0,3}(`{3,}|~{3,})(.*)$/u;
18
+ const FENCE_CLOSE_REGEX = /^ {0,3}(`{3,}|~{3,})[\t ]*$/u;
19
+
20
+ interface OpenFence {
21
+ marker: "`" | "~";
22
+ length: number;
23
+ }
24
+
25
+ const fenceOpener = (line: string): OpenFence | null => {
26
+ const match = FENCE_REGEX.exec(line);
27
+ const run = match?.[1];
28
+ const suffix = match?.[2] ?? "";
29
+ if (!run || (run[0] === "`" && suffix.includes("`"))) return null;
30
+ return { marker: run[0] as OpenFence["marker"], length: run.length };
31
+ };
32
+
33
+ const closesFence = (line: string, fence: OpenFence): boolean => {
34
+ const run = FENCE_CLOSE_REGEX.exec(line)?.[1];
35
+ return Boolean(run && run[0] === fence.marker && run.length >= fence.length);
36
+ };
17
37
 
18
38
  export function slugifySectionTitle(title: string): string {
19
39
  return (
@@ -32,8 +52,18 @@ export function extractSections(content: string): DocumentSection[] {
32
52
  const sections: DocumentSection[] = [];
33
53
  const counts = new Map<string, number>();
34
54
  const lines = content.split("\n");
55
+ let openFence: OpenFence | null = null;
35
56
 
36
57
  for (const [index, line] of lines.entries()) {
58
+ if (openFence) {
59
+ if (closesFence(line, openFence)) openFence = null;
60
+ continue;
61
+ }
62
+ const opener = fenceOpener(line);
63
+ if (opener) {
64
+ openFence = opener;
65
+ continue;
66
+ }
37
67
  const match = HEADING_REGEX.exec(line);
38
68
  if (!match) {
39
69
  continue;
@@ -60,3 +90,36 @@ export function extractSections(content: string): DocumentSection[] {
60
90
 
61
91
  return sections;
62
92
  }
93
+
94
+ /** Extract one inclusive, 1-based line range without normalizing source bytes. */
95
+ export function extractInclusiveLines(
96
+ content: string,
97
+ startLine: number,
98
+ endLine: number
99
+ ): string | null {
100
+ if (
101
+ content.includes("\r") ||
102
+ !Number.isSafeInteger(startLine) ||
103
+ !Number.isSafeInteger(endLine) ||
104
+ startLine < 1 ||
105
+ endLine < startLine
106
+ ) {
107
+ return null;
108
+ }
109
+ const lines = content.split("\n");
110
+ if (endLine > lines.length) return null;
111
+ return lines.slice(startLine - 1, endLine).join("\n");
112
+ }
113
+
114
+ /** Find the nearest Markdown heading governing a 1-based source line. */
115
+ export function headingForLine(
116
+ sections: readonly DocumentSection[],
117
+ line: number
118
+ ): string | null {
119
+ let heading: string | null = null;
120
+ for (const section of sections) {
121
+ if (section.line > line) break;
122
+ heading = section.title;
123
+ }
124
+ return heading;
125
+ }
package/src/mcp/server.ts CHANGED
@@ -13,7 +13,13 @@ import { dirname, join } from "node:path";
13
13
  import type { Collection, Config } from "../config/types";
14
14
  import type { SqliteAdapter } from "../store/sqlite/adapter";
15
15
 
16
- import { MCP_SERVER_NAME, VERSION, getIndexDbPath } from "../app/constants";
16
+ import {
17
+ DEFAULT_INDEX_NAME,
18
+ MCP_SERVER_NAME,
19
+ VERSION,
20
+ getIndexDbPath,
21
+ } from "../app/constants";
22
+ import { canonicalizeIndexName } from "../app/index-name";
17
23
  import { JobManager } from "../core/job-manager";
18
24
  import { envIsSet } from "../llm/policy";
19
25
  import { MCP_ACTIVATION_VERIFICATION_ENV } from "./activation-verification-mode";
@@ -58,7 +64,7 @@ export interface ToolContext {
58
64
  config: Config;
59
65
  collections: Collection[];
60
66
  actualConfigPath: string;
61
- indexName?: string;
67
+ indexName: string;
62
68
  toolMutex: Mutex;
63
69
  jobManager: JobManager;
64
70
  serverInstanceId: string;
@@ -120,7 +126,7 @@ export async function startMcpServer(options: McpServerOptions): Promise<void> {
120
126
  MCP_ACTIVATION_VERIFICATION_ENV
121
127
  );
122
128
  const init = await initStore({
123
- indexName: options.indexName,
129
+ indexName: canonicalizeIndexName(options.indexName ?? DEFAULT_INDEX_NAME),
124
130
  configPath: options.configPath,
125
131
  syncConfig: !activationVerification,
126
132
  });
@@ -172,7 +178,7 @@ export async function startMcpServer(options: McpServerOptions): Promise<void> {
172
178
  config,
173
179
  collections,
174
180
  actualConfigPath,
175
- indexName: options.indexName,
181
+ indexName: canonicalizeIndexName(options.indexName ?? DEFAULT_INDEX_NAME),
176
182
  toolMutex,
177
183
  jobManager,
178
184
  serverInstanceId,
@@ -0,0 +1,229 @@
1
+ /** MCP Context Capsule tools over the shared application runtime. */
2
+
3
+ import type { EmbeddingPort, RerankPort } from "../../llm/types";
4
+ import type { VectorIndexPort } from "../../store/vector";
5
+ import type { ToolContext } from "../server";
6
+ import type { ToolResult } from "./index";
7
+
8
+ import { formatContextCapsuleAgentJson } from "../../app/context-agent-projection";
9
+ import { formatContextCapsuleVerificationMarkdown } from "../../app/context-format";
10
+ import {
11
+ buildContextCapsule,
12
+ canonicalVerifiedContextCapsuleJson,
13
+ validateContextCapsuleBuildInput,
14
+ verifyContextCapsuleRuntime,
15
+ } from "../../app/context-runtime";
16
+ import {
17
+ contextSurfaceError,
18
+ parseContextBuildSurfaceInput,
19
+ parseContextVerifySurfaceInput,
20
+ } from "../../app/context-surface";
21
+ import { createNonTtyProgressRenderer } from "../../cli/progress";
22
+ import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
23
+ import { resolveDownloadPolicy } from "../../llm/policy";
24
+ import { resolveModelUri } from "../../llm/registry";
25
+ import { createVectorIndexPort } from "../../store/vector";
26
+
27
+ interface ContextToolResultData {
28
+ structuredContent: Record<string, unknown>;
29
+ text: string;
30
+ }
31
+
32
+ const asToolResult = (data: ContextToolResultData): ToolResult => ({
33
+ content: [{ type: "text", text: data.text }],
34
+ structuredContent: data.structuredContent,
35
+ });
36
+
37
+ const asToolError = (error: unknown): ToolResult => {
38
+ const publicError = contextSurfaceError(error);
39
+ return {
40
+ isError: true,
41
+ content: [
42
+ {
43
+ type: "text",
44
+ text: `Error [${publicError.code}]: ${publicError.message}`,
45
+ },
46
+ ],
47
+ structuredContent: {
48
+ error: publicError.code,
49
+ message: publicError.message,
50
+ },
51
+ };
52
+ };
53
+
54
+ const runContextTool = async (
55
+ context: ToolContext,
56
+ operation: () => Promise<ContextToolResultData>
57
+ ): Promise<ToolResult> => {
58
+ if (context.isShuttingDown()) {
59
+ return asToolError(
60
+ Object.assign(new Error("Server is shutting down"), {
61
+ code: "runtime_error",
62
+ })
63
+ );
64
+ }
65
+ const release = await context.toolMutex.acquire();
66
+ try {
67
+ return asToolResult(await operation());
68
+ } catch (error) {
69
+ return asToolError(error);
70
+ } finally {
71
+ release();
72
+ }
73
+ };
74
+
75
+ interface McpModelPorts {
76
+ embedPort: EmbeddingPort | null;
77
+ rerankPort: RerankPort | null;
78
+ vectorIndex: VectorIndexPort | null;
79
+ dispose(): Promise<void>;
80
+ }
81
+
82
+ interface McpModelPortFactory {
83
+ createEmbeddingPort: LlmAdapter["createEmbeddingPort"];
84
+ createRerankPort: LlmAdapter["createRerankPort"];
85
+ dispose: LlmAdapter["dispose"];
86
+ }
87
+
88
+ export const disposeContextModelOwners = async (
89
+ portOwners: readonly { dispose(): Promise<void> }[],
90
+ managerOwner: { dispose(): Promise<void> }
91
+ ): Promise<void> => {
92
+ await Promise.allSettled(
93
+ portOwners.map((owner) => Promise.resolve().then(() => owner.dispose()))
94
+ );
95
+ await Promise.allSettled([
96
+ Promise.resolve().then(() => managerOwner.dispose()),
97
+ ]);
98
+ };
99
+
100
+ export const createMcpModelPorts = async (
101
+ context: ToolContext,
102
+ collection?: string,
103
+ factoryOverride?: McpModelPortFactory
104
+ ): Promise<McpModelPorts> => {
105
+ const llm = new LlmAdapter(context.config);
106
+ const factory = factoryOverride ?? llm;
107
+ const policy = resolveDownloadPolicy(process.env, {});
108
+ const progress = createNonTtyProgressRenderer();
109
+ const embedUri = resolveModelUri(
110
+ context.config,
111
+ "embed",
112
+ undefined,
113
+ collection
114
+ );
115
+ let embedPort: EmbeddingPort | null = null;
116
+ let ownedEmbedPort: EmbeddingPort | null = null;
117
+ let rerankPort: RerankPort | null = null;
118
+ let vectorIndex: VectorIndexPort | null = null;
119
+ try {
120
+ const embedResult = await factory.createEmbeddingPort(embedUri, {
121
+ policy,
122
+ onProgress: (value) => progress("embed", value),
123
+ });
124
+ if (embedResult.ok) {
125
+ // Take ownership before init: init failures must not leak the port.
126
+ ownedEmbedPort = embedResult.value;
127
+ const initialized = await ownedEmbedPort.init();
128
+ if (initialized.ok) {
129
+ embedPort = ownedEmbedPort;
130
+ const vectorResult = await createVectorIndexPort(
131
+ context.store.getRawDb(),
132
+ { model: embedUri, dimensions: ownedEmbedPort.dimensions() }
133
+ );
134
+ if (vectorResult.ok) vectorIndex = vectorResult.value;
135
+ }
136
+ }
137
+ const rerankResult = await factory.createRerankPort(
138
+ resolveModelUri(context.config, "rerank", undefined, collection),
139
+ {
140
+ policy,
141
+ onProgress: (value) => progress("rerank", value),
142
+ }
143
+ );
144
+ if (rerankResult.ok) rerankPort = rerankResult.value;
145
+ return {
146
+ embedPort,
147
+ rerankPort,
148
+ vectorIndex,
149
+ async dispose() {
150
+ await disposeContextModelOwners(
151
+ [ownedEmbedPort, rerankPort].filter(
152
+ (port): port is EmbeddingPort | RerankPort => port !== null
153
+ ),
154
+ factory
155
+ );
156
+ },
157
+ };
158
+ } catch (error) {
159
+ await disposeContextModelOwners(
160
+ [ownedEmbedPort, rerankPort].filter(
161
+ (port): port is EmbeddingPort | RerankPort => port !== null
162
+ ),
163
+ factory
164
+ );
165
+ throw error;
166
+ }
167
+ };
168
+
169
+ export const handleContext = (
170
+ args: unknown,
171
+ context: ToolContext
172
+ ): Promise<ToolResult> =>
173
+ runContextTool(context, async () => {
174
+ const parsed = parseContextBuildSurfaceInput(args, context.indexName);
175
+ // This guard is intentionally before any model construction/download.
176
+ validateContextCapsuleBuildInput(
177
+ parsed.input,
178
+ context.indexName,
179
+ context.config.collections.map((collection) => collection.name)
180
+ );
181
+ const useModels = parsed.input.depthPolicy !== "fast";
182
+ const modelPorts = useModels
183
+ ? await createMcpModelPorts(
184
+ context,
185
+ parsed.input.collections?.length === 1
186
+ ? parsed.input.collections[0]
187
+ : undefined
188
+ )
189
+ : null;
190
+ try {
191
+ const capsule = await buildContextCapsule(parsed.input, {
192
+ store: context.store,
193
+ config: context.config,
194
+ indexName: context.indexName,
195
+ vectorIndex: modelPorts?.vectorIndex ?? null,
196
+ embedPort: modelPorts?.embedPort ?? null,
197
+ rerankPort: modelPorts?.rerankPort ?? null,
198
+ });
199
+ return {
200
+ structuredContent: capsule as unknown as Record<string, unknown>,
201
+ // MCP model context receives this projection exactly once. The full
202
+ // canonical capsule remains available to application clients through
203
+ // structuredContent and is deliberately not duplicated in text.
204
+ text: formatContextCapsuleAgentJson(capsule),
205
+ };
206
+ } finally {
207
+ await modelPorts?.dispose();
208
+ }
209
+ });
210
+
211
+ export const handleContextVerify = (
212
+ args: unknown,
213
+ context: ToolContext
214
+ ): Promise<ToolResult> =>
215
+ runContextTool(context, async () => {
216
+ const parsed = parseContextVerifySurfaceInput(args);
217
+ const receipt = await verifyContextCapsuleRuntime(parsed.capsule, {
218
+ store: context.store,
219
+ config: context.config,
220
+ indexName: context.indexName,
221
+ });
222
+ return {
223
+ structuredContent: receipt as unknown as Record<string, unknown>,
224
+ text:
225
+ parsed.format === "md"
226
+ ? formatContextCapsuleVerificationMarkdown(receipt)
227
+ : canonicalVerifiedContextCapsuleJson(receipt),
228
+ };
229
+ });
@@ -10,12 +10,17 @@ import { z } from "zod";
10
10
 
11
11
  import type { ToolContext } from "../server";
12
12
 
13
+ import {
14
+ contextBuildSurfaceSchema,
15
+ contextVerifySurfaceSchema,
16
+ } from "../../app/context-surface";
13
17
  import { CAPTURE_MAX_TEXT_BYTES } from "../../core/capture";
14
18
  import { NOTE_PRESETS, type NotePresetId } from "../../core/note-presets";
15
19
  import { normalizeTag } from "../../core/tags";
16
20
  import { handleAddCollection } from "./add-collection";
17
21
  import { handleCapture } from "./capture";
18
22
  import { handleClearCollectionEmbeddings } from "./clear-collection-embeddings";
23
+ import { handleContext, handleContextVerify } from "./context";
19
24
  import { handleEmbed } from "./embed";
20
25
  import { handleGet } from "./get";
21
26
  import { handleIndex } from "./index-cmd";
@@ -72,6 +77,10 @@ export const MCP_TOOL_DESCRIPTIONS = {
72
77
  "Retrieve multiple documents by refs array or glob pattern. Use after gno_search/gno_query to batch top result URIs/docids; set maxBytes and lineNumbers to control context size.",
73
78
  status:
74
79
  "Get index health: collection count, document count, chunk count, embedding backlog, and per-collection stats. Check first when vector/hybrid results look stale or unavailable.",
80
+ context:
81
+ "Compile one deterministic, budgeted, extractive evidence Capsule with exact line spans, coverage gaps, omissions, provenance, and verification fingerprints. Raw search/get tools remain available for manual retrieval.",
82
+ contextVerify:
83
+ "Verify a saved Context Capsule without rebuilding or mutating it. Reports unchanged, stale, missing, reranked, and fingerprint drift states against the active index.",
75
84
  } as const;
76
85
 
77
86
  // ─────────────────────────────────────────────────────────────────────────────
@@ -899,6 +908,24 @@ function parseErrorMessage(message: string): { [x: string]: unknown } {
899
908
 
900
909
  export function registerTools(server: McpServer, ctx: ToolContext): void {
901
910
  // Tool IDs use underscores (MCP pattern: ^[a-zA-Z0-9_-]{1,64}$)
911
+ server.registerTool(
912
+ "gno_context",
913
+ {
914
+ description: MCP_TOOL_DESCRIPTIONS.context,
915
+ inputSchema: contextBuildSurfaceSchema,
916
+ },
917
+ (args) => handleContext(args, ctx)
918
+ );
919
+
920
+ server.registerTool(
921
+ "gno_context_verify",
922
+ {
923
+ description: MCP_TOOL_DESCRIPTIONS.contextVerify,
924
+ inputSchema: contextVerifySurfaceSchema,
925
+ },
926
+ (args) => handleContextVerify(args, ctx)
927
+ );
928
+
902
929
  server.tool(
903
930
  "gno_search",
904
931
  MCP_TOOL_DESCRIPTIONS.search,
@@ -42,3 +42,36 @@ export function createChunkLookup(
42
42
  return bySeq.get(seq);
43
43
  };
44
44
  }
45
+
46
+ /**
47
+ * Prove that persisted chunk coordinates still address the exact stored
48
+ * canonical mirror bytes. Chunk text may begin or end mid-line; callers that
49
+ * need citation spans must expand it to full lines separately.
50
+ */
51
+ export function chunkMatchesCanonicalContent(
52
+ chunk: ChunkRow,
53
+ content: string
54
+ ): boolean {
55
+ if (
56
+ content.includes("\r") ||
57
+ chunk.pos < 0 ||
58
+ chunk.startLine < 1 ||
59
+ chunk.endLine < chunk.startLine ||
60
+ chunk.text.length === 0 ||
61
+ chunk.pos + chunk.text.length > content.length
62
+ ) {
63
+ return false;
64
+ }
65
+ if (content.slice(chunk.pos, chunk.pos + chunk.text.length) !== chunk.text) {
66
+ return false;
67
+ }
68
+ let startLine = 1;
69
+ let endLine = 1;
70
+ const endOffset = chunk.pos + chunk.text.length - 1;
71
+ for (let offset = 0; offset < endOffset; offset += 1) {
72
+ if (content[offset] !== "\n") continue;
73
+ if (offset < chunk.pos) startLine += 1;
74
+ endLine += 1;
75
+ }
76
+ return startLine === chunk.startLine && endLine === chunk.endLine;
77
+ }
@@ -54,7 +54,10 @@ import {
54
54
  resolveTemporalRange,
55
55
  shouldSortByRecency,
56
56
  } from "./temporal";
57
- import { DEFAULT_PIPELINE_CONFIG } from "./types";
57
+ import {
58
+ DEFAULT_PIPELINE_CONFIG,
59
+ SEARCH_RESULT_PLANNER_METADATA,
60
+ } from "./types";
58
61
 
59
62
  // ─────────────────────────────────────────────────────────────────────────────
60
63
  // Dependencies
@@ -719,7 +722,16 @@ export async function searchHybrid(
719
722
  }
720
723
 
721
724
  // Build lookup maps.
722
- const docByMirrorHash = new Map<string, (typeof docsResult.value)[number]>();
725
+ const docsByMirrorHash = new Map<
726
+ string,
727
+ (typeof docsResult.value)[number][]
728
+ >();
729
+ const addDocument = (doc: (typeof docsResult.value)[number]): void => {
730
+ if (!doc.mirrorHash) return;
731
+ const docs = docsByMirrorHash.get(doc.mirrorHash) ?? [];
732
+ docs.push(doc);
733
+ docsByMirrorHash.set(doc.mirrorHash, docs);
734
+ };
723
735
  const matchesMetadataFilters = (
724
736
  doc: (typeof docsResult.value)[number]
725
737
  ): boolean => {
@@ -761,7 +773,7 @@ export async function searchHybrid(
761
773
  candidateDocs.push(doc);
762
774
  } else {
763
775
  if (matchesMetadataFilters(doc)) {
764
- docByMirrorHash.set(doc.mirrorHash, doc);
776
+ addDocument(doc);
765
777
  }
766
778
  }
767
779
  }
@@ -789,12 +801,20 @@ export async function searchHybrid(
789
801
  }
790
802
 
791
803
  if (doc.mirrorHash && matchesMetadataFilters(doc)) {
792
- docByMirrorHash.set(doc.mirrorHash, doc);
804
+ addDocument(doc);
793
805
  }
794
806
  }
795
807
  }
796
808
  }
797
809
 
810
+ for (const docs of docsByMirrorHash.values()) {
811
+ docs.sort((left, right) => {
812
+ if (left.uri < right.uri) return -1;
813
+ if (left.uri > right.uri) return 1;
814
+ return left.docid < right.docid ? -1 : left.docid > right.docid ? 1 : 0;
815
+ });
816
+ }
817
+
798
818
  const collectionPaths = new Map<string, string>();
799
819
  if (collectionsResult.ok) {
800
820
  for (const c of collectionsResult.value) {
@@ -823,34 +843,19 @@ export async function searchHybrid(
823
843
  const seenDocids = new Set<string>();
824
844
 
825
845
  // Iterate until we have enough results (don't slice early - deduping may skip candidates)
826
- for (const candidate of filteredCandidates) {
846
+ for (const [candidateIndex, candidate] of filteredCandidates.entries()) {
827
847
  // Stop when we have enough results
828
848
  if (results.length >= assemblyLimit) {
829
849
  break;
830
850
  }
831
851
 
832
852
  // Find document from pre-fetched map
833
- const doc = docByMirrorHash.get(candidate.mirrorHash);
834
- if (!doc) {
853
+ const candidateDocs = docsByMirrorHash.get(candidate.mirrorHash) ?? [];
854
+ if (candidateDocs.length === 0) {
835
855
  continue;
836
856
  }
837
857
 
838
858
  const docChunks = chunksMap.get(candidate.mirrorHash) ?? [];
839
- const filterEval = evaluateDocumentChunkFilters(
840
- query,
841
- doc,
842
- docChunks,
843
- options
844
- );
845
- if (!filterEval.matches) {
846
- continue;
847
- }
848
-
849
- // For --full mode, de-dupe by docid (keep best scoring candidate per doc)
850
- if (options.full && seenDocids.has(doc.docid)) {
851
- continue;
852
- }
853
-
854
859
  // Get chunk via O(1) lookup
855
860
  // For doc-level FTS (seq=0), fall back to first available chunk if exact lookup fails
856
861
  let chunk = getChunk(candidate.mirrorHash, candidate.seq);
@@ -868,10 +873,6 @@ export async function searchHybrid(
868
873
  continue;
869
874
  }
870
875
 
871
- docidMap.set(`${candidate.mirrorHash}:${candidate.seq}`, doc.docid);
872
-
873
- const collectionPath = collectionPaths.get(doc.collection);
874
-
875
876
  // For --full mode, fetch full mirror content
876
877
  const snippetChunk =
877
878
  options.full || !options.intent?.trim()
@@ -907,37 +908,58 @@ export async function searchHybrid(
907
908
  // Fallback to chunk text if content unavailable
908
909
  }
909
910
 
910
- seenDocids.add(doc.docid);
911
-
912
- results.push({
913
- docid: doc.docid,
914
- score: candidate.blendedScore,
915
- uri: doc.uri,
916
- title: doc.title ?? undefined,
917
- contentType: doc.contentType ?? undefined,
918
- categories: doc.categories ?? undefined,
919
- line: snippetChunk.startLine,
920
- snippet,
921
- snippetLanguage: chunk.language ?? undefined,
922
- snippetRange,
923
- source: {
924
- relPath: doc.relPath,
925
- absPath: collectionPath
926
- ? `${collectionPath}/${doc.relPath}`
927
- : undefined,
928
- mime: doc.sourceMime,
929
- ext: doc.sourceExt,
930
- modifiedAt: doc.sourceMtime,
931
- documentDate: doc.frontmatterDate ?? undefined,
932
- sizeBytes: doc.sourceSize,
933
- sourceHash: doc.sourceHash,
934
- },
935
- conversion: {
936
- mirrorHash: candidate.mirrorHash,
937
- converterId: doc.converterId ?? undefined,
938
- converterVersion: doc.converterVersion ?? undefined,
939
- },
940
- });
911
+ for (const doc of candidateDocs) {
912
+ if (results.length >= assemblyLimit) break;
913
+ const filterEval = evaluateDocumentChunkFilters(
914
+ query,
915
+ doc,
916
+ docChunks,
917
+ options
918
+ );
919
+ if (!filterEval.matches || (options.full && seenDocids.has(doc.docid))) {
920
+ continue;
921
+ }
922
+ const docidKey = `${candidate.mirrorHash}:${candidate.seq}`;
923
+ if (!docidMap.has(docidKey)) docidMap.set(docidKey, doc.docid);
924
+ const collectionPath = collectionPaths.get(doc.collection);
925
+ 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
+ });
962
+ }
941
963
  }
942
964
  timings.assemblyMs = performance.now() - assemblyStartedAt;
943
965
  timings.totalMs = performance.now() - runStartedAt;
@@ -37,6 +37,19 @@ export interface SnippetRange {
37
37
  endLine: number;
38
38
  }
39
39
 
40
+ /** Symbol-keyed planner metadata; omitted from JSON/API projections. */
41
+ export const SEARCH_RESULT_PLANNER_METADATA = Symbol(
42
+ "gno.searchResultPlannerMetadata"
43
+ );
44
+
45
+ export interface SearchResultPlannerMetadata {
46
+ retrievalRank: number;
47
+ mirrorHash: string;
48
+ seq: number;
49
+ sources: FusionSource[];
50
+ graphExpanded: boolean;
51
+ }
52
+
40
53
  /** Single search result matching output schema */
41
54
  export interface SearchResult {
42
55
  docid: string;
@@ -53,6 +66,7 @@ export interface SearchResult {
53
66
  context?: string;
54
67
  source: SearchResultSource;
55
68
  conversion?: SearchResultConversion;
69
+ [SEARCH_RESULT_PLANNER_METADATA]?: SearchResultPlannerMetadata;
56
70
  }
57
71
 
58
72
  /** Search mode enum */