@mmerterden/multi-agent-toolkit-mcp 3.0.0 → 3.1.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/CHANGELOG.md +68 -0
- package/README.md +2 -2
- package/README.tr.md +2 -2
- package/index.js +36 -0
- package/package.json +3 -3
- package/tools/offload/index.js +201 -0
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,74 @@ Releases before this file exists are recorded in the git tags and commit history
|
|
|
17
17
|
|
|
18
18
|
## Unreleased
|
|
19
19
|
|
|
20
|
+
## 3.1.0
|
|
21
|
+
|
|
22
|
+
One new tool and the offload path behind it. Minor rather than patch because the
|
|
23
|
+
tool surface grew: pipeline-side minimums that need `agent_query_output` should
|
|
24
|
+
declare `>= 3.1.0`.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **`agent_query_output` (tool 84) and large-result offload.** A UI tree, a
|
|
29
|
+
logcat window or an xcresult dump can be tens of thousands of lines. The
|
|
30
|
+
server met that with two lossy caps: `spawnCollect` dropped the OLDEST chunks
|
|
31
|
+
at `SPAWN_OUTPUT_CAP`, and error text was cut at 600 chars. For a build log or
|
|
32
|
+
a test run the interesting part is the END, so "truncated" often meant the
|
|
33
|
+
failure was gone; `ios_get_ui_tree` could write a file, but only when the
|
|
34
|
+
caller thought to pass `path`.
|
|
35
|
+
|
|
36
|
+
Prose payloads over 24 KB are now written whole to
|
|
37
|
+
`~/.claude/logs/multi-agent-toolkit/<tool>-<timestamp>.txt`, and the tool
|
|
38
|
+
returns a head + tail window with the line count and the path.
|
|
39
|
+
`agent_query_output {pattern, path?, context_lines?, max_matches?,
|
|
40
|
+
ignore_case?}` searches that file and returns matching lines with numbered
|
|
41
|
+
context, so a follow-up question does not mean re-running an expensive tool -
|
|
42
|
+
and for a UI dump a second run is not even the same evidence.
|
|
43
|
+
|
|
44
|
+
Only prose is offloaded: a tool that declares an `outputSchema` answers with
|
|
45
|
+
JSON the host parses as `structuredContent`, and a summary would break that
|
|
46
|
+
parse. A write failure falls back to returning the payload whole, because
|
|
47
|
+
spending context is recoverable and dropping the tail is not. Bad regex,
|
|
48
|
+
missing file and nothing-offloaded are reported as `ERROR:` strings, never
|
|
49
|
+
thrown.
|
|
50
|
+
|
|
51
|
+
Pattern source: the multi-agent pipeline's own Phase 4 Step 1.9 (cap the
|
|
52
|
+
diff, write the full copy to `.review-diff.txt`, leave a marker in the
|
|
53
|
+
prompt), which is the same shape yamadashy/repomix uses for packed output.
|
|
54
|
+
Logic lives in `tools/offload/` with 17 unit tests covering the threshold,
|
|
55
|
+
byte-for-byte preservation, tail retention, retrieval of content the inline
|
|
56
|
+
window omitted, the match cap, and every failure mode.
|
|
57
|
+
|
|
58
|
+
### Fixed (same release, found by reviewing the above)
|
|
59
|
+
|
|
60
|
+
- **The failure path kept none of this.** `run()` / `runAsync()` cut a failed
|
|
61
|
+
command's output to `ERROR_MAX_CHARS` (600) at the source, and the CallTool
|
|
62
|
+
handler returns on `isFailure` before the offload branch - so the case the
|
|
63
|
+
offload was justified by (xcodebuild puts the failing assertion and the
|
|
64
|
+
compiler error at the END) was untouched, and only successful large outputs
|
|
65
|
+
were saved. `truncateError` now offloads the full text and returns a
|
|
66
|
+
character-window head + tail with the path; the old head-only cut remains
|
|
67
|
+
only as the fallback when the write fails.
|
|
68
|
+
- **`agent_query_output` accepted any absolute path.** It resolved
|
|
69
|
+
`args.path` unvalidated, which made a tool whose purpose is "read back what
|
|
70
|
+
this server saved" into an arbitrary-file reader - the opposite direction from
|
|
71
|
+
the 2.26.0 hardening. A caller-supplied path is now confined to the offload
|
|
72
|
+
directory, and confinement is checked BEFORE existence so the refusal cannot
|
|
73
|
+
be used as an existence oracle for paths outside it. The path this server
|
|
74
|
+
recorded itself needs no check: it wrote it.
|
|
75
|
+
- **The offload directory had no retention owner.** It grew without bound at
|
|
76
|
+
24 KB per entry; the pipeline's `prune-logs` targets a different path and an
|
|
77
|
+
MCP-only user has no pipeline at all. A successful write now prunes the
|
|
78
|
+
directory to the newest 50 files and drops anything older than 7 days, with
|
|
79
|
+
non-`.txt` files and unreadable directories left alone. The file just written
|
|
80
|
+
is never pruned regardless of the retention numbers - review round 1 found
|
|
81
|
+
that a `keepFiles` of 0 deleted it while the returned text still promised its
|
|
82
|
+
path, which is the payload loss this module exists to prevent.
|
|
83
|
+
- **`agent_query_output` carried no `readOnlyHint`.** It was absent from
|
|
84
|
+
`READ_ONLY_TOOLS`, and the test asserting its contract said "read-only" in its
|
|
85
|
+
name while checking only the input schema. Both fixed; the annotation is now
|
|
86
|
+
asserted.
|
|
87
|
+
|
|
20
88
|
## 2.26.0
|
|
21
89
|
|
|
22
90
|
Security-hardening release from a multi-agent refactor audit. No tool added or
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
🇹🇷 Türkçe: [README.tr.md](./README.tr.md)
|
|
9
9
|
|
|
10
|
-
**
|
|
10
|
+
**84 tools** for iOS Simulator, Android Emulator, and headless web control. MCP server that lets your AI coding assistant see, interact with, and audit your mobile apps - plus drive browsers, run an 18-rule App Store compliance audit, and orchestrate multi-step batch flows.
|
|
11
11
|
|
|
12
12
|
Distributed on the **public npm registry** - `npx @mmerterden/multi-agent-toolkit-mcp` resolves with no auth, no token, no `~/.npmrc` setup.
|
|
13
13
|
|
|
@@ -22,7 +22,7 @@ That distinction is worth keeping straight. This line once called five hosts "th
|
|
|
22
22
|
- **Store Compliance** (5 tools) - App Store / Play Store readiness; **18-rule deep `ios_app_store_audit`** cross-references Apple ITMS error codes + App Store Review Guidelines (privacy manifest, required-reason API, Info.plist, code signing, entitlements, embedded SDK, IPv6, debug-tool leak, ...)
|
|
23
23
|
- **Web Automation** (8 tools) - Playwright-powered: goto, click (CSS selectors), type, eval JS, wait for selector, extract text, screenshot. Chromium / WebKit / Firefox engines. **Requires `playwright` peer dependency.**
|
|
24
24
|
- **Design Audit** (6 tools) - mock-mode vs Figma conformance: scenario inventory, mock detection, mock launch, live UI geometry, pixel/geometry/typography compare, and the HTML/PDF report with its coverage gate
|
|
25
|
-
- **Autonomous Agent DSL** (
|
|
25
|
+
- **Autonomous Agent DSL** (2 tools) - `agent_run_steps` executes a batch array of {tool, args, continue_on_error?, wait_ms?} steps in one MCP round trip. Ideal for scripted login flows, form fills, multi-step QA paths. `agent_query_output` searches the full output of an earlier call that was too large to return inline, so a follow-up question does not mean re-running an expensive tool.
|
|
26
26
|
|
|
27
27
|
## Quick Start
|
|
28
28
|
|
package/README.tr.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
🇬🇧 English: [README.md](./README.md)
|
|
9
9
|
|
|
10
|
-
iOS Simulator, Android Emulator ve headless web kontrolü için **
|
|
10
|
+
iOS Simulator, Android Emulator ve headless web kontrolü için **84 araç**. AI kodlama asistanının mobil uygulamalarını görmesini, onlarla etkileşime girmesini ve denetlemesini sağlayan bir MCP sunucusu - ayrıca tarayıcıları sürer, 18-kurallık bir App Store uyumluluk denetimi çalıştırır ve çok-adımlı batch akışlarını orkestre eder.
|
|
11
11
|
|
|
12
12
|
**Public npm registry** üzerinden dağıtılır - `npx @mmerterden/multi-agent-toolkit-mcp`, auth'suz, token'sız, `~/.npmrc` ayarı gerekmeden çözülür.
|
|
13
13
|
|
|
@@ -22,7 +22,7 @@ Bu ayrımı net tutmakta fayda var. Bu satır bir zamanlar beş host'u "multi-ag
|
|
|
22
22
|
- **Store Compliance** (5 araç) - App Store / Play Store hazırlığı; **18-kurallık derin `ios_app_store_audit`**, Apple ITMS hata kodları + App Store Review Guidelines'a çapraz referans verir (privacy manifest, required-reason API, Info.plist, code signing, entitlements, gömülü SDK, IPv6, debug-tool sızıntısı, ...)
|
|
23
23
|
- **Web Automation** (8 araç) - Playwright-destekli: goto, click (CSS selector'lar), type, JS eval, selector bekleme, metin çıkarma, screenshot. Chromium / WebKit / Firefox motorları. **`playwright` peer dependency'si gerektirir.**
|
|
24
24
|
- **Design Audit** (6 araç) - mock-mode vs Figma uygunluğu: scenario envanteri, mock tespiti, mock launch, canlı UI geometrisi, piksel/geometri/tipografi karşılaştırması, ve coverage kapısıyla birlikte HTML/PDF rapor
|
|
25
|
-
- **Autonomous Agent DSL** (
|
|
25
|
+
- **Autonomous Agent DSL** (2 araç) - `agent_run_steps`, tek bir MCP round trip'inde {tool, args, continue_on_error?, wait_ms?} adımlarından oluşan bir batch dizisini çalıştırır. Scriptlenmiş login akışları, form doldurma, çok-adımlı QA yolları için ideal. `agent_query_output`, satır içi dönemeyecek kadar büyük olan önceki bir çağrının tam çıktısını arar; böylece bir takip sorusu pahalı aracı yeniden koşturmak anlamına gelmez.
|
|
26
26
|
|
|
27
27
|
## Hızlı Başlangıç
|
|
28
28
|
|
package/index.js
CHANGED
|
@@ -30,6 +30,11 @@ import {
|
|
|
30
30
|
import { DESIGN_TOOLS, handleDesign } from "./tools/design-check/index.js";
|
|
31
31
|
import { interactiveElements } from "./tools/ui-inspect/index.js";
|
|
32
32
|
import { selectCrashReports } from "./tools/crash-logs/index.js";
|
|
33
|
+
import {
|
|
34
|
+
offloadLargeText,
|
|
35
|
+
queryOffloadedOutput,
|
|
36
|
+
offloadedErrorSummary,
|
|
37
|
+
} from "./tools/offload/index.js";
|
|
33
38
|
|
|
34
39
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
35
40
|
const SCREENSHOT_DIR = join(process.env.TMPDIR || "/tmp", "mobile-dev-mcp");
|
|
@@ -47,9 +52,16 @@ const ERROR_PREFIX = "ERROR: ";
|
|
|
47
52
|
// head of the message.
|
|
48
53
|
const ERROR_MAX_CHARS = 600;
|
|
49
54
|
|
|
55
|
+
// A long failure is the case where the dropped part matters most: xcodebuild
|
|
56
|
+
// puts the failing assertion and the compiler error at the END of its output, so
|
|
57
|
+
// cutting at ERROR_MAX_CHARS deleted the answer and kept the banner. The full
|
|
58
|
+
// text now goes to disk and the caller gets a head + tail window with the path.
|
|
59
|
+
// Only the fallback - when the write fails - is the old head-only truncation.
|
|
50
60
|
function truncateError(msg) {
|
|
51
61
|
const flat = String(msg).trim();
|
|
52
62
|
if (flat.length <= ERROR_MAX_CHARS) return flat;
|
|
63
|
+
const { offloaded, path } = offloadLargeText("error", flat, { minChars: ERROR_MAX_CHARS });
|
|
64
|
+
if (offloaded) return offloadedErrorSummary(flat, path);
|
|
53
65
|
return `${flat.slice(0, ERROR_MAX_CHARS)}\n... [${flat.length - ERROR_MAX_CHARS} more chars truncated]`;
|
|
54
66
|
}
|
|
55
67
|
|
|
@@ -1197,6 +1209,20 @@ const AGENT_TOOLS = [
|
|
|
1197
1209
|
required: ["steps"],
|
|
1198
1210
|
},
|
|
1199
1211
|
},
|
|
1212
|
+
{
|
|
1213
|
+
name: "agent_query_output",
|
|
1214
|
+
description: "Search the full output of a previous tool call that was too large to return inline. When a tool's payload is offloaded, its text ends with the path of the saved file; this reads that file (the most recent one by default) and returns only the matching lines with context. Use it instead of re-running an expensive tool - re-running a UI dump gives a different tree, so a second call is not the same evidence.",
|
|
1215
|
+
inputSchema: {
|
|
1216
|
+
type: "object",
|
|
1217
|
+
properties: {
|
|
1218
|
+
pattern: { type: "string", description: "JavaScript regular expression, matched per line. Omit to get the file's head." },
|
|
1219
|
+
path: { type: "string", description: "Which offloaded file to read. Defaults to the most recent one this server wrote." },
|
|
1220
|
+
context_lines: { type: "number", description: "Lines of context around each match. Default 2." },
|
|
1221
|
+
max_matches: { type: "number", description: "Stop after this many matches. Default 50." },
|
|
1222
|
+
ignore_case: { type: "boolean", description: "Case-insensitive match. Default false." },
|
|
1223
|
+
},
|
|
1224
|
+
},
|
|
1225
|
+
},
|
|
1200
1226
|
];
|
|
1201
1227
|
|
|
1202
1228
|
// design_* is dispatched here too: the tool description offers it, and a design
|
|
@@ -1221,6 +1247,7 @@ async function dispatchStep(tool, stepArgs) {
|
|
|
1221
1247
|
}
|
|
1222
1248
|
|
|
1223
1249
|
async function handleAgent(name, args) {
|
|
1250
|
+
if (name === "agent_query_output") return queryOffloadedOutput(args, ERROR_PREFIX);
|
|
1224
1251
|
if (name !== "agent_run_steps") return null;
|
|
1225
1252
|
const steps = Array.isArray(args.steps) ? args.steps : [];
|
|
1226
1253
|
const stopOnError = args.stop_on_first_error !== false;
|
|
@@ -1341,6 +1368,7 @@ function validateArgs(name, args) {
|
|
|
1341
1368
|
// for deciding what may run unattended, so the classification lives here in one
|
|
1342
1369
|
// auditable place rather than inline on every tool literal.
|
|
1343
1370
|
const READ_ONLY_TOOLS = new Set([
|
|
1371
|
+
"agent_query_output",
|
|
1344
1372
|
"ios_list_devices", "ios_screenshot", "ios_list_apps", "ios_get_ui_tree", "ios_get_app_container",
|
|
1345
1373
|
"ios_accessibility_audit", "ios_archive_audit", "ios_app_store_audit", "ios_xcresult", "ios_visual_diff",
|
|
1346
1374
|
"ios_list_crashes",
|
|
@@ -1700,6 +1728,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
|
1700
1728
|
if (isFailure(result)) {
|
|
1701
1729
|
return { content: [{ type: "text", text: String(result) }], isError: true };
|
|
1702
1730
|
}
|
|
1731
|
+
// Prose payloads only: a tool with an outputSchema answers with JSON the
|
|
1732
|
+
// caller parses, and a head+tail summary would break that parse. The query
|
|
1733
|
+
// tool is exempt for the obvious reason.
|
|
1734
|
+
if (!OUTPUT_SCHEMAS[name] && name !== "agent_query_output") {
|
|
1735
|
+
const { text, offloaded } = offloadLargeText(name, String(result));
|
|
1736
|
+
if (offloaded) return { content: [{ type: "text", text }] };
|
|
1737
|
+
return withStructured(name, text);
|
|
1738
|
+
}
|
|
1703
1739
|
return withStructured(name, String(result));
|
|
1704
1740
|
} catch (e) {
|
|
1705
1741
|
return {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmerterden/multi-agent-toolkit-mcp",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "MCP server for iOS Simulator, Android Emulator and headless web control.
|
|
3
|
+
"version": "3.1.0",
|
|
4
|
+
"description": "MCP server for iOS Simulator, Android Emulator and headless web control. 84 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",
|
|
7
7
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"start": "node index.js",
|
|
13
|
-
"test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
|
|
13
|
+
"test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/offload/__tests__/offload.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
|
|
14
14
|
"gates": "bash scripts/gates.sh"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Large-result offload.
|
|
2
|
+
//
|
|
3
|
+
// A UI tree, a logcat window or an xcresult dump can be tens of thousands of
|
|
4
|
+
// lines. Returning it whole spends the caller's whole context; truncating it in
|
|
5
|
+
// place - which is what this server used to do, dropping the oldest chunks at
|
|
6
|
+
// SPAWN_OUTPUT_CAP and cutting error text at 600 chars - throws the tail away,
|
|
7
|
+
// and the tail is where the assertion failure and the crash frame live.
|
|
8
|
+
//
|
|
9
|
+
// So: write the payload to a file, return a head + tail window plus the path,
|
|
10
|
+
// and let the caller pull what it needs with agent_query_output. The pipeline
|
|
11
|
+
// already solved the same problem this way in Phase 4 Step 1.9 (diff cap, full
|
|
12
|
+
// diff to `.review-diff.txt`, marker in the prompt); this is that pattern.
|
|
13
|
+
//
|
|
14
|
+
// Only prose payloads are offloaded by the caller. A tool that declares an
|
|
15
|
+
// outputSchema answers with JSON the host parses as structuredContent, and
|
|
16
|
+
// replacing that with a summary would break the parse.
|
|
17
|
+
|
|
18
|
+
import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from "fs";
|
|
19
|
+
import { join, resolve, sep } from "path";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
|
|
22
|
+
export const OFFLOAD_DIR = join(homedir(), ".claude", "logs", "multi-agent-toolkit");
|
|
23
|
+
export const OFFLOAD_MIN_CHARS = 24 * 1024;
|
|
24
|
+
const HEAD_LINES = 40;
|
|
25
|
+
const TAIL_LINES = 20;
|
|
26
|
+
|
|
27
|
+
// Retention lives with the writer, not with an external cleaner. An MCP-only
|
|
28
|
+
// user has no pipeline installed, so "some other tool prunes it" would mean
|
|
29
|
+
// nobody does, and these files are 24 KB and up. Newest-N plus an age cut, both
|
|
30
|
+
// applied after a successful write, cost one readdir.
|
|
31
|
+
export const OFFLOAD_KEEP_FILES = 50;
|
|
32
|
+
export const OFFLOAD_KEEP_DAYS = 7;
|
|
33
|
+
|
|
34
|
+
// `keep` is the file the caller just wrote. It is never deleted, whatever the
|
|
35
|
+
// retention numbers say: the returned text promises that path to the caller, and
|
|
36
|
+
// this module exists precisely because losing the payload is the failure mode. A
|
|
37
|
+
// keepFiles of 0 must bound the directory, not break the answer.
|
|
38
|
+
export function pruneOffloadDir(dir, opts = {}) {
|
|
39
|
+
const keepFiles = opts.keepFiles ?? OFFLOAD_KEEP_FILES;
|
|
40
|
+
const keepDays = opts.keepDays ?? OFFLOAD_KEEP_DAYS;
|
|
41
|
+
const now = opts.now ?? Date.now();
|
|
42
|
+
const keep = typeof opts.keep === "string" ? resolve(opts.keep) : null;
|
|
43
|
+
const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
|
|
44
|
+
let removed = 0;
|
|
45
|
+
try {
|
|
46
|
+
const entries = readdirSync(dir)
|
|
47
|
+
.filter((n) => n.endsWith(".txt"))
|
|
48
|
+
.map((n) => {
|
|
49
|
+
const full = join(dir, n);
|
|
50
|
+
try {
|
|
51
|
+
return { full, mtime: statSync(full).mtimeMs };
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < entries.length; i++) {
|
|
60
|
+
if (keep && resolve(entries[i].full) === keep) continue;
|
|
61
|
+
const tooOld = entries[i].mtime < cutoff;
|
|
62
|
+
const tooMany = i >= keepFiles;
|
|
63
|
+
if (!tooOld && !tooMany) continue;
|
|
64
|
+
try {
|
|
65
|
+
unlinkSync(entries[i].full);
|
|
66
|
+
removed++;
|
|
67
|
+
} catch {
|
|
68
|
+
// A file another process holds open is skipped, not fatal.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// No directory yet, or an unreadable one: nothing to prune.
|
|
73
|
+
}
|
|
74
|
+
return removed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let lastOffload = null;
|
|
78
|
+
|
|
79
|
+
export function lastOffloadRecord() {
|
|
80
|
+
return lastOffload;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function resetOffloadState() {
|
|
84
|
+
lastOffload = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Errors arrive as one or two long lines, so the line-based window degenerates:
|
|
88
|
+
// head and tail would both be the whole message. This builds the equivalent
|
|
89
|
+
// window in characters and is used by the failure path.
|
|
90
|
+
export function offloadedErrorSummary(text, path, headChars = 400, tailChars = 250) {
|
|
91
|
+
if (text.length <= headChars + tailChars) return text;
|
|
92
|
+
const omitted = text.length - headChars - tailChars;
|
|
93
|
+
return (
|
|
94
|
+
`${text.slice(0, headChars)}\n... [${omitted} chars omitted. Full output at ${path} - ` +
|
|
95
|
+
`read it with agent_query_output {pattern: "..."} ] ...\n${text.slice(-tailChars)}`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function offloadLargeText(tool, text, opts = {}) {
|
|
100
|
+
const dir = opts.dir ?? OFFLOAD_DIR;
|
|
101
|
+
const minChars = opts.minChars ?? OFFLOAD_MIN_CHARS;
|
|
102
|
+
const stamp = opts.stamp ?? new Date().toISOString().replace(/[:.]/g, "-");
|
|
103
|
+
if (text.length < minChars) return { text, offloaded: false };
|
|
104
|
+
|
|
105
|
+
let path;
|
|
106
|
+
try {
|
|
107
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
108
|
+
path = join(dir, `${tool}-${stamp}.txt`);
|
|
109
|
+
writeFileSync(path, text);
|
|
110
|
+
} catch {
|
|
111
|
+
// A write failure must not lose the payload: fall back to returning it
|
|
112
|
+
// whole. Spending context is recoverable; dropping the tail is not.
|
|
113
|
+
return { text, offloaded: false };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
pruneOffloadDir(dir, { ...opts, keep: path });
|
|
117
|
+
|
|
118
|
+
const lines = text.split("\n");
|
|
119
|
+
lastOffload = { tool, path, lines: lines.length, bytes: text.length };
|
|
120
|
+
const head = lines.slice(0, HEAD_LINES).join("\n");
|
|
121
|
+
const tail = lines.slice(-TAIL_LINES).join("\n");
|
|
122
|
+
const hidden = Math.max(0, lines.length - HEAD_LINES - TAIL_LINES);
|
|
123
|
+
return {
|
|
124
|
+
offloaded: true,
|
|
125
|
+
path,
|
|
126
|
+
text:
|
|
127
|
+
`${head}\n\n[... ${hidden} line(s) not shown. Full output (${lines.length} lines, ` +
|
|
128
|
+
`${text.length} bytes) saved to ${path} - search it with ` +
|
|
129
|
+
`agent_query_output {pattern: "..."} instead of re-running this tool ...]\n\n${tail}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A caller-supplied path is confined to the offload directory. The tool's whole
|
|
134
|
+
// purpose is "read back the file this server just wrote", and accepting any
|
|
135
|
+
// absolute path made it an arbitrary-file reader - which contradicts the 2.26.0
|
|
136
|
+
// hardening pass rather than extending it. The path this server itself recorded
|
|
137
|
+
// needs no check: it wrote it.
|
|
138
|
+
function pathAllowed(candidate, allowedDirs) {
|
|
139
|
+
const target = resolve(candidate);
|
|
140
|
+
return allowedDirs.some((dir) => {
|
|
141
|
+
const root = resolve(dir);
|
|
142
|
+
return target === root || target.startsWith(root + sep);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Reads back an offloaded payload. Returns matching lines with context rather
|
|
147
|
+
// than the file, so a 40k-line UI tree answers a question without becoming the
|
|
148
|
+
// answer. A bad regex is reported, never thrown: the caller can fix a pattern
|
|
149
|
+
// but cannot fix a crashed server.
|
|
150
|
+
export function queryOffloadedOutput(args = {}, errorPrefix = "ERROR: ", opts = {}) {
|
|
151
|
+
const allowedDirs = opts.allowedDirs ?? [OFFLOAD_DIR];
|
|
152
|
+
const asked = typeof args.path === "string" && args.path ? args.path : null;
|
|
153
|
+
if (asked && !pathAllowed(asked, allowedDirs)) {
|
|
154
|
+
return `${errorPrefix}path outside the offload directory is refused: ${asked}. This tool reads only what this server saved under ${allowedDirs.join(", ")}.`;
|
|
155
|
+
}
|
|
156
|
+
const path = asked ?? lastOffload?.path;
|
|
157
|
+
if (!path) {
|
|
158
|
+
return `${errorPrefix}no offloaded output to search: nothing has been saved in this session, and no path was given`;
|
|
159
|
+
}
|
|
160
|
+
if (!existsSync(path)) {
|
|
161
|
+
return `${errorPrefix}offloaded output not found at ${path}`;
|
|
162
|
+
}
|
|
163
|
+
let body;
|
|
164
|
+
try {
|
|
165
|
+
body = readFileSync(path, "utf8");
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return `${errorPrefix}cannot read ${path}: ${e.message}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const lines = body.split("\n");
|
|
171
|
+
const contextLines = Number.isFinite(args.context_lines)
|
|
172
|
+
? Math.max(0, Math.trunc(args.context_lines))
|
|
173
|
+
: 2;
|
|
174
|
+
const maxMatches = Number.isFinite(args.max_matches) ? Math.max(1, Math.trunc(args.max_matches)) : 50;
|
|
175
|
+
|
|
176
|
+
if (typeof args.pattern !== "string" || args.pattern === "") {
|
|
177
|
+
return `${path} (${lines.length} lines)\n\n${lines.slice(0, 60).join("\n")}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let re;
|
|
181
|
+
try {
|
|
182
|
+
re = new RegExp(args.pattern, args.ignore_case ? "i" : "");
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return `${errorPrefix}invalid pattern: ${e.message}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const blocks = [];
|
|
188
|
+
let matches = 0;
|
|
189
|
+
for (let i = 0; i < lines.length && matches < maxMatches; i++) {
|
|
190
|
+
if (!re.test(lines[i])) continue;
|
|
191
|
+
matches++;
|
|
192
|
+
const from = Math.max(0, i - contextLines);
|
|
193
|
+
const to = Math.min(lines.length - 1, i + contextLines);
|
|
194
|
+
const chunk = [];
|
|
195
|
+
for (let j = from; j <= to; j++) chunk.push(`${j + 1}${j === i ? ":" : "-"} ${lines[j]}`);
|
|
196
|
+
blocks.push(chunk.join("\n"));
|
|
197
|
+
}
|
|
198
|
+
if (!matches) return `${path}: no line matches /${args.pattern}/ (${lines.length} lines searched)`;
|
|
199
|
+
const capped = matches >= maxMatches ? ` (stopped at max_matches=${maxMatches})` : "";
|
|
200
|
+
return `${path}: ${matches} match(es)${capped}\n\n${blocks.join("\n--\n")}`;
|
|
201
|
+
}
|