@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.
- package/README.md +20 -16
- package/assets/skill/SKILL.md +8 -5
- package/package.json +1 -1
- package/src/app/context-agent-projection.ts +303 -0
- package/src/app/context-format.ts +249 -0
- package/src/app/context-runtime-contract.ts +325 -0
- package/src/app/context-runtime-input.ts +362 -0
- package/src/app/context-runtime-types.ts +65 -0
- package/src/app/context-runtime.ts +170 -0
- package/src/app/context-surface.ts +145 -0
- package/src/cli/commands/context-build.ts +149 -0
- package/src/cli/commands/context-verify.ts +90 -0
- package/src/cli/options.ts +4 -0
- package/src/cli/program.ts +178 -0
- package/src/core/context-budget.ts +461 -0
- package/src/core/context-capsule-index-schema.ts +15 -0
- package/src/core/context-capsule-retrieval-schema.ts +81 -0
- package/src/core/context-capsule-schema.ts +473 -0
- package/src/core/context-capsule-validation.ts +416 -0
- package/src/core/context-capsule-verification.ts +218 -0
- package/src/core/context-capsule.ts +439 -0
- package/src/core/context-compiler.ts +513 -0
- package/src/core/context-evidence-metadata.ts +33 -0
- package/src/core/context-evidence.ts +495 -0
- package/src/core/context-facets.ts +163 -0
- package/src/core/context-guidance.ts +69 -0
- package/src/core/context-scope.ts +32 -0
- package/src/core/context-verifier-canonical.ts +90 -0
- package/src/core/context-verifier-input.ts +66 -0
- package/src/core/context-verifier.ts +447 -0
- package/src/core/sections.ts +63 -0
- package/src/mcp/server.ts +10 -4
- package/src/mcp/tools/context.ts +229 -0
- package/src/mcp/tools/index.ts +27 -0
- package/src/pipeline/chunk-lookup.ts +33 -0
- package/src/pipeline/hybrid.ts +79 -57
- package/src/pipeline/types.ts +14 -0
- package/src/sdk/client.ts +68 -6
- package/src/sdk/index.ts +21 -0
- package/src/sdk/types.ts +24 -0
- package/src/serve/background-runtime.ts +1 -0
- package/src/serve/context-capsule.ts +136 -0
- package/src/serve/context.ts +10 -1
- package/src/serve/routes/api.ts +2 -0
- package/src/serve/server.ts +23 -0
- package/src/store/sqlite/adapter.ts +38 -20
package/src/core/sections.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
+
});
|
package/src/mcp/tools/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/pipeline/hybrid.ts
CHANGED
|
@@ -54,7 +54,10 @@ import {
|
|
|
54
54
|
resolveTemporalRange,
|
|
55
55
|
shouldSortByRecency,
|
|
56
56
|
} from "./temporal";
|
|
57
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
834
|
-
if (
|
|
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
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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;
|
package/src/pipeline/types.ts
CHANGED
|
@@ -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 */
|