@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-toolkit-mcp",
3
- "version": "3.12.0",
4
- "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 99 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.",
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
- "playwright": ">=1.60.0 <2"
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
+ }
@@ -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 ...]\n\n${tail}`,
230
+ `agent_query_output {pattern: "..."} instead of re-running this tool ...]` +
231
+ `${signal}\n\n${tail}`,
176
232
  };
177
233
  }
178
234