@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.
- package/dist/index.js +156 -57
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +293 -180
- package/src/mcp.bugs.test.ts +23 -0
- package/src/mcp.schema-gaps.test.ts +384 -0
- package/src/response-builders.ts +118 -50
- package/src/response_builders.test.ts +266 -0
package/src/mcp.bugs.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/response-builders.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
237
|
-
|
|
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:
|
|
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
|
-
|
|
249
|
-
const
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
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]
|
|
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 =
|
|
311
|
-
for (const m of
|
|
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 =
|
|
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(
|
|
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
|
}
|