scythe-context-mcp 0.1.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.
@@ -0,0 +1,187 @@
1
+ import { loadSqliteVec } from "./sqliteVec.js";
2
+ export function vectorTableName(dimensions) {
3
+ if (!Number.isInteger(dimensions) || dimensions <= 0) {
4
+ throw new Error("Vector dimensions must be a positive integer");
5
+ }
6
+ return `vec_embeddings_${dimensions}`;
7
+ }
8
+ export function initializeStorageSchema(db, options) {
9
+ loadSqliteVec(db);
10
+ db.pragma("foreign_keys = ON");
11
+ if (db.name !== ":memory:") {
12
+ db.pragma("journal_mode = WAL");
13
+ }
14
+ db.exec(`
15
+ create table if not exists schema_migrations (
16
+ version integer primary key,
17
+ applied_at text not null default (datetime('now'))
18
+ );
19
+
20
+ create table if not exists files (
21
+ id integer primary key,
22
+ project_path text not null,
23
+ path text not null,
24
+ mtime_ms real not null,
25
+ size integer not null,
26
+ hash text not null,
27
+ unique(project_path, path)
28
+ );
29
+
30
+ create table if not exists chunks (
31
+ id integer primary key,
32
+ file_id integer not null references files(id) on delete cascade,
33
+ start_line integer not null,
34
+ end_line integer not null,
35
+ language text,
36
+ title text,
37
+ text text not null,
38
+ hash text not null,
39
+ unique(file_id, start_line, end_line, hash)
40
+ );
41
+
42
+ create virtual table if not exists chunk_fts using fts5(
43
+ path,
44
+ title,
45
+ text,
46
+ tokenize='trigram'
47
+ );
48
+
49
+ create table if not exists embedding_sets (
50
+ id integer primary key,
51
+ provider text not null,
52
+ base_url_hash text not null,
53
+ model text not null,
54
+ dimensions integer not null,
55
+ created_at text not null default (datetime('now')),
56
+ unique(provider, base_url_hash, model, dimensions)
57
+ );
58
+
59
+ create table if not exists embeddings (
60
+ id integer primary key,
61
+ chunk_id integer not null references chunks(id) on delete cascade,
62
+ embedding_set_id integer not null references embedding_sets(id) on delete cascade,
63
+ created_at text not null default (datetime('now')),
64
+ unique(chunk_id, embedding_set_id)
65
+ );
66
+
67
+ create table if not exists file_symbols (
68
+ id integer primary key,
69
+ file_id integer not null references files(id) on delete cascade,
70
+ name text not null,
71
+ kind text not null,
72
+ line integer not null,
73
+ signature text not null,
74
+ exported integer not null default 0
75
+ );
76
+
77
+ create index if not exists idx_file_symbols_name on file_symbols(name);
78
+ create index if not exists idx_file_symbols_file_id on file_symbols(file_id);
79
+
80
+ create table if not exists file_dependencies (
81
+ id integer primary key,
82
+ file_id integer not null references files(id) on delete cascade,
83
+ specifier text not null,
84
+ resolved_path text,
85
+ line integer not null
86
+ );
87
+
88
+ create index if not exists idx_file_dependencies_file_id on file_dependencies(file_id);
89
+ create index if not exists idx_file_dependencies_resolved_path on file_dependencies(resolved_path);
90
+
91
+ insert or ignore into schema_migrations(version) values (1);
92
+ insert or ignore into schema_migrations(version) values (2);
93
+ `);
94
+ const tableName = vectorTableName(options.vectorDimensions);
95
+ db.exec(`create virtual table if not exists ${tableName} using vec0(embedding float[${options.vectorDimensions}]);`);
96
+ }
97
+ export function upsertFile(db, input) {
98
+ db.prepare(`
99
+ insert into files(project_path, path, mtime_ms, size, hash)
100
+ values (@projectPath, @path, @mtimeMs, @size, @hash)
101
+ on conflict(project_path, path) do update set
102
+ mtime_ms = excluded.mtime_ms,
103
+ size = excluded.size,
104
+ hash = excluded.hash
105
+ `).run(input);
106
+ const row = db
107
+ .prepare("select id from files where project_path = ? and path = ?")
108
+ .get(input.projectPath, input.path);
109
+ return row.id;
110
+ }
111
+ export function insertChunk(db, input) {
112
+ db.prepare(`
113
+ insert or ignore into chunks(file_id, start_line, end_line, language, title, text, hash)
114
+ values (@fileId, @startLine, @endLine, @language, @title, @text, @hash)
115
+ `).run({
116
+ ...input,
117
+ language: input.language ?? null,
118
+ title: input.title ?? null,
119
+ });
120
+ const row = db
121
+ .prepare("select id from chunks where file_id = ? and start_line = ? and end_line = ? and hash = ?")
122
+ .get(input.fileId, input.startLine, input.endLine, input.hash);
123
+ db.prepare("delete from chunk_fts where rowid = ?").run(row.id);
124
+ db.prepare("insert into chunk_fts(rowid, path, title, text) values (?, ?, ?, ?)").run(row.id, input.path, input.title ?? "", input.text);
125
+ return row.id;
126
+ }
127
+ export function deleteChunksForFile(db, fileId) {
128
+ const rows = db.prepare("select id from chunks where file_id = ?").all(fileId);
129
+ for (const row of rows) {
130
+ db.prepare("delete from chunk_fts where rowid = ?").run(row.id);
131
+ }
132
+ db.prepare("delete from chunks where file_id = ?").run(fileId);
133
+ }
134
+ export function rebuildChunkFtsForFile(db, fileId, filePath) {
135
+ const rows = db.prepare("select id, title, text from chunks where file_id = ?").all(fileId);
136
+ for (const row of rows) {
137
+ db.prepare("delete from chunk_fts where rowid = ?").run(row.id);
138
+ db.prepare("insert into chunk_fts(rowid, path, title, text) values (?, ?, ?, ?)").run(row.id, filePath, row.title ?? "", row.text);
139
+ }
140
+ }
141
+ export function getOrCreateEmbeddingSet(db, input) {
142
+ db.prepare(`
143
+ insert or ignore into embedding_sets(provider, base_url_hash, model, dimensions)
144
+ values (@provider, @baseUrlHash, @model, @dimensions)
145
+ `).run(input);
146
+ const row = db
147
+ .prepare(`
148
+ select id from embedding_sets
149
+ where provider = ? and base_url_hash = ? and model = ? and dimensions = ?
150
+ `)
151
+ .get(input.provider, input.baseUrlHash, input.model, input.dimensions);
152
+ return row.id;
153
+ }
154
+ export function getOrCreateEmbeddingRecord(db, input) {
155
+ db.prepare(`
156
+ insert or ignore into embeddings(chunk_id, embedding_set_id)
157
+ values (@chunkId, @embeddingSetId)
158
+ `).run(input);
159
+ const row = db
160
+ .prepare("select id from embeddings where chunk_id = ? and embedding_set_id = ?")
161
+ .get(input.chunkId, input.embeddingSetId);
162
+ return row.id;
163
+ }
164
+ export function replaceSymbolGraphForFile(db, fileId, symbols, dependencies) {
165
+ db.prepare("delete from file_symbols where file_id = ?").run(fileId);
166
+ db.prepare("delete from file_dependencies where file_id = ?").run(fileId);
167
+ const insertSymbol = db.prepare(`
168
+ insert into file_symbols(file_id, name, kind, line, signature, exported)
169
+ values (@fileId, @name, @kind, @line, @signature, @exported)
170
+ `);
171
+ for (const symbol of symbols) {
172
+ insertSymbol.run({
173
+ ...symbol,
174
+ exported: symbol.exported ? 1 : 0,
175
+ });
176
+ }
177
+ const insertDependency = db.prepare(`
178
+ insert into file_dependencies(file_id, specifier, resolved_path, line)
179
+ values (@fileId, @specifier, @resolvedPath, @line)
180
+ `);
181
+ for (const dependency of dependencies) {
182
+ insertDependency.run({
183
+ ...dependency,
184
+ resolvedPath: dependency.resolvedPath ?? null,
185
+ });
186
+ }
187
+ }
@@ -0,0 +1,17 @@
1
+ import * as sqliteVec from "sqlite-vec";
2
+ export function loadSqliteVec(db) {
3
+ sqliteVec.load(db);
4
+ const row = db.prepare("select vec_version() as version").get();
5
+ return row.version;
6
+ }
7
+ export function vectorToFloat32Buffer(vector, expectedDimensions) {
8
+ if (vector.length !== expectedDimensions) {
9
+ throw new Error(`Expected vector with ${expectedDimensions} dimensions, received ${vector.length}`);
10
+ }
11
+ for (const value of vector) {
12
+ if (!Number.isFinite(value)) {
13
+ throw new Error("Vector contains a non-finite value");
14
+ }
15
+ }
16
+ return Buffer.from(new Float32Array(vector).buffer);
17
+ }
@@ -0,0 +1,364 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { z } from "zod";
4
+ import { buildContextPack } from "../indexing/contextPack.js";
5
+ import { reindexDryRun } from "../indexing/dryRun.js";
6
+ import { indexMissingEmbeddings } from "../indexing/embeddingWriter.js";
7
+ import { searchHybrid } from "../indexing/hybridSearch.js";
8
+ import { readDetailedIndexStatus, readIndexFreshness, recommendedNextActions } from "../indexing/indexStatus.js";
9
+ import { persistentReindexMetadata } from "../indexing/indexWriter.js";
10
+ import { readRelatedFileGraph, readRelatedFiles } from "../indexing/relatedFiles.js";
11
+ import { readRelatedSnippets } from "../indexing/relatedSnippets.js";
12
+ import { formatSearchResults } from "../indexing/resultFormat.js";
13
+ import { searchByVector } from "../indexing/semanticSearch.js";
14
+ import { buildGeminiEndpoint, GeminiEmbeddingError, GeminiEmbeddingProvider, normalizeGeminiBaseUrl } from "../providers/gemini.js";
15
+ function asJsonText(value) {
16
+ return {
17
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
18
+ };
19
+ }
20
+ function searchIndexedChunks(options) {
21
+ return options.mode === "semantic"
22
+ ? searchByVector({
23
+ dbPath: options.dbPath,
24
+ dimensions: options.dimensions,
25
+ queryVector: options.queryVector,
26
+ maxResults: options.maxResults,
27
+ maxSnippetChars: options.maxSnippetChars,
28
+ })
29
+ : searchHybrid({
30
+ dbPath: options.dbPath,
31
+ query: options.query,
32
+ dimensions: options.dimensions,
33
+ queryVector: options.queryVector,
34
+ maxResults: options.maxResults,
35
+ maxSnippetChars: options.maxSnippetChars,
36
+ });
37
+ }
38
+ function buildGeminiDiagnostics(config, expectedDimensions) {
39
+ const diagnostics = {
40
+ baseUrl: config.baseUrl,
41
+ model: config.model,
42
+ expectedDimensions,
43
+ authMode: config.authMode,
44
+ hasApiKey: Boolean(config.apiKey),
45
+ };
46
+ try {
47
+ diagnostics.normalizedBaseUrl = normalizeGeminiBaseUrl(config.baseUrl);
48
+ diagnostics.endpoint = buildGeminiEndpoint(config.baseUrl, config.model, "embedContent").toString();
49
+ }
50
+ catch (error) {
51
+ diagnostics.configError = error instanceof Error ? error.message : String(error);
52
+ }
53
+ return diagnostics;
54
+ }
55
+ export function registerTools(server, config) {
56
+ const embeddingProvider = new GeminiEmbeddingProvider(config.gemini);
57
+ const expectedDimensions = config.gemini.outputDimensionality ?? 1536;
58
+ server.registerTool("repo_index_status", {
59
+ title: "Repo Index Status",
60
+ description: "Show Scythe Context configuration and current index status.",
61
+ inputSchema: {
62
+ project_path: z.string().optional(),
63
+ },
64
+ }, async ({ project_path }) => {
65
+ const projectPath = path.resolve(project_path || config.defaultProjectPath);
66
+ const dbPath = path.join(projectPath, config.indexDirName, "index.sqlite");
67
+ const index = readDetailedIndexStatus(dbPath);
68
+ const freshness = await readIndexFreshness({
69
+ projectPath,
70
+ dbPath,
71
+ limits: { maxFileBytes: config.indexing.maxFileBytes },
72
+ });
73
+ return asJsonText({
74
+ projectPath,
75
+ indexPath: path.join(projectPath, config.indexDirName),
76
+ index,
77
+ recommendedNextActions: recommendedNextActions(index, {
78
+ desiredDimensions: expectedDimensions,
79
+ freshness,
80
+ }),
81
+ freshness,
82
+ status: "usable_mvp",
83
+ implemented: [
84
+ "mcp_server",
85
+ "gemini_embedding_provider",
86
+ "config",
87
+ "file_scanner",
88
+ "chunker",
89
+ "reindex_dry_run",
90
+ "sqlite_schema",
91
+ "persistent_metadata_index",
92
+ "embedding_index_writer",
93
+ "semantic_vector_search",
94
+ "symbol_graph",
95
+ "related_files",
96
+ "context_budgeting",
97
+ "context_packer",
98
+ "multi_hop_related_files",
99
+ "related_snippet_packing",
100
+ ],
101
+ pending: ["tree_sitter_symbols"],
102
+ indexing: config.indexing,
103
+ gemini: {
104
+ baseUrl: config.gemini.baseUrl,
105
+ model: config.gemini.model,
106
+ outputDimensionality: config.gemini.outputDimensionality,
107
+ authMode: config.gemini.authMode,
108
+ hasApiKey: Boolean(config.gemini.apiKey),
109
+ },
110
+ });
111
+ });
112
+ server.registerTool("gemini_embedding_probe", {
113
+ title: "Gemini Embedding Probe",
114
+ description: "Send one embedding request and return diagnostics for official Gemini or proxy compatibility.",
115
+ inputSchema: {
116
+ text: z.string().min(1),
117
+ },
118
+ }, async ({ text }) => {
119
+ const startedAt = Date.now();
120
+ const diagnostics = buildGeminiDiagnostics(config.gemini, expectedDimensions);
121
+ try {
122
+ const result = await embeddingProvider.embed({ kind: "query", text });
123
+ return asJsonText({
124
+ status: "ok",
125
+ latencyMs: Date.now() - startedAt,
126
+ diagnostics,
127
+ model: result.model,
128
+ dimensions: result.dimensions,
129
+ dimensionsMatchExpected: result.dimensions === expectedDimensions,
130
+ sample: result.vector.slice(0, 8),
131
+ });
132
+ }
133
+ catch (error) {
134
+ const geminiError = error instanceof GeminiEmbeddingError ? error : undefined;
135
+ return asJsonText({
136
+ status: "embedding_probe_failed",
137
+ latencyMs: Date.now() - startedAt,
138
+ diagnostics,
139
+ error: {
140
+ type: error instanceof Error ? error.name : "UnknownError",
141
+ message: error instanceof Error ? error.message : String(error),
142
+ httpStatus: geminiError?.status,
143
+ retryable: geminiError?.retryable ?? false,
144
+ bodySnippet: geminiError?.bodySnippet,
145
+ },
146
+ recommendedNextActions: [
147
+ "Verify GEMINI_API_KEY is present in the environment Codex launches from.",
148
+ "Verify GEMINI_BASE_URL points to the provider root and can include or omit /v1beta.",
149
+ "Verify GEMINI_AUTH_MODE matches the proxy requirement: x-goog-api-key, bearer, or query.",
150
+ "Verify the provider supports models/{model}:embedContent and the requested output dimensionality.",
151
+ ],
152
+ });
153
+ }
154
+ });
155
+ server.registerTool("repo_reindex", {
156
+ title: "Repo Reindex",
157
+ description: "Scan a project, write metadata, and optionally index embeddings.",
158
+ inputSchema: {
159
+ project_path: z.string().optional(),
160
+ dry_run: z.boolean().default(true),
161
+ max_file_bytes: z.number().int().positive().optional(),
162
+ target_chunk_chars: z.number().int().positive().optional(),
163
+ chunk_overlap_chars: z.number().int().nonnegative().optional(),
164
+ max_chunks_per_file: z.number().int().positive().optional(),
165
+ index_embeddings: z.boolean().default(false),
166
+ embedding_batch_size: z.number().int().positive().max(128).optional(),
167
+ max_embedding_chunks: z.number().int().positive().max(10000).optional(),
168
+ },
169
+ }, async ({ project_path, dry_run, max_file_bytes, target_chunk_chars, chunk_overlap_chars, max_chunks_per_file, index_embeddings, embedding_batch_size, max_embedding_chunks, }) => {
170
+ const commonOptions = {
171
+ projectPath: path.resolve(project_path || config.defaultProjectPath),
172
+ maxFileBytes: max_file_bytes ?? config.indexing.maxFileBytes,
173
+ targetChunkChars: target_chunk_chars ?? config.indexing.targetChunkChars,
174
+ chunkOverlapChars: chunk_overlap_chars ?? config.indexing.chunkOverlapChars,
175
+ maxChunksPerFile: max_chunks_per_file ?? config.indexing.maxChunksPerFile,
176
+ };
177
+ if (dry_run) {
178
+ return asJsonText(await reindexDryRun(commonOptions));
179
+ }
180
+ const metadataResult = await persistentReindexMetadata({
181
+ ...commonOptions,
182
+ indexDirName: config.indexDirName,
183
+ vectorDimensions: expectedDimensions,
184
+ });
185
+ if (!index_embeddings) {
186
+ return asJsonText(metadataResult);
187
+ }
188
+ const embeddingResult = await indexMissingEmbeddings({
189
+ dbPath: metadataResult.dbPath,
190
+ providerName: "gemini",
191
+ providerBaseUrl: config.gemini.baseUrl,
192
+ model: config.gemini.model,
193
+ dimensions: expectedDimensions,
194
+ batchSize: embedding_batch_size ?? config.indexing.embeddingBatchSize,
195
+ maxChunks: max_embedding_chunks ?? config.indexing.maxEmbeddingChunks,
196
+ provider: embeddingProvider,
197
+ });
198
+ return asJsonText({
199
+ ...metadataResult,
200
+ status: "metadata_and_embeddings_indexed",
201
+ embeddings: embeddingResult,
202
+ });
203
+ });
204
+ server.registerTool("repo_semantic_search", {
205
+ title: "Repo Semantic Search",
206
+ description: "Search indexed code chunks by semantic similarity. Requires repo_reindex with index_embeddings=true first.",
207
+ inputSchema: {
208
+ query: z.string().min(1),
209
+ project_path: z.string().optional(),
210
+ max_results: z.number().int().positive().max(50).default(8),
211
+ max_snippet_chars: z.number().int().positive().max(4000).default(1200),
212
+ max_context_chars: z.number().int().positive().max(100000).default(12000),
213
+ mode: z.enum(["hybrid", "semantic"]).default("hybrid"),
214
+ },
215
+ }, async ({ query, project_path, max_results, max_snippet_chars, max_context_chars, mode }) => {
216
+ const projectPath = path.resolve(project_path || config.defaultProjectPath);
217
+ const dbPath = path.join(projectPath, config.indexDirName, "index.sqlite");
218
+ if (!fs.existsSync(dbPath)) {
219
+ return asJsonText({
220
+ query,
221
+ projectPath,
222
+ status: "index_missing",
223
+ message: "Run repo_reindex with dry_run=false and index_embeddings=true before semantic search.",
224
+ });
225
+ }
226
+ const queryEmbedding = await embeddingProvider.embed({ kind: "query", text: query });
227
+ const dimensions = expectedDimensions;
228
+ if (queryEmbedding.dimensions !== dimensions) {
229
+ throw new Error(`Query embedding dimensions mismatch: expected ${dimensions}, got ${queryEmbedding.dimensions}`);
230
+ }
231
+ const rawResults = searchIndexedChunks({
232
+ dbPath,
233
+ query,
234
+ dimensions,
235
+ queryVector: queryEmbedding.vector,
236
+ maxResults: max_results,
237
+ maxSnippetChars: max_snippet_chars,
238
+ mode,
239
+ });
240
+ const formatted = formatSearchResults(query, rawResults, { maxContextChars: max_context_chars });
241
+ return asJsonText({
242
+ query,
243
+ projectPath,
244
+ dbPath,
245
+ dimensions,
246
+ mode,
247
+ results: formatted.results,
248
+ context: formatted.summary,
249
+ resultCount: rawResults.length,
250
+ });
251
+ });
252
+ server.registerTool("repo_related_files", {
253
+ title: "Repo Related Files",
254
+ description: "Show symbols, imports, and reverse imports for an indexed file.",
255
+ inputSchema: {
256
+ path: z.string().min(1),
257
+ project_path: z.string().optional(),
258
+ max_results: z.number().int().positive().max(100).default(24),
259
+ },
260
+ }, async ({ path: filePath, project_path, max_results }) => {
261
+ const projectPath = path.resolve(project_path || config.defaultProjectPath);
262
+ const dbPath = path.join(projectPath, config.indexDirName, "index.sqlite");
263
+ if (!fs.existsSync(dbPath)) {
264
+ return asJsonText({
265
+ path: filePath,
266
+ projectPath,
267
+ status: "index_missing",
268
+ message: "Run repo_reindex with dry_run=false before related file lookup.",
269
+ });
270
+ }
271
+ return asJsonText({
272
+ projectPath,
273
+ dbPath,
274
+ ...readRelatedFiles({
275
+ dbPath,
276
+ filePath,
277
+ maxResults: max_results,
278
+ }),
279
+ });
280
+ });
281
+ server.registerTool("repo_context_pack", {
282
+ title: "Repo Context Pack",
283
+ description: "Search code and package primary snippets with symbols/imports/reverse imports for matched files.",
284
+ inputSchema: {
285
+ query: z.string().min(1),
286
+ project_path: z.string().optional(),
287
+ max_results: z.number().int().positive().max(30).default(6),
288
+ max_snippet_chars: z.number().int().positive().max(4000).default(1200),
289
+ max_context_chars: z.number().int().positive().max(100000).default(16000),
290
+ max_seed_files: z.number().int().positive().max(10).default(3),
291
+ max_related_files: z.number().int().nonnegative().max(30).default(10),
292
+ max_related_items: z.number().int().positive().max(50).default(8),
293
+ include_related_snippets: z.boolean().default(false),
294
+ max_related_snippets_per_file: z.number().int().positive().max(5).default(1),
295
+ max_related_snippet_chars: z.number().int().positive().max(2000).default(600),
296
+ max_related_context_chars: z.number().int().nonnegative().max(50000).default(4000),
297
+ related_depth: z.number().int().nonnegative().max(3).default(1),
298
+ mode: z.enum(["hybrid", "semantic"]).default("hybrid"),
299
+ },
300
+ }, async ({ query, project_path, max_results, max_snippet_chars, max_context_chars, max_seed_files, max_related_files, max_related_items, include_related_snippets, max_related_snippets_per_file, max_related_snippet_chars, max_related_context_chars, related_depth, mode, }) => {
301
+ const projectPath = path.resolve(project_path || config.defaultProjectPath);
302
+ const dbPath = path.join(projectPath, config.indexDirName, "index.sqlite");
303
+ if (!fs.existsSync(dbPath)) {
304
+ return asJsonText({
305
+ query,
306
+ projectPath,
307
+ status: "index_missing",
308
+ message: "Run repo_reindex with dry_run=false and index_embeddings=true before building a context pack.",
309
+ });
310
+ }
311
+ const queryEmbedding = await embeddingProvider.embed({ kind: "query", text: query });
312
+ const dimensions = expectedDimensions;
313
+ if (queryEmbedding.dimensions !== dimensions) {
314
+ throw new Error(`Query embedding dimensions mismatch: expected ${dimensions}, got ${queryEmbedding.dimensions}`);
315
+ }
316
+ const rawResults = searchIndexedChunks({
317
+ dbPath,
318
+ query,
319
+ dimensions,
320
+ queryVector: queryEmbedding.vector,
321
+ maxResults: max_results,
322
+ maxSnippetChars: max_snippet_chars,
323
+ mode,
324
+ });
325
+ const relatedPaths = Array.from(new Set(rawResults.map((result) => result.path))).slice(0, Math.min(max_seed_files, max_related_files));
326
+ const relatedFiles = readRelatedFileGraph({
327
+ dbPath,
328
+ seedPaths: relatedPaths,
329
+ maxDepth: related_depth,
330
+ maxFiles: max_related_files,
331
+ maxResultsPerFile: max_related_items,
332
+ });
333
+ const primaryPathSet = new Set(rawResults.map((result) => result.path));
334
+ const relatedSnippetPaths = relatedFiles
335
+ .map((file) => file.path)
336
+ .filter((filePath) => !primaryPathSet.has(filePath));
337
+ const relatedSnippets = include_related_snippets && max_related_context_chars > 0
338
+ ? readRelatedSnippets({
339
+ dbPath,
340
+ paths: relatedSnippetPaths,
341
+ maxSnippetsPerFile: max_related_snippets_per_file,
342
+ maxSnippetChars: max_related_snippet_chars,
343
+ maxRelatedContextChars: max_related_context_chars,
344
+ })
345
+ : undefined;
346
+ const pack = buildContextPack(query, rawResults, relatedFiles, {
347
+ maxContextChars: max_context_chars,
348
+ maxRelatedFiles: max_related_files,
349
+ maxRelatedItems: max_related_items,
350
+ relatedSnippets,
351
+ });
352
+ return asJsonText({
353
+ query,
354
+ projectPath,
355
+ dbPath,
356
+ dimensions,
357
+ mode,
358
+ relatedDepth: related_depth,
359
+ relatedSeedCount: relatedPaths.length,
360
+ includeRelatedSnippets: include_related_snippets,
361
+ ...pack,
362
+ });
363
+ });
364
+ }