@xynogen/pix-9router 0.3.1 → 0.3.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.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Pi extension — 9Router provider + fetch/search tools via router API",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -42,7 +42,7 @@
42
42
  "@earendil-works/pi-ai": "*"
43
43
  },
44
44
  "dependencies": {
45
- "@xynogen/pix-data": "*",
45
+ "@xynogen/pix-data": "^0.3.0",
46
46
  "typebox": "^1.1.38"
47
47
  }
48
48
  }
package/src/data.test.ts CHANGED
@@ -1,10 +1,5 @@
1
1
  import { afterEach, describe, expect, it } from "bun:test";
2
- import {
3
- buildModelsDevIndex,
4
- lookupInIndex,
5
- type ModelGrepModel,
6
- routerBaseUrl,
7
- } from "./data.ts";
2
+ import { buildModelsDevIndex, lookupInIndex, type ModelGrepModel, routerBaseUrl } from "./data.ts";
8
3
 
9
4
  // modelgrep-shaped fixture builder (buildModelsDevIndex now takes ModelGrepModel[]).
10
5
  function mg(
@@ -92,33 +87,25 @@ describe("lookupInIndex", () => {
92
87
  ]);
93
88
 
94
89
  it("finds exact match", () => {
95
- expect(lookupInIndex("claude-sonnet-4-5", index)?.name).toBe(
96
- "Claude Sonnet 4.5",
97
- );
90
+ expect(lookupInIndex("claude-sonnet-4-5", index)?.name).toBe("Claude Sonnet 4.5");
98
91
  });
99
92
 
100
93
  it("finds by stripping provider prefix (router style: provider/model)", () => {
101
- expect(lookupInIndex("anthropic/claude-opus-4", index)?.name).toBe(
102
- "Claude Opus 4",
103
- );
94
+ expect(lookupInIndex("anthropic/claude-opus-4", index)?.name).toBe("Claude Opus 4");
104
95
  });
105
96
 
106
97
  it("finds with deep prefix e.g. cc/claude-opus-4", () => {
107
- expect(lookupInIndex("cc/claude-opus-4", index)?.name).toBe(
108
- "Claude Opus 4",
109
- );
98
+ expect(lookupInIndex("cc/claude-opus-4", index)?.name).toBe("Claude Opus 4");
110
99
  });
111
100
 
112
101
  it("finds with date suffix stripped", () => {
113
- expect(lookupInIndex("claude-sonnet-4-5-20250514", index)?.name).toBe(
114
- "Claude Sonnet 4.5",
115
- );
102
+ expect(lookupInIndex("claude-sonnet-4-5-20250514", index)?.name).toBe("Claude Sonnet 4.5");
116
103
  });
117
104
 
118
105
  it("finds with provider prefix + date suffix", () => {
119
- expect(
120
- lookupInIndex("anthropic/claude-sonnet-4-5-20250514", index)?.name,
121
- ).toBe("Claude Sonnet 4.5");
106
+ expect(lookupInIndex("anthropic/claude-sonnet-4-5-20250514", index)?.name).toBe(
107
+ "Claude Sonnet 4.5",
108
+ );
122
109
  });
123
110
 
124
111
  it("returns undefined for unknown model", () => {
package/src/data.ts CHANGED
@@ -52,10 +52,7 @@ interface RouterModelsResponse {
52
52
  const ROUTER_DEFAULT_BASE = "https://9router.example.com/v1";
53
53
 
54
54
  export function routerBaseUrl(): string {
55
- return (process.env.ROUTER_API_BASE || ROUTER_DEFAULT_BASE).replace(
56
- /\/$/,
57
- "",
58
- );
55
+ return (process.env.ROUTER_API_BASE || ROUTER_DEFAULT_BASE).replace(/\/$/, "");
59
56
  }
60
57
 
61
58
  // ── Router models ──────────────────────────────────────────────────────────────
@@ -70,12 +67,9 @@ export const routerModels = new DataSource<RouterModel[]>({
70
67
  skip: () => !process.env.ROUTER_API_KEY,
71
68
  cachePath: join(CACHE_DIR, "9router.json"),
72
69
  ttlMs: 30 * 60 * 1000, // 30 minutes
73
- parse: (raw) =>
74
- ((raw as RouterModelsResponse).data ?? []).filter((m) => Boolean(m.id)),
70
+ parse: (raw) => ((raw as RouterModelsResponse).data ?? []).filter((m) => Boolean(m.id)),
75
71
  parseCache: (data) =>
76
- ((data as RouterModelsResponse | undefined)?.data ?? []).filter((m) =>
77
- Boolean(m.id),
78
- ),
72
+ ((data as RouterModelsResponse | undefined)?.data ?? []).filter((m) => Boolean(m.id)),
79
73
  empty: [],
80
74
  });
81
75
 
package/src/fetch.ts CHANGED
@@ -28,9 +28,7 @@ async function apiPost(
28
28
  const key = auth();
29
29
  const controller = new AbortController();
30
30
  const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
31
- const s = signal
32
- ? AbortSignal.any([signal, controller.signal])
33
- : controller.signal;
31
+ const s = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
34
32
 
35
33
  try {
36
34
  const res = await fetch(url, {
@@ -93,10 +91,7 @@ export function formatFetchResult(raw: string): string {
93
91
  content?: { text?: string | null } | string | null;
94
92
  };
95
93
 
96
- const text =
97
- typeof obj.content === "string"
98
- ? obj.content
99
- : (obj.content?.text ?? undefined);
94
+ const text = typeof obj.content === "string" ? obj.content : (obj.content?.text ?? undefined);
100
95
  if (typeof text !== "string") return raw;
101
96
 
102
97
  const header: string[] = [];
@@ -110,15 +105,13 @@ export default function registerFetch(pi: ExtensionAPI): void {
110
105
  pi.registerTool({
111
106
  name: "fetch",
112
107
  label: "Fetch",
113
- description:
114
- "Fetch a web page as markdown, text, or raw HTML via exa. Returns page content.",
108
+ description: "Fetch a web page as markdown, text, or raw HTML via exa. Returns page content.",
115
109
  promptSnippet:
116
110
  "fetch(url, format, max_characters?) — format: 'markdown', 'text', or 'html'. Read page content via exa.",
117
111
  promptGuidelines: [
118
112
  "Use fetch when you need to read the full content of a specific URL.",
119
113
  "Prefer format='markdown' for readable content, 'text' for plain text extraction, 'html' for raw source.",
120
114
  "Set max_characters to cap response size (default 1000, 0 = unlimited). Use 5000-10000 for typical pages.",
121
- "Always prefer fetch over raw curl/browser for reading page content.",
122
115
  ],
123
116
  renderCall: makeRenderCall("fetch", (args) => String(args.url ?? "")),
124
117
  renderResult: makeRenderResult(),
@@ -142,9 +135,7 @@ export default function registerFetch(pi: ExtensionAPI): void {
142
135
 
143
136
  try {
144
137
  onUpdate?.({
145
- content: [
146
- { type: "text", text: `Fetching (${fmt}): ${params.url}...` },
147
- ],
138
+ content: [{ type: "text", text: `Fetching (${fmt}): ${params.url}...` }],
148
139
  details: undefined,
149
140
  });
150
141
 
@@ -203,8 +194,7 @@ export default function registerFetch(pi: ExtensionAPI): void {
203
194
  details: { source: "curl-fallback", chars: html.length },
204
195
  };
205
196
  } catch (curlErr: unknown) {
206
- const curlMsg =
207
- curlErr instanceof Error ? curlErr.message : String(curlErr);
197
+ const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
208
198
  return {
209
199
  content: [
210
200
  {
@@ -36,9 +36,7 @@ describe("formatSearchResults", () => {
36
36
  });
37
37
 
38
38
  it("handles empty results", () => {
39
- expect(formatSearchResults(JSON.stringify({ results: [] }))).toBe(
40
- "No results.",
41
- );
39
+ expect(formatSearchResults(JSON.stringify({ results: [] }))).toBe("No results.");
42
40
  });
43
41
 
44
42
  it("falls back to pretty JSON for unexpected shapes", () => {
@@ -52,14 +50,10 @@ describe("formatSearchResults", () => {
52
50
 
53
51
  it("truncates long snippets to 300 chars", () => {
54
52
  const raw = JSON.stringify({
55
- results: [
56
- { title: "T", url: "https://x.dev", snippet: "y".repeat(1000) },
57
- ],
53
+ results: [{ title: "T", url: "https://x.dev", snippet: "y".repeat(1000) }],
58
54
  });
59
55
  const out = formatSearchResults(raw);
60
- const snippetLine = out
61
- .split("\n")
62
- .find((l) => l.trimStart().startsWith("yyy"));
56
+ const snippetLine = out.split("\n").find((l) => l.trimStart().startsWith("yyy"));
63
57
  expect(snippetLine?.trim().length).toBe(300);
64
58
  });
65
59
  });
@@ -74,9 +68,7 @@ describe("formatFetchResult", () => {
74
68
  });
75
69
 
76
70
  const out = formatFetchResult(raw);
77
- expect(out).toBe(
78
- "# Example Page\nURL: https://example.com\n\n# Hello\n\nBody text.",
79
- );
71
+ expect(out).toBe("# Example Page\nURL: https://example.com\n\n# Hello\n\nBody text.");
80
72
  });
81
73
 
82
74
  it("supports content as a plain string", () => {
@@ -8,18 +8,9 @@ import type { ModelsDevModel, RouterModel } from "./data.ts";
8
8
  const DEFAULT_CONTEXT_WINDOW = 128_000;
9
9
  const DEFAULT_MAX_TOKENS = 16_384;
10
10
 
11
- const IMAGE_CAPABLE_PATTERNS = [
12
- /claude/i,
13
- /gpt-5/i,
14
- /gpt-4/i,
15
- /kimi-k2/i,
16
- /hy3/i,
17
- ];
18
-
19
- function getInputTypes(
20
- model: RouterModel,
21
- devModel?: ModelsDevModel,
22
- ): ("text" | "image")[] {
11
+ const IMAGE_CAPABLE_PATTERNS = [/claude/i, /gpt-5/i, /gpt-4/i, /kimi-k2/i, /hy3/i];
12
+
13
+ function getInputTypes(model: RouterModel, devModel?: ModelsDevModel): ("text" | "image")[] {
23
14
  if (devModel?.modalities?.input) {
24
15
  const inputs = devModel.modalities.input.filter(
25
16
  (i): i is "text" | "image" => i === "text" || i === "image",
@@ -35,10 +26,7 @@ function getModelName(model: RouterModel, devModel?: ModelsDevModel): string {
35
26
  return model.name || devModel?.name || model.id || "unknown";
36
27
  }
37
28
 
38
- function getContextWindow(
39
- model: RouterModel,
40
- devModel?: ModelsDevModel,
41
- ): number {
29
+ function getContextWindow(model: RouterModel, devModel?: ModelsDevModel): number {
42
30
  return (
43
31
  model.context_window ||
44
32
  model.contextWindow ||
@@ -48,19 +36,12 @@ function getContextWindow(
48
36
  }
49
37
 
50
38
  function getMaxTokens(model: RouterModel, devModel?: ModelsDevModel): number {
51
- return (
52
- model.max_tokens ||
53
- model.maxTokens ||
54
- devModel?.limit?.output ||
55
- DEFAULT_MAX_TOKENS
56
- );
39
+ return model.max_tokens || model.maxTokens || devModel?.limit?.output || DEFAULT_MAX_TOKENS;
57
40
  }
58
41
 
59
42
  function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boolean {
60
43
  if (typeof devModel?.reasoning === "boolean") return devModel.reasoning;
61
- return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet/i.test(
62
- model.id ?? "",
63
- );
44
+ return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet/i.test(model.id ?? "");
64
45
  }
65
46
 
66
47
  // ── getInputTypes ────────────────────────────────────────────────────────────
@@ -85,10 +66,7 @@ describe("getInputTypes", () => {
85
66
  });
86
67
 
87
68
  it("falls back to pattern match for claude", () => {
88
- expect(getInputTypes({ id: "claude-sonnet-4-5" })).toEqual([
89
- "text",
90
- "image",
91
- ]);
69
+ expect(getInputTypes({ id: "claude-sonnet-4-5" })).toEqual(["text", "image"]);
92
70
  });
93
71
 
94
72
  it("falls back to pattern match for gpt-4o", () => {
@@ -96,10 +74,7 @@ describe("getInputTypes", () => {
96
74
  });
97
75
 
98
76
  it("falls back to pattern match for kimi-k2", () => {
99
- expect(getInputTypes({ id: "kimi-k2-instruct" })).toEqual([
100
- "text",
101
- "image",
102
- ]);
77
+ expect(getInputTypes({ id: "kimi-k2-instruct" })).toEqual(["text", "image"]);
103
78
  });
104
79
 
105
80
  it("returns text-only for unknown model with no devModel", () => {
@@ -120,18 +95,13 @@ describe("getInputTypes", () => {
120
95
 
121
96
  describe("getModelName", () => {
122
97
  it("prefers router model.name", () => {
123
- expect(
124
- getModelName(
125
- { id: "x", name: "Router Name" },
126
- { id: "x", name: "Dev Name" },
127
- ),
128
- ).toBe("Router Name");
98
+ expect(getModelName({ id: "x", name: "Router Name" }, { id: "x", name: "Dev Name" })).toBe(
99
+ "Router Name",
100
+ );
129
101
  });
130
102
 
131
103
  it("falls back to devModel.name", () => {
132
- expect(getModelName({ id: "x" }, { id: "x", name: "Dev Name" })).toBe(
133
- "Dev Name",
134
- );
104
+ expect(getModelName({ id: "x" }, { id: "x", name: "Dev Name" })).toBe("Dev Name");
135
105
  });
136
106
 
137
107
  it("falls back to model.id", () => {
@@ -160,9 +130,7 @@ describe("getContextWindow", () => {
160
130
  });
161
131
 
162
132
  it("falls back to devModel.limit.context", () => {
163
- expect(
164
- getContextWindow({ id: "x" }, { id: "x", limit: { context: 200_000 } }),
165
- ).toBe(200_000);
133
+ expect(getContextWindow({ id: "x" }, { id: "x", limit: { context: 200_000 } })).toBe(200_000);
166
134
  });
167
135
 
168
136
  it("falls back to DEFAULT_CONTEXT_WINDOW", () => {
@@ -175,10 +143,7 @@ describe("getContextWindow", () => {
175
143
  describe("getMaxTokens", () => {
176
144
  it("prefers model.max_tokens", () => {
177
145
  expect(
178
- getMaxTokens(
179
- { id: "x", max_tokens: 4_096 },
180
- { id: "x", limit: { output: 32_000 } },
181
- ),
146
+ getMaxTokens({ id: "x", max_tokens: 4_096 }, { id: "x", limit: { output: 32_000 } }),
182
147
  ).toBe(4_096);
183
148
  });
184
149
 
@@ -187,9 +152,7 @@ describe("getMaxTokens", () => {
187
152
  });
188
153
 
189
154
  it("falls back to devModel.limit.output", () => {
190
- expect(
191
- getMaxTokens({ id: "x" }, { id: "x", limit: { output: 16_000 } }),
192
- ).toBe(16_000);
155
+ expect(getMaxTokens({ id: "x" }, { id: "x", limit: { output: 16_000 } })).toBe(16_000);
193
156
  });
194
157
 
195
158
  it("falls back to DEFAULT_MAX_TOKENS", () => {
@@ -206,12 +169,9 @@ describe("getReasoning", () => {
206
169
 
207
170
  it("uses devModel.reasoning when boolean false", () => {
208
171
  // even if pattern would match, devModel wins
209
- expect(
210
- getReasoning(
211
- { id: "claude-opus-4" },
212
- { id: "claude-opus-4", reasoning: false },
213
- ),
214
- ).toBe(false);
172
+ expect(getReasoning({ id: "claude-opus-4" }, { id: "claude-opus-4", reasoning: false })).toBe(
173
+ false,
174
+ );
215
175
  });
216
176
 
217
177
  it("falls back to pattern — opus", () => {
package/src/provider.ts CHANGED
@@ -11,12 +11,7 @@
11
11
 
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import type { ModelsDevModel, RouterModel } from "./data.js";
14
- import {
15
- fetchModelsDevIndex,
16
- lookupInIndex,
17
- routerBaseUrl,
18
- routerModels,
19
- } from "./data.js";
14
+ import { fetchModelsDevIndex, lookupInIndex, routerBaseUrl, routerModels } from "./data.js";
20
15
 
21
16
  const DEFAULT_CONTEXT_WINDOW = 128_000;
22
17
  const DEFAULT_MAX_TOKENS = 16_384;
@@ -29,18 +24,9 @@ const ZERO_COST = {
29
24
  };
30
25
 
31
26
  // Fallback pattern-based detection if models.dev lookup fails
32
- const IMAGE_CAPABLE_PATTERNS = [
33
- /claude/i,
34
- /gpt-5/i,
35
- /gpt-4/i,
36
- /kimi-k2/i,
37
- /hy3/i,
38
- ];
27
+ const IMAGE_CAPABLE_PATTERNS = [/claude/i, /gpt-5/i, /gpt-4/i, /kimi-k2/i, /hy3/i];
39
28
 
40
- function getInputTypes(
41
- model: RouterModel,
42
- devModel?: ModelsDevModel,
43
- ): ("text" | "image")[] {
29
+ function getInputTypes(model: RouterModel, devModel?: ModelsDevModel): ("text" | "image")[] {
44
30
  if (devModel?.modalities?.input) {
45
31
  const inputs = devModel.modalities.input.filter(
46
32
  (i): i is "text" | "image" => i === "text" || i === "image",
@@ -56,10 +42,7 @@ function getModelName(model: RouterModel, devModel?: ModelsDevModel): string {
56
42
  return model.name || devModel?.name || model.id || "unknown";
57
43
  }
58
44
 
59
- function getContextWindow(
60
- model: RouterModel,
61
- devModel?: ModelsDevModel,
62
- ): number {
45
+ function getContextWindow(model: RouterModel, devModel?: ModelsDevModel): number {
63
46
  return (
64
47
  model.context_window ||
65
48
  model.contextWindow ||
@@ -69,24 +52,15 @@ function getContextWindow(
69
52
  }
70
53
 
71
54
  function getMaxTokens(model: RouterModel, devModel?: ModelsDevModel): number {
72
- return (
73
- model.max_tokens ||
74
- model.maxTokens ||
75
- devModel?.limit?.output ||
76
- DEFAULT_MAX_TOKENS
77
- );
55
+ return model.max_tokens || model.maxTokens || devModel?.limit?.output || DEFAULT_MAX_TOKENS;
78
56
  }
79
57
 
80
58
  function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boolean {
81
59
  if (typeof devModel?.reasoning === "boolean") return devModel.reasoning;
82
- return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet/i.test(
83
- model.id ?? "",
84
- );
60
+ return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet/i.test(model.id ?? "");
85
61
  }
86
62
 
87
- export default async function registerProvider(
88
- pi: ExtensionAPI,
89
- ): Promise<void> {
63
+ export default async function registerProvider(pi: ExtensionAPI): Promise<void> {
90
64
  const apiKey = process.env.ROUTER_API_KEY;
91
65
 
92
66
  if (!apiKey) {
package/src/render.ts CHANGED
@@ -37,15 +37,13 @@ const MAX_PREVIEW_LINES = 32;
37
37
 
38
38
  let TextComponent: TextCtor | undefined;
39
39
  try {
40
- TextComponent = (require("@earendil-works/pi-tui") as { Text: TextCtor })
41
- .Text;
40
+ TextComponent = (require("@earendil-works/pi-tui") as { Text: TextCtor }).Text;
42
41
  } catch {
43
42
  TextComponent = undefined;
44
43
  }
45
44
 
46
45
  function getText(lastComponent: Component | undefined): TextLike {
47
- if (lastComponent && "setText" in lastComponent)
48
- return lastComponent as TextLike;
46
+ if (lastComponent && "setText" in lastComponent) return lastComponent as TextLike;
49
47
  // TextComponent is always present in the interactive TUI (pi-tui peer); the
50
48
  // require() guard only matters for headless/test contexts where renderers
51
49
  // are never invoked.
@@ -71,16 +69,11 @@ function dimBody(body: string, theme: Theme, expanded: boolean): string {
71
69
  }
72
70
 
73
71
  /** Build a `renderCall` that shows `<title> <accent arg>`. */
74
- export function makeRenderCall<TArgs>(
75
- title: string,
76
- pickArg: (args: TArgs) => string,
77
- ) {
72
+ export function makeRenderCall<TArgs>(title: string, pickArg: (args: TArgs) => string) {
78
73
  return (args: TArgs, theme: Theme, ctx: RenderCtx): Component => {
79
74
  const text = getText(ctx.lastComponent);
80
75
  const arg = pickArg(args);
81
- text.setText(
82
- `${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("accent", arg)}`,
83
- );
76
+ text.setText(`${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("accent", arg)}`);
84
77
  return text;
85
78
  };
86
79
  }
package/src/search.ts CHANGED
@@ -28,9 +28,7 @@ async function apiPost(
28
28
  const key = auth();
29
29
  const controller = new AbortController();
30
30
  const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
31
- const s = signal
32
- ? AbortSignal.any([signal, controller.signal])
33
- : controller.signal;
31
+ const s = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
34
32
 
35
33
  try {
36
34
  const res = await fetch(url, {
@@ -128,14 +126,12 @@ export default function registerSearch(pi: ExtensionAPI): void {
128
126
  pi.registerTool({
129
127
  name: "search",
130
128
  label: "Search",
131
- description:
132
- "Web or news search via exa. Returns title, url, and snippet for each result.",
129
+ description: "Web or news search via exa. Returns title, url, and snippet for each result.",
133
130
  promptSnippet:
134
131
  "search(query, search_type, max_results?) — search_type: 'web' or 'news'. Defaults to 5 results, max 10.",
135
132
  promptGuidelines: [
136
133
  "Use search when you need up-to-date information or facts from the web.",
137
134
  "Set search_type='web' for general web results, search_type='news' for recent news articles.",
138
- "Always prefer search over raw curl/browser requests for information lookup.",
139
135
  ],
140
136
  renderCall: makeRenderCall("search", (args) => String(args.query ?? "")),
141
137
  renderResult: makeRenderResult(),
@@ -229,8 +225,7 @@ export default function registerSearch(pi: ExtensionAPI): void {
229
225
  details: { source: "curl-fallback" },
230
226
  };
231
227
  } catch (curlErr: unknown) {
232
- const curlMsg =
233
- curlErr instanceof Error ? curlErr.message : String(curlErr);
228
+ const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
234
229
  return {
235
230
  content: [
236
231
  {
@@ -95,9 +95,7 @@ describe("parseTranscriptionResponse", () => {
95
95
  });
96
96
 
97
97
  it("returns plain text as-is when not JSON", () => {
98
- expect(parseTranscriptionResponse("Just plain text")).toBe(
99
- "Just plain text",
100
- );
98
+ expect(parseTranscriptionResponse("Just plain text")).toBe("Just plain text");
101
99
  });
102
100
 
103
101
  it("returns empty string from JSON with empty text", () => {
@@ -203,12 +201,7 @@ describe("buildTranscriptionResult", () => {
203
201
 
204
202
  it("returns inline text (truncated at 50_000) when no output_file is set", async () => {
205
203
  const text = "a".repeat(60_000);
206
- const result = await buildTranscriptionResult(
207
- text,
208
- "dg/nova-3",
209
- "api",
210
- undefined,
211
- );
204
+ const result = await buildTranscriptionResult(text, "dg/nova-3", "api", undefined);
212
205
  expect(result.content).toHaveLength(1);
213
206
  expect(result.content[0]?.type).toBe("text");
214
207
  expect(result.content[0]?.text.length).toBe(50_000);
@@ -221,12 +214,7 @@ describe("buildTranscriptionResult", () => {
221
214
 
222
215
  it("returns inline text (untruncated) when short and no output_file", async () => {
223
216
  const text = "short transcript";
224
- const result = await buildTranscriptionResult(
225
- text,
226
- "dg/nova-3",
227
- "api",
228
- undefined,
229
- );
217
+ const result = await buildTranscriptionResult(text, "dg/nova-3", "api", undefined);
230
218
  expect(result.content[0]?.text).toBe("short transcript");
231
219
  expect(result.details.chars).toBe(text.length);
232
220
  });
@@ -234,12 +222,7 @@ describe("buildTranscriptionResult", () => {
234
222
  it("writes full text to file and returns short path summary when output_file is set", async () => {
235
223
  const text = "a".repeat(100_000); // way over 50k
236
224
  const file = join(tmpRoot, "result-a.txt");
237
- const result = await buildTranscriptionResult(
238
- text,
239
- "dg/nova-3",
240
- "api",
241
- file,
242
- );
225
+ const result = await buildTranscriptionResult(text, "dg/nova-3", "api", file);
243
226
 
244
227
  // content is a short summary, not the full text
245
228
  expect(result.content).toHaveLength(1);
@@ -261,12 +244,7 @@ describe("buildTranscriptionResult", () => {
261
244
 
262
245
  it("passes through curl-fallback source label", async () => {
263
246
  const text = "hi";
264
- const result = await buildTranscriptionResult(
265
- text,
266
- "dg/nova-3",
267
- "curl-fallback",
268
- undefined,
269
- );
247
+ const result = await buildTranscriptionResult(text, "dg/nova-3", "curl-fallback", undefined);
270
248
  expect(result.details.source).toBe("curl-fallback");
271
249
  });
272
250
 
@@ -274,12 +252,7 @@ describe("buildTranscriptionResult", () => {
274
252
  const text = "relative path content";
275
253
  const relDir = join(tmpRoot, "rel", "sub");
276
254
  const relFile = join(relDir, "out.txt");
277
- const result = await buildTranscriptionResult(
278
- text,
279
- "dg/nova-3",
280
- "api",
281
- relFile,
282
- );
255
+ const result = await buildTranscriptionResult(text, "dg/nova-3", "api", relFile);
283
256
 
284
257
  expect(result.details.output_path).toBe(relFile);
285
258
  expect(await readFile(relFile, "utf-8")).toBe("relative path content");
@@ -302,9 +275,7 @@ describe("validateOutputPath", () => {
302
275
  });
303
276
 
304
277
  it("accepts a fresh path several levels deep", async () => {
305
- const result = await validateOutputPath(
306
- join(tmpRoot, "a", "b", "c", "deep.txt"),
307
- );
278
+ const result = await validateOutputPath(join(tmpRoot, "a", "b", "c", "deep.txt"));
308
279
  expect(result.ok).toBe(true);
309
280
  });
310
281
 
@@ -404,9 +375,7 @@ describe("writeTranscriptionFile — rejection propagation", () => {
404
375
  });
405
376
 
406
377
  it("throws on null byte", async () => {
407
- await expect(
408
- writeTranscriptionFile("/tmp/pix-test-\0x.txt", "x"),
409
- ).rejects.toThrow(/null byte/);
378
+ await expect(writeTranscriptionFile("/tmp/pix-test-\0x.txt", "x")).rejects.toThrow(/null byte/);
410
379
  });
411
380
  });
412
381
 
@@ -415,12 +384,7 @@ describe("writeTranscriptionFile — rejection propagation", () => {
415
384
  describe("buildTranscriptionResult — write failure path", () => {
416
385
  it("returns isError + inline fallback when output_file is rejected", async () => {
417
386
  const text = "the actual transcription that was successfully produced";
418
- const result = await buildTranscriptionResult(
419
- text,
420
- "dg/nova-3",
421
- "api",
422
- "/etc/passwd",
423
- );
387
+ const result = await buildTranscriptionResult(text, "dg/nova-3", "api", "/etc/passwd");
424
388
  expect(result.isError).toBe(true);
425
389
  expect(result.details.write_error).toMatch(/refusing to write/);
426
390
  expect(result.details.output_path).toBeUndefined();
package/src/transcribe.ts CHANGED
@@ -55,9 +55,7 @@ async function apiMultipart(
55
55
  const key = auth();
56
56
  const controller = new AbortController();
57
57
  const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
58
- const s = signal
59
- ? AbortSignal.any([signal, controller.signal])
60
- : controller.signal;
58
+ const s = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
61
59
 
62
60
  try {
63
61
  const fileData = await readFile(filePath);
@@ -119,9 +117,7 @@ export function parseTranscriptionResponse(raw: string): string {
119
117
 
120
118
  /** Resolve a possibly-relative `output_file` to an absolute path. */
121
119
  export function resolveOutputPath(outputFile: string): string {
122
- return isAbsolute(outputFile)
123
- ? outputFile
124
- : resolve(process.cwd(), outputFile);
120
+ return isAbsolute(outputFile) ? outputFile : resolve(process.cwd(), outputFile);
125
121
  }
126
122
 
127
123
  /**
@@ -132,9 +128,7 @@ export function resolveOutputPath(outputFile: string): string {
132
128
  * Returns a discriminated result so the caller can surface a precise reason to
133
129
  * the model without leaking OS internals.
134
130
  */
135
- export type PathValidation =
136
- | { ok: true; path: string }
137
- | { ok: false; reason: string };
131
+ export type PathValidation = { ok: true; path: string } | { ok: false; reason: string };
138
132
 
139
133
  /** Block-list of absolute prefixes that should never receive transcription output. */
140
134
  function sensitivePrefixes(): string[] {
@@ -151,9 +145,7 @@ function sensitivePrefixes(): string[] {
151
145
  ];
152
146
  }
153
147
 
154
- export async function validateOutputPath(
155
- absPath: string,
156
- ): Promise<PathValidation> {
148
+ export async function validateOutputPath(absPath: string): Promise<PathValidation> {
157
149
  // 1. Null byte injection guard (defence in depth — Node already rejects, fail fast with clear msg)
158
150
  if (absPath.includes("\0")) {
159
151
  return { ok: false, reason: "path contains a null byte" };
@@ -240,10 +232,7 @@ export async function validateOutputPath(
240
232
 
241
233
  /** Write transcription text to disk, creating parent directories as needed.
242
234
  * Performs pre-flight safety checks; throws on rejection. */
243
- export async function writeTranscriptionFile(
244
- outputFile: string,
245
- text: string,
246
- ): Promise<string> {
235
+ export async function writeTranscriptionFile(outputFile: string, text: string): Promise<string> {
247
236
  const abs = resolveOutputPath(outputFile);
248
237
  const validation = await validateOutputPath(abs);
249
238
  if (!validation.ok) {
@@ -289,8 +278,7 @@ export async function buildTranscriptionResult(
289
278
  details,
290
279
  };
291
280
  } catch (writeErr) {
292
- const msg =
293
- writeErr instanceof Error ? writeErr.message : String(writeErr);
281
+ const msg = writeErr instanceof Error ? writeErr.message : String(writeErr);
294
282
  details.write_error = msg;
295
283
  return {
296
284
  content: [
@@ -348,8 +336,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
348
336
  ),
349
337
  language: Type.Optional(
350
338
  Type.String({
351
- description:
352
- "ISO 639-1 language code (e.g. 'en', 'es', 'fr') for better accuracy",
339
+ description: "ISO 639-1 language code (e.g. 'en', 'es', 'fr') for better accuracy",
353
340
  }),
354
341
  ),
355
342
  }),
@@ -361,12 +348,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
361
348
  let apiMsg = "";
362
349
 
363
350
  const run = async (source: "api" | "curl-fallback", raw: string) =>
364
- buildTranscriptionResult(
365
- parseTranscriptionResponse(raw),
366
- model,
367
- source,
368
- outputFile,
369
- );
351
+ buildTranscriptionResult(parseTranscriptionResponse(raw), model, source, outputFile);
370
352
 
371
353
  try {
372
354
  onUpdate?.({
@@ -418,8 +400,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
418
400
  const raw = await curl(curlArgs);
419
401
  return await run("curl-fallback", raw);
420
402
  } catch (curlErr: unknown) {
421
- const curlMsg =
422
- curlErr instanceof Error ? curlErr.message : String(curlErr);
403
+ const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
423
404
  return {
424
405
  content: [
425
406
  {