@finchagentic/mcp 4.6.1 → 4.6.2

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.
Files changed (58) hide show
  1. package/package.json +5 -6
  2. package/dist/_http-cache.js +0 -96
  3. package/dist/_text-search.js +0 -39
  4. package/dist/agent-loop.js +0 -301
  5. package/dist/annotations.js +0 -122
  6. package/dist/cli.js +0 -1391
  7. package/dist/clink-input.js +0 -15
  8. package/dist/config.js +0 -132
  9. package/dist/convex.js +0 -175
  10. package/dist/dex-pair.js +0 -54
  11. package/dist/enrichment-router.js +0 -315
  12. package/dist/index.js +0 -258
  13. package/dist/llm.js +0 -298
  14. package/dist/local-memory-file.js +0 -150
  15. package/dist/local-memory.js +0 -135
  16. package/dist/local-vault.js +0 -456
  17. package/dist/output-schemas.js +0 -605
  18. package/dist/project.js +0 -36
  19. package/dist/prompts.js +0 -111
  20. package/dist/public-url.js +0 -107
  21. package/dist/resources.js +0 -111
  22. package/dist/server.js +0 -322
  23. package/dist/signal-gate.js +0 -57
  24. package/dist/token-decimals.js +0 -26
  25. package/dist/token-gate.js +0 -88
  26. package/dist/tool-filter.js +0 -53
  27. package/dist/tools/_solidity-scan.js +0 -313
  28. package/dist/tools/agents.js +0 -441
  29. package/dist/tools/automation.js +0 -354
  30. package/dist/tools/base-mcp.js +0 -466
  31. package/dist/tools/base.js +0 -283
  32. package/dist/tools/chronicle.js +0 -268
  33. package/dist/tools/coder.js +0 -94
  34. package/dist/tools/deep-research.js +0 -1421
  35. package/dist/tools/defi.js +0 -292
  36. package/dist/tools/equity.js +0 -372
  37. package/dist/tools/events.js +0 -182
  38. package/dist/tools/github.js +0 -564
  39. package/dist/tools/insider.js +0 -264
  40. package/dist/tools/insight.js +0 -630
  41. package/dist/tools/market.js +0 -555
  42. package/dist/tools/memory.js +0 -1059
  43. package/dist/tools/miroshark.js +0 -350
  44. package/dist/tools/monitor.js +0 -319
  45. package/dist/tools/os.js +0 -236
  46. package/dist/tools/packets.js +0 -296
  47. package/dist/tools/research-chain.js +0 -226
  48. package/dist/tools/research-compare.js +0 -280
  49. package/dist/tools/research.js +0 -188
  50. package/dist/tools/rh-bridge.js +0 -148
  51. package/dist/tools/rh-mcp.js +0 -1448
  52. package/dist/tools/rh-orders.js +0 -556
  53. package/dist/tools/scanner.js +0 -564
  54. package/dist/tools/stake.js +0 -369
  55. package/dist/tools/vault.js +0 -1020
  56. package/dist/tools/wallet.js +0 -200
  57. package/dist/types.js +0 -2
  58. package/dist/wallet.js +0 -372
