@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.
@@ -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: "image",
15
- data: c.data ?? "",
16
- mimeType: c.mimeType ?? "image/png",
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
  }
@@ -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, readFileSync } from "node:fs";
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 (index.ts)
95
- * by walking up from this file's location through node_modules.
96
- * Returns the absolute path, or null if the package is not installed.
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
- const rel = "node_modules/pi-mcp-adapter/package.json";
101
- const root = "/";
102
- let dir = __dirname;
103
- for (let i = 0; i < 20; i++) {
104
- const pkgPath = resolve(dir, rel);
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
- // Previously this was documented as a "History rehydration
252
- // limitation" the UI saw the transcript but pi did not.
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
- const textBlocks = msg.content ? [{ type: "text", text: msg.content }] : [];
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: textBlocks,
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: "stop",
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Always-on coding agent for Aexol — branded pi wrapper with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,