@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: "
|
|
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/relay/dispatcher.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/server/pi-bridge.js
CHANGED
|
@@ -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
|
-
//
|
|
231
|
-
//
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
}
|