@adeu/mcp-server 1.13.0 → 1.15.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 +179 -24
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/formatter.qa.test.ts +71 -0
- package/src/formatter.test.ts +12 -6
- package/src/index.ts +76 -16
- package/src/mcp.bugs.test.ts +28 -0
- package/src/response-builders.ts +151 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { formatBatchResult } from "./index.js";
|
|
3
|
+
|
|
4
|
+
describe("QA Report V2: Formatter Parity", () => {
|
|
5
|
+
it("F3 & F4: Formats match_mode, occurrences_modified, heading_path, and pages correctly", () => {
|
|
6
|
+
// Mock the stats object that the engine produces when `all` mode is successful
|
|
7
|
+
const stats = {
|
|
8
|
+
actions_applied: 0,
|
|
9
|
+
actions_skipped: 0,
|
|
10
|
+
edits_applied: 1,
|
|
11
|
+
edits_skipped: 0,
|
|
12
|
+
edits: [
|
|
13
|
+
{
|
|
14
|
+
status: "applied",
|
|
15
|
+
target_text: "the Board of Directors",
|
|
16
|
+
new_text: "the Governing Body",
|
|
17
|
+
warning: null,
|
|
18
|
+
error: null,
|
|
19
|
+
critic_markup: "the {--Board of Directors--}{++Governing Body++}",
|
|
20
|
+
clean_text: "the Governing Body",
|
|
21
|
+
pages: [3, 12],
|
|
22
|
+
heading_path: "6. Term > 6.1",
|
|
23
|
+
occurrences_modified: 33,
|
|
24
|
+
match_mode: "all"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
skipped_details: []
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const res = formatBatchResult(stats, "dummy.docx", false);
|
|
31
|
+
|
|
32
|
+
// Verify the new §5.4.2 visual format
|
|
33
|
+
expect(res).toContain("### Edit 1 ✅ [applied] (p3, p12)");
|
|
34
|
+
expect(res).toContain("**Path:** `6. Term > 6.1`");
|
|
35
|
+
expect(res).toContain("**Mode:** `all` (33 occurrences modified)");
|
|
36
|
+
expect(res).toContain("*Preview (CriticMarkup):*");
|
|
37
|
+
expect(res).toContain("> the {--Board of Directors--}{++Governing Body++}");
|
|
38
|
+
expect(res).toContain("*Preview (Clean):*");
|
|
39
|
+
expect(res).toContain("> the Governing Body");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("R1: Formats dry-run with full enrichment fields identical to real writes", () => {
|
|
43
|
+
const stats = {
|
|
44
|
+
actions_applied: 0,
|
|
45
|
+
actions_skipped: 0,
|
|
46
|
+
edits_applied: 1,
|
|
47
|
+
edits_skipped: 0,
|
|
48
|
+
edits: [
|
|
49
|
+
{
|
|
50
|
+
status: "applied",
|
|
51
|
+
target_text: "Target",
|
|
52
|
+
new_text: "Replaced",
|
|
53
|
+
warning: null,
|
|
54
|
+
error: null,
|
|
55
|
+
critic_markup: "{--Target--}{++Replaced++}",
|
|
56
|
+
clean_text: "Replaced",
|
|
57
|
+
pages: [1],
|
|
58
|
+
heading_path: "1. Intro",
|
|
59
|
+
occurrences_modified: 1,
|
|
60
|
+
match_mode: "all"
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
skipped_details: []
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const res = formatBatchResult(stats, "dummy.docx", true);
|
|
67
|
+
expect(res).toContain("**Path:** `1. Intro`");
|
|
68
|
+
expect(res).toContain("**Mode:** `all` (1 occurrence modified)");
|
|
69
|
+
expect(res).toContain("(p1)");
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/formatter.test.ts
CHANGED
|
@@ -16,7 +16,11 @@ describe("MCP Server Tool Output Formatter", () => {
|
|
|
16
16
|
warning: "Warning: target_text contains punctuation.",
|
|
17
17
|
error: null,
|
|
18
18
|
critic_markup: "The {--quick brown fox--}{++fast red fox++} jumps over",
|
|
19
|
-
clean_text: "The fast red fox jumps over"
|
|
19
|
+
clean_text: "The fast red fox jumps over",
|
|
20
|
+
match_mode: "strict",
|
|
21
|
+
occurrences_modified: 1,
|
|
22
|
+
pages: [1],
|
|
23
|
+
heading_path: "1. Intro"
|
|
20
24
|
}
|
|
21
25
|
],
|
|
22
26
|
skipped_details: []
|
|
@@ -29,9 +33,11 @@ describe("MCP Server Tool Output Formatter", () => {
|
|
|
29
33
|
expect(res).toContain("Actions: 0 applied");
|
|
30
34
|
expect(res).toContain("Edits: 1 applied");
|
|
31
35
|
expect(res).toContain("Detailed Edit Reports:");
|
|
32
|
-
expect(res).toContain("✅ [applied]");
|
|
33
|
-
expect(res).toContain("
|
|
34
|
-
expect(res).toContain("
|
|
36
|
+
expect(res).toContain("### Edit 1 ✅ [applied] (p1)");
|
|
37
|
+
expect(res).toContain("**Path:** `1. Intro`");
|
|
38
|
+
expect(res).toContain("**Mode:** `strict` (1 occurrence modified)");
|
|
39
|
+
expect(res).toContain("*Warning:* Warning: target_text contains punctuation.");
|
|
40
|
+
expect(res).toContain("*Preview (CriticMarkup):*\n> The {--quick brown fox--}{++fast red fox++} jumps over");
|
|
35
41
|
});
|
|
36
42
|
|
|
37
43
|
it("formats a failed batch result correctly", () => {
|
|
@@ -57,8 +63,8 @@ describe("MCP Server Tool Output Formatter", () => {
|
|
|
57
63
|
const res = formatBatchResult(stats, "dummy_processed.docx", false);
|
|
58
64
|
|
|
59
65
|
expect(res).toContain("Batch complete. Saved to: dummy_processed.docx");
|
|
60
|
-
expect(res).toContain("❌ [failed]");
|
|
61
|
-
expect(res).toContain("Error
|
|
66
|
+
expect(res).toContain("### Edit 1 ❌ [failed]");
|
|
67
|
+
expect(res).toContain("*Error:* Target text not found in document");
|
|
62
68
|
expect(res).toContain("Skipped Details:\n- Failed to apply edit targeting: 'NON_EXISTENT...'");
|
|
63
69
|
});
|
|
64
70
|
});
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
build_paginated_response,
|
|
25
25
|
build_outline_response,
|
|
26
26
|
build_appendix_response,
|
|
27
|
+
build_search_response,
|
|
27
28
|
} from "./response-builders.js";
|
|
28
29
|
|
|
29
30
|
import { login_to_adeu_cloud, logout_of_adeu_cloud } from "./tools/auth.js";
|
|
@@ -224,9 +225,9 @@ registerAppTool(
|
|
|
224
225
|
"'full' returns body content. 'outline' returns a structural heading map. 'appendix' returns defined terms.",
|
|
225
226
|
),
|
|
226
227
|
page: z
|
|
227
|
-
.number()
|
|
228
|
+
.union([z.number(), z.literal("all")])
|
|
228
229
|
.default(1)
|
|
229
|
-
.describe("Page number (1-indexed) for mode='full'
|
|
230
|
+
.describe("Page number (1-indexed) for mode='full', or 'all' to return unpaginated."),
|
|
230
231
|
outline_max_level: z
|
|
231
232
|
.number()
|
|
232
233
|
.default(2)
|
|
@@ -235,6 +236,18 @@ registerAppTool(
|
|
|
235
236
|
.boolean()
|
|
236
237
|
.default(false)
|
|
237
238
|
.describe("For mode='outline' only: includes metadata."),
|
|
239
|
+
search_query: z
|
|
240
|
+
.string()
|
|
241
|
+
.optional()
|
|
242
|
+
.describe("The substring or regex pattern to search for. When provided, filters results to matching paragraphs."),
|
|
243
|
+
search_regex: z
|
|
244
|
+
.boolean()
|
|
245
|
+
.default(false)
|
|
246
|
+
.describe("Set to true to interpret search_query as a regular expression."),
|
|
247
|
+
search_case_sensitive: z
|
|
248
|
+
.boolean()
|
|
249
|
+
.default(true)
|
|
250
|
+
.describe("Set to false to perform case-insensitive matching."),
|
|
238
251
|
}),
|
|
239
252
|
_meta: { ui: { resourceUri: MARKDOWN_UI_URI } },
|
|
240
253
|
},
|
|
@@ -245,6 +258,9 @@ registerAppTool(
|
|
|
245
258
|
page,
|
|
246
259
|
outline_max_level,
|
|
247
260
|
outline_verbose,
|
|
261
|
+
search_query,
|
|
262
|
+
search_regex,
|
|
263
|
+
search_case_sensitive,
|
|
248
264
|
}) => {
|
|
249
265
|
try {
|
|
250
266
|
const buf = readFileBytesOrThrow(file_path);
|
|
@@ -267,11 +283,15 @@ registerAppTool(
|
|
|
267
283
|
}
|
|
268
284
|
|
|
269
285
|
const text = await extractTextFromBuffer(buf, clean_view);
|
|
286
|
+
if (search_query !== undefined && search_query !== null) {
|
|
287
|
+
const res = build_search_response(text, search_query, search_regex, search_case_sensitive, page, file_path);
|
|
288
|
+
return res as any;
|
|
289
|
+
}
|
|
270
290
|
if (mode === "appendix") {
|
|
271
|
-
const res = build_appendix_response(text, page, file_path);
|
|
291
|
+
const res = build_appendix_response(text, typeof page === "number" ? page : parseInt(page, 10) || 1, file_path);
|
|
272
292
|
return res as any;
|
|
273
293
|
}
|
|
274
|
-
const res = build_paginated_response(text, page, file_path);
|
|
294
|
+
const res = build_paginated_response(text, typeof page === "number" ? page : parseInt(page, 10) || 1, file_path);
|
|
275
295
|
return res as any;
|
|
276
296
|
} catch (e: any) {
|
|
277
297
|
return {
|
|
@@ -334,6 +354,28 @@ server.registerTool(
|
|
|
334
354
|
content: [{ type: "text", text: "Error: No changes provided." }],
|
|
335
355
|
};
|
|
336
356
|
|
|
357
|
+
// Defensive sanitization at the MCP boundary: some LLM clients
|
|
358
|
+
// "double-serialize" nested arrays, delivering each element of `changes`
|
|
359
|
+
// as a JSON string instead of an object. The core engine also guards
|
|
360
|
+
// against this, but we normalize here too so the tool layer never hands
|
|
361
|
+
// raw string primitives downstream regardless of the engine version
|
|
362
|
+
// bundled. Genuine objects and unparseable strings pass through
|
|
363
|
+
// untouched so validation surfaces a clear error rather than crashing.
|
|
364
|
+
const sanitizedChanges = changes.map((item: any) => {
|
|
365
|
+
if (typeof item === "string") {
|
|
366
|
+
try {
|
|
367
|
+
const parsed = JSON.parse(item);
|
|
368
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
369
|
+
return parsed;
|
|
370
|
+
}
|
|
371
|
+
return item;
|
|
372
|
+
} catch {
|
|
373
|
+
return item;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return item;
|
|
377
|
+
});
|
|
378
|
+
|
|
337
379
|
let outPath = output_path;
|
|
338
380
|
if (!outPath) {
|
|
339
381
|
const ext = extname(original_docx_path);
|
|
@@ -348,7 +390,7 @@ server.registerTool(
|
|
|
348
390
|
|
|
349
391
|
let stats;
|
|
350
392
|
try {
|
|
351
|
-
stats = engine.process_batch(
|
|
393
|
+
stats = engine.process_batch(sanitizedChanges, dry_run);
|
|
352
394
|
} catch (e: any) {
|
|
353
395
|
if (e instanceof BatchValidationError) {
|
|
354
396
|
return {
|
|
@@ -706,8 +748,11 @@ export function formatBatchResult(
|
|
|
706
748
|
} else {
|
|
707
749
|
res = `Batch complete. Saved to: ${outPath}\n`;
|
|
708
750
|
}
|
|
751
|
+
const total_occurrences = stats.edits ? stats.edits.reduce((acc: number, e: any) => acc + (e.status === "applied" ? (e.occurrences_modified || 1) : 0), 0) : 0;
|
|
752
|
+
const occ_text = total_occurrences > stats.edits_applied ? ` (${total_occurrences} occurrences)` : "";
|
|
753
|
+
|
|
709
754
|
res += `Actions: ${stats.actions_applied} applied, ${stats.actions_skipped} skipped.\n`;
|
|
710
|
-
res += `Edits: ${stats.edits_applied} applied, ${stats.edits_skipped} skipped.\n`;
|
|
755
|
+
res += `Edits: ${stats.edits_applied} applied${occ_text}, ${stats.edits_skipped} skipped.\n`;
|
|
711
756
|
|
|
712
757
|
if (stats.edits && stats.edits.length > 0) {
|
|
713
758
|
res += "\nDetailed Edit Reports:\n";
|
|
@@ -715,28 +760,43 @@ export function formatBatchResult(
|
|
|
715
760
|
const report = stats.edits[i];
|
|
716
761
|
const status_indicator =
|
|
717
762
|
report.status === "applied" ? "✅ [applied]" : "❌ [failed]";
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
763
|
+
|
|
764
|
+
const pagesStr = report.pages && report.pages.length > 0
|
|
765
|
+
? ` (p${report.pages.join(", p")})`
|
|
766
|
+
: "";
|
|
767
|
+
|
|
768
|
+
res += `### Edit ${i + 1} ${status_indicator}${pagesStr}\n`;
|
|
769
|
+
|
|
770
|
+
if (report.heading_path) {
|
|
771
|
+
res += `**Path:** \`${report.heading_path}\`\n`;
|
|
723
772
|
}
|
|
773
|
+
|
|
774
|
+
if (report.match_mode) {
|
|
775
|
+
const occ = report.occurrences_modified || (report.status === "applied" ? 1 : 0);
|
|
776
|
+
res += `**Mode:** \`${report.match_mode}\` (${occ} occurrence${occ !== 1 ? 's' : ''} modified)\n`;
|
|
777
|
+
}
|
|
778
|
+
|
|
724
779
|
if (report.error) {
|
|
725
|
-
res +=
|
|
780
|
+
res += `*Error:* ${report.error}\n`;
|
|
781
|
+
}
|
|
782
|
+
if (report.warning) {
|
|
783
|
+
res += `*Warning:* ${report.warning}\n`;
|
|
726
784
|
}
|
|
785
|
+
|
|
727
786
|
if (report.critic_markup) {
|
|
728
|
-
res +=
|
|
787
|
+
res += `*Preview (CriticMarkup):*\n> ${report.critic_markup.split('\\n').join('\\n> ')}\n`;
|
|
729
788
|
}
|
|
730
789
|
if (report.clean_text) {
|
|
731
|
-
res +=
|
|
790
|
+
res += `*Preview (Clean):*\n> ${report.clean_text.split('\\n').join('\\n> ')}\n`;
|
|
732
791
|
}
|
|
792
|
+
res += "\n";
|
|
733
793
|
}
|
|
734
794
|
}
|
|
735
795
|
|
|
736
796
|
if (stats.skipped_details && stats.skipped_details.length > 0) {
|
|
737
|
-
res +=
|
|
797
|
+
res += `Skipped Details:\n${stats.skipped_details.join("\n")}`;
|
|
738
798
|
}
|
|
739
|
-
return res;
|
|
799
|
+
return res.trim();
|
|
740
800
|
}
|
|
741
801
|
|
|
742
802
|
// --- Startup ---
|
package/src/mcp.bugs.test.ts
CHANGED
|
@@ -161,4 +161,32 @@ describe("Resolved Bugs MCP Server Verification", () => {
|
|
|
161
161
|
expect(res.result.content[0].text).toContain("uv tool install adeu");
|
|
162
162
|
expect(res.result.content[0].text).not.toContain("ENOENT"); // Raw node error must not leak
|
|
163
163
|
});
|
|
164
|
+
|
|
165
|
+
it("Double-Serialization: process_document_batch fails with TypeError when changes array contains double-serialized JSON strings", async () => {
|
|
166
|
+
const res = await sendRpc(
|
|
167
|
+
"tools/call",
|
|
168
|
+
{
|
|
169
|
+
name: "process_document_batch",
|
|
170
|
+
arguments: {
|
|
171
|
+
original_docx_path: cleanDocPath,
|
|
172
|
+
author_name: "Agent",
|
|
173
|
+
changes: [
|
|
174
|
+
JSON.stringify({
|
|
175
|
+
type: "modify",
|
|
176
|
+
target_text: "document",
|
|
177
|
+
new_text: "clean document",
|
|
178
|
+
}),
|
|
179
|
+
],
|
|
180
|
+
dry_run: true,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
105,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// On unpatched code, the tool catches the TypeError and returns it inside a standard MCP error response, causing this test to fail.
|
|
187
|
+
// On patched code, the tool successfully parses and applies the double-serialized JSON strings, returning a successful response.
|
|
188
|
+
expect(res.result.isError).toBeUndefined();
|
|
189
|
+
expect(res.result.content[0].text).toContain("Dry-run simulation complete.");
|
|
190
|
+
});
|
|
164
191
|
});
|
|
192
|
+
|
package/src/response-builders.ts
CHANGED
|
@@ -208,3 +208,154 @@ export function build_appendix_response(
|
|
|
208
208
|
},
|
|
209
209
|
};
|
|
210
210
|
}
|
|
211
|
+
|
|
212
|
+
export function build_search_response(
|
|
213
|
+
text: string,
|
|
214
|
+
search_query: string,
|
|
215
|
+
search_regex: boolean,
|
|
216
|
+
search_case_sensitive: boolean,
|
|
217
|
+
page: number | string,
|
|
218
|
+
file_path: string,
|
|
219
|
+
): ToolResult {
|
|
220
|
+
const [body] = split_structural_appendix(text);
|
|
221
|
+
const escapeRegExp = (s: string) =>
|
|
222
|
+
s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
223
|
+
const flags = search_case_sensitive ? "g" : "gi";
|
|
224
|
+
const patternStr = search_regex ? search_query : escapeRegExp(search_query);
|
|
225
|
+
|
|
226
|
+
let regex: RegExp;
|
|
227
|
+
try {
|
|
228
|
+
regex = new RegExp(patternStr, flags);
|
|
229
|
+
} catch (e: any) {
|
|
230
|
+
throw new Error(`Invalid regex pattern: ${e.message}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const matches = Array.from(body.matchAll(regex));
|
|
234
|
+
|
|
235
|
+
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}`;
|
|
238
|
+
return {
|
|
239
|
+
content: [{ type: "text", text: llm_content }],
|
|
240
|
+
structuredContent: {
|
|
241
|
+
markdown: ui_markdown,
|
|
242
|
+
title: `Search: ${basename(file_path)}`,
|
|
243
|
+
file_path: resolve(file_path),
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
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);
|
|
269
|
+
|
|
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}\`.`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const occurrences_map: Record<string, number> = {};
|
|
280
|
+
for (const m of matches) {
|
|
281
|
+
const matched_str = m[0];
|
|
282
|
+
occurrences_map[matched_str] = (occurrences_map[matched_str] || 0) + 1;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function get_heading(idx: number, txt: string): string {
|
|
286
|
+
const txtBefore = txt.substring(0, idx);
|
|
287
|
+
const lines = txtBefore.split("\n");
|
|
288
|
+
const path: string[] = [];
|
|
289
|
+
let current_level = 999;
|
|
290
|
+
|
|
291
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
292
|
+
const line = lines[i];
|
|
293
|
+
const m = line.match(/^(#{1,6})\s+(.*)/);
|
|
294
|
+
if (m) {
|
|
295
|
+
const level = m[1].length;
|
|
296
|
+
if (level < current_level) {
|
|
297
|
+
let cleanHeading = m[2].replace(/\*\*|__|[*_]/g, "").replace(/\{#[^}]+\}/g, "").trim();
|
|
298
|
+
if (cleanHeading.length > 80) {
|
|
299
|
+
cleanHeading = cleanHeading.substring(0, 80) + "...";
|
|
300
|
+
}
|
|
301
|
+
path.unshift(cleanHeading);
|
|
302
|
+
current_level = level;
|
|
303
|
+
if (level === 1) break;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return path.join(" > ");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let i = start_idx + 1;
|
|
311
|
+
for (const m of page_matches) {
|
|
312
|
+
const matched_str = m[0];
|
|
313
|
+
const m_start = m.index!;
|
|
314
|
+
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
|
+
}
|
|
324
|
+
|
|
325
|
+
const snippet_start = Math.max(0, m_start - 100);
|
|
326
|
+
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);
|
|
328
|
+
|
|
329
|
+
const snippet_lines = snippet
|
|
330
|
+
.split("\n")
|
|
331
|
+
.filter((line) => line.trim().length > 0)
|
|
332
|
+
.map((line) => `> ${line}`)
|
|
333
|
+
.join("\n");
|
|
334
|
+
|
|
335
|
+
ui_parts.push("---");
|
|
336
|
+
ui_parts.push(`### Match ${i} (p${p_num})`);
|
|
337
|
+
|
|
338
|
+
const h_path = get_heading(m_start, body);
|
|
339
|
+
if (h_path) {
|
|
340
|
+
ui_parts.push(`**Path:** \`${h_path}\``);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const count = occurrences_map[matched_str];
|
|
344
|
+
ui_parts.push(snippet_lines);
|
|
345
|
+
ui_parts.push(`*Occurrences:* This exact phrasing appears ${count} time${count !== 1 ? "s" : ""} in the document.`);
|
|
346
|
+
|
|
347
|
+
i++;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const ui_markdown = ui_parts.join("\n\n");
|
|
351
|
+
const llm_content = `> **File Path:** \`${resolve(file_path)}\`\n\n${ui_markdown}`;
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
content: [{ type: "text", text: llm_content }],
|
|
355
|
+
structuredContent: {
|
|
356
|
+
markdown: ui_markdown,
|
|
357
|
+
title: `Search: ${basename(file_path)}`,
|
|
358
|
+
file_path: resolve(file_path),
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|