@xynogen/pix-9router 0.3.9 → 0.3.11

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/README.md CHANGED
@@ -35,7 +35,7 @@ export ROUTER_API_KEY="your-key-here"
35
35
 
36
36
  - **Provider**: on load, fetches `/models` from the router and registers them with Pi. The [modelgrep](https://modelgrep.com) catalog (via [`@xynogen/pix-data`](https://github.com/xynogen/pix-mono/tree/main/packages/pix-data)'s shared cache) is used internally to fill missing context window / modality fields where the router response omits them. Model list is cached at `~/.cache/pi/9router.json` (TTL 30 min).
37
37
  - **fetch / search**: POST to `/web/fetch` and `/search` on the router (which proxies to exa). If the router is unreachable, falls back to `curl` (for `fetch`, raw URL fetch; for `search`, the same `/search` endpoint via curl). Tool output is rendered dimmed so fetched web content reads like faded context rather than primary output.
38
- - **transcribe**: POST to `/audio/transcriptions` (Deepgram Nova 3 by default). Accepts any audio file path; falls back to curl.
38
+ - **transcribe**: POST to `/audio/transcriptions` (Deepgram Nova 3 by default). Accepts any audio file path; falls back to curl. Pass the optional `output_file` to write the full transcription to disk (parent dirs created, path pre-flight checked) and return only a short path summary to the model — useful for long results; without it the text is returned inline (truncated to 50,000 chars).
39
39
 
40
40
  ## Compact results
41
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-9router",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Pi extension — 9Router provider + fetch/search tools via router API",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -46,6 +46,6 @@
46
46
  "@xynogen/pix-data": "^0.4.0",
47
47
  "@xynogen/pix-pretty": "^1.7.21",
48
48
  "typebox": "^1.1.38",
49
- "@xynogen/pix-runtime": "^0.3.0"
49
+ "@xynogen/pix-runtime": "^0.4.0"
50
50
  }
51
51
  }
package/src/fetch.ts CHANGED
@@ -241,10 +241,12 @@ export default function registerFetch(pi: ExtensionAPI): void {
241
241
  ),
