@mmerterden/multi-agent-toolkit-mcp 3.12.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 +177 -21
- package/README.md +6 -3
- package/README.tr.md +6 -3
- package/index.js +857 -14
- package/package.json +17 -4
- package/tools/context/index.js +192 -0
- package/tools/offload/index.js +57 -1
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.13.1",
|
|
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",
|
|
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/code-intel/__tests__/code-intel.test.mjs tools/pass-kit/__tests__/pass-kit.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/a11y/__tests__/a11y.test.mjs tools/launch-time/__tests__/launch-time.test.mjs tools/memory/__tests__/memory.test.mjs tools/offload/__tests__/offload.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/code-intel/__tests__/code-intel.test.mjs tools/pass-kit/__tests__/pass-kit.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/a11y/__tests__/a11y.test.mjs tools/launch-time/__tests__/launch-time.test.mjs tools/memory/__tests__/memory.test.mjs tools/offload/__tests__/offload.test.mjs tools/context/__tests__/context.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
|
|
14
14
|
"gates": "bash scripts/gates.sh"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Full-text index over offloaded payloads.
|
|
2
|
+
//
|
|
3
|
+
// The offload module already writes a large result to a file and hands back a
|
|
4
|
+
// head+tail window plus the path, and `agent_query_output` greps that file.
|
|
5
|
+
// Grep answers "which lines contain this string"; it cannot answer "which part
|
|
6
|
+
// of this is about X", and it has no notion of one passage being a better match
|
|
7
|
+
// than another. For a 40k-line log that is the difference between reading the
|
|
8
|
+
// right 60 lines and reading the first 60 that happen to contain the word.
|
|
9
|
+
//
|
|
10
|
+
// So the same file is also indexed: chunked, stored in SQLite FTS5, and ranked
|
|
11
|
+
// with bm25(). node:sqlite ships with Node, so this costs no dependency.
|
|
12
|
+
//
|
|
13
|
+
// Three layers, cheapest first:
|
|
14
|
+
// context_search ranked snippets, ~50-100 tokens, enough to decide
|
|
15
|
+
// context_get one chunk in full, by the id search returned
|
|
16
|
+
// the file path everything, which is what the caller already had
|
|
17
|
+
//
|
|
18
|
+
// agent_query_output is untouched. It is in the cross-CLI contract and removing
|
|
19
|
+
// it would be a breaking change; this sits beside it.
|
|
20
|
+
|
|
21
|
+
import { DatabaseSync } from "node:sqlite";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
|
|
27
|
+
// Overridable so a gate can index a fixture corpus without writing into the
|
|
28
|
+
// caller's real index, and so the ranking it asserts is over a known set rather
|
|
29
|
+
// than over whatever that machine happened to offload last.
|
|
30
|
+
export const INDEX_DIR =
|
|
31
|
+
process.env.MCP_TOOLKIT_INDEX_DIR ||
|
|
32
|
+
join(homedir(), ".claude", "logs", "multi-agent-toolkit", ".index");
|
|
33
|
+
|
|
34
|
+
// Chunks are lines, not bytes: a log line cut in half matches nothing and reads
|
|
35
|
+
// as nonsense. 40 lines is small enough that a hit points at a place and large
|
|
36
|
+
// enough to carry its own context.
|
|
37
|
+
export const CHUNK_LINES = 40;
|
|
38
|
+
export const CHUNK_OVERLAP = 8;
|
|
39
|
+
|
|
40
|
+
function dbPathFor(dir) {
|
|
41
|
+
return join(dir, "context.db");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function openIndex(dir = INDEX_DIR) {
|
|
45
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
46
|
+
const db = new DatabaseSync(dbPathFor(dir));
|
|
47
|
+
db.exec(`
|
|
48
|
+
create table if not exists docs (
|
|
49
|
+
id integer primary key,
|
|
50
|
+
path text not null,
|
|
51
|
+
mtime integer not null,
|
|
52
|
+
size integer not null,
|
|
53
|
+
unique(path)
|
|
54
|
+
);
|
|
55
|
+
create virtual table if not exists chunks using fts5(
|
|
56
|
+
body,
|
|
57
|
+
doc_id unindexed,
|
|
58
|
+
first_line unindexed,
|
|
59
|
+
last_line unindexed
|
|
60
|
+
);
|
|
61
|
+
`);
|
|
62
|
+
return db;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function chunk(text, lines = CHUNK_LINES, overlap = CHUNK_OVERLAP) {
|
|
66
|
+
const all = String(text).split("\n");
|
|
67
|
+
const out = [];
|
|
68
|
+
const step = Math.max(1, lines - overlap);
|
|
69
|
+
for (let start = 0; start < all.length; start += step) {
|
|
70
|
+
const slice = all.slice(start, start + lines);
|
|
71
|
+
if (!slice.join("").trim()) continue;
|
|
72
|
+
out.push({ body: slice.join("\n"), firstLine: start + 1, lastLine: start + slice.length });
|
|
73
|
+
if (start + lines >= all.length) break;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Re-indexing an unchanged file is wasted work AND duplicate hits, so the
|
|
79
|
+
// (mtime, size) pair decides. It is not a hash: hashing a 40 MB log to learn
|
|
80
|
+
// nothing changed costs more than the indexing it saves.
|
|
81
|
+
export function indexFile(db, path) {
|
|
82
|
+
if (!existsSync(path)) return { indexed: false, reason: "no such file" };
|
|
83
|
+
const st = statSync(path);
|
|
84
|
+
const existing = db.prepare("select id, mtime, size from docs where path = ?").get(path);
|
|
85
|
+
if (existing && existing.mtime === Math.floor(st.mtimeMs) && existing.size === st.size) {
|
|
86
|
+
const n = db.prepare("select count(*) as n from chunks where doc_id = ?").get(existing.id);
|
|
87
|
+
return { indexed: false, reason: "unchanged", docId: existing.id, chunks: n?.n ?? 0 };
|
|
88
|
+
}
|
|
89
|
+
if (existing) {
|
|
90
|
+
db.prepare("delete from chunks where doc_id = ?").run(existing.id);
|
|
91
|
+
db.prepare("delete from docs where id = ?").run(existing.id);
|
|
92
|
+
}
|
|
93
|
+
db.prepare("insert into docs (path, mtime, size) values (?, ?, ?)").run(
|
|
94
|
+
path,
|
|
95
|
+
Math.floor(st.mtimeMs),
|
|
96
|
+
st.size,
|
|
97
|
+
);
|
|
98
|
+
const docId = db.prepare("select id from docs where path = ?").get(path).id;
|
|
99
|
+
const insert = db.prepare(
|
|
100
|
+
"insert into chunks (body, doc_id, first_line, last_line) values (?, ?, ?, ?)",
|
|
101
|
+
);
|
|
102
|
+
const pieces = chunk(readFileSync(path, "utf8"));
|
|
103
|
+
for (const p of pieces) insert.run(p.body, docId, p.firstLine, p.lastLine);
|
|
104
|
+
return { indexed: true, docId, chunks: pieces.length };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// FTS5 treats a bare `-` or an unbalanced quote as syntax, and a caller typing
|
|
108
|
+
// a log line into the query gets a SQL error instead of results. Quoting each
|
|
109
|
+
// term makes every query a literal phrase search, which is what a caller
|
|
110
|
+
// searching a log actually means.
|
|
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" } = {}) {
|
|
120
|
+
const terms = String(query)
|
|
121
|
+
.split(/\s+/)
|
|
122
|
+
.map((t) => t.replace(/"/g, ""))
|
|
123
|
+
.filter(Boolean);
|
|
124
|
+
if (!terms.length) return null;
|
|
125
|
+
return terms.map((t) => `"${t}"`).join(mode === "or" ? " OR " : " ");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function search(db, query, { limit = 5, path = null } = {}) {
|
|
129
|
+
const where = path
|
|
130
|
+
? "where chunks match ? and doc_id = (select id from docs where path = ?)"
|
|
131
|
+
: "where chunks match ?";
|
|
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
|
+
}
|
|
152
|
+
return rows.map((r) => ({
|
|
153
|
+
id: r.rowid,
|
|
154
|
+
docId: r.doc_id,
|
|
155
|
+
path: r.path,
|
|
156
|
+
firstLine: r.first_line,
|
|
157
|
+
lastLine: r.last_line,
|
|
158
|
+
score: r.score,
|
|
159
|
+
snippet: snippetOf(r.body, query),
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A window around the first matching line, not the head of the chunk: the
|
|
164
|
+
// caller is deciding whether to open this, and the head of a 40-line chunk
|
|
165
|
+
// often says nothing about why it matched.
|
|
166
|
+
export function snippetOf(body, query, radius = 2) {
|
|
167
|
+
const lines = String(body).split("\n");
|
|
168
|
+
const needles = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
169
|
+
let hit = lines.findIndex((l) => needles.some((n) => l.toLowerCase().includes(n)));
|
|
170
|
+
if (hit < 0) hit = 0;
|
|
171
|
+
const from = Math.max(0, hit - radius);
|
|
172
|
+
return lines.slice(from, hit + radius + 1).join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function getChunk(db, id) {
|
|
176
|
+
const row = db
|
|
177
|
+
.prepare(
|
|
178
|
+
`select c.rowid as id, c.body, c.first_line, c.last_line, d.path
|
|
179
|
+
from chunks c join docs d on d.id = c.doc_id where c.rowid = ?`,
|
|
180
|
+
)
|
|
181
|
+
.get(Number(id));
|
|
182
|
+
return row ?? null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function docPathId(path) {
|
|
186
|
+
return createHash("sha256").update(String(path)).digest("hex").slice(0, 12);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function ensureParent(path) {
|
|
190
|
+
const parent = dirname(path);
|
|
191
|
+
if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
|
|
192
|
+
}
|
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
|
|