@mmerterden/multi-agent-toolkit-mcp 3.13.0 → 3.13.1
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/CHANGELOG.md +93 -21
- package/index.js +245 -24
- package/package.json +15 -2
- package/tools/context/index.js +30 -13
- package/tools/offload/index.js +57 -1
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,76 @@ Releases before this file exists are recorded in the git tags and commit history
|
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
22
|
+
## 3.13.1 - 2026-09-21
|
|
23
|
+
|
|
24
|
+
Fixes to the web and context families, each held by a gate proven to fail
|
|
25
|
+
without it. The acceptance numbers at the end were measured against live sites.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **A question asked of the index returned nothing at all.** FTS5 reads the
|
|
30
|
+
space between two terms as AND, so a nine-word question demanded all nine
|
|
31
|
+
words from one chunk. `context_search` now runs the every-term query first,
|
|
32
|
+
because a caller who typed all the terms meant all of them, and falls back to
|
|
33
|
+
an any-term query when that finds nothing; `bm25` then ranks by how many of
|
|
34
|
+
the terms a chunk carries and how rare each one is. A log line still matches
|
|
35
|
+
exactly as before.
|
|
36
|
+
- **`web_crawl`, `web_extract` and `web_map` crawled `about:blank`.** Run before
|
|
37
|
+
the session has navigated, extraction succeeded and returned nothing: the
|
|
38
|
+
crawl reported one page, the index gained an empty document, and the next
|
|
39
|
+
search ranked it. The three now name the missing `web_goto` instead of
|
|
40
|
+
returning an empty success.
|
|
41
|
+
- **A bounded crawl spent its budget on site furniture.** Links were followed in
|
|
42
|
+
DOM order, so a global index, a feedback form and a breadcrumb came before the
|
|
43
|
+
second body link. Links inside the page's content region are handed back
|
|
44
|
+
first, and a link that differs from another only by its query string is
|
|
45
|
+
treated as the same document. Measured on a 20-page crawl of the Python
|
|
46
|
+
standard library index: 14 material pages before, 18 after.
|
|
47
|
+
- **Refs from inside a frame were never registered.** Playwright stamps a frame
|
|
48
|
+
generation onto refs handed out past the first navigation (`f2e8`, not `e8`),
|
|
49
|
+
so the registry accepted nothing from the second page onward and refused every
|
|
50
|
+
ref the snapshot plainly showed.
|
|
51
|
+
- **`web_click` reported a timeout without saying what caused it.** A refusal
|
|
52
|
+
now names the reason: disabled, not displayed, transparent, zero-sized,
|
|
53
|
+
off-viewport, `pointer-events: none`, or the element that is on top of it at
|
|
54
|
+
the click point.
|
|
55
|
+
- **Readable extraction fell back to the page source.** The Readability and
|
|
56
|
+
Turndown browser bundles are resolved by their browser filenames and injected
|
|
57
|
+
into the page; when neither is available the fallback reads the live content
|
|
58
|
+
container's rendered text and drops chrome line by line.
|
|
59
|
+
- **`context_search` said which lines matched but not which file.** Each hit now
|
|
60
|
+
carries its path.
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
|
|
64
|
+
- **`web_crawl` feeds the full-text index.** Each page is written beside the
|
|
65
|
+
index and indexed under its URL, so twenty pages arrive searchable instead of
|
|
66
|
+
as a wall of text. Failure to index never fails the crawl.
|
|
67
|
+
- **An offloaded payload's summary carries its signal lines.** A head-and-tail
|
|
68
|
+
window keeps the two places a large payload is least likely to carry its
|
|
69
|
+
answer; the tool name is already known, so up to twenty matching lines are
|
|
70
|
+
surfaced with their line numbers - `FATAL` and `Abort trap` for a crash log,
|
|
71
|
+
`error:` and `Undefined symbol` for a build, tappable nodes for a UI dump,
|
|
72
|
+
measurements for a memory report. Patterns only, no model.
|
|
73
|
+
- **`@mozilla/readability` and `turndown` as optional peers**, alongside
|
|
74
|
+
`playwright`. Absent, extraction uses the fallback above.
|
|
75
|
+
- **`MCP_TOOLKIT_INDEX_DIR`** overrides where the index lives, so a gate ranks a
|
|
76
|
+
corpus it owns.
|
|
77
|
+
|
|
78
|
+
### Acceptance
|
|
79
|
+
|
|
80
|
+
Run against live sites before release:
|
|
81
|
+
|
|
82
|
+
| Criterion | Result |
|
|
83
|
+
| ---------------------------------------------------------------------------------------- | --------------------- |
|
|
84
|
+
| Click by ref on ten real sites, asserting the page moved | 10/10 |
|
|
85
|
+
| Article body extracted from ten pages, no nav, cookie or footer text | 10/10 |
|
|
86
|
+
| `max_pages: 20` crawl of a documentation site, then five questions ranked from the index | 5/5 first-hit correct |
|
|
87
|
+
|
|
88
|
+
None of the five questions names the module it should find.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
22
92
|
## 3.13.0 - 2026-09-20
|
|
23
93
|
|
|
24
94
|
### Added
|
|
@@ -177,8 +247,7 @@ because the thing that broke was the assumption that the host is a Mac.
|
|
|
177
247
|
`pass_inspect`, `pass_certificates`. Build, sign, validate and inspect Apple
|
|
178
248
|
Wallet passes; offline, deterministic, and generic by construction. All five
|
|
179
249
|
of Apple's styles are first-class with a per-style image contract, and no
|
|
180
|
-
brand, airline or project appears anywhere in the directory. 95 tools become
|
|
181
|
-
99. Three are read-only; `pass_build` writes the file the caller named.
|
|
250
|
+
brand, airline or project appears anywhere in the directory. 95 tools become 99. Three are read-only; `pass_build` writes the file the caller named.
|
|
182
251
|
|
|
183
252
|
**A literal passphrase is not an accepted input.** The caller names an
|
|
184
253
|
environment variable (`passphrase_env`) or a keychain entry, and the value
|
|
@@ -576,7 +645,7 @@ unverifiable without a device or emulator to run adb against.
|
|
|
576
645
|
|
|
577
646
|
- **The accessibility audits reported a clean screen they had never read.** On a
|
|
578
647
|
tree that came back empty they returned `elements_scanned: 0, total_issues: 0,
|
|
579
|
-
|
|
648
|
+
critical: 0, important: 0, warning: 0` - a full clean bill of health,
|
|
580
649
|
indistinguishable from an accessible screen, and the first reading is the one
|
|
581
650
|
anyone believes. Both audits now carry `measurable` and a `reason`, and an
|
|
582
651
|
unmeasurable run reports `total_issues: null`, never 0. A count of zero is a
|
|
@@ -704,7 +773,7 @@ declare `>= 3.1.0`.
|
|
|
704
773
|
`~/.claude/logs/multi-agent-toolkit/<tool>-<timestamp>.txt`, and the tool
|
|
705
774
|
returns a head + tail window with the line count and the path.
|
|
706
775
|
`agent_query_output {pattern, path?, context_lines?, max_matches?,
|
|
707
|
-
|
|
776
|
+
ignore_case?}` searches that file and returns matching lines with numbered
|
|
708
777
|
context, so a follow-up question does not mean re-running an expensive tool -
|
|
709
778
|
and for a UI dump a second run is not even the same evidence.
|
|
710
779
|
|
|
@@ -787,7 +856,7 @@ removed (still 83); input handling is tightened but stays backward-compatible.
|
|
|
787
856
|
- `ios_record_video` and `android_record_screen` actually record. Both previously
|
|
788
857
|
returned a literal "Run: ..." instruction for the caller to execute by hand. Now
|
|
789
858
|
`action:"start"` spawns the recorder (`simctl io recordVideo` / `adb shell
|
|
790
|
-
|
|
859
|
+
screenrecord`) in the background with PID tracking and returns immediately;
|
|
791
860
|
`action:"stop"` interrupts it (SIGINT, so the mp4 container is finalized), pulls
|
|
792
861
|
the file off the device on Android, and returns the local path. One recording
|
|
793
862
|
per device; a stop with nothing running is an error.
|
|
@@ -1006,10 +1075,11 @@ Documentation that claimed more than it checked, and the gate that let it.
|
|
|
1006
1075
|
|
|
1007
1076
|
**Editor support is now stated as verified vs compatible.** The README opened with
|
|
1008
1077
|
"Works with Claude Code, Copilot CLI, Cursor, Antigravity, and VS Code Copilot Chat
|
|
1078
|
+
|
|
1009
1079
|
- the multi-agent-pipeline's full-orchestration targets". That parenthetical stopped
|
|
1010
|
-
being true in pipeline v10.7.0, when the Cursor / Antigravity / Codex / Copilot Chat
|
|
1011
|
-
adapters were deleted; the pipeline has targeted Claude Code and Copilot CLI only
|
|
1012
|
-
ever since.
|
|
1080
|
+
being true in pipeline v10.7.0, when the Cursor / Antigravity / Codex / Copilot Chat
|
|
1081
|
+
adapters were deleted; the pipeline has targeted Claude Code and Copilot CLI only
|
|
1082
|
+
ever since.
|
|
1013
1083
|
|
|
1014
1084
|
The list is not simply wrong, though: this is a plain stdio MCP server, so it does
|
|
1015
1085
|
run in any MCP client, and the config snippets for those clients stay. What was
|
|
@@ -1021,12 +1091,12 @@ of the 78 - in the primary host, for two releases, with every gate green.
|
|
|
1021
1091
|
**Gate 3b: per-family counts, not just the headline.** Gate 3 checked the two
|
|
1022
1092
|
headline numbers and nothing else, so every subcount drifted behind a green check:
|
|
1023
1093
|
|
|
1024
|
-
| README said
|
|
1025
|
-
|
|
1026
|
-
| Device Control (42 tools)
|
|
1027
|
-
| Store Compliance (4 tools)
|
|
1028
|
-
| iOS Tools (34), diagram (31) | 35
|
|
1029
|
-
| 5 categories summing to 57
|
|
1094
|
+
| README said | reality |
|
|
1095
|
+
| ---------------------------- | ------- |
|
|
1096
|
+
| Device Control (42 tools) | 58 |
|
|
1097
|
+
| Store Compliance (4 tools) | 3 |
|
|
1098
|
+
| iOS Tools (34), diagram (31) | 35 |
|
|
1099
|
+
| 5 categories summing to 57 | 78 |
|
|
1030
1100
|
|
|
1031
1101
|
The last row is the interesting one: the whole `design_*` family, six tools, was
|
|
1032
1102
|
missing from the category list entirely, which is why the listed categories could
|
|
@@ -1253,12 +1323,14 @@ content beside it. 14 gates, all green.
|
|
|
1253
1323
|
## [3.0.0] - 2026-08-22
|
|
1254
1324
|
|
|
1255
1325
|
### Changed
|
|
1326
|
+
|
|
1256
1327
|
- **Renamed to `@mmerterden/multi-agent-toolkit-mcp`.** The old name read as internal scaffolding for one pipeline; this server is a standalone MCP over stdio with three runtime dependencies and no coupling to any orchestrator, and the name now says which family it belongs to. Major, because a package rename breaks every consumer that resolves it by name.
|
|
1257
1328
|
- **The MCP server identity reported over the protocol is now `multi-agent-toolkit-mcp`.** Hosts key their registration off this, so an existing `dev-toolkit` entry does not upgrade in place - it has to be removed and re-added. The pipeline installer does that automatically; a hand-registered client needs `<cli> mcp remove dev-toolkit` once.
|
|
1258
1329
|
- **`dev-toolkit-mcp` is kept as a second `bin` alias** so a script that invokes the old binary name keeps working through the transition.
|
|
1259
1330
|
- Package description and keywords lead with what the server does (iOS Simulator, Android Emulator, headless web, 83 tools) rather than with the family it ships in, because that is what someone searching for it will search for.
|
|
1260
1331
|
|
|
1261
1332
|
### Migration
|
|
1333
|
+
|
|
1262
1334
|
- `npm i -g @mmerterden/multi-agent-toolkit-mcp` then remove the old registration: `claude mcp remove dev-toolkit` (same for `copilot` / `codex`).
|
|
1263
1335
|
- `@mmerterden/dev-toolkit-mcp` stays published at 2.26.0 and is deprecated with a pointer to the new name. Nothing is unpublished; a pinned consumer keeps resolving.
|
|
1264
1336
|
|
|
@@ -1480,7 +1552,7 @@ rarely the same size.
|
|
|
1480
1552
|
size finding that buried the real defects. The comparison now reports
|
|
1481
1553
|
per-element edge insets (left/right) rather than raw width, gaps between
|
|
1482
1554
|
consecutive elements, vertical placement, and font size, family and text
|
|
1483
|
-
colour sampled from the text ink rather than the box average -
|
|
1555
|
+
colour sampled from the text ink rather than the box average - the box average
|
|
1484
1556
|
previously returned the background colour and so passed every check. Pass
|
|
1485
1557
|
`responsive: false` to restore the previous absolute-delta behaviour.
|
|
1486
1558
|
- **Height is advisory.** It is still reported, but a content-driven height
|
|
@@ -1512,20 +1584,20 @@ because no failure ever carried `isError`, no host could tell.
|
|
|
1512
1584
|
### Fixed
|
|
1513
1585
|
|
|
1514
1586
|
- **`isError` on every failure.** The `CallTool` dispatch returned command
|
|
1515
|
-
failures as ordinary text, so a host -
|
|
1516
|
-
results -
|
|
1587
|
+
failures as ordinary text, so a host - and the pipeline gates reading these
|
|
1588
|
+
results - saw failure as success. Failures now return `isError: true`. Failing
|
|
1517
1589
|
command output is also capped at 600 chars: a failed `simctl` call was
|
|
1518
1590
|
inlining its entire ~3 KB usage page into the caller's context.
|
|
1519
1591
|
- **`ios_biometric` was a no-op that claimed success.** `simctl keychain
|
|
1520
|
-
|
|
1592
|
+
<device> biometric-enroll` / `biometric-match` do not exist - `keychain`
|
|
1521
1593
|
supports only `add-root-cert`, `add-cert`, `reset`. Now drives the BiometricKit
|
|
1522
1594
|
notification via `notifyutil` inside the simulator, and because `notifyutil`
|
|
1523
1595
|
exits 0 even when it cannot post the name, its output is inspected: a "Failed
|
|
1524
1596
|
with code N" line is reported as a failure with next steps instead of
|
|
1525
1597
|
"success simulated".
|
|
1526
1598
|
- **`ios_go_home` was a no-op.** `simctl io <device> pressButton` does not exist
|
|
1527
|
-
-
|
|
1528
|
-
|
|
1599
|
+
- `io` supports only `enumerate`, `poll`, `recordVideo`, `screenshot`,
|
|
1600
|
+
`screenConfig`. Routed through `idb ui button HOME`, like tap/swipe/type.
|
|
1529
1601
|
- **`android_set_locale` reported success unconditionally.** It broadcast the
|
|
1530
1602
|
dead pre-Android-7 `SET_LOCALE` intent with stderr sent to `/dev/null`, and
|
|
1531
1603
|
`am broadcast` exits 0 even when nothing handles the intent. Now uses the
|
|
@@ -1550,7 +1622,7 @@ because no failure ever carried `isError`, no host could tell.
|
|
|
1550
1622
|
|
|
1551
1623
|
- **`sdk-floor` audit rule** (ITMS-90725), bringing `ios_app_store_audit` to
|
|
1552
1624
|
**18 rules**. Asserts `DTSDKName` / `DTPlatformVersion` major >= 26 and
|
|
1553
|
-
`DTXcode` >= 2600, the iOS 26 / Xcode 26 floor in force since 2026-04-28 -
|
|
1625
|
+
`DTXcode` >= 2600, the iOS 26 / Xcode 26 floor in force since 2026-04-28 - a
|
|
1554
1626
|
hard upload rejection that nothing checked. Grouped as `core` because it costs
|
|
1555
1627
|
nothing to run. Reports a WARNING when an archive carries no build receipts at
|
|
1556
1628
|
all, rather than passing silently.
|
package/index.js
CHANGED
|
@@ -1589,15 +1589,82 @@ function fenceUntrusted(source, body) {
|
|
|
1589
1589
|
return `${UNTRUSTED_OPEN} (${source}) <<<\n${body}\n${UNTRUSTED_CLOSE}`;
|
|
1590
1590
|
}
|
|
1591
1591
|
|
|
1592
|
+
// Why an element that exists refused to be acted on. Returns null when nothing
|
|
1593
|
+
// obvious is wrong, so the caller still gets Playwright's own message rather
|
|
1594
|
+
// than a guess dressed up as a diagnosis.
|
|
1595
|
+
async function describeUnactionable(locator) {
|
|
1596
|
+
try {
|
|
1597
|
+
if ((await locator.count()) === 0) return "no element matches it any more";
|
|
1598
|
+
const state = await locator.evaluate((el) => {
|
|
1599
|
+
const cs = getComputedStyle(el);
|
|
1600
|
+
const r = el.getBoundingClientRect();
|
|
1601
|
+
return {
|
|
1602
|
+
display: cs.display,
|
|
1603
|
+
visibility: cs.visibility,
|
|
1604
|
+
opacity: cs.opacity,
|
|
1605
|
+
pointerEvents: cs.pointerEvents,
|
|
1606
|
+
w: r.width,
|
|
1607
|
+
h: r.height,
|
|
1608
|
+
offLeft: r.right <= 0,
|
|
1609
|
+
offTop: r.bottom <= 0,
|
|
1610
|
+
disabled: el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true",
|
|
1611
|
+
label: (el.innerText || el.getAttribute("aria-label") || "").trim().slice(0, 40),
|
|
1612
|
+
};
|
|
1613
|
+
});
|
|
1614
|
+
const named = state.label ? ` ("${state.label}")` : "";
|
|
1615
|
+
if (state.disabled) return `it is disabled${named}`;
|
|
1616
|
+
if (state.display === "none") return `it is display:none${named}`;
|
|
1617
|
+
if (state.visibility === "hidden") return `it is visibility:hidden${named}`;
|
|
1618
|
+
if (Number(state.opacity) === 0) return `it is fully transparent${named}`;
|
|
1619
|
+
if (state.w === 0 || state.h === 0) return `it has no size${named}`;
|
|
1620
|
+
if (state.offLeft || state.offTop) {
|
|
1621
|
+
return `it sits outside the viewport${named} - a skip link or an off-screen menu, visible only once focused`;
|
|
1622
|
+
}
|
|
1623
|
+
if (state.pointerEvents === "none") return `it ignores pointer events${named}`;
|
|
1624
|
+
|
|
1625
|
+
// Everything above says the element is there and paintable, which is where
|
|
1626
|
+
// Playwright's timeout usually comes from: it waits to deliver a pointer
|
|
1627
|
+
// event and something else is on top. Asking the document what is actually
|
|
1628
|
+
// at that point names the blocker instead of leaving the caller with a
|
|
1629
|
+
// duration.
|
|
1630
|
+
const covering = await locator.evaluate((el) => {
|
|
1631
|
+
const r = el.getBoundingClientRect();
|
|
1632
|
+
const x = r.left + r.width / 2;
|
|
1633
|
+
const y = r.top + r.height / 2;
|
|
1634
|
+
if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return "__outside__";
|
|
1635
|
+
const hit = document.elementFromPoint(x, y);
|
|
1636
|
+
if (!hit || hit === el || el.contains(hit) || hit.contains(el)) return null;
|
|
1637
|
+
const name = hit.tagName.toLowerCase();
|
|
1638
|
+
const id = hit.id ? `#${hit.id}` : "";
|
|
1639
|
+
const cls = hit.className && typeof hit.className === "string"
|
|
1640
|
+
? `.${hit.className.trim().split(/\s+/).slice(0, 2).join(".")}`
|
|
1641
|
+
: "";
|
|
1642
|
+
return `${name}${id}${cls}`;
|
|
1643
|
+
});
|
|
1644
|
+
if (covering === "__outside__") {
|
|
1645
|
+
return `its centre is outside the viewport${named} - scroll to it, or pick a target that is on screen`;
|
|
1646
|
+
}
|
|
1647
|
+
if (covering) {
|
|
1648
|
+
return `${covering} is on top of it${named} - a sticky header or an overlay is taking the click`;
|
|
1649
|
+
}
|
|
1650
|
+
return null;
|
|
1651
|
+
} catch {
|
|
1652
|
+
return null;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1592
1656
|
async function extractReadable(page) {
|
|
1593
1657
|
const paths = [];
|
|
1594
1658
|
for (const mod of ["@mozilla/readability", "turndown"]) {
|
|
1595
1659
|
try { paths.push(requireFrom.resolve(mod)); } catch { paths.push(null); }
|
|
1596
1660
|
}
|
|
1597
|
-
|
|
1661
|
+
const bundles = paths[0] && paths[1]
|
|
1662
|
+
? [readabilityBundlePath(paths[0]), turndownBundlePath(paths[1])]
|
|
1663
|
+
: [null, null];
|
|
1664
|
+
if (bundles[0] && bundles[1]) {
|
|
1598
1665
|
try {
|
|
1599
|
-
await page.addScriptTag({ path:
|
|
1600
|
-
await page.addScriptTag({ path:
|
|
1666
|
+
await page.addScriptTag({ path: bundles[0] });
|
|
1667
|
+
await page.addScriptTag({ path: bundles[1] });
|
|
1601
1668
|
const out = await page.evaluate(() => {
|
|
1602
1669
|
const clone = document.cloneNode(true);
|
|
1603
1670
|
// eslint-disable-next-line no-undef
|
|
@@ -1612,35 +1679,127 @@ async function extractReadable(page) {
|
|
|
1612
1679
|
// fall through to the plain extraction
|
|
1613
1680
|
}
|
|
1614
1681
|
}
|
|
1682
|
+
// The fallback used to clone the body, strip chrome from the clone and read
|
|
1683
|
+
// its innerText. innerText is computed from layout, and a detached node has
|
|
1684
|
+
// none - so on a real page it returned a fraction of the text or nothing,
|
|
1685
|
+
// while a small fixture with everything above the fold looked fine.
|
|
1686
|
+
//
|
|
1687
|
+
// So: find the LIVE container that holds the article, read its innerText, and
|
|
1688
|
+
// drop chrome by line. Reading the live node is what makes the text real; the
|
|
1689
|
+
// line filter is what keeps "Jump to content" out of it.
|
|
1615
1690
|
return page.evaluate(() => {
|
|
1616
|
-
const
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1691
|
+
const CONTAINERS = [
|
|
1692
|
+
"main article",
|
|
1693
|
+
"article",
|
|
1694
|
+
"main",
|
|
1695
|
+
"[role=main]",
|
|
1696
|
+
"#mw-content-text",
|
|
1697
|
+
"#content",
|
|
1698
|
+
".markdown-body",
|
|
1699
|
+
".post-content",
|
|
1700
|
+
];
|
|
1701
|
+
let host = null;
|
|
1702
|
+
for (const sel of CONTAINERS) {
|
|
1703
|
+
const el = document.querySelector(sel);
|
|
1704
|
+
if (el && (el.innerText || "").trim().length > 200) {
|
|
1705
|
+
host = el;
|
|
1706
|
+
break;
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
if (!host) host = document.body;
|
|
1710
|
+
|
|
1711
|
+
const CHROME = [
|
|
1712
|
+
/^jump to (content|navigation|search)$/i,
|
|
1713
|
+
/^skip to (main|content)/i,
|
|
1714
|
+
/^(main )?menu$/i,
|
|
1715
|
+
/^toggle the table of contents$/i,
|
|
1716
|
+
/^(search|sign in|log in|sign up|subscribe|donate)$/i,
|
|
1717
|
+
/^(accept|reject) (all )?cookies?/i,
|
|
1718
|
+
/^cookie (settings|preferences)/i,
|
|
1719
|
+
/^edit (source|this page)$/i,
|
|
1720
|
+
/^\[edit\]$/i,
|
|
1721
|
+
/^(privacy policy|terms of use|contact us)$/i,
|
|
1722
|
+
];
|
|
1723
|
+
const kept = (host.innerText || "")
|
|
1724
|
+
.split("\n")
|
|
1725
|
+
.filter((line) => {
|
|
1726
|
+
const t = line.trim();
|
|
1727
|
+
if (!t) return true;
|
|
1728
|
+
return !CHROME.some((rx) => rx.test(t));
|
|
1729
|
+
})
|
|
1730
|
+
.join("\n")
|
|
1731
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
1732
|
+
.trim();
|
|
1733
|
+
|
|
1734
|
+
// document.title carries the site suffix ("Bloom filter - Wikipedia").
|
|
1735
|
+
// The first h1 inside the container is the article's own name.
|
|
1736
|
+
const h1 = host.querySelector("h1") || document.querySelector("h1");
|
|
1737
|
+
const title = (h1 && h1.innerText.trim()) || document.title || "";
|
|
1738
|
+
return { markdown: `# ${title}\n\n${kept}`, text: kept };
|
|
1622
1739
|
});
|
|
1623
1740
|
}
|
|
1624
1741
|
|
|
1742
|
+
// Both packages ship a Node entry point and a browser bundle, and only the
|
|
1743
|
+
// browser bundle can be injected into a page: the Node entry is CommonJS, so
|
|
1744
|
+
// addScriptTag loads it, `module` is undefined, and the whole extraction falls
|
|
1745
|
+
// through to the plain path without ever saying why.
|
|
1746
|
+
//
|
|
1747
|
+
// The file is checked rather than assumed. A layout change upstream returns
|
|
1748
|
+
// null here, which keeps the fallback rather than injecting a file that throws.
|
|
1625
1749
|
function readabilityBundlePath(resolved) {
|
|
1626
|
-
|
|
1750
|
+
const candidate = join(dirname(resolved), "Readability.js");
|
|
1751
|
+
return existsSync(candidate) ? candidate : null;
|
|
1627
1752
|
}
|
|
1628
1753
|
function turndownBundlePath(resolved) {
|
|
1629
|
-
|
|
1754
|
+
for (const name of ["turndown.browser.umd.js", "turndown.umd.js", "turndown.js"]) {
|
|
1755
|
+
const candidate = join(dirname(resolved), name);
|
|
1756
|
+
if (existsSync(candidate)) return candidate;
|
|
1757
|
+
}
|
|
1758
|
+
return null;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// Every tool below reads the page that is already open. A session that has not
|
|
1762
|
+
// navigated yet is sitting on `about:blank`, where extraction succeeds and
|
|
1763
|
+
// returns nothing: the crawl reports "1 page", the index gains an empty
|
|
1764
|
+
// document, and the search that follows ranks it. Name the missing step
|
|
1765
|
+
// instead of returning an empty success.
|
|
1766
|
+
function blankPageError(page, tool) {
|
|
1767
|
+
const url = page.url();
|
|
1768
|
+
if (url && url !== "about:blank" && !url.startsWith("chrome-error://")) return null;
|
|
1769
|
+
return `ERROR: ${tool} reads the page that is currently open, and nothing has been opened yet (${url || "no url"}). Call web_goto first.`;
|
|
1630
1770
|
}
|
|
1631
1771
|
|
|
1632
1772
|
async function sameOriginLinks(page) {
|
|
1633
1773
|
return page.evaluate(() => {
|
|
1634
1774
|
const here = location.origin;
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1775
|
+
// A documentation page carries two kinds of link: the ones in its body,
|
|
1776
|
+
// which are the material, and the site chrome - a global index, a
|
|
1777
|
+
// "report a bug" form, the breadcrumb back to the root - which is the
|
|
1778
|
+
// same handful of pages on every page of the site. A bounded crawl that
|
|
1779
|
+
// takes them in DOM order spends a third of its budget on chrome before
|
|
1780
|
+
// it reaches the second body link, so body links are handed back first.
|
|
1781
|
+
const CONTENT = ["main article", "article", "main", "[role=main]", "#content", ".markdown-body", ".body"];
|
|
1782
|
+
let host = null;
|
|
1783
|
+
for (const sel of CONTENT) {
|
|
1784
|
+
const el = document.querySelector(sel);
|
|
1785
|
+
if (el && el.querySelector("a[href]")) { host = el; break; }
|
|
1786
|
+
}
|
|
1787
|
+
const collect = (root) => {
|
|
1788
|
+
const found = [];
|
|
1789
|
+
for (const a of root.querySelectorAll("a[href]")) {
|
|
1790
|
+
let u;
|
|
1791
|
+
try { u = new URL(a.getAttribute("href"), location.href); } catch { continue; }
|
|
1792
|
+
if (u.origin !== here) continue;
|
|
1793
|
+
// A link that differs from another only by its query string is the
|
|
1794
|
+
// same document with a feedback form or a tracking tag attached.
|
|
1795
|
+
if (u.search && /^(https?:)?[^?]*\.(html?|md|txt)$/i.test(u.pathname)) u.search = "";
|
|
1796
|
+
u.hash = "";
|
|
1797
|
+
found.push(u.toString());
|
|
1798
|
+
}
|
|
1799
|
+
return found;
|
|
1800
|
+
};
|
|
1801
|
+
const ordered = host ? [...collect(host), ...collect(document)] : collect(document);
|
|
1802
|
+
return [...new Set(ordered)];
|
|
1644
1803
|
});
|
|
1645
1804
|
}
|
|
1646
1805
|
|
|
@@ -1724,7 +1883,7 @@ const WEB_TOOLS = [
|
|
|
1724
1883
|
{ name: "web_storage_state", description: "Read cookies and localStorage for the current page, or restore a previously read state.", inputSchema: { type: "object", properties: { restore: { type: "string", description: "A state JSON string from a previous call. Omit to read." } } } },
|
|
1725
1884
|
{ name: "web_extract", description: "The page's article content as Markdown, with navigation, footers and cookie banners dropped. Falls back to a plain text extraction when the readability helpers are not installed.", inputSchema: { type: "object", properties: { as: { type: "string", enum: ["markdown", "text"], description: "Default markdown." } } } },
|
|
1726
1885
|
{ name: "web_map", description: "Same-origin links reachable from the current page, deduplicated. A cheap site map without fetching anything.", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Default 200." } } } },
|
|
1727
|
-
{ name: "web_crawl", description: "Follow same-origin links from the current page, extracting each one. Bounded: max_pages default 20 (cap 200), max_depth default 2, one request at a time with a delay, robots.txt respected, and a self-identifying User-Agent.", inputSchema: { type: "object", properties: { max_pages: { type: "number" }, max_depth: { type: "number" }, delay_ms: { type: "number", description: "Default 250." }, respect_robots: { type: "boolean", description: "Default true." } } } },
|
|
1886
|
+
{ name: "web_crawl", description: "Follow same-origin links from the current page, extracting each one and indexing it so context_search can rank the crawl afterwards. Bounded: max_pages default 20 (cap 200), max_depth default 2, one request at a time with a delay, robots.txt respected, and a self-identifying User-Agent.", inputSchema: { type: "object", properties: { max_pages: { type: "number" }, max_depth: { type: "number" }, delay_ms: { type: "number", description: "Default 250." }, index: { type: "boolean", description: "Index each page into the full-text index. Default true - a crawl whose pages cannot be searched afterwards returns a wall of text nobody can query." }, respect_robots: { type: "boolean", description: "Default true." } } } },
|
|
1728
1887
|
{ name: "web_press_key", description: "Press a key, optionally focusing an element first.", inputSchema: { type: "object", properties: { key: { type: "string", description: "Playwright key name: Enter, Escape, Tab, ArrowDown, Control+A ..." }, selector: { type: "string" }, ref: { type: "string" } }, required: ["key"] } },
|
|
1729
1888
|
{ name: "web_select_option", description: "Choose an option in a <select>, by value or by visible label.", inputSchema: { type: "object", properties: { selector: { type: "string" }, ref: { type: "string" }, value: { type: "string" }, label: { type: "string" } } } },
|
|
1730
1889
|
];
|
|
@@ -1768,7 +1927,21 @@ async function handleWeb(name, args) {
|
|
|
1768
1927
|
case "web_click": {
|
|
1769
1928
|
const t = locate(page, args);
|
|
1770
1929
|
if (t.error) return t.error;
|
|
1771
|
-
|
|
1930
|
+
try {
|
|
1931
|
+
await t.locator.first().click({ timeout: args.timeout_ms || 5000 });
|
|
1932
|
+
} catch (e) {
|
|
1933
|
+
// Playwright waits for the element to be actionable and then reports a
|
|
1934
|
+
// timeout, which says nothing about WHY. The commonest reason on a real
|
|
1935
|
+
// page is a target the snapshot listed and the layout hides: the first
|
|
1936
|
+
// link on an accessibility-first site is a "Skip to content" link
|
|
1937
|
+
// positioned off-screen until it takes focus. A caller told only
|
|
1938
|
+
// "Timeout 5000ms exceeded" re-tries the same ref; one told the element
|
|
1939
|
+
// is hidden picks a different one.
|
|
1940
|
+
const why = await describeUnactionable(t.locator.first());
|
|
1941
|
+
return why
|
|
1942
|
+
? `${ERROR_PREFIX}${t.label} could not be clicked: ${why}`
|
|
1943
|
+
: `${ERROR_PREFIX}${t.label} could not be clicked: ${String(e.message || e).split("\n")[0]}`;
|
|
1944
|
+
}
|
|
1772
1945
|
return `Clicked: ${t.label}`;
|
|
1773
1946
|
}
|
|
1774
1947
|
case "web_type": {
|
|
@@ -1799,7 +1972,14 @@ async function handleWeb(name, args) {
|
|
|
1799
1972
|
const tree = await target.ariaSnapshot({ mode: "ai" });
|
|
1800
1973
|
// Register every ref the snapshot handed out, stamped with this
|
|
1801
1974
|
// snapshot's id so a later navigation invalidates them as a set.
|
|
1802
|
-
|
|
1975
|
+
//
|
|
1976
|
+
// An element inside an iframe is handed out as `f<frame>e<n>`, not `e<n>`.
|
|
1977
|
+
// Matching only the bare form registers nothing on a page that frames
|
|
1978
|
+
// anything - a cookie banner, an embedded video, an analytics pixel - so
|
|
1979
|
+
// every ref on it is refused as unknown while the snapshot plainly shows
|
|
1980
|
+
// it. Playwright resolves both spellings through the same `aria-ref=`
|
|
1981
|
+
// engine; only this registry needed to accept them.
|
|
1982
|
+
for (const m of String(tree).matchAll(/\[ref=((?:f\d+)?e\d+)\]/g)) {
|
|
1803
1983
|
_refs.set(m[1], { snapshot: _snapshotId });
|
|
1804
1984
|
}
|
|
1805
1985
|
return `${_refs.size} refs\n${tree}`;
|
|
@@ -1864,24 +2044,43 @@ async function handleWeb(name, args) {
|
|
|
1864
2044
|
}
|
|
1865
2045
|
|
|
1866
2046
|
case "web_extract": {
|
|
2047
|
+
const blank = blankPageError(page, "web_extract");
|
|
2048
|
+
if (blank) return blank;
|
|
1867
2049
|
const text = await extractReadable(page);
|
|
1868
2050
|
const body = args.as === "text" ? text.text : text.markdown;
|
|
1869
2051
|
return fenceUntrusted(page.url(), body);
|
|
1870
2052
|
}
|
|
1871
2053
|
|
|
1872
2054
|
case "web_map": {
|
|
2055
|
+
const blank = blankPageError(page, "web_map");
|
|
2056
|
+
if (blank) return blank;
|
|
1873
2057
|
const links = await sameOriginLinks(page);
|
|
1874
2058
|
const limit = args.limit || 200;
|
|
1875
2059
|
return links.slice(0, limit).join("\n") || "(no same-origin links on this page)";
|
|
1876
2060
|
}
|
|
1877
2061
|
|
|
1878
2062
|
case "web_crawl": {
|
|
2063
|
+
const blank = blankPageError(page, "web_crawl");
|
|
2064
|
+
if (blank) return blank;
|
|
1879
2065
|
const maxPages = Math.min(Number(args.max_pages) || 20, 200);
|
|
1880
2066
|
const maxDepth = Number(args.max_depth) || 2;
|
|
1881
2067
|
const delay = Number(args.delay_ms) || 250;
|
|
1882
2068
|
const respectRobots = args.respect_robots !== false;
|
|
1883
2069
|
const start = page.url();
|
|
1884
2070
|
const origin = new URL(start).origin;
|
|
2071
|
+
const wantIndex = args.index !== false;
|
|
2072
|
+
let indexed = 0;
|
|
2073
|
+
let indexDb = null;
|
|
2074
|
+
let crawlDir = null;
|
|
2075
|
+
if (wantIndex) {
|
|
2076
|
+
try {
|
|
2077
|
+
indexDb = ctxIndex.openIndex();
|
|
2078
|
+
crawlDir = join(ctxIndex.INDEX_DIR, "crawl");
|
|
2079
|
+
if (!existsSync(crawlDir)) mkdirSync(crawlDir, { recursive: true });
|
|
2080
|
+
} catch {
|
|
2081
|
+
indexDb = null;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
1885
2084
|
const disallowed = respectRobots ? await robotsDisallow(page, origin) : [];
|
|
1886
2085
|
const seen = new Set([start]);
|
|
1887
2086
|
const queue = [{ url: start, depth: 0 }];
|
|
@@ -1897,6 +2096,23 @@ async function handleWeb(name, args) {
|
|
|
1897
2096
|
}
|
|
1898
2097
|
const doc = await extractReadable(page);
|
|
1899
2098
|
out.push(`--- ${url}\n${doc.markdown}`);
|
|
2099
|
+
// A crawl that cannot be searched afterwards is a wall of text: twenty
|
|
2100
|
+
// pages arrive at once and the caller has no way to ask which of them
|
|
2101
|
+
// answers the question. Each page is written beside the index and
|
|
2102
|
+
// indexed under its URL, so context_search ranks the crawl the same way
|
|
2103
|
+
// it ranks an offloaded payload. Failure here never fails the crawl -
|
|
2104
|
+
// the pages are already in the reply.
|
|
2105
|
+
if (wantIndex && indexDb) {
|
|
2106
|
+
try {
|
|
2107
|
+
const slug = url.replace(/[^a-z0-9]+/gi, "-").slice(0, 120);
|
|
2108
|
+
const file = join(crawlDir, `${slug || "page"}.md`);
|
|
2109
|
+
writeFileSync(file, `# ${url}\n\n${doc.markdown}\n`);
|
|
2110
|
+
ctxIndex.indexFile(indexDb, file);
|
|
2111
|
+
indexed += 1;
|
|
2112
|
+
} catch {
|
|
2113
|
+
/* the page is in the reply; the index is the bonus */
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
1900
2116
|
if (depth < maxDepth) {
|
|
1901
2117
|
for (const link of await sameOriginLinks(page)) {
|
|
1902
2118
|
if (seen.has(link) || seen.size >= maxPages * 4) continue;
|
|
@@ -1909,7 +2125,12 @@ async function handleWeb(name, args) {
|
|
|
1909
2125
|
if (queue.length && out.length < maxPages) await page.waitForTimeout(delay);
|
|
1910
2126
|
}
|
|
1911
2127
|
resetRefs();
|
|
1912
|
-
|
|
2128
|
+
const indexNote = indexed
|
|
2129
|
+
? ` - ${indexed} indexed, searchable with context_search`
|
|
2130
|
+
: wantIndex && indexDb === null
|
|
2131
|
+
? " - the index could not be opened, so these pages are not searchable"
|
|
2132
|
+
: "";
|
|
2133
|
+
return `${out.length} page(s)${indexNote}\n\n${fenceUntrusted(`${out.length} page(s) from ${origin && origin !== "null" ? origin : start}`, out.join("\n\n"))}`;
|
|
1913
2134
|
}
|
|
1914
2135
|
|
|
1915
2136
|
case "web_press_key": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmerterden/multi-agent-toolkit-mcp",
|
|
3
|
-
"version": "3.13.
|
|
3
|
+
"version": "3.13.1",
|
|
4
4
|
"description": "MCP server for iOS Simulator, Android Emulator and headless web control. 115 tools: device automation (tap/swipe/type), accessibility audits, visual diff, crash logs, App Store / Play Store pre-submission compliance. Runs standalone over stdio with any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -85,11 +85,24 @@
|
|
|
85
85
|
"pngjs": "^7.0.0"
|
|
86
86
|
},
|
|
87
87
|
"peerDependencies": {
|
|
88
|
-
"
|
|
88
|
+
"@mozilla/readability": "^0.6.0",
|
|
89
|
+
"playwright": ">=1.60.0 <2",
|
|
90
|
+
"turndown": "^7.2.0"
|
|
89
91
|
},
|
|
90
92
|
"peerDependenciesMeta": {
|
|
91
93
|
"playwright": {
|
|
92
94
|
"optional": true
|
|
95
|
+
},
|
|
96
|
+
"@mozilla/readability": {
|
|
97
|
+
"optional": true
|
|
98
|
+
},
|
|
99
|
+
"turndown": {
|
|
100
|
+
"optional": true
|
|
93
101
|
}
|
|
102
|
+
},
|
|
103
|
+
"devDependencies": {
|
|
104
|
+
"@mozilla/readability": "^0.6.0",
|
|
105
|
+
"playwright": "^1.63.0",
|
|
106
|
+
"turndown": "^7.2.4"
|
|
94
107
|
}
|
|
95
108
|
}
|
package/tools/context/index.js
CHANGED
|
@@ -108,30 +108,47 @@ export function indexFile(db, path) {
|
|
|
108
108
|
// a log line into the query gets a SQL error instead of results. Quoting each
|
|
109
109
|
// term makes every query a literal phrase search, which is what a caller
|
|
110
110
|
// searching a log actually means.
|
|
111
|
-
|
|
111
|
+
//
|
|
112
|
+
// Space between two FTS5 terms is AND. That is right for a log line, where
|
|
113
|
+
// every word is a fact about the one entry being looked for, and wrong for a
|
|
114
|
+
// question: "pack numbers into a bytes object using a format string" demands
|
|
115
|
+
// all nine words from a single chunk and returns nothing at all rather than
|
|
116
|
+
// the page that carries seven of them. `mode: "or"` builds the same query
|
|
117
|
+
// with OR, which is what `search` falls back to; bm25 then ranks by how many
|
|
118
|
+
// of the terms a chunk carries and how rare each one is.
|
|
119
|
+
export function toMatchQuery(query, { mode = "and" } = {}) {
|
|
112
120
|
const terms = String(query)
|
|
113
121
|
.split(/\s+/)
|
|
114
122
|
.map((t) => t.replace(/"/g, ""))
|
|
115
123
|
.filter(Boolean);
|
|
116
124
|
if (!terms.length) return null;
|
|
117
|
-
return terms.map((t) => `"${t}"`).join(" ");
|
|
125
|
+
return terms.map((t) => `"${t}"`).join(mode === "or" ? " OR " : " ");
|
|
118
126
|
}
|
|
119
127
|
|
|
120
128
|
export function search(db, query, { limit = 5, path = null } = {}) {
|
|
121
|
-
const match = toMatchQuery(query);
|
|
122
|
-
if (!match) return [];
|
|
123
129
|
const where = path
|
|
124
130
|
? "where chunks match ? and doc_id = (select id from docs where path = ?)"
|
|
125
131
|
: "where chunks match ?";
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
.all(...params, limit);
|
|
132
|
+
const sql = db.prepare(
|
|
133
|
+
`select chunks.rowid, doc_id, first_line, last_line, body,
|
|
134
|
+
(select path from docs where docs.id = chunks.doc_id) as path,
|
|
135
|
+
bm25(chunks) as score
|
|
136
|
+
from chunks ${where} order by score limit ?`,
|
|
137
|
+
);
|
|
138
|
+
const run = (match) => {
|
|
139
|
+
const params = path ? [match, path] : [match];
|
|
140
|
+
return sql.all(...params, limit);
|
|
141
|
+
};
|
|
142
|
+
// Every term first, because a caller who typed all of them meant all of
|
|
143
|
+
// them. Any term second, so a question that no single chunk answers word
|
|
144
|
+
// for word still comes back ranked instead of empty.
|
|
145
|
+
let rows = [];
|
|
146
|
+
for (const mode of ["and", "or"]) {
|
|
147
|
+
const match = toMatchQuery(query, { mode });
|
|
148
|
+
if (!match) return [];
|
|
149
|
+
rows = run(match);
|
|
150
|
+
if (rows.length) break;
|
|
151
|
+
}
|
|
135
152
|
return rows.map((r) => ({
|
|
136
153
|
id: r.rowid,
|
|
137
154
|
docId: r.doc_id,
|
package/tools/offload/index.js
CHANGED
|
@@ -142,6 +142,60 @@ export function offloadedErrorSummary(text, path, headChars = 400, tailChars = 2
|
|
|
142
142
|
);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
// What a payload IS decides what is worth keeping out of it.
|
|
146
|
+
//
|
|
147
|
+
// A head-plus-tail window keeps the two places a large payload is least likely
|
|
148
|
+
// to carry its answer. A logcat's meaning is one FATAL line somewhere in the
|
|
149
|
+
// middle; a build log's is its `error:` lines; a UI dump's is the handful of
|
|
150
|
+
// nodes you can actually tap. The tool name is already here, so the window can
|
|
151
|
+
// be chosen rather than fixed - and the lines it surfaces come with their line
|
|
152
|
+
// numbers, so the caller can go straight to them in the saved file.
|
|
153
|
+
//
|
|
154
|
+
// Patterns only, no model: the cost of being wrong is a few extra lines in a
|
|
155
|
+
// window that already exists, which is the right trade for a dependency-free
|
|
156
|
+
// check that runs on every offload.
|
|
157
|
+
const SIGNAL_FAMILIES = [
|
|
158
|
+
{
|
|
159
|
+
when: /logcat|crash|crashes/i,
|
|
160
|
+
label: "crash",
|
|
161
|
+
rx: /\b(FATAL|AndroidRuntime|Exception|SIGABRT|SIGSEGV|SIGILL|Thread \d+ Crashed|Abort trap)\b/,
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
when: /xcodebuild|xcresult|build|test/i,
|
|
165
|
+
label: "failure",
|
|
166
|
+
rx: /(^|\s)(error:|FAILED|failed:|XCTAssert\w*|Undefined symbol|linker command failed|\*\* BUILD FAILED)/,
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
when: /ui_tree|accessibility|inspect/i,
|
|
170
|
+
label: "interactive",
|
|
171
|
+
rx: /\b(AXButton|AXTextField|AXSwitch|clickable=true|enabled=true|focusable=true)\b/,
|
|
172
|
+
},
|
|
173
|
+
{ when: /leaks|meminfo|memory/i, label: "measurement", rx: /\b(Leak|leaked|TOTAL|Total PSS|bytes)\b/ },
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
export const SIGNAL_LINE_CAP = 20;
|
|
177
|
+
const SIGNAL_LINE_CHARS = 200;
|
|
178
|
+
|
|
179
|
+
// Lines from the part the window hides, that carry what this kind of payload is
|
|
180
|
+
// read for. Returns "" when the family is unknown or nothing matched, so the
|
|
181
|
+
// window is exactly what it was before.
|
|
182
|
+
export function signalLines(tool, lines, headCount, tailCount, cap = SIGNAL_LINE_CAP) {
|
|
183
|
+
const family = SIGNAL_FAMILIES.find((f) => f.when.test(String(tool)));
|
|
184
|
+
if (!family) return "";
|
|
185
|
+
const from = headCount;
|
|
186
|
+
const to = Math.max(headCount, lines.length - tailCount);
|
|
187
|
+
const hits = [];
|
|
188
|
+
for (let i = from; i < to && hits.length < cap; i++) {
|
|
189
|
+
if (family.rx.test(lines[i])) {
|
|
190
|
+
const body = lines[i].length > SIGNAL_LINE_CHARS ? `${lines[i].slice(0, SIGNAL_LINE_CHARS)}...` : lines[i];
|
|
191
|
+
hits.push(` ${i + 1}: ${body}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!hits.length) return "";
|
|
195
|
+
const more = hits.length === cap ? ` (first ${cap})` : "";
|
|
196
|
+
return `\n\n[${family.label} lines from the hidden middle${more}]\n${hits.join("\n")}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
145
199
|
export function offloadLargeText(tool, text, opts = {}) {
|
|
146
200
|
const dir = opts.dir ?? OFFLOAD_DIR;
|
|
147
201
|
const minChars = opts.minChars ?? OFFLOAD_MIN_CHARS;
|
|
@@ -166,13 +220,15 @@ export function offloadLargeText(tool, text, opts = {}) {
|
|
|
166
220
|
const head = lines.slice(0, HEAD_LINES).join("\n");
|
|
167
221
|
const tail = lines.slice(-TAIL_LINES).join("\n");
|
|
168
222
|
const hidden = Math.max(0, lines.length - HEAD_LINES - TAIL_LINES);
|
|
223
|
+
const signal = signalLines(tool, lines, HEAD_LINES, TAIL_LINES);
|
|
169
224
|
return {
|
|
170
225
|
offloaded: true,
|
|
171
226
|
path,
|
|
172
227
|
text:
|
|
173
228
|
`${head}\n\n[... ${hidden} line(s) not shown. Full output (${lines.length} lines, ` +
|
|
174
229
|
`${text.length} bytes) saved to ${path} - search it with ` +
|
|
175
|
-
`agent_query_output {pattern: "..."} instead of re-running this tool ...]
|
|
230
|
+
`agent_query_output {pattern: "..."} instead of re-running this tool ...]` +
|
|
231
|
+
`${signal}\n\n${tail}`,
|
|
176
232
|
};
|
|
177
233
|
}
|
|
178
234
|
|