@aexol/spectral 0.2.7 → 0.2.9

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
  }
@@ -39,6 +39,7 @@
39
39
  * transparent to this layer — we just respond when we can.
40
40
  */
41
41
  import { BadRequestError, NotFoundError } from "../server/handlers/errors.js";
42
+ import { handlePathAutocomplete } from "../server/handlers/paths-autocomplete.js";
42
43
  import { handleCreateProject, handleDeleteProject, handleListProjects, handleListSessionsByProject, handleUpdateProject, } from "../server/handlers/projects.js";
43
44
  import { handleCreateSession, handleDeleteSession, handleGetSessionDetail, handleUpdateSession, } from "../server/handlers/sessions.js";
44
45
  import { shutdownState } from "../server/shutdown.js";
@@ -50,9 +51,16 @@ import { shutdownState } from "../server/shutdown.js";
50
51
  * also marginally slower and harder to read for ~9 routes.
51
52
  */
52
53
  export function matchRoute(method, path) {
53
- // Strip query string if any (we don't use any, but be defensive).
54
+ // Strip query string for path matching but keep it for the handler.
54
55
  const qIdx = path.indexOf("?");
55
56
  const cleanPath = qIdx === -1 ? path : path.slice(0, qIdx);
57
+ const query = qIdx >= 0 ? new URLSearchParams(path.slice(qIdx + 1)) : undefined;
58
+ // /api/paths/autocomplete
59
+ if (cleanPath === "/api/paths/autocomplete") {
60
+ if (method === "GET")
61
+ return { route: "list_path_autocomplete", query };
62
+ return null;
63
+ }
56
64
  // /api/projects
57
65
  if (cleanPath === "/api/projects") {
58
66
  if (method === "GET")
@@ -267,6 +275,10 @@ function dispatchRoute(match, body, deps) {
267
275
  }
268
276
  return { ok: true };
269
277
  }
278
+ case "list_path_autocomplete": {
279
+ const prefix = match.query?.get("prefix") ?? "";
280
+ return handlePathAutocomplete(prefix);
281
+ }
270
282
  }
271
283
  }
272
284
  /**
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Filesystem autocomplete for project path creation.
3
+ *
4
+ * When a user types a partial path in the New Project form, the browser
5
+ * sends `GET /api/paths/autocomplete?prefix=<partial>`. This handler
6
+ * expands the prefix (tilde → $HOME, relative → absolute), lists the
7
+ * containing directory, and returns up to 10 directory suggestions whose
8
+ * basenames start with the typed partial segment.
9
+ *
10
+ * Dotfiles are excluded — they are rarely intentional project roots.
11
+ * Symlinks to directories ARE included (readdirSync with withFileTypes
12
+ * returns true for both real dirs and symlink dirs).
13
+ *
14
+ * Thread-safety: synchronous, called from the relay-dispatcher hot path.
15
+ * fs ops block the event loop for microseconds on local SSDs; the form
16
+ * is typed by a single human, so this is perfectly fine.
17
+ */
18
+ import { readdirSync } from "node:fs";
19
+ import { join, sep } from "node:path";
20
+ import { expandPath } from "../paths.js";
21
+ /**
22
+ * List directories inside `expandedPrefix`'s parent that start with the
23
+ * trailing (post-separator) segment of the expanded prefix.
24
+ *
25
+ * Examples (assume $HOME=/Users/alice, dirs are `projects`, `proj-old`,
26
+ * `Documents`, `.config`):
27
+ * prefix="~/pro" → expanded="/Users/alice/pro", parent="/Users/alice/"
28
+ * → ["/Users/alice/projects", "/Users/alice/proj-old"]
29
+ * prefix="~/proj" → expanded="/Users/alice/proj"
30
+ * → ["/Users/alice/projects"]
31
+ * prefix="~" → expanded="/Users/alice", parent="/Users/alice/"
32
+ * → all top-level dirs in $HOME (except dotfiles)
33
+ */
34
+ export function handlePathAutocomplete(prefix) {
35
+ const expanded = expandPath(prefix);
36
+ // Split into parent directory and basename prefix.
37
+ // On POSIX, `expanded` is always absolute after expandPath, so we
38
+ // always hit the else branch below with a trailing sep.
39
+ const lastSep = expanded.lastIndexOf(sep);
40
+ let parentDir;
41
+ let basenamePrefix;
42
+ if (lastSep < 0) {
43
+ // Shouldn't happen after expandPath, but be defensive.
44
+ parentDir = expanded;
45
+ basenamePrefix = "";
46
+ }
47
+ else {
48
+ parentDir = expanded.slice(0, lastSep + 1);
49
+ basenamePrefix = expanded.slice(lastSep + 1);
50
+ }
51
+ let suggestions = [];
52
+ try {
53
+ const entries = readdirSync(parentDir, { withFileTypes: true });
54
+ suggestions = entries
55
+ .filter((e) => e.isDirectory() &&
56
+ e.name.startsWith(basenamePrefix) &&
57
+ !e.name.startsWith("."))
58
+ .map((e) => join(parentDir, e.name))
59
+ .slice(0, 10); // keep the list short for the UI
60
+ }
61
+ catch {
62
+ // Directory doesn't exist or is unreadable → return empty list.
63
+ // The caller (dispatcher) returns a 200 with an empty suggestions[]
64
+ // so the UI can show a "no matching directories" hint.
65
+ }
66
+ return { suggestions, expandedPrefix: expanded };
67
+ }
@@ -158,6 +158,84 @@ function lookupPricing(modelId) {
158
158
  }
159
159
  return null;
160
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
+ }
161
239
  export class PiBridge {
162
240
  session;
163
241
  unsubscribe;
@@ -227,8 +305,11 @@ export class PiBridge {
227
305
  const sessionManager = SessionManager.inMemory(this.opts.cwd);
228
306
  // Rehydrate session history so the LLM sees the full conversation
229
307
  // transcript from the beginning (not just the current prompt).
230
- // Previously this was documented as a "History rehydration
231
- // 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.
232
313
  if (this.opts.history && this.opts.history.length > 0) {
233
314
  for (const msg of this.opts.history) {
234
315
  if (msg.role === "user") {
@@ -249,10 +330,31 @@ export class PiBridge {
249
330
  });
250
331
  }
251
332
  else if (msg.role === "assistant") {
252
- 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
+ }
253
355
  sessionManager.appendMessage({
254
356
  role: "assistant",
255
- content: textBlocks,
357
+ content,
256
358
  api: "anthropic-messages",
257
359
  provider: "spectral-proxy-anthropic",
258
360
  model: "unknown",
@@ -264,9 +366,34 @@ export class PiBridge {
264
366
  totalTokens: 0,
265
367
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
266
368
  },
267
- stopReason: "stop",
369
+ stopReason: toolCalls.length > 0
370
+ ? "toolUse"
371
+ : "stop",
268
372
  timestamp: msg.createdAt,
269
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
+ }
270
397
  }
271
398
  // system messages are informational only; skip for LLM context
272
399
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
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,