@adeu/mcp-server 1.15.1 → 1.16.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.
@@ -189,6 +189,29 @@ describe("Resolved Bugs MCP Server Verification", () => {
189
189
  expect(res.result.content[0].text).toContain("Dry-run simulation complete.");
190
190
  });
191
191
 
192
+ it("Unparseable String: process_document_batch gracefully rejects raw strings instead of crashing", async () => {
193
+ const res = await sendRpc(
194
+ "tools/call",
195
+ {
196
+ name: "process_document_batch",
197
+ arguments: {
198
+ original_docx_path: cleanDocPath,
199
+ author_name: "Agent",
200
+ changes: [
201
+ "modify document to be clean document" // Raw unparseable string
202
+ ],
203
+ dry_run: false,
204
+ },
205
+ },
206
+ 110,
207
+ );
208
+
209
+ expect(res.result.isError).toBe(true);
210
+ expect(res.result.content[0].text).toContain("Batch rejected. Some edits failed validation");
211
+ expect(res.result.content[0].text).toContain("Invalid change format");
212
+ expect(res.result.content[0].text).toContain("received a primitive string");
213
+ });
214
+
192
215
  it("BUG-12: Accepts stringified numbers for numeric arguments without Zod validation errors", async () => {
193
216
  const res = await sendRpc(
194
217
  "tools/call",
@@ -0,0 +1,384 @@
1
+ // FILE: node/packages/mcp-server/src/mcp.schema-gaps.test.ts
2
+ //
3
+ // Guards the MCP boundary's HONESTY: what the server advertises over `tools/list`
4
+ // (the schema + documentation an LLM sees) must match what the tools really do.
5
+ // Each block below closed a gap where an agent, behaving exactly as documented,
6
+ // was either misled or kept from a capability that exists — "under-documented
7
+ // power is unused power." These tests now assert the corrected state and fail if
8
+ // any gap regresses.
9
+ //
10
+ // Gaps closed:
11
+ // • process_document_batch — `changes` now publishes a typed item schema so the
12
+ // six DocumentChange variants are discoverable (ADEU_TOOL_ISSUES #1), and the
13
+ // real `match_mode`/`regex` options are documented in both schema and prose
14
+ // (#10). Both are still proven honored at the live boundary.
15
+ // • read_docx — its description carries the build stamp exactly once (UI tools
16
+ // were previously double-wrapped and stamped twice).
17
+ // • diff_docx_files — described as the custom `@@ Word Patch @@` format it
18
+ // actually emits, no longer mislabeled a "unified diff".
19
+ // • finalize_document — discloses that `protection_mode:'encrypt'` is unsupported
20
+ // in the zero-dependency Node build and falls back to a read-only lock.
21
+
22
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
23
+ import { spawn, ChildProcess } from "node:child_process";
24
+ import { resolve, join } from "node:path";
25
+ import { tmpdir } from "node:os";
26
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
27
+ import { fileURLToPath } from "node:url";
28
+ import { DocumentObject } from "@adeu/core";
29
+
30
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
31
+
32
+ const CHANGE_VARIANTS = [
33
+ "modify",
34
+ "accept",
35
+ "reject",
36
+ "reply",
37
+ "insert_row",
38
+ "delete_row",
39
+ ] as const;
40
+
41
+ const BUILD_STAMP_RE = /\[Adeu v[^\]]*\]/g;
42
+
43
+ describe("MCP tools — advertised schema/docs match real capability", () => {
44
+ let serverProc: ChildProcess;
45
+ let allTools: any[] = [];
46
+ const tempPaths: string[] = [];
47
+
48
+ // Fixtures
49
+ let pdbFixture: string; // repeated phrase + currency, for match_mode/regex proofs
50
+ let diffOrig: string;
51
+ let diffMod: string;
52
+ let finalizeInput: string;
53
+
54
+ const getTool = (name: string) => allTools.find((t) => t.name === name);
55
+
56
+ // --- Robust line-buffered JSON-RPC plumbing over stdio ---
57
+ const pending = new Map<number, (msg: any) => void>();
58
+ let rpcId = 100;
59
+ let stdoutBuffer = "";
60
+
61
+ function rpc(method: string, params: any): Promise<any> {
62
+ const id = ++rpcId;
63
+ return new Promise((resolveRpc, rejectRpc) => {
64
+ const timeout = setTimeout(
65
+ () => rejectRpc(new Error(`RPC timeout for ${method}`)),
66
+ 15000,
67
+ );
68
+ pending.set(id, (msg) => {
69
+ clearTimeout(timeout);
70
+ resolveRpc(msg);
71
+ });
72
+ serverProc.stdin?.write(
73
+ JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n",
74
+ );
75
+ });
76
+ }
77
+
78
+ function notify(method: string, params: any): void {
79
+ serverProc.stdin?.write(
80
+ JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n",
81
+ );
82
+ }
83
+
84
+ // Build a docx from a list of paragraph strings (cloning the empty fixture and
85
+ // clearing its body — `@adeu/core` does not export its test-utils). Tracks the
86
+ // path for cleanup.
87
+ async function buildDoc(paragraphs: string[]): Promise<string> {
88
+ const initialPath = resolve(
89
+ __dirname,
90
+ "../../../../shared/fixtures/initial.docx",
91
+ );
92
+ const doc = await DocumentObject.load(readFileSync(initialPath));
93
+ const body = doc.element;
94
+ while (body.firstChild) body.removeChild(body.firstChild);
95
+
96
+ for (const text of paragraphs) {
97
+ const xmlDoc = body.ownerDocument!;
98
+ const p = xmlDoc.createElement("w:p");
99
+ const r = xmlDoc.createElement("w:r");
100
+ const t = xmlDoc.createElement("w:t");
101
+ t.textContent = text;
102
+ if (/\s/.test(text)) t.setAttribute("xml:space", "preserve");
103
+ r.appendChild(t);
104
+ p.appendChild(r);
105
+ body.appendChild(p);
106
+ }
107
+
108
+ const outPath = join(
109
+ tmpdir(),
110
+ `adeu_schemagap_${Date.now()}_${tempPaths.length}.docx`,
111
+ );
112
+ writeFileSync(outPath, await doc.save());
113
+ tempPaths.push(outPath);
114
+ return outPath;
115
+ }
116
+
117
+ function tempOut(label: string): string {
118
+ const p = join(
119
+ tmpdir(),
120
+ `adeu_schemagap_out_${label}_${Date.now()}_${tempPaths.length}.docx`,
121
+ );
122
+ tempPaths.push(p);
123
+ return p;
124
+ }
125
+
126
+ beforeAll(async () => {
127
+ const serverPath = resolve(__dirname, "../dist/index.js");
128
+ if (!existsSync(serverPath)) {
129
+ throw new Error(
130
+ "MCP server not built. Run 'npm run build' before tests.",
131
+ );
132
+ }
133
+
134
+ serverProc = spawn("node", [serverPath]);
135
+ serverProc.stdout?.on("data", (data: Buffer) => {
136
+ stdoutBuffer += data.toString();
137
+ let idx: number;
138
+ while ((idx = stdoutBuffer.indexOf("\n")) !== -1) {
139
+ const line = stdoutBuffer.slice(0, idx).trim();
140
+ stdoutBuffer = stdoutBuffer.slice(idx + 1);
141
+ if (!line.startsWith("{")) continue;
142
+ try {
143
+ const msg = JSON.parse(line);
144
+ if (msg.id !== undefined && pending.has(msg.id)) {
145
+ const cb = pending.get(msg.id)!;
146
+ pending.delete(msg.id);
147
+ cb(msg);
148
+ }
149
+ } catch {
150
+ // ignore non-JSON / partial lines
151
+ }
152
+ }
153
+ });
154
+
155
+ // Proper MCP handshake, then snapshot the advertised tool list.
156
+ await rpc("initialize", {
157
+ protocolVersion: "2024-11-05",
158
+ capabilities: {},
159
+ clientInfo: { name: "schema-gap-test", version: "0.0.0" },
160
+ });
161
+ notify("notifications/initialized", {});
162
+
163
+ const list = await rpc("tools/list", {});
164
+ allTools = list.result.tools ?? [];
165
+
166
+ pdbFixture = await buildDoc([
167
+ "The Confidential Information shall remain protected.",
168
+ "Some unrelated clause about delivery schedules.",
169
+ "The Confidential Information shall not be disclosed.",
170
+ "Setup fee is $500 due on signing.",
171
+ ]);
172
+ diffOrig = await buildDoc(["The quick brown fox.", "Second clause stays."]);
173
+ diffMod = await buildDoc([
174
+ "The slow green turtle.",
175
+ "Second clause stays.",
176
+ ]);
177
+ finalizeInput = await buildDoc(["Some content to finalize."]);
178
+ });
179
+
180
+ afterAll(() => {
181
+ if (serverProc && !serverProc.killed) serverProc.kill();
182
+ for (const p of tempPaths) {
183
+ if (existsSync(p)) {
184
+ try {
185
+ unlinkSync(p);
186
+ } catch {
187
+ // best-effort cleanup
188
+ }
189
+ }
190
+ }
191
+ });
192
+
193
+ // ======================================================================
194
+ // process_document_batch — ADEU_TOOL_ISSUES #1: `changes` items are typed
195
+ // ======================================================================
196
+ describe("process_document_batch #1: `changes` publishes a typed item schema", () => {
197
+ it("exposes a `changes.items` schema enumerating all six change variants", () => {
198
+ const pdbTool = getTool("process_document_batch");
199
+ expect(
200
+ pdbTool,
201
+ "process_document_batch must be advertised",
202
+ ).toBeDefined();
203
+
204
+ const changesProp = pdbTool.inputSchema?.properties?.changes;
205
+ expect(changesProp?.type).toBe("array");
206
+
207
+ const items = changesProp.items;
208
+ expect(
209
+ items,
210
+ "changes.items must describe the change shape",
211
+ ).toBeTruthy();
212
+
213
+ // Every DocumentChange discriminator is now machine-discoverable.
214
+ const itemsJson = JSON.stringify(items);
215
+ for (const variant of CHANGE_VARIANTS) {
216
+ expect(
217
+ itemsJson,
218
+ `variant '${variant}' should be discoverable`,
219
+ ).toContain(variant);
220
+ }
221
+ });
222
+
223
+ it("exposes the per-variant fields (target_text / new_text / target_id / text), not just prose", () => {
224
+ const itemsJson = JSON.stringify(
225
+ getTool("process_document_batch").inputSchema.properties.changes.items,
226
+ );
227
+ for (const field of ["target_text", "new_text", "target_id", "text"]) {
228
+ expect(itemsJson).toContain(field);
229
+ }
230
+ });
231
+ });
232
+
233
+ // ======================================================================
234
+ // process_document_batch — ADEU_TOOL_ISSUES #10: match_mode / regex surfaced
235
+ // ======================================================================
236
+ describe("process_document_batch #10: match_mode / regex are documented and honored", () => {
237
+ it("documents `match_mode` and `regex` in both the schema and the description", () => {
238
+ const pdbTool = getTool("process_document_batch");
239
+ const schemaJson = JSON.stringify(pdbTool.inputSchema).toLowerCase();
240
+ const description: string = (pdbTool.description ?? "").toLowerCase();
241
+
242
+ // Discoverable from the schema (the typed change item)...
243
+ expect(schemaJson).toContain("match_mode");
244
+ expect(schemaJson).toContain("regex");
245
+ // ...and called out in the prose guidance too.
246
+ expect(description).toContain("match_mode");
247
+ expect(description).toContain("regex");
248
+ });
249
+
250
+ it("honors match_mode:'all' at the live MCP boundary — edits every occurrence (2)", async () => {
251
+ const res = await rpc("tools/call", {
252
+ name: "process_document_batch",
253
+ arguments: {
254
+ original_docx_path: pdbFixture,
255
+ author_name: "Schema Gap Test",
256
+ changes: [
257
+ {
258
+ type: "modify",
259
+ target_text: "The Confidential Information",
260
+ new_text: "The Proprietary Data",
261
+ match_mode: "all",
262
+ },
263
+ ],
264
+ dry_run: true,
265
+ },
266
+ });
267
+
268
+ const text: string = res.result.content[0].text;
269
+ expect(res.result.isError).toBeFalsy();
270
+ expect(text).toMatch(/2 occurrences/);
271
+ expect(text).toContain("`all`");
272
+ });
273
+
274
+ it("honors regex:true (with a capture group) at the live MCP boundary", async () => {
275
+ const res = await rpc("tools/call", {
276
+ name: "process_document_batch",
277
+ arguments: {
278
+ original_docx_path: pdbFixture,
279
+ author_name: "Schema Gap Test",
280
+ changes: [
281
+ {
282
+ type: "modify",
283
+ // A regex, not a literal — `target_text` never appears verbatim in
284
+ // the doc, so a successful edit proves regex mode was honored.
285
+ target_text: "\\$(\\d+)",
286
+ new_text: "USD $1",
287
+ regex: true,
288
+ },
289
+ ],
290
+ dry_run: true,
291
+ },
292
+ });
293
+
294
+ const text: string = res.result.content[0].text;
295
+ expect(res.result.isError).toBeFalsy();
296
+ expect(text).toContain("USD 500"); // capture group $1 substituted
297
+ });
298
+ });
299
+
300
+ // ======================================================================
301
+ // read_docx — build stamp appears exactly once
302
+ // ======================================================================
303
+ describe("read_docx: build stamp appears exactly once", () => {
304
+ it("stamps the build tag once — UI tools are no longer double-wrapped", () => {
305
+ const readDocx = getTool("read_docx");
306
+ expect(readDocx, "read_docx must be advertised").toBeDefined();
307
+
308
+ const stamps = readDocx.description.match(BUILD_STAMP_RE) ?? [];
309
+ expect(stamps.length).toBe(1);
310
+
311
+ // Parity with a plain (non-UI) tool, which was always stamped once.
312
+ const pdbStamps =
313
+ getTool("process_document_batch").description.match(BUILD_STAMP_RE) ??
314
+ [];
315
+ expect(pdbStamps.length).toBe(1);
316
+ });
317
+ });
318
+
319
+ // ======================================================================
320
+ // diff_docx_files — described as the Word Patch format it actually emits
321
+ // ======================================================================
322
+ describe("diff_docx_files: described as the Word Patch format it emits", () => {
323
+ it("describes its output as a Word Patch, not a 'unified diff'", () => {
324
+ const desc = getTool("diff_docx_files").description.toLowerCase();
325
+ expect(desc).not.toContain("unified diff");
326
+ expect(desc).toContain("word patch");
327
+ });
328
+
329
+ it("emits the custom `@@ Word Patch @@` format at runtime (matching its description)", async () => {
330
+ const res = await rpc("tools/call", {
331
+ name: "diff_docx_files",
332
+ arguments: {
333
+ original_path: diffOrig,
334
+ modified_path: diffMod,
335
+ compare_clean: true,
336
+ },
337
+ });
338
+
339
+ const text: string = res.result.content[0].text;
340
+ expect(res.result.isError).toBeFalsy();
341
+ expect(text).toContain("@@ Word Patch @@");
342
+ // It is NOT a standard line-based unified diff (no `@@ -l,s +l,s @@` header).
343
+ expect(text).not.toMatch(/@@ -\d+(,\d+)? \+\d+(,\d+)? @@/);
344
+ });
345
+ });
346
+
347
+ // ======================================================================
348
+ // finalize_document — encrypt fallback is disclosed
349
+ // ======================================================================
350
+ describe("finalize_document: discloses the encrypt → read-only fallback", () => {
351
+ it("advertises `encrypt` honestly — dropped from the enum, or its fallback disclosed", () => {
352
+ const finalizeTool = getTool("finalize_document");
353
+ const enumVals: string[] =
354
+ finalizeTool.inputSchema.properties.protection_mode.enum;
355
+ const desc: string = (finalizeTool.description ?? "").toLowerCase();
356
+
357
+ const honest =
358
+ !enumVals.includes("encrypt") ||
359
+ (desc.includes("encrypt") &&
360
+ /read-only|falls back|fallback|unsupported/.test(desc));
361
+ expect(
362
+ honest,
363
+ "encrypt must be dropped from the Node enum or its read-only fallback disclosed",
364
+ ).toBe(true);
365
+ });
366
+
367
+ it("downgrades encrypt to a read-only lock at runtime, with a warning (matching the disclosure)", async () => {
368
+ const res = await rpc("tools/call", {
369
+ name: "finalize_document",
370
+ arguments: {
371
+ file_path: finalizeInput,
372
+ output_path: tempOut("finalize_encrypt"),
373
+ protection_mode: "encrypt",
374
+ },
375
+ });
376
+
377
+ const text: string = res.result.content[0].text;
378
+ expect(res.result.isError).toBeFalsy();
379
+ expect(text).toContain("Encryption mode");
380
+ expect(text.toLowerCase()).toContain("unsupported");
381
+ expect(text).toMatch(/read-only/i);
382
+ });
383
+ });
384
+ });
@@ -214,12 +214,11 @@ export function build_search_response(
214
214
  search_query: string,
215
215
  search_regex: boolean,
216
216
  search_case_sensitive: boolean,
217
- page: number | string,
217
+ page: number | string | undefined,
218
218
  file_path: string,
219
219
  ): ToolResult {
220
220
  const [body] = split_structural_appendix(text);
221
- const escapeRegExp = (s: string) =>
222
- s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
221
+ const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
223
222
  const flags = search_case_sensitive ? "g" : "gi";
224
223
  const patternStr = search_regex ? search_query : escapeRegExp(search_query);
225
224
 
@@ -230,54 +229,123 @@ export function build_search_response(
230
229
  throw new Error(`Invalid regex pattern: ${e.message}`);
231
230
  }
232
231
 
233
- const matches = Array.from(body.matchAll(regex));
232
+ const allMatches = Array.from(body.matchAll(regex));
234
233
 
234
+ // Compute document pagination once — needed for both annotation and filtering.
235
+ const pag_res = paginate(body, "");
236
+ const page_offsets = pag_res.body_page_offsets;
237
+ const total_doc_pages = pag_res.total_pages;
238
+
239
+ // Resolve `page` parameter to either "all" or a concrete document-page number.
240
+ // Undefined → "all" (search across the whole document).
241
+ // "all" (case-insensitive) → "all".
242
+ // A positive integer N → filter matches to document page N.
243
+ // Anything else → hard error.
244
+ let filter_doc_page: number | null = null; // null means "all"
245
+ if (page !== undefined && page !== null) {
246
+ const pageStr = String(page).toLowerCase();
247
+ if (pageStr !== "all") {
248
+ const parsed = parseInt(pageStr, 10);
249
+ if (isNaN(parsed) || parsed < 1) {
250
+ throw new Error(
251
+ `Invalid page value: \`${page}\`. Pass a positive integer to restrict the search to that document page, omit \`page\` to search all pages, or pass \`page='all'\` explicitly.`,
252
+ );
253
+ }
254
+ if (parsed > total_doc_pages) {
255
+ throw new Error(
256
+ `Document page ${parsed} is out of range — the document has ${total_doc_pages} page(s). In search mode, \`page\` filters matches to a specific document page; omit it or pass \`page='all'\` to search the whole document.`,
257
+ );
258
+ }
259
+ filter_doc_page = parsed;
260
+ }
261
+ }
262
+
263
+ // Helper: which document page does an offset live on?
264
+ const pageOfOffset = (offset: number): number => {
265
+ let p = 1;
266
+ for (let j = 0; j < page_offsets.length; j++) {
267
+ if (offset >= page_offsets[j]) p = j + 1;
268
+ else break;
269
+ }
270
+ return p;
271
+ };
272
+
273
+ // Apply the filter (if any), but keep a record of all pages that had hits
274
+ // so we can show a useful summary even when filtered.
275
+ const pagesWithHits = new Set<number>();
276
+ for (const m of allMatches) {
277
+ pagesWithHits.add(pageOfOffset(m.index!));
278
+ }
279
+
280
+ const matches =
281
+ filter_doc_page === null
282
+ ? allMatches
283
+ : allMatches.filter((m) => pageOfOffset(m.index!) === filter_doc_page);
284
+
285
+ // --- Empty result ---
235
286
  if (matches.length === 0) {
236
- const ui_markdown = `> **Search Results** — No matches found for query \`${search_query}\` in \`${basename(file_path)}\`.\n\nVerify your search spelling, or try setting \`search_case_sensitive\` to false or enabling \`search_regex\` if you used pattern wildcards.`;
237
- const llm_content = `> **File Path:** \`${resolve(file_path)}\`\n\n${ui_markdown}`;
287
+ let body_msg: string;
288
+ if (filter_doc_page !== null) {
289
+ if (allMatches.length === 0) {
290
+ body_msg = `> **Search Results** — No matches found for query \`${search_query}\` in \`${basename(file_path)}\`.\n\nVerify your search spelling, or try setting \`search_case_sensitive\` to false or enabling \`search_regex\` if you used pattern wildcards.`;
291
+ } else {
292
+ const hitPages = Array.from(pagesWithHits).sort((a, b) => a - b);
293
+ body_msg = `> **Search Results** — No matches for \`${search_query}\` on document page ${filter_doc_page}.\n\nThe query DOES appear elsewhere in the document (${allMatches.length} match${allMatches.length !== 1 ? "es" : ""} on page${hitPages.length !== 1 ? "s" : ""} ${hitPages.join(", ")}). Omit \`page\` or pass \`page='all'\` to see them.`;
294
+ }
295
+ } else {
296
+ body_msg = `> **Search Results** — No matches found for query \`${search_query}\` in \`${basename(file_path)}\`.\n\nVerify your search spelling, or try setting \`search_case_sensitive\` to false or enabling \`search_regex\` if you used pattern wildcards.`;
297
+ }
298
+ const llm_content = `> **File Path:** \`${resolve(file_path)}\`\n\n${body_msg}`;
238
299
  return {
239
300
  content: [{ type: "text", text: llm_content }],
240
301
  structuredContent: {
241
- markdown: ui_markdown,
302
+ markdown: body_msg,
242
303
  title: `Search: ${basename(file_path)}`,
243
304
  file_path: resolve(file_path),
244
305
  },
245
306
  };
246
307
  }
247
308
 
248
- const pag_res = paginate(body, "");
249
- const page_offsets = pag_res.body_page_offsets;
250
- const total_matches = matches.length;
251
- const total_pages = Math.ceil(total_matches / 10);
252
-
253
- let start_idx = 0;
254
- let end_idx = total_matches;
255
- let page_text = "all";
256
-
257
- const pageStr = String(page).toLowerCase();
258
- if (pageStr !== "all") {
259
- const page_num = parseInt(pageStr, 10);
260
- if (isNaN(page_num) || page_num < 1 || page_num > total_pages) {
261
- throw new Error(`Page ${page} out of range (search has ${total_pages} pages).`);
262
- }
263
- start_idx = (page_num - 1) * 10;
264
- end_idx = Math.min(start_idx + 10, total_matches);
265
- page_text = `${page_num} of ${total_pages}`;
266
- }
267
-
268
- const page_matches = matches.slice(start_idx, end_idx);
309
+ // --- Build the response ---
310
+ const ui_parts: string[] = [];
269
311
 
270
- const ui_parts: string[] = [
271
- `> **Search Results** — Found ${total_matches} matches for query \`${search_query}\` in \`${basename(file_path)}\`.`,
272
- ];
273
-
274
- if (total_pages > 1 && pageStr !== "all") {
275
- const nextPage = parseInt(pageStr, 10) + 1;
276
- ui_parts.push(`> Showing page ${page_text} (matches ${start_idx + 1}-${end_idx}). To see more matches, call \`read_docx\` with \`search_query='${search_query}'\`, \`search_regex=${search_regex ? "true" : "false"}\`, and \`page=${nextPage}\`.`);
312
+ if (filter_doc_page !== null) {
313
+ ui_parts.push(
314
+ `> **Search Results** — Found ${matches.length} match${matches.length !== 1 ? "es" : ""} for \`${search_query}\` on document page ${filter_doc_page} of ${total_doc_pages} in \`${basename(file_path)}\`.`,
315
+ );
316
+ const otherPages = Array.from(pagesWithHits)
317
+ .filter((p) => p !== filter_doc_page)
318
+ .sort((a, b) => a - b);
319
+ if (otherPages.length > 0) {
320
+ ui_parts.push(
321
+ `> Additional matches exist on page${otherPages.length !== 1 ? "s" : ""} ${otherPages.join(", ")} — omit \`page\` or pass \`page='all'\` to see them.`,
322
+ );
323
+ }
324
+ } else {
325
+ ui_parts.push(
326
+ `> **Search Results** — Found ${matches.length} match${matches.length !== 1 ? "es" : ""} for \`${search_query}\` in \`${basename(file_path)}\`.`,
327
+ );
328
+ if (total_doc_pages > 1) {
329
+ // Build a per-page hit distribution: "p1: 3, p3: 1, p7: 12"
330
+ const counts = new Map<number, number>();
331
+ for (const m of allMatches) {
332
+ const p = pageOfOffset(m.index!);
333
+ counts.set(p, (counts.get(p) || 0) + 1);
334
+ }
335
+ const distribution = Array.from(counts.entries())
336
+ .sort((a, b) => a[0] - b[0])
337
+ .map(([p, n]) => `p${p}: ${n}`)
338
+ .join(", ");
339
+ ui_parts.push(
340
+ `> Distribution across ${total_doc_pages} document pages — ${distribution}. Pass \`page=N\` to filter to a specific document page.`,
341
+ );
342
+ }
277
343
  }
278
344
 
345
+ // Per-match occurrence counts use the FULL match set, not the filtered one —
346
+ // this gives the LLM accurate global counts even when filtering.
279
347
  const occurrences_map: Record<string, number> = {};
280
- for (const m of matches) {
348
+ for (const m of allMatches) {
281
349
  const matched_str = m[0];
282
350
  occurrences_map[matched_str] = (occurrences_map[matched_str] || 0) + 1;
283
351
  }
@@ -294,7 +362,10 @@ export function build_search_response(
294
362
  if (m) {
295
363
  const level = m[1].length;
296
364
  if (level < current_level) {
297
- let cleanHeading = m[2].replace(/\*\*|__|[*_]/g, "").replace(/\{#[^}]+\}/g, "").trim();
365
+ let cleanHeading = m[2]
366
+ .replace(/\*\*|__|[*_]/g, "")
367
+ .replace(/\{#[^}]+\}/g, "")
368
+ .trim();
298
369
  if (cleanHeading.length > 80) {
299
370
  cleanHeading = cleanHeading.substring(0, 80) + "...";
300
371
  }
@@ -307,24 +378,19 @@ export function build_search_response(
307
378
  return path.join(" > ");
308
379
  }
309
380
 
310
- let i = start_idx + 1;
311
- for (const m of page_matches) {
381
+ let i = 1;
382
+ for (const m of matches) {
312
383
  const matched_str = m[0];
313
384
  const m_start = m.index!;
314
385
  const m_end = m_start + matched_str.length;
315
-
316
- let p_num = 1;
317
- for (let j = 0; j < page_offsets.length; j++) {
318
- if (m_start >= page_offsets[j]) {
319
- p_num = j + 1;
320
- } else {
321
- break;
322
- }
323
- }
386
+ const p_num = pageOfOffset(m_start);
324
387
 
325
388
  const snippet_start = Math.max(0, m_start - 100);
326
389
  const snippet_end = Math.min(body.length, m_end + 100);
327
- const snippet = body.substring(snippet_start, m_start) + `**${matched_str}**` + body.substring(m_end, snippet_end);
390
+ const snippet =
391
+ body.substring(snippet_start, m_start) +
392
+ `**${matched_str}**` +
393
+ body.substring(m_end, snippet_end);
328
394
 
329
395
  const snippet_lines = snippet
330
396
  .split("\n")
@@ -342,7 +408,9 @@ export function build_search_response(
342
408
 
343
409
  const count = occurrences_map[matched_str];
344
410
  ui_parts.push(snippet_lines);
345
- ui_parts.push(`*Occurrences:* This exact phrasing appears ${count} time${count !== 1 ? "s" : ""} in the document.`);
411
+ ui_parts.push(
412
+ `*Occurrences:* This exact phrasing appears ${count} time${count !== 1 ? "s" : ""} in the document.`,
413
+ );
346
414
 
347
415
  i++;
348
416
  }