242
242
  parameters: Type.Object({
243
243
  url: Type.String({ description: "URL to fetch" }),
244
- format: StringEnum(["markdown", "text", "html"] as const, {
245
- description:
246
- 'Required choice. Enter exactly "markdown" for readable Markdown, "text" for plain text, or "html" for raw HTML.',
247
- }),
244
+ format: Type.Optional(
245
+ StringEnum(["markdown", "text", "html"] as const, {
246
+ description:
247
+ 'Optional choice. Enter exactly "markdown" for readable Markdown, "text" for plain text, or "html" for raw HTML. Default is "markdown".',
248
+ }),
249
+ ),
248
250
  max_characters: Type.Optional(
249
251
  Type.Number({
250
252
  description: "Max characters (default 1000, 0 = unlimited)",
@@ -101,6 +101,30 @@ describe("fetch execution metadata", () => {
101
101
  expect(result.content).toEqual(originalContent);
102
102
  });
103
103
 
104
+ it("defaults format to markdown when omitted (schema now optional)", async () => {
105
+ let sentFormat: unknown;
106
+ const raw = JSON.stringify({ url: "https://example.com", content: { text: "hello" } });
107
+ const result = await executeFetch(
108
+ // No `format` — valid now that the schema no longer requires it.
109
+ { url: "https://example.com" },
110
+ undefined,
111
+ undefined,
112
+ {
113
+ apiPost: async (_path, body) => {
114
+ sentFormat = (body as { format?: unknown }).format;
115
+ return raw;
116
+ },
117
+ curl: async () => {
118
+ throw new Error("curl should not run");
119
+ },
120
+ },
121
+ );
122
+
123
+ // The default flows through to both the API request and the metadata.
124
+ expect(sentFormat).toBe("markdown");
125
+ expect(result.details).toMatchObject({ outcome: "success", format: "markdown" });
126
+ });
127
+
104
128
  it("reports curl fallback metadata while preserving the fallback banner", async () => {
105
129
  const result = await executeFetch(
106
130
  { url: "https://example.com", format: "html", max_characters: 1000 },
package/src/http.ts CHANGED
@@ -8,11 +8,9 @@
8
8
  */
9
9
 
10
10
  import { type ExecFileException, execFile } from "node:child_process";
11
+ import { ioTimeoutMs, ioTimeoutSignal } from "@xynogen/pix-runtime/io";
11
12
  import { routerBaseUrl } from "./data.js";
12
13
 
13
- /** Default request timeout — audio transcription overrides with a longer one. */
14
- export const REQUEST_TIMEOUT_MS = 30_000;
15
-
16
14
  export function auth(): string | undefined {
17
15
  return process.env.ROUTER_API_KEY;
18
16
  }
@@ -22,32 +20,27 @@ export async function apiPost(
22
20
  path: string,
23
21
  body: Record<string, unknown>,
24
22
  signal?: AbortSignal,
25
- timeoutMs: number = REQUEST_TIMEOUT_MS,
23
+ timeoutMs: number = ioTimeoutMs(),
26
24
  ): Promise<string> {
27
25
  const url = `${routerBaseUrl()}${path}`;
28
26
  const key = auth();
29
- const controller = new AbortController();
30
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
31
- const s = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
32
-
33
- try {
34
- const res = await fetch(url, {
35
- method: "POST",
36
- headers: {
37
- "Content-Type": "application/json",
38
- ...(key ? { Authorization: `Bearer ${key}` } : {}),
39
- },
40
- body: JSON.stringify(body),
41
- signal: s,
42
- });
43
- if (!res.ok) {
44
- const errText = await res.text().catch(() => "");
45
- throw new Error(`API ${res.status}: ${errText.slice(0, 500)}`);
46
- }
47
- return await res.text();
48
- } finally {
49
- clearTimeout(timeout);
27
+ const timeoutSignal =
28
+ timeoutMs === ioTimeoutMs() ? ioTimeoutSignal() : AbortSignal.timeout(timeoutMs);
29
+ const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
30
+ const res = await fetch(url, {
31
+ method: "POST",
32
+ headers: {
33
+ "Content-Type": "application/json",
34
+ ...(key ? { Authorization: `Bearer ${key}` } : {}),
35
+ },
36
+ body: JSON.stringify(body),
37
+ signal: requestSignal,
38
+ });
39
+ if (!res.ok) {
40
+ const errText = await res.text().catch(() => "");
41
+ throw new Error(`API ${res.status}: ${errText.slice(0, 500)}`);
50
42
  }
43
+ return res.text();
51
44
  }
52
45
 
53
46
  export interface CurlOptions {
@@ -61,7 +54,9 @@ export interface CurlOptions {
61
54
 
62
55
  /** Run curl with sane defaults, rejecting on non-zero exit or timeout. */
63
56
  export function curl(args: string[], opts: CurlOptions = {}): Promise<string> {
64
- const { maxTime = 25, timeoutMs = 30_000, stdin } = opts;
57
+ const timeoutMs = opts.timeoutMs ?? ioTimeoutMs();
58
+ const maxTime = opts.maxTime ?? timeoutMs / 1000;
59
+ const { stdin } = opts;
65
60
  return new Promise((resolve, reject) => {
66
61
  const child = execFile(
67
62
  "curl",
@@ -4,6 +4,7 @@ import registerSearch from "./search.ts";
4
4
 
5
5
  function captureParameters(register: (pi: never) => void): {
6
6
  properties: Record<string, { type?: string; enum?: string[]; description?: string }>;
7
+ required?: string[];
7
8
  } {
8
9
  let parameters: unknown;
9
10
  register({
@@ -14,6 +15,7 @@ function captureParameters(register: (pi: never) => void): {
14
15
  if (!parameters) throw new Error("tool parameters not captured");
15
16
  return parameters as {
16
17
  properties: Record<string, { type?: string; enum?: string[]; description?: string }>;
18
+ required?: string[];
17
19
  };
18
20
  }
19
21
 
@@ -38,3 +40,21 @@ describe("enum-like tool parameters", () => {
38
40
  expect(format.description).toContain('"html"');
39
41
  });
40
42
  });
43
+
44
+ describe("fetch parameter requiredness", () => {
45
+ test("only url is required; format is optional and defaults to markdown", () => {
46
+ const params = captureParameters(registerFetch);
47
+
48
+ // url must be required, format must NOT be — this is the exact validator
49
+ // bug the optionality fix addresses (executeFetch already defaults format
50
+ // to "markdown"), so guard against a regression back to a required format.
51
+ expect(params.required).toEqual(["url"]);
52
+ expect(params.required).not.toContain("format");
53
+
54
+ // format stays a fully described enum in properties even while optional.
55
+ const format = params.properties.format;
56
+ if (!format) throw new Error("format schema not found");
57
+ expect(format.enum).toEqual(["markdown", "text", "html"]);
58
+ expect(format.description?.toLowerCase()).toContain("default");
59
+ });
60
+ });
package/src/transcribe.ts CHANGED
@@ -16,12 +16,12 @@ import { access, lstat, mkdir, readFile, writeFile } from "node:fs/promises";
16
16
  import { homedir } from "node:os";
17
17
  import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
18
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { ioTimeoutSignal } from "@xynogen/pix-runtime/io";
19
20
  import { Type } from "typebox";
20
21
  import { routerBaseUrl } from "./data.js";
21
22
  import { auth, curl } from "./http.js";
22
23
  import { makeRenderCall, makeRenderResult } from "./render.js";
23
24
 
24
- const REQUEST_TIMEOUT_MS = 120_000; // audio transcription can take longer
25
25
  const CHAT_TRUNCATE_LIMIT = 50_000; // only when no output_file is provided
26
26
  const DEFAULT_MODEL = "dg/nova-3";
27
27
 
@@ -71,35 +71,28 @@ async function apiMultipart(
71
71
  ): Promise<string> {
72
72
  const url = `${routerBaseUrl()}${path}`;
73
73
  const key = auth();
74
- const controller = new AbortController();
75
- const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
76
- const s = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
77
-
78
- try {
79
- const fileData = await readFile(filePath);
80
- const blob = new Blob([fileData], { type: mimeType(filePath) });
81
-
82
- const form = new FormData();
83
- form.append("file", blob, basename(filePath));
84
- form.append("model", model);
85
- if (language) form.append("language", language);
86
-
87
- const res = await fetch(url, {
88
- method: "POST",
89
- headers: {
90
- ...(key ? { Authorization: `Bearer ${key}` } : {}),
91
- },
92
- body: form,
93
- signal: s,
94
- });
95
- if (!res.ok) {
96
- const errText = await res.text().catch(() => "");
97
- throw new Error(`API ${res.status}: ${errText.slice(0, 500)}`);
98
- }
99
- return await res.text();
100
- } finally {
101
- clearTimeout(timeout);
74
+ const requestSignal = ioTimeoutSignal(signal);
75
+ const fileData = await readFile(filePath, { signal: requestSignal });
76
+ const blob = new Blob([fileData], { type: mimeType(filePath) });
77
+
78
+ const form = new FormData();
79
+ form.append("file", blob, basename(filePath));
80
+ form.append("model", model);
81
+ if (language) form.append("language", language);
82
+
83
+ const res = await fetch(url, {
84
+ method: "POST",
85
+ headers: {
86
+ ...(key ? { Authorization: `Bearer ${key}` } : {}),
87
+ },
88
+ body: form,
89
+ signal: requestSignal,
90
+ });
91
+ if (!res.ok) {
92
+ const errText = await res.text().catch(() => "");
93
+ throw new Error(`API ${res.status}: ${errText.slice(0, 500)}`);
102
94
  }
95
+ return res.text();
103
96
  }
104
97
 
105
98
  /** Extract the `text` field from a JSON envelope, or return the raw string. */
@@ -448,7 +441,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
448
441
  `${routerBaseUrl()}/audio/transcriptions`,
449
442
  ];
450
443
 
451
- const raw = await curl(curlArgs, { maxTime: 120, timeoutMs: REQUEST_TIMEOUT_MS });
444
+ const raw = await curl(curlArgs);
452
445
  return await run("curl-fallback", raw);
453
446
  } catch (curlErr: unknown) {
454
447
  const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);