@xynogen/pix-9router 0.4.0 → 0.4.3

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": "@xynogen/pix-9router",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "description": "Pi extension — 9Router provider + fetch/search tools via router API",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -43,7 +43,7 @@
43
43
  "@earendil-works/pi-tui": "*"
44
44
  },
45
45
  "dependencies": {
46
- "@xynogen/pix-pretty": "^1.11.2",
46
+ "@xynogen/pix-pretty": "^1.19.0",
47
47
  "typebox": "^1.1.38",
48
48
  "@xynogen/pix-runtime": "^0.8.0"
49
49
  }
package/src/fetch.ts CHANGED
@@ -206,9 +206,7 @@ export default function registerFetch(pi: ExtensionAPI): void {
206
206
  promptSnippet:
207
207
  "fetch(url, format, max_characters?) — format: 'markdown', 'text', or 'html'. Read page content via exa.",
208
208
  promptGuidelines: [
209
- "Use fetch when you need to read the full content of a specific URL.",
210
- "Prefer format='markdown' for readable content, 'text' for plain text extraction, 'html' for raw source.",
211
- "Set max_characters to cap response size (default 1000, 0 = unlimited). Use 5000-10000 for typical pages.",
209
+ "fetch: format='markdown' for readable content, 'text' for plain extraction, 'html' for raw source. max_characters caps size (default 1000, 0 = unlimited; 5000-10000 for typical pages).",
212
210
  ],
213
211
  renderCall: makeRenderCall<unknown>("fetch", (args) =>
214
212
  String((args as Partial<FetchParams>).url ?? ""),
package/src/provider.ts CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { RefreshModelsContext } from "@earendil-works/pi-ai";
13
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import { ioTimeoutSignal } from "@xynogen/pix-runtime/io";
14
15
  import type { ModelsDevModel, RouterModel } from "./data.js";
15
16
  import { fetchModelsDevIndex, lookupInIndex, routerBaseUrl, routerModels } from "./data.js";
16
17
 
@@ -147,7 +148,7 @@ function providerConfig(
147
148
  async refreshModels({ signal, allowNetwork }: RefreshModelsContext) {
148
149
  if (!allowNetwork) return models.map(toModelConfig(devIndex));
149
150
  const res = await fetch(`${routerBaseUrl()}/models`, {
150
- signal: AbortSignal.any([AbortSignal.timeout(8_000), ...(signal ? [signal] : [])]),
151
+ signal: ioTimeoutSignal(signal),
151
152
  headers: {
152
153
  Authorization: `Bearer ${apiKey}`,
153
154
  "User-Agent": "pi-coding-agent",
@@ -13,7 +13,7 @@ function stubComponent() {
13
13
  setText: (s: string) => {
14
14
  captured = s;
15
15
  },
16
- render: () => [],
16
+ render: (_width?: number) => captured.split("\n"),
17
17
  invalidate: () => {},
18
18
  };
19
19
  return { component, get: () => captured };
@@ -89,13 +89,14 @@ test("partial updates keep detail visible and do not schedule collapse", () => {
89
89
  context(stub, { state }),
90
90
  );
91
91
  expect(stub.get()).toContain("streaming body");
92
+ expect(stub.component.render(20)).toEqual([" [dim]streaming body"]);
92
93
  expect(state.timer).toBeUndefined();
93
94
  });
94
95
 
95
96
  test("expanded mode restores full joined text", () => {
96
97
  const stub = stubComponent();
97
98
  const rr = makeRenderResult(config);
98
- rr(
99
+ const component = rr(
99
100
  {
100
101
  content: [
101
102
  { type: "text", text: "first" },
@@ -109,6 +110,54 @@ test("expanded mode restores full joined text", () => {
109
110
  context(stub, { state: { collapsed: true }, expanded: true }),
110
111
  );
111
112
  expect(stub.get()).toBe(" [dim]first\n [dim]second");
113
+ expect(component.render(20)).toEqual([
114
+ "[success]────────────────────",
115
+ " [dim]first",
116
+ " [dim]second",
117
+ "[success]────────────────────",
118
+ ]);
119
+ });
120
+
121
+ test("structured errors get red open frames but collapsed summaries stay unframed", () => {
122
+ const rr = makeRenderResult({ ...config, status: () => "error" as const });
123
+ const open = stubComponent();
124
+ const openComponent = rr(
125
+ {
126
+ content: [{ type: "text", text: "failed body" }],
127
+ details: { _type: "fetchResult", chars: 11 },
128
+ } as never,
129
+ { expanded: false, isPartial: false },
130
+ theme,
131
+ context(open),
132
+ );
133
+ expect(openComponent.render(20)[0]).toBe("[error]────────────────────");
134
+
135
+ const collapsed = stubComponent();
136
+ const collapsedComponent = rr(
137
+ {
138
+ content: [{ type: "text", text: "failed body" }],
139
+ details: { _type: "fetchResult", chars: 11 },
140
+ } as never,
141
+ { expanded: false, isPartial: false },
142
+ theme,
143
+ context(collapsed, { state: { collapsed: true } }),
144
+ );
145
+ expect(collapsedComponent.render(20).join("\n")).not.toContain("────");
146
+ });
147
+
148
+ test("terminal error body gets an error frame", () => {
149
+ const stub = stubComponent();
150
+ const component = makeRenderResult(config)(
151
+ { content: [{ type: "text", text: "boom" }] } as never,
152
+ { expanded: true, isPartial: false },
153
+ theme,
154
+ context(stub, { isError: true, expanded: true }),
155
+ );
156
+ expect(component.render(20)).toEqual([
157
+ "[error]────────────────────",
158
+ " [error]boom",
159
+ "[error]────────────────────",
160
+ ]);
112
161
  });
113
162
 
114
163
  test("renderResult caps the normal preview", () => {
package/src/render.ts CHANGED
@@ -19,7 +19,9 @@ import type { Component } from "@earendil-works/pi-tui";
19
19
  import {
20
20
  type CollapsedToolStatus,
21
21
  formatCollapsedToolRow,
22
+ frameToolResult,
22
23
  hideCollapsedToolCall,
24
+ unframeToolResult,
23
25
  } from "@xynogen/pix-pretty/utils";
24
26
  import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
25
27
 
@@ -59,7 +61,8 @@ try {
59
61
  }
60
62
 
61
63
  function getText(lastComponent: Component | undefined): TextLike {
62
- if (lastComponent && "setText" in lastComponent) return lastComponent as TextLike;
64
+ if (lastComponent && "setText" in lastComponent)
65
+ return unframeToolResult(lastComponent as TextLike);
63
66
  // TextComponent is always present in the interactive TUI (pi-tui peer); the
64
67
  // require() guard only matters for headless/test contexts where renderers
65
68
  // are never invoked.
@@ -115,14 +118,15 @@ export function makeRenderResult<TDetails>(config?: CompactRendererConfig<TDetai
115
118
 
116
119
  if (ctx.isError) {
117
120
  text.setText(` ${theme.fg("error", body || "Error")}`);
118
- return text;
121
+ return opts.isPartial ? text : frameToolResult(text, theme, true);
119
122
  }
120
123
  if (!body.trim()) {
121
124
  text.setText(` ${theme.fg("muted", "(empty)")}`);
122
- return text;
125
+ return opts.isPartial ? text : frameToolResult(text, theme, false);
123
126
  }
124
127
 
125
128
  const details = result.details as TDetails | undefined;
129
+ const isError = Boolean(details && config?.status?.(details) === "error");
126
130
  if (
127
131
  !opts.isPartial &&
128
132
  config &&
@@ -142,6 +146,6 @@ export function makeRenderResult<TDetails>(config?: CompactRendererConfig<TDetai
142
146
  }
143
147
 
144
148
  text.setText(dimBody(body, theme, opts.expanded));
145
- return text;
149
+ return opts.isPartial ? text : frameToolResult(text, theme, isError);
146
150
  };
147
151
  }
package/src/search.ts CHANGED
@@ -232,8 +232,7 @@ export default function registerSearch(pi: ExtensionAPI): void {
232
232
  promptSnippet:
233
233
  "search(query, search_type, max_results?) — search_type: 'web' or 'news'. Defaults to 5 results, max 10.",
234
234
  promptGuidelines: [
235
- "Use search when you need up-to-date information or facts from the web.",
236
- "Set search_type='web' for general web results, search_type='news' for recent news articles.",
235
+ "search: search_type='web' for general web results, 'news' for recent news articles.",
237
236
  ],
238
237
  renderCall: makeRenderCall<unknown>("search", (args) =>
239
238
  String((args as Partial<SearchParams>).query ?? ""),
package/src/transcribe.ts CHANGED
@@ -340,14 +340,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
340
340
  isError: context.isError && options.expanded,
341
341
  }),
342
342
  promptGuidelines: [
343
- "Use transcribe when you need to convert speech/audio to text.",
344
- "The file parameter should be an absolute or relative path to an audio file on disk.",
345
- "Supports common audio formats: mp3, wav, flac, ogg, m4a, webm, mp4.",
346
- "Default model is dg/nova-3 (Deepgram Nova 3). Override with model parameter if needed.",
347
- "Optionally specify language as ISO 639-1 code (e.g. 'en', 'es', 'fr') for better accuracy.",
348
- "Pass output_file to write the full transcription to disk — useful when the result is long, when it will be re-read or piped to another tool, or to keep chat context small. Relative paths are resolved against the current working directory.",
349
- "output_file is pre-flight checked: paths under /etc, /proc, /sys, /boot, ~/.ssh, ~/.aws, ~/.gnupg, and ~/.config/gh are rejected; the target must not be a symlink or existing directory; the parent must exist and be writable; symlinks anywhere in the parent chain are rejected. On rejection, the transcription text is still returned inline so it is not lost.",
350
- "Without output_file the transcribed text is returned inline (truncated to 50,000 chars).",
343
+ "transcribe: `language` as ISO 639-1 (e.g. 'en', 'es') improves accuracy. Pass `output_file` for long results — writes full text to disk (sensitive paths/symlinks rejected; text still returned inline on rejection) and returns a path summary. Without it, text returns inline truncated to 50,000 chars.",
351
344
  ],
352
345
  parameters: Type.Object({
353
346
  file: Type.String({