@aexol/spectral 0.2.6 → 0.2.8
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/dist/mcp/tool-registrar.js +46 -7
- package/dist/server/pi-bridge.js +141 -35
- package/package.json +1 -1
|
@@ -1,28 +1,67 @@
|
|
|
1
1
|
// tool-registrar.ts - MCP content transformation
|
|
2
2
|
// NOTE: Tools are NOT registered with Pi - only the unified `mcp` proxy tool is registered.
|
|
3
3
|
// This keeps the LLM context small (1 tool instead of 100s).
|
|
4
|
+
/**
|
|
5
|
+
* Maximum characters per text block from MCP tool results.
|
|
6
|
+
*
|
|
7
|
+
* MCP tools (especially Playwright's browser_snapshot, browser_evaluate,
|
|
8
|
+
* browser_network_requests) can return massive content — accessibility
|
|
9
|
+
* trees of 100-500KB, large JSON arrays, etc. When passed untruncated into
|
|
10
|
+
* pi's ToolResultMessage.content, they blow up the LLM context window,
|
|
11
|
+
* causing API errors (400/413) and dead sessions ("skipping empty
|
|
12
|
+
* intermediate message" loops).
|
|
13
|
+
*
|
|
14
|
+
* 20,000 characters ≈ 5,000-8,000 tokens depending on the model, which
|
|
15
|
+
* keeps individual tool results manageable even across multiple MCP calls
|
|
16
|
+
* in the same turn.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_MCP_TEXT_LENGTH = 20_000;
|
|
19
|
+
function truncateTextBlock(block) {
|
|
20
|
+
if (block.type !== "text")
|
|
21
|
+
return block;
|
|
22
|
+
if (block.text.length <= MAX_MCP_TEXT_LENGTH)
|
|
23
|
+
return block;
|
|
24
|
+
const omitted = block.text.length - MAX_MCP_TEXT_LENGTH;
|
|
25
|
+
return {
|
|
26
|
+
type: "text",
|
|
27
|
+
text: block.text.slice(0, MAX_MCP_TEXT_LENGTH) +
|
|
28
|
+
`\n\n[truncated — ${omitted.toLocaleString()} bytes omitted. ` +
|
|
29
|
+
`Use more specific tool calls or narrower queries to get complete results.]`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
4
32
|
/**
|
|
5
33
|
* Transform MCP content types to Pi content blocks.
|
|
34
|
+
*
|
|
35
|
+
* Text blocks exceeding MAX_MCP_TEXT_LENGTH are truncated to prevent
|
|
36
|
+
* context-window overflow in downstream LLM calls.
|
|
6
37
|
*/
|
|
7
38
|
export function transformMcpContent(content) {
|
|
8
39
|
return content.map(c => {
|
|
9
40
|
if (c.type === "text") {
|
|
10
|
-
return { type: "text", text: c.text ?? "" };
|
|
41
|
+
return truncateTextBlock({ type: "text", text: c.text ?? "" });
|
|
11
42
|
}
|
|
12
43
|
if (c.type === "image") {
|
|
44
|
+
// Most LLM APIs reject ImageContent blocks inside tool-result
|
|
45
|
+
// messages (even multimodal models often only accept images in
|
|
46
|
+
// user-turn content, not in tool results). Passing through
|
|
47
|
+
// base64 images here causes 400 errors and dead sessions.
|
|
48
|
+
// Convert to a text placeholder that preserves metadata.
|
|
49
|
+
const sizeBytes = c.data?.length ?? 0;
|
|
13
50
|
return {
|
|
14
|
-
type: "
|
|
15
|
-
|
|
16
|
-
|
|
51
|
+
type: "text",
|
|
52
|
+
text: `[Image: ${c.mimeType ?? "image/png"}, ${sizeBytes.toLocaleString()} bytes base64]` +
|
|
53
|
+
`\n(Image data is available in the raw MCP result — use direct tool\n` +
|
|
54
|
+
`calls like browser_take_screenshot or browser_run_code to\n` +
|
|
55
|
+
`save/process images to disk for further analysis.)`,
|
|
17
56
|
};
|
|
18
57
|
}
|
|
19
58
|
if (c.type === "resource") {
|
|
20
59
|
const resourceUri = c.resource?.uri ?? "(no URI)";
|
|
21
60
|
const resourceContent = c.resource?.text ?? (c.resource ? JSON.stringify(c.resource) : "(no content)");
|
|
22
|
-
return {
|
|
61
|
+
return truncateTextBlock({
|
|
23
62
|
type: "text",
|
|
24
63
|
text: `[Resource: ${resourceUri}]\n${resourceContent}`,
|
|
25
|
-
};
|
|
64
|
+
});
|
|
26
65
|
}
|
|
27
66
|
if (c.type === "resource_link") {
|
|
28
67
|
const linkName = c.name ?? c.uri ?? "unknown";
|
|
@@ -38,6 +77,6 @@ export function transformMcpContent(content) {
|
|
|
38
77
|
text: `[Audio content: ${c.mimeType ?? "audio/*"}]`,
|
|
39
78
|
};
|
|
40
79
|
}
|
|
41
|
-
return { type: "text", text: JSON.stringify(c) };
|
|
80
|
+
return truncateTextBlock({ type: "text", text: JSON.stringify(c) });
|
|
42
81
|
});
|
|
43
82
|
}
|
package/dist/server/pi-bridge.js
CHANGED
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
import { AuthStorage, createAgentSession, DefaultResourceLoader, ModelRegistry, SessionManager, } from "@mariozechner/pi-coding-agent";
|
|
52
52
|
import { createJiti } from "@mariozechner/jiti";
|
|
53
53
|
import { randomUUID } from "node:crypto";
|
|
54
|
-
import { existsSync
|
|
54
|
+
import { existsSync } from "node:fs";
|
|
55
55
|
import { dirname, resolve } from "node:path";
|
|
56
56
|
import { fileURLToPath } from "node:url";
|
|
57
57
|
import aexolMcpExtension from "../extensions/aexol-mcp.js";
|
|
@@ -91,38 +91,17 @@ function extractTextFromContent(content) {
|
|
|
91
91
|
return out;
|
|
92
92
|
}
|
|
93
93
|
/**
|
|
94
|
-
* Resolve the entry point of the pi-mcp-adapter extension
|
|
95
|
-
*
|
|
96
|
-
* Returns the absolute path, or null if the
|
|
94
|
+
* Resolve the entry point of the pi-mcp-adapter extension.
|
|
95
|
+
* Uses the bundled copy in dist/mcp/index.js (same directory as this file).
|
|
96
|
+
* Returns the absolute path, or null if the bundled file is missing.
|
|
97
97
|
*/
|
|
98
98
|
function resolveMcpAdapterEntry() {
|
|
99
99
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
const raw = readFileSync(pkgPath, "utf8");
|
|
107
|
-
const pkg = JSON.parse(raw);
|
|
108
|
-
const extRel = pkg.pi?.extensions?.[0];
|
|
109
|
-
if (extRel) {
|
|
110
|
-
return resolve(dirname(pkgPath), extRel);
|
|
111
|
-
}
|
|
112
|
-
// Package found but no pi.extensions — try index.ts as fallback
|
|
113
|
-
const indexTs = resolve(dirname(pkgPath), "index.ts");
|
|
114
|
-
if (existsSync(indexTs))
|
|
115
|
-
return indexTs;
|
|
116
|
-
break;
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
// package.json not readable at this level, keep walking up
|
|
120
|
-
}
|
|
121
|
-
const parent = dirname(dir);
|
|
122
|
-
if (parent === dir || parent === root)
|
|
123
|
-
break;
|
|
124
|
-
dir = parent;
|
|
125
|
-
}
|
|
100
|
+
// pi-bridge.ts compiles to dist/server/pi-bridge.js;
|
|
101
|
+
// the bundled MCP adapter sits next door at dist/mcp/index.js.
|
|
102
|
+
const bundledIndex = resolve(__dirname, "..", "mcp", "index.js");
|
|
103
|
+
if (existsSync(bundledIndex))
|
|
104
|
+
return bundledIndex;
|
|
126
105
|
return null;
|
|
127
106
|
}
|
|
128
107
|
/**
|
|
@@ -179,6 +158,84 @@ function lookupPricing(modelId) {
|
|
|
179
158
|
}
|
|
180
159
|
return null;
|
|
181
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Parse the newline-delimited JSON of wire events persisted alongside an
|
|
163
|
+
* assistant message and extract all tool_call / tool_result events.
|
|
164
|
+
*
|
|
165
|
+
* These are used during session rehydration to reconstruct the full tool
|
|
166
|
+
* interaction history (call → result) so the LLM context is complete —
|
|
167
|
+
* without this, reconnected sessions would see only the assistant text
|
|
168
|
+
* and never the tool results, causing the model to get confused about
|
|
169
|
+
* the conversation state.
|
|
170
|
+
*/
|
|
171
|
+
function parseWireToolEvents(eventsJsonl) {
|
|
172
|
+
const toolCalls = [];
|
|
173
|
+
const toolResults = [];
|
|
174
|
+
if (!eventsJsonl)
|
|
175
|
+
return { toolCalls, toolResults };
|
|
176
|
+
for (const line of eventsJsonl.split("\n")) {
|
|
177
|
+
const trimmed = line.trim();
|
|
178
|
+
if (!trimmed)
|
|
179
|
+
continue;
|
|
180
|
+
try {
|
|
181
|
+
const ev = JSON.parse(trimmed);
|
|
182
|
+
if (ev.type === "tool_call") {
|
|
183
|
+
toolCalls.push({
|
|
184
|
+
id: ev.id,
|
|
185
|
+
name: ev.name,
|
|
186
|
+
arguments: ev.args ?? {},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
else if (ev.type === "tool_result") {
|
|
190
|
+
toolResults.push({
|
|
191
|
+
id: ev.id,
|
|
192
|
+
result: ev.result,
|
|
193
|
+
isError: ev.isError ?? false,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Skip malformed JSON lines — they won't be meaningful for
|
|
199
|
+
// rehydration and we'd rather keep the session alive than fail.
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { toolCalls, toolResults };
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Safety clean-up for content blocks rehydrated from persisted wire events.
|
|
206
|
+
*
|
|
207
|
+
* Sessions created before the fixes in tool-registrar.ts may carry raw
|
|
208
|
+
* ImageContent (base64 blobs) and untruncated large text blocks in their
|
|
209
|
+
* events_jsonl. When replayed into a fresh session manager without cleanup
|
|
210
|
+
* they can trigger 400 API errors (unsupported image content in tool results)
|
|
211
|
+
* or context-window overflow — both surface as "skipping empty intermediate
|
|
212
|
+
* message" / hung sessions after reconnect.
|
|
213
|
+
*/
|
|
214
|
+
const MAX_REHYDRATED_TEXT = 20_000;
|
|
215
|
+
function sanitizeRehydratedBlock(block) {
|
|
216
|
+
// Convert images to text placeholders — most LLM APIs reject
|
|
217
|
+
// ImageContent inside tool-result messages (even multimodal
|
|
218
|
+
// models often only accept images in user-turn content).
|
|
219
|
+
if (block.type === "image") {
|
|
220
|
+
const sizeBytes = typeof block.data === "string" ? block.data.length : 0;
|
|
221
|
+
return {
|
|
222
|
+
type: "text",
|
|
223
|
+
text: `[Image: ${block.mimeType ?? "image/png"}, ${sizeBytes.toLocaleString()} bytes base64]` +
|
|
224
|
+
`\n(Image data is available in the raw MCP result.)`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
// Truncate oversized text blocks to prevent context overflow.
|
|
228
|
+
if (block.type !== "text")
|
|
229
|
+
return block;
|
|
230
|
+
if (block.text.length <= MAX_REHYDRATED_TEXT)
|
|
231
|
+
return block;
|
|
232
|
+
const omitted = block.text.length - MAX_REHYDRATED_TEXT;
|
|
233
|
+
return {
|
|
234
|
+
...block,
|
|
235
|
+
text: block.text.slice(0, MAX_REHYDRATED_TEXT) +
|
|
236
|
+
`\n\n[truncated — ${omitted.toLocaleString()} bytes omitted]`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
182
239
|
export class PiBridge {
|
|
183
240
|
session;
|
|
184
241
|
unsubscribe;
|
|
@@ -248,8 +305,11 @@ export class PiBridge {
|
|
|
248
305
|
const sessionManager = SessionManager.inMemory(this.opts.cwd);
|
|
249
306
|
// Rehydrate session history so the LLM sees the full conversation
|
|
250
307
|
// transcript from the beginning (not just the current prompt).
|
|
251
|
-
//
|
|
252
|
-
//
|
|
308
|
+
// Tool calls and their results are reconstructed from the persisted
|
|
309
|
+
// wire-event JSONL so the LLM context is complete — without them
|
|
310
|
+
// reconnected sessions would see assistant messages that reference
|
|
311
|
+
// tool invocations whose results never appear, causing hung/confused
|
|
312
|
+
// responses.
|
|
253
313
|
if (this.opts.history && this.opts.history.length > 0) {
|
|
254
314
|
for (const msg of this.opts.history) {
|
|
255
315
|
if (msg.role === "user") {
|
|
@@ -270,10 +330,31 @@ export class PiBridge {
|
|
|
270
330
|
});
|
|
271
331
|
}
|
|
272
332
|
else if (msg.role === "assistant") {
|
|
273
|
-
|
|
333
|
+
// Parse wire events to reconstruct tool calls & results from the
|
|
334
|
+
// original turn. Without this, reconnected sessions lose all tool
|
|
335
|
+
// interaction context.
|
|
336
|
+
const { toolCalls, toolResults } = parseWireToolEvents(msg.events);
|
|
337
|
+
// Build a toolCallId → toolName lookup for tool result messages.
|
|
338
|
+
const toolNameById = new Map();
|
|
339
|
+
for (const tc of toolCalls) {
|
|
340
|
+
toolNameById.set(tc.id, tc.name);
|
|
341
|
+
}
|
|
342
|
+
// Build assistant message content: text + reconstructed ToolCall blocks.
|
|
343
|
+
const content = [];
|
|
344
|
+
if (msg.content) {
|
|
345
|
+
content.push({ type: "text", text: msg.content });
|
|
346
|
+
}
|
|
347
|
+
for (const tc of toolCalls) {
|
|
348
|
+
content.push({
|
|
349
|
+
type: "toolCall",
|
|
350
|
+
id: tc.id,
|
|
351
|
+
name: tc.name,
|
|
352
|
+
arguments: tc.arguments,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
274
355
|
sessionManager.appendMessage({
|
|
275
356
|
role: "assistant",
|
|
276
|
-
content
|
|
357
|
+
content,
|
|
277
358
|
api: "anthropic-messages",
|
|
278
359
|
provider: "spectral-proxy-anthropic",
|
|
279
360
|
model: "unknown",
|
|
@@ -285,9 +366,34 @@ export class PiBridge {
|
|
|
285
366
|
totalTokens: 0,
|
|
286
367
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
287
368
|
},
|
|
288
|
-
stopReason:
|
|
369
|
+
stopReason: toolCalls.length > 0
|
|
370
|
+
? "toolUse"
|
|
371
|
+
: "stop",
|
|
289
372
|
timestamp: msg.createdAt,
|
|
290
373
|
});
|
|
374
|
+
// Append tool result messages so the LLM sees the full tool
|
|
375
|
+
// interaction (call → result → next assistant response).
|
|
376
|
+
//
|
|
377
|
+
// Safety: truncate text blocks here too — sessions created before
|
|
378
|
+
// the truncation fix (in tool-registrar.ts) may have untruncated
|
|
379
|
+
// large MCP results in their wire events, which would overflow the
|
|
380
|
+
// context window when replayed into the fresh session manager.
|
|
381
|
+
for (const tr of toolResults) {
|
|
382
|
+
const toolName = toolNameById.get(tr.id) ?? "unknown";
|
|
383
|
+
const rawResult = tr.result;
|
|
384
|
+
const rawContent = Array.isArray(rawResult?.content)
|
|
385
|
+
? rawResult.content
|
|
386
|
+
: [];
|
|
387
|
+
sessionManager.appendMessage({
|
|
388
|
+
role: "toolResult",
|
|
389
|
+
toolCallId: tr.id,
|
|
390
|
+
toolName,
|
|
391
|
+
content: rawContent.map(sanitizeRehydratedBlock),
|
|
392
|
+
details: rawResult?.details,
|
|
393
|
+
isError: tr.isError,
|
|
394
|
+
timestamp: msg.createdAt,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
291
397
|
}
|
|
292
398
|
// system messages are informational only; skip for LLM context
|
|
293
399
|
}
|