@@ -1,226 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RESEARCH_CHAIN_TOOLS = void 0;
4
- exports.handleResearchChain = handleResearchChain;
5
- const zod_1 = require("zod");
6
- const convex_js_1 = require("../convex.js");
7
- // ─── Tool schema ──────────────────────────────────────────────────────────────
8
- exports.RESEARCH_CHAIN_TOOLS = [
9
- {
10
- name: "research_chain",
11
- description: "Walk a research topic's timeline. Follows `continues` relations both backward and forward from a starting report and returns the chronological list with each report's date/title/TL;DR, plus the rubric for YOU to write the 'net evolution' — what shifted at each step. No API key needed.",
12
- inputSchema: {
13
- type: "object",
14
- properties: {
15
- startKey: {
16
- type: "string",
17
- description: "Vault key of any research entry in the chain. The tool walks both directions from there. Format: 'research/...'",
18
- },
19
- maxDepth: {
20
- type: "number",
21
- description: "Max number of entries to walk in each direction. Default 8 (so up to 17 entries including start). Capped at 20.",
22
- },
23
- synthesize: {
24
- type: "boolean",
25
- description: "Append the 'net evolution' rubric after the timeline. Default true. Set false if you only want the raw timeline.",
26
- },
27
- },
28
- required: ["startKey"],
29
- },
30
- },
31
- ];
32
- const InputSchema = zod_1.z.object({
33
- startKey: zod_1.z.string().min(3).max(200),
34
- maxDepth: zod_1.z.number().int().min(1).max(20).optional(),
35
- synthesize: zod_1.z.boolean().optional(),
36
- });
37
- async function loadEntry(key) {
38
- try {
39
- const e = (await (0, convex_js_1.callConvex)(`/vault/entry?key=${encodeURIComponent(key)}`, "GET", undefined, "vault_read"));
40
- if (!e?.content)
41
- return null;
42
- return {
43
- key: e.key ?? key,
44
- title: e.title ?? key,
45
- updatedAt: e.updatedAt,
46
- content: e.content,
47
- tldr: extractTLDR(e.content),
48
- };
49
- }
50
- catch {
51
- return null;
52
- }
53
- }
54
- function extractTLDR(content) {
55
- // Look for "## TL;DR" / "## Summary" section.
56
- const m = content.match(/##\s*(TL;DR|Summary)[\s\S]*?(?=\n##|\n#|$)/i);
57
- if (m) {
58
- const body = m[0].replace(/^##.*$/m, "").trim();
59
- return body.split("\n\n")[0].trim().slice(0, 320);
60
- }
61
- // Fallback: first non-header paragraph
62
- const para = content
63
- .split("\n\n")
64
- .find((p) => p.trim() && !p.trim().startsWith("#") && !p.trim().startsWith("📁") && !p.trim().startsWith("🔬"));
65
- return (para ?? "").trim().slice(0, 320);
66
- }
67
- async function getLinkedKeys(key, relation) {
68
- // /vault/related returns linked entries via vault_related. Each result has
69
- // direction "outbound" (this -> other) or "inbound" (other -> this).
70
- // continues = outbound continues link → next-older report
71
- // continued_by = inbound continues link → next-newer report
72
- try {
73
- const result = (await (0, convex_js_1.callConvex)(`/vault/related?key=${encodeURIComponent(key)}&relation=continues`, "GET", undefined, "vault_related"));
74
- const wanted = relation === "continues" ? "outbound" : "inbound";
75
- return (result?.related ?? [])
76
- .filter((r) => r.direction === wanted)
77
- .map((r) => r.key);
78
- }
79
- catch {
80
- return [];
81
- }
82
- }
83
- async function walkChain(startKey, maxDepth) {
84
- const startEntry = await loadEntry(startKey);
85
- if (!startEntry)
86
- return [];
87
- const visited = new Set([startKey]);
88
- // Walk backward: follow outbound `continues` links (this report continues prior).
89
- const backward = [];
90
- let backCursor = startKey;
91
- for (let i = 0; i < maxDepth; i++) {
92
- const linkedKeys = await getLinkedKeys(backCursor, "continues");
93
- const next = linkedKeys.find((k) => !visited.has(k));
94
- if (!next)
95
- break;
96
- visited.add(next);
97
- const entry = await loadEntry(next);
98
- if (!entry)
99
- break;
100
- backward.unshift(entry); // oldest at front
101
- backCursor = next;
102
- }
103
- // Walk forward: follow inbound `continues` links (other reports continue this one).
104
- const forward = [];
105
- let fwdCursor = startKey;
106
- for (let i = 0; i < maxDepth; i++) {
107
- const linkedKeys = await getLinkedKeys(fwdCursor, "continued_by");
108
- const next = linkedKeys.find((k) => !visited.has(k));
109
- if (!next)
110
- break;
111
- visited.add(next);
112
- const entry = await loadEntry(next);
113
- if (!entry)
114
- break;
115
- forward.push(entry);
116
- fwdCursor = next;
117
- }
118
- return [...backward, startEntry, ...forward];
119
- }
120
- // The timeline is the tool's real work: walking `continues` relations in both
121
- // directions, loading each entry, extracting its TL;DR and ordering the stops.
122
- // Reading the arc off that timeline is model work, so the rubric travels with
123
- // the data instead of a server-side summary the caller can't steer.
124
- function evolutionRubric(chain) {
125
- const spanDays = chain[0].updatedAt && chain[chain.length - 1].updatedAt
126
- ? Math.round((chain[chain.length - 1].updatedAt - chain[0].updatedAt) / 86400000)
127
- : null;
128
- return [
129
- `## Write the net evolution from this`,
130
- ``,
131
- `4-6 sentences on how the understanding evolved across these ${chain.length} stops` +
132
- `${spanDays !== null ? ` (${spanDays} day${spanDays === 1 ? "" : "s"})` : ""}. Call out:`,
133
- ``,
134
- `- **Position shifts** — a claim that went from confident to weak, or the reverse`,
135
- `- **New entities or data points** and the exact stop where they appeared`,
136
- `- **Predictions** that came true or were falsified`,
137
- `- **Current state vs starting state**`,
138
- ``,
139
- `Synthesize the arc — do not list the reports back. Emphasise what CHANGED, not what held steady. ` +
140
- `Each TL;DR above is truncated: if the arc turns on a detail you can't see, read the full entry with ` +
141
- `\`vault_read\` before writing.`,
142
- ].join("\n");
143
- }
144
- // ─── Handler ──────────────────────────────────────────────────────────────────
145
- async function handleResearchChain(name, args) {
146
- if (name !== "research_chain")
147
- return null;
148
- const parsed = InputSchema.safeParse(args);
149
- if (!parsed.success) {
150
- return { content: [{ type: "text", text: `${parsed.error.issues[0].message}` }], isError: true };
151
- }
152
- const { startKey } = parsed.data;
153
- const maxDepth = parsed.data.maxDepth ?? 8;
154
- const synthesize = parsed.data.synthesize ?? true;
155
- const progress = [];
156
- const log = (line) => progress.push(line);
157
- log(`🔍 Loading start entry: \`${startKey}\``);
158
- log(`🧬 Walking continues chain (max ${maxDepth} steps each direction)...`);
159
- const chain = await walkChain(startKey, maxDepth);
160
- if (chain.length === 0) {
161
- return {
162
- content: [{
163
- type: "text",
164
- text: `Could not load \`${startKey}\` - check the vault key (use vault_list type:research) and make sure you're authenticated.`,
165
- }],
166
- isError: true,
167
- };
168
- }
169
- if (chain.length === 1) {
170
- return {
171
- content: [{
172
- type: "text",
173
- text: `🧬 **Research Chain** - \`${startKey}\` is a standalone report.\n\n` +
174
- `No \`continues\` links found in either direction. To start building a chain, ` +
175
- `run \`deep_research\` again on the same topic with \`continueFrom="${startKey}"\` - ` +
176
- `that creates the temporal link.\n\n` +
177
- `Once you have 2+ continuations, this tool will walk and synthesize the evolution.`,
178
- }],
179
- };
180
- }
181
- log(`✅ Loaded ${chain.length} entries in chain.`);
182
- // Render the timeline
183
- const timelineLines = [];
184
- for (let i = 0; i < chain.length; i++) {
185
- const e = chain[i];
186
- const date = e.updatedAt ? new Date(e.updatedAt).toISOString().slice(0, 10) : "-";
187
- const marker = e.key === startKey ? " ← you are here" : "";
188
- timelineLines.push(`### [${i + 1}/${chain.length}] ${date}${marker}`);
189
- timelineLines.push(`**${e.title}**`);
190
- timelineLines.push(`\`${e.key}\``);
191
- timelineLines.push("");
192
- timelineLines.push(e.tldr);
193
- timelineLines.push("");
194
- if (i < chain.length - 1) {
195
- timelineLines.push("⬇ *continues into*");
196
- timelineLines.push("");
197
- }
198
- }
199
- if (synthesize && chain.length >= 2) {
200
- log(`📐 Attached the net-evolution rubric for ${chain.length} stops.`);
201
- }
202
- const netEvolution = synthesize && chain.length >= 2 ? evolutionRubric(chain) : "";
203
- const header = [
204
- `🧬 **Research Chain** - ${chain.length} reports across the timeline`,
205
- `📍 You are at: \`${startKey}\``,
206
- chain[0].updatedAt && chain[chain.length - 1].updatedAt
207
- ? `🗓 Spans ${new Date(chain[0].updatedAt).toISOString().slice(0, 10)} → ${new Date(chain[chain.length - 1].updatedAt).toISOString().slice(0, 10)}`
208
- : "",
209
- ``,
210
- `<details><summary>📋 Process log</summary>`,
211
- ``,
212
- progress.map((p) => `- ${p}`).join("\n"),
213
- ``,
214
- `</details>`,
215
- ``,
216
- ].filter(Boolean).join("\n");
217
- const evolutionBlock = netEvolution ? `\n---\n\n${netEvolution}\n` : "";
218
- const text = [
219
- header,
220
- `## Timeline`,
221
- ``,
222
- timelineLines.join("\n"),
223
- evolutionBlock,
224
- ].join("\n");
225
- return { content: [{ type: "text", text }] };
226
- }
@@ -1,280 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RESEARCH_COMPARE_TOOLS = void 0;
4
- exports.handleResearchCompare = handleResearchCompare;
5
- const zod_1 = require("zod");
6
- const convex_js_1 = require("../convex.js");
7
- // ─── Tool schema ──────────────────────────────────────────────────────────────
8
- exports.RESEARCH_COMPARE_TOOLS = [
9
- {
10
- name: "research_compare",
11
- description: "Diff two vault research reports. Two-pass, no API key needed. " +
12
- "PASS 1 — call with keyA + keyB: loads both reports, extracts their section structure, and returns " +
13
- "both bodies plus the comparison rubric for YOU to write. " +
14
- "PASS 2 — call again with the same keys plus `comparison`: saves your write-up as type:research and " +
15
- "auto-links it to both source reports.",
16
- inputSchema: {
17
- type: "object",
18
- properties: {
19
- keyA: {
20
- type: "string",
21
- description: "Vault key of the FIRST (older / baseline) report. Format: 'research/...' - use vault_list type:research to find candidates.",
22
- },
23
- keyB: {
24
- type: "string",
25
- description: "Vault key of the SECOND (newer / current) report to compare against keyA.",
26
- },
27
- focus: {
28
- type: "string",
29
- description: "Optional aspect to focus the comparison on: 'numbers', 'sentiment', 'sources', 'predictions', 'consensus'. Default: all.",
30
- },
31
- comparison: {
32
- type: "string",
33
- description: "PASS 2 only. Your finished comparison in Markdown. Supplying it switches this tool from 'return the evidence' to 'save and link the result'.",
34
- },
35
- saveToVault: { type: "boolean", description: "Save the pass-2 comparison to vault (default true)" },
36
- maxChars: {
37
- type: "number",
38
- description: "Max characters of each report body to return in pass 1 (default 12000, max 40000). Raise it if the reports are long and you need the full text.",
39
- },
40
- },
41
- required: ["keyA", "keyB"],
42
- },
43
- },
44
- ];
45
- const InputSchema = zod_1.z.object({
46
- keyA: zod_1.z.string().min(3).max(200),
47
- keyB: zod_1.z.string().min(3).max(200),
48
- focus: zod_1.z.string().max(80).optional(),
49
- comparison: zod_1.z.string().min(1).optional(),
50
- saveToVault: zod_1.z.boolean().optional(),
51
- maxChars: zod_1.z.number().int().min(1000).max(40000).optional(),
52
- });
53
- // ─── Helpers ──────────────────────────────────────────────────────────────────
54
- async function loadVaultEntry(key) {
55
- try {
56
- const entry = await (0, convex_js_1.callConvex)(`/vault/entry?key=${encodeURIComponent(key)}`, "GET", undefined, "vault_read");
57
- if (!entry?.content)
58
- return null;
59
- return {
60
- key: entry.key ?? key,
61
- title: entry.title ?? key,
62
- content: entry.content,
63
- updatedAt: entry.updatedAt,
64
- };
65
- }
66
- catch {
67
- return null;
68
- }
69
- }
70
- function slugifyKey(key) {
71
- return key.replace(/^research\//, "").replace(/[^a-z0-9-]/gi, "-").slice(0, 50);
72
- }
73
- // ─── Structural diff ──────────────────────────────────────────────────────────
74
- // Mechanical work the caller shouldn't have to eyeball: which sections exist in
75
- // one report and not the other, and how the bodies differ in size. It is not a
76
- // judgement about what changed - that's the caller's job - but it tells them
77
- // where to look first.
78
- function sectionHeadings(content) {
79
- const out = [];
80
- for (const line of content.split("\n")) {
81
- const m = line.match(/^#{2,3}\s+(.+?)\s*$/);
82
- if (m)
83
- out.push(m[1].replace(/[*`]/g, "").trim());
84
- }
85
- return out;
86
- }
87
- function normHeading(h) {
88
- return h.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
89
- }
90
- function diffStructure(a, b) {
91
- const ha = sectionHeadings(a);
92
- const hb = sectionHeadings(b);
93
- const na = new Map(ha.map((h) => [normHeading(h), h]));
94
- const nb = new Map(hb.map((h) => [normHeading(h), h]));
95
- const onlyInA = [];
96
- const onlyInB = [];
97
- const shared = [];
98
- for (const [k, h] of na)
99
- (nb.has(k) ? shared : onlyInA).push(h);
100
- for (const [k, h] of nb)
101
- if (!na.has(k))
102
- onlyInB.push(h);
103
- return { onlyInA, onlyInB, shared };
104
- }
105
- // The discipline the old synthesis prompt enforced, moved into the output so
106
- // the user can see it and steer it. The "Weakened or Removed" section is the
107
- // one that matters most and the one a model skips first if not told to keep it.
108
- function comparisonRubric(dateA, dateB, focus) {
109
- return [
110
- `## Write the comparison from this`,
111
- ``,
112
- `Show what **changed** in the understanding — not a side-by-side rehash of both reports.`,
113
- ``,
114
- `### TL;DR`,
115
- `2-3 sentences. Lead with the most important shift. No filler.`,
116
- ``,
117
- `### Net Direction`,
118
- `One line: **STRENGTHENED** / **WEAKENED** / **PIVOTED** / **REFINED**, then 1-2 sentences on why.`,
119
- ``,
120
- `### At a Glance`,
121
- `A table with exactly these columns, 5-8 rows of the most important shifts, ↑/↓/→/⚡/❓ in Change:`,
122
- ``,
123
- `| Dimension | ${dateA} (A) | ${dateB} (B) | Change |`,
124
- `|---|---|---|---|`,
125
- ``,
126
- `### 🆕 New in B`,
127
- `3-6 bullets — findings, entities or data points present in B and absent from A.`,
128
- ``,
129
- `### 🔄 Updated`,
130
- `3-6 bullets, each as "{Claim}: was {A's position}, now {B's position}".`,
131
- ``,
132
- `### ⚠️ Weakened or Removed`,
133
- `2-4 bullets — claims A made that B contradicts, drops, or is less confident about. ` +
134
- `**Do not skip this section.** It is the "what we got wrong" record and it is the whole point of ` +
135
- `comparing. If nothing weakened, say so explicitly rather than omitting the heading.`,
136
- ``,
137
- `### Confidence Shift`,
138
- `Did overall confidence rise or fall? Are B's sources stronger? Are its predictions more grounded?`,
139
- ``,
140
- `### What to Watch Next`,
141
- `3-4 forward-looking questions this comparison raised.`,
142
- ``,
143
- `**Rules:** quote actual numbers and names from both bodies. No hedging filler. If A was right and B ` +
144
- `is worse-sourced, say that. Don't manufacture differences — an unchanged dimension gets one line.` +
145
- (focus ? ` **Focus on ${focus}; down-weight everything else.**` : ""),
146
- ``,
147
- `When you're done, call \`research_compare\` again with the same keys plus \`comparison: "<your markdown>"\` ` +
148
- `to save it to the vault and link it to both sources.`,
149
- ].join("\n");
150
- }
151
- // ─── Handler ──────────────────────────────────────────────────────────────────
152
- async function handleResearchCompare(name, args) {
153
- if (name !== "research_compare")
154
- return null;
155
- const parsed = InputSchema.safeParse(args);
156
- if (!parsed.success) {
157
- return { content: [{ type: "text", text: `${parsed.error.issues[0].message}` }], isError: true };
158
- }
159
- const { keyA, keyB, focus, comparison } = parsed.data;
160
- const saveToVault = parsed.data.saveToVault ?? true;
161
- const maxChars = parsed.data.maxChars ?? 12000;
162
- if (keyA === keyB) {
163
- return { content: [{ type: "text", text: "keyA and keyB are the same - comparison needs two different reports." }], isError: true };
164
- }
165
- // Load both reports in parallel
166
- const progress = [];
167
- const log = (line) => progress.push(line);
168
- log(`📂 Loading report A: \`${keyA}\``);
169
- log(`📂 Loading report B: \`${keyB}\``);
170
- const [reportA, reportB] = await Promise.all([loadVaultEntry(keyA), loadVaultEntry(keyB)]);
171
- if (!reportA) {
172
- return { content: [{ type: "text", text: `Could not load report A (\`${keyA}\`). Check the vault key is correct and you're authenticated. Use vault_list type:research to find candidates.` }], isError: true };
173
- }
174
- if (!reportB) {
175
- return { content: [{ type: "text", text: `Could not load report B (\`${keyB}\`). Check the vault key is correct.` }], isError: true };
176
- }
177
- log(`✅ Loaded A (${reportA.content.length} chars) and B (${reportB.content.length} chars).`);
178
- const dateA = reportA.updatedAt ? new Date(reportA.updatedAt).toISOString().slice(0, 10) : "earlier";
179
- const dateB = reportB.updatedAt ? new Date(reportB.updatedAt).toISOString().slice(0, 10) : "later";
180
- // ── PASS 2: caller brought the finished comparison - save it and link it ──
181
- if (comparison) {
182
- let compareKey = null;
183
- if (saveToVault) {
184
- try {
185
- const titleA = reportA.title.replace(/^Deep Research(?:\s*\(cont\.\))?:\s*/i, "").slice(0, 30);
186
- const titleB = reportB.title.replace(/^Deep Research(?:\s*\(cont\.\))?:\s*/i, "").slice(0, 30);
187
- const r = (await (0, convex_js_1.callConvex)("/vault/save", "POST", {
188
- type: "research",
189
- title: `Compare: ${titleA} vs ${titleB}`,
190
- content: comparison,
191
- tags: ["research-compare", "comparison", ...(focus ? [`focus:${focus}`] : [])],
192
- agentId: "research",
193
- commitMsg: `compare ${slugifyKey(keyA)} vs ${slugifyKey(keyB)}`,
194
- }, "vault_save"));
195
- compareKey = r?.key ?? null;
196
- log(`💾 Saved comparison to vault: \`${compareKey}\``);
197
- }
198
- catch {
199
- log(`⚠️ Vault save skipped (not authenticated).`);
200
- }
201
- }
202
- // Auto-link to both source reports
203
- if (compareKey) {
204
- for (const target of [keyA, keyB]) {
205
- try {
206
- await (0, convex_js_1.callConvex)("/vault/link", "POST", {
207
- fromKey: compareKey,
208
- toKey: target,
209
- relation: "references",
210
- }, "vault_link");
211
- }
212
- catch { /* silent */ }
213
- }
214
- log(`🔗 Linked comparison → ${keyA} (references)`);
215
- log(`🔗 Linked comparison → ${keyB} (references)`);
216
- }
217
- return {
218
- content: [{
219
- type: "text",
220
- text: [
221
- compareKey
222
- ? `📊 **Comparison saved** — \`${compareKey}\`, linked to \`${keyA}\` and \`${keyB}\`.`
223
- : `📊 **Comparison not saved** — vault write unavailable (not authenticated) or \`saveToVault: false\`.`,
224
- `🧬 Your knowledge base is now queryable across time.`,
225
- ``,
226
- `<details><summary>📋 Process log</summary>`,
227
- ``,
228
- progress.map((p) => `- ${p}`).join("\n"),
229
- ``,
230
- `</details>`,
231
- ].join("\n"),
232
- }],
233
- };
234
- }
235
- // ── PASS 1: return both bodies + the structural diff + the rubric ────────
236
- const struct = diffStructure(reportA.content, reportB.content);
237
- log(`🔍 Structural diff: ${struct.shared.length} shared sections, ${struct.onlyInA.length} only in A, ${struct.onlyInB.length} only in B.`);
238
- const clip = (s) => s.length > maxChars
239
- ? `${s.slice(0, maxChars)}\n\n…[truncated ${s.length - maxChars} chars — raise \`maxChars\` for the rest]`
240
- : s;
241
- const structLines = [
242
- `**Sections only in A:** ${struct.onlyInA.length ? struct.onlyInA.join(" · ") : "none"}`,
243
- `**Sections only in B:** ${struct.onlyInB.length ? struct.onlyInB.join(" · ") : "none"}`,
244
- `**Shared sections:** ${struct.shared.length ? struct.shared.join(" · ") : "none"}`,
245
- `**Length:** A ${reportA.content.length.toLocaleString()} chars → B ${reportB.content.length.toLocaleString()} chars ` +
246
- `(${reportB.content.length >= reportA.content.length ? "+" : ""}${(((reportB.content.length - reportA.content.length) / Math.max(reportA.content.length, 1)) * 100).toFixed(0)}%)`,
247
- ].join("\n");
248
- const text = [
249
- `📊 **Research Compare** — \`${reportA.key}\` ⇄ \`${reportB.key}\``,
250
- ``,
251
- `<details><summary>📋 Process log</summary>`,
252
- ``,
253
- progress.map((p) => `- ${p}`).join("\n"),
254
- ``,
255
- `</details>`,
256
- ``,
257
- `## Structural diff`,
258
- ``,
259
- structLines,
260
- ``,
261
- `---`,
262
- ``,
263
- `## Report A — ${dateA} · ${reportA.title}`,
264
- `\`${reportA.key}\``,
265
- ``,
266
- clip(reportA.content),
267
- ``,
268
- `---`,
269
- ``,
270
- `## Report B — ${dateB} · ${reportB.title}`,
271
- `\`${reportB.key}\``,
272
- ``,
273
- clip(reportB.content),
274
- ``,
275
- `---`,
276
- ``,
277
- comparisonRubric(dateA, dateB, focus),
278
- ].join("\n");
279
- return { content: [{ type: "text", text }] };
280
- }