@aliou/pi-synthetic 0.17.0 → 0.17.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-synthetic",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Synthetic-specific context overflow error detection.
3
+ *
4
+ * Some Synthetic backend errors are not matched by Pi's built-in
5
+ * isContextOverflow() patterns. This module provides the regex
6
+ * to detect them so the provider extension can normalize the
7
+ * errorMessage with the `context_length_exceeded:` prefix that
8
+ * Pi recognizes.
9
+ */
10
+
11
+ /**
12
+ * Matches Synthetic context overflow errors that Pi's built-in
13
+ * overflow detector does not catch:
14
+ *
15
+ * 1. "Error from inference backend: 400 The input (N tokens) is longer
16
+ * than the model's context length (M tokens)."
17
+ * 2. "Context limit exceeded"
18
+ */
19
+ export const SYNTHETIC_OVERFLOW_PATTERN =
20
+ /input \(\d+ tokens\) is longer than the model's context length|Context limit exceeded/i;
@@ -27,6 +27,7 @@ import {
27
27
  type SyntheticQuotasRequestPayload,
28
28
  } from "../../types/quotas";
29
29
  import { fetchQuotas } from "../../utils/quotas";
30
+ import { SYNTHETIC_OVERFLOW_PATTERN } from "./context-overflow";
30
31
  import { SYNTHETIC_MODELS } from "./models";
31
32
 
32
33
  export function buildSyntheticProviderModels(includeProxiedModels: boolean) {
@@ -111,6 +112,24 @@ export default async function (pi: ExtensionAPI) {
111
112
  if (quotas) quotaStore.ingest(quotas, "header");
112
113
  });
113
114
 
115
+ pi.on("message_end", (event) => {
116
+ const msg = event.message;
117
+ if (msg.role !== "assistant") return;
118
+ if (msg.stopReason !== "error") return;
119
+ if (msg.provider !== "synthetic") return;
120
+
121
+ const errorMessage = msg.errorMessage ?? "";
122
+ if (errorMessage.includes("context_length_exceeded")) return;
123
+ if (!SYNTHETIC_OVERFLOW_PATTERN.test(errorMessage)) return;
124
+
125
+ return {
126
+ message: {
127
+ ...msg,
128
+ errorMessage: `context_length_exceeded: ${errorMessage}`,
129
+ },
130
+ };
131
+ });
132
+
114
133
  pi.events.on(SYNTHETIC_QUOTAS_REQUEST_EVENT, async (data: unknown) => {
115
134
  const payload = data as SyntheticQuotasRequestPayload | undefined;
116
135
  const snapshot = await quotaStore.refreshFromApi(fetchQuotasFromAuth);
@@ -1,3 +1,7 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
1
5
  import { ToolCallHeader, ToolFooter } from "@aliou/pi-utils-ui";
2
6
  import type {
3
7
  AgentToolResult,
@@ -6,8 +10,14 @@ import type {
6
10
  Theme,
7
11
  ToolRenderResultOptions,
8
12
  } from "@earendil-works/pi-coding-agent";
9
- import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
10
- import { Container, Markdown, Text } from "@earendil-works/pi-tui";
13
+ import {
14
+ DEFAULT_MAX_BYTES,
15
+ DEFAULT_MAX_LINES,
16
+ formatSize,
17
+ keyHint,
18
+ truncateHead,
19
+ } from "@earendil-works/pi-coding-agent";
20
+ import { Container, Text } from "@earendil-works/pi-tui";
11
21
  import { type Static, Type } from "typebox";
12
22
  import { configLoader } from "../../config";
13
23
  import { getSyntheticApiKey } from "../../lib/env";
@@ -25,8 +35,20 @@ interface SyntheticSearchResponse {
25
35
  results: SyntheticSearchResult[];
26
36
  }
27
37
 
38
+ interface WebSearchResultDetails {
39
+ title: string;
40
+ url: string;
41
+ published: string;
42
+ truncated: boolean;
43
+ tempFilePath?: string;
44
+ totalLines: number;
45
+ totalBytes: number;
46
+ outputLines: number;
47
+ outputBytes: number;
48
+ }
49
+
28
50
  interface WebSearchDetails {
29
- results?: SyntheticSearchResult[];
51
+ results?: WebSearchResultDetails[];
30
52
  query?: string;
31
53
  }
32
54
 
@@ -42,8 +64,7 @@ export function registerSyntheticWebSearchTool(pi: ExtensionAPI): void {
42
64
  pi.registerTool<typeof SearchParams, WebSearchDetails>({
43
65
  name: SYNTHETIC_WEB_SEARCH_TOOL,
44
66
  label: "Synthetic: Web Search",
45
- description:
46
- "Search the web using Synthetic's zero-data-retention API. Returns search results with titles, URLs, content snippets, and publication dates. Use for finding documentation, articles, recent information, or any web content. Results are fresh and not cached by Synthetic.",
67
+ description: `Search the web using Synthetic's zero-data-retention API. Returns search results with titles, URLs, content snippets, and publication dates. Use for finding documentation, articles, recent information, or any web content. Results are fresh and not cached by Synthetic. Results are truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)} (whichever is hit first). If truncated, full output is saved to a temp file.`,
47
68
  promptSnippet: "Search the web using Synthetic's zero-data-retention API",
48
69
  promptGuidelines: [
49
70
  "Use synthetic_web_search for finding documentation, articles, recent information, or any web content.",
@@ -106,18 +127,55 @@ export function registerSyntheticWebSearchTool(pi: ExtensionAPI): void {
106
127
  }
107
128
 
108
129
  let content = `Found ${data.results.length} result(s):\n\n`;
109
- for (const result of data.results) {
130
+ const resultDetails: WebSearchResultDetails[] = [];
131
+
132
+ for (let i = 0; i < data.results.length; i++) {
133
+ const result = data.results[i];
134
+ const slug = result.title
135
+ .toLowerCase()
136
+ .replace(/[^a-z0-9]+/g, "-")
137
+ .replace(/(^-|-$)/g, "")
138
+ .slice(0, 40);
139
+ const truncation = truncateHead(result.text, {
140
+ maxLines: DEFAULT_MAX_LINES,
141
+ maxBytes: DEFAULT_MAX_BYTES,
142
+ });
143
+
144
+ let preview = truncation.content;
145
+ let tempFilePath: string | undefined;
146
+
147
+ if (truncation.truncated) {
148
+ tempFilePath = join(
149
+ tmpdir(),
150
+ `pi-synthetic-search-${slug}-${randomBytes(4).toString("hex")}.md`,
151
+ );
152
+ await writeFile(tempFilePath, result.text, "utf8");
153
+ preview += `\n\n[Result truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full result: ${tempFilePath}]`;
154
+ }
155
+
110
156
  content += `## ${result.title}\n`;
111
157
  content += `URL: ${result.url}\n`;
112
158
  content += `Published: ${result.published}\n`;
113
- content += `\n${result.text}\n`;
159
+ content += `\n${preview}\n`;
114
160
  content += "\n---\n\n";
161
+
162
+ resultDetails.push({
163
+ title: result.title,
164
+ url: result.url,
165
+ published: result.published,
166
+ truncated: truncation.truncated,
167
+ tempFilePath,
168
+ totalLines: truncation.totalLines,
169
+ totalBytes: truncation.totalBytes,
170
+ outputLines: truncation.outputLines,
171
+ outputBytes: truncation.outputBytes,
172
+ });
115
173
  }
116
174
 
117
175
  return {
118
176
  content: [{ type: "text", text: content }],
119
177
  details: {
120
- results: data.results,
178
+ results: resultDetails,
121
179
  query: params.query,
122
180
  },
123
181
  };
@@ -140,7 +198,6 @@ export function registerSyntheticWebSearchTool(pi: ExtensionAPI): void {
140
198
  theme: Theme,
141
199
  ) {
142
200
  const { expanded, isPartial } = options;
143
- const SNIPPET_LINES = 5;
144
201
 
145
202
  if (isPartial) {
146
203
  return new Text(
@@ -165,6 +222,8 @@ export function registerSyntheticWebSearchTool(pi: ExtensionAPI): void {
165
222
  return container;
166
223
  }
167
224
 
225
+ const hasTruncation = results.some((r) => r.truncated);
226
+
168
227
  if (results.length === 0) {
169
228
  container.addChild(
170
229
  new Text(theme.fg("muted", "Synthetic: WebSearch: no results"), 0, 0),
@@ -172,6 +231,9 @@ export function registerSyntheticWebSearchTool(pi: ExtensionAPI): void {
172
231
  } else if (!expanded) {
173
232
  // Collapsed: show result count + first result title
174
233
  let text = theme.fg("success", `Found ${results.length} result(s)`);
234
+ if (hasTruncation) {
235
+ text += theme.fg("warning", " (truncated)");
236
+ }
175
237
  const first = results[0];
176
238
  if (first) {
177
239
  text += `\n ${theme.fg("dim", first.title)}`;
@@ -214,26 +276,40 @@ export function registerSyntheticWebSearchTool(pi: ExtensionAPI): void {
214
276
  );
215
277
  }
216
278
 
217
- if (r.text) {
218
- container.addChild(new Text("", 0, 0));
219
- const snippet = r.text
220
- .split("\n")
221
- .slice(0, SNIPPET_LINES)
222
- .map((line) => `> ${line}`)
223
- .join("\n");
279
+ if (r.truncated) {
224
280
  container.addChild(
225
- new Markdown(snippet, 0, 0, getMarkdownTheme(), {
226
- color: (text: string) => theme.fg("toolOutput", text),
227
- }),
281
+ new Text(
282
+ ` ${theme.fg("warning", `Truncated: ${r.outputLines} of ${r.totalLines} lines (${formatSize(r.outputBytes)} of ${formatSize(r.totalBytes)}). Full content: ${r.tempFilePath}`)}`,
283
+ 0,
284
+ 0,
285
+ ),
228
286
  );
229
287
  }
230
288
  }
231
289
  }
232
290
 
291
+ const footerItems: { label: string; value: string }[] = [];
292
+ footerItems.push({
293
+ label: "results",
294
+ value: `${results.length} result(s)`,
295
+ });
296
+ if (hasTruncation) {
297
+ const truncatedCount = results.filter((r) => r.truncated).length;
298
+ footerItems.push({
299
+ label: "truncated",
300
+ value: `${truncatedCount}`,
301
+ });
302
+ }
303
+ if (!expanded) {
304
+ footerItems.push({
305
+ label: "",
306
+ value: keyHint("app.tools.expand", "to expand"),
307
+ });
308
+ }
233
309
  container.addChild(new Text("", 0, 0));
234
310
  container.addChild(
235
311
  new ToolFooter(theme, {
236
- items: [{ label: "results", value: `${results.length} result(s)` }],
312
+ items: footerItems,
237
313
  separator: " | ",
238
314
  }),
239
315
  );