@xynogen/pix-9router 0.6.0 → 0.8.0

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/src/http.ts DELETED
@@ -1,102 +0,0 @@
1
- /**
2
- * http.ts — shared 9Router HTTP plumbing.
3
- *
4
- * auth and curl serve the transcribe/tts fallback paths and the catalog reader.
5
- * curl differs only by timeout (audio needs longer) and stdin body, so both are
6
- * parameters with the old per-file defaults.
7
- */
8
-
9
- import { readFileSync } from "node:fs";
10
- import { basename } from "node:path";
11
- import { ioTimeoutMs } from "@xynogen/pix-runtime/io";
12
-
13
- export function auth(): string | undefined {
14
- return process.env.NINEROUTER_KEY || process.env.ROUTER_API_KEY;
15
- }
16
-
17
- export interface CurlOptions {
18
- /** Request timeout in ms (default ioTimeoutMs()). */
19
- timeoutMs?: number;
20
- /** Optional request body (was piped to curl stdin). */
21
- stdin?: string;
22
- }
23
-
24
- // ponytail: this is NOT a general curl shim — it parses only the flag forms the
25
- // three fallback call sites actually pass (-L, -X, -H, -d, -F key=value /
26
- // file=@path, plus the -sS/--connect-timeout/--max-time defaults added below).
27
- // New curl flags won't work here; extend the parser or call fetch directly.
28
- function curlArgsToRequest(args: string[]): { url: string; init: RequestInit } {
29
- const headers = new Headers();
30
- let method = "GET";
31
- let body: string | undefined;
32
- let form: FormData | undefined;
33
- let url = "";
34
- for (let i = 0; i < args.length; i++) {
35
- const a = args[i] ?? "";
36
- switch (a) {
37
- case "-sS":
38
- case "-L":
39
- break; // silent / follow-redirects are fetch defaults
40
- case "--connect-timeout":
41
- case "--max-time":
42
- i++; // value handled by AbortSignal below
43
- break;
44
- case "-X":
45
- method = args[++i] ?? method;
46
- break;
47
- case "-H": {
48
- const h = args[++i] ?? "";
49
- const idx = h.indexOf(":");
50
- if (idx >= 0) headers.set(h.slice(0, idx).trim(), h.slice(idx + 1).trim());
51
- break;
52
- }
53
- case "-d":
54
- body = args[++i] ?? "";
55
- break;
56
- case "-F": {
57
- form ??= new FormData();
58
- const field = args[++i] ?? "";
59
- const eq = field.indexOf("=");
60
- const key = field.slice(0, eq);
61
- const val = field.slice(eq + 1);
62
- if (val.startsWith("@")) {
63
- const path = val.slice(1);
64
- form.set(key, new Blob([readFileSync(path)]), basename(path));
65
- } else {
66
- form.set(key, val);
67
- }
68
- break;
69
- }
70
- default:
71
- if (!a.startsWith("-")) url = a; // positional = URL
72
- }
73
- }
74
- const init: RequestInit = { method };
75
- if (form)
76
- init.body = form; // let fetch set the multipart boundary
77
- else if (body !== undefined) init.body = body;
78
- if ([...headers.keys()].length > 0) init.headers = headers;
79
- return { url, init };
80
- }
81
-
82
- /** Raw fetch fallback (curl-signature compatible), rejecting on non-2xx or timeout. */
83
- export async function curl(args: string[], opts: CurlOptions = {}): Promise<string> {
84
- const timeoutMs = opts.timeoutMs ?? ioTimeoutMs();
85
- const { url, init } = curlArgsToRequest(args);
86
- if (opts.stdin !== undefined) init.body = opts.stdin;
87
- init.signal = AbortSignal.timeout(timeoutMs);
88
- let res: Response;
89
- try {
90
- res = await fetch(url, init);
91
- } catch (err) {
92
- if (err instanceof DOMException && err.name === "TimeoutError") {
93
- throw new Error("curl timed out");
94
- }
95
- throw new Error(`curl failed: ${err instanceof Error ? err.message : String(err)}`);
96
- }
97
- if (!res.ok) {
98
- const errText = await res.text().catch(() => "");
99
- throw new Error(`curl exit ${res.status}: ${errText.slice(0, 300)}`);
100
- }
101
- return res.text();
102
- }
package/src/recorder.ts DELETED
@@ -1,103 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { findExecutableSync } from "@xynogen/pix-runtime/which";
6
-
7
- export function parsePulseSources(output: string): string[] {
8
- return output
9
- .split("\n")
10
- .map((line) => line.split("\t")[1]?.trim())
11
- .filter((name): name is string => Boolean(name));
12
- }
13
-
14
- export function parseRmsDb(output: string): number | undefined {
15
- const matches = [
16
- ...output.matchAll(/(?:RMS level dB:\s*|lavfi\.astats\.Overall\.RMS_level=)(-?\d+(?:\.\d+)?)/g),
17
- ];
18
- const value = matches.at(-1)?.[1];
19
- return value === undefined ? undefined : Number(value);
20
- }
21
-
22
- export function ffmpegRecordArgs(device: string, output: string): string[] {
23
- return [
24
- "-hide_banner",
25
- "-loglevel",
26
- "info",
27
- "-f",
28
- "pulse",
29
- "-i",
30
- device,
31
- "-ac",
32
- "1",
33
- "-ar",
34
- "16000",
35
- "-af",
36
- "astats=metadata=1:reset=1,ametadata=print:key=lavfi.astats.Overall.RMS_level",
37
- "-y",
38
- output,
39
- ];
40
- }
41
-
42
- function run(command: string, args: string[]): Promise<string> {
43
- return new Promise((resolve, reject) => {
44
- const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
45
- let stdout = "";
46
- let stderr = "";
47
- child.stdout.on("data", (data) => (stdout += String(data)));
48
- child.stderr.on("data", (data) => (stderr += String(data)));
49
- child.once("error", reject);
50
- child.once("exit", (code) =>
51
- code === 0
52
- ? resolve(stdout)
53
- : reject(new Error(stderr.trim() || `${command} exited ${code}`)),
54
- );
55
- });
56
- }
57
-
58
- export async function microphoneDevices(): Promise<string[]> {
59
- const pactl = findExecutableSync("pactl");
60
- if (!pactl) return ["default"];
61
- const names = parsePulseSources(await run(pactl, ["list", "short", "sources"]));
62
- return [...new Set(["default", ...names])];
63
- }
64
-
65
- export interface Recording {
66
- path: string;
67
- stop(): Promise<number | undefined>;
68
- }
69
-
70
- export function startRecording(device: string, onLevel: (db: number) => void): Recording {
71
- const ffmpeg = findExecutableSync("ffmpeg");
72
- if (!ffmpeg) throw new Error("Microphone recording needs ffmpeg on PATH.");
73
- const path = join(tmpdir(), `pix-stt-${randomUUID()}.wav`);
74
- const child = spawn(ffmpeg, ffmpegRecordArgs(device, path), {
75
- stdio: ["pipe", "ignore", "pipe"],
76
- });
77
- let stderr = "";
78
- let lastLevel: number | undefined;
79
- child.stderr.on("data", (data) => {
80
- const text = String(data);
81
- stderr += text;
82
- const level = parseRmsDb(text);
83
- if (level !== undefined) {
84
- lastLevel = level;
85
- onLevel(level);
86
- }
87
- });
88
- const exit = new Promise<void>((resolve, reject) => {
89
- child.once("error", reject);
90
- child.once("exit", (code) =>
91
- code === 0 ? resolve() : reject(new Error(stderr.trim() || `ffmpeg exited ${code}`)),
92
- );
93
- });
94
- return {
95
- path,
96
- async stop() {
97
- child.stdin.write("q");
98
- child.stdin.end();
99
- await exit;
100
- return lastLevel;
101
- },
102
- };
103
- }
package/src/render.ts DELETED
@@ -1,151 +0,0 @@
1
- /**
2
- * render.ts — shared dim renderers for fetch/search tool output.
3
- *
4
- * fetch/search return external web content the model already consumed; the
5
- * full body is noise in the transcript. We render it dim (theme `dim` token)
6
- * so it reads like faded reasoning — present for inspection, visually quiet.
7
- * The tool title line stays normal so calls remain scannable.
8
- *
9
- * pi-tui's Text component is loaded via require() so the renderers degrade to
10
- * the default pi renderer (undefined-safe via cast) when pi-tui is absent.
11
- */
12
-
13
- import type {
14
- AgentToolResult,
15
- Theme,
16
- ToolRenderResultOptions,
17
- } from "@earendil-works/pi-coding-agent";
18
- import type { Component } from "@earendil-works/pi-tui";
19
- import {
20
- type CollapsedToolStatus,
21
- formatCollapsedToolRow,
22
- frameToolResult,
23
- hideCollapsedToolCall,
24
- unframeToolResult,
25
- } from "@xynogen/pix-pretty/utils";
26
- import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
27
-
28
- interface TextLike extends Component {
29
- setText(text: string): void;
30
- }
31
-
32
- // ToolRenderContext is not re-exported by pi-coding-agent; model the fields the
33
- // renderers actually read. Structurally compatible with the SDK context.
34
- interface RenderCtx {
35
- lastComponent: Component | undefined;
36
- isError: boolean;
37
- state: Record<string, unknown>;
38
- expanded: boolean;
39
- invalidate: () => void;
40
- }
41
-
42
- interface CompactRendererConfig<TDetails> {
43
- tool: string;
44
- target: (details: TDetails) => string;
45
- meta: (details: TDetails) => string;
46
- status?: (details: TDetails) => CollapsedToolStatus;
47
- }
48
-
49
- type TextCtor = new (text?: string, padX?: number, padY?: number) => TextLike;
50
-
51
- // Preview cap is intentionally generous — dimming already de-emphasises the
52
- // body, so we only truncate to keep pathological multi-thousand-line dumps
53
- // from flooding the viewport. Expanding shows everything.
54
- const MAX_PREVIEW_LINES = 32;
55
-
56
- let TextComponent: TextCtor | undefined;
57
- try {
58
- TextComponent = (require("@earendil-works/pi-tui") as { Text: TextCtor }).Text;
59
- } catch {
60
- TextComponent = undefined;
61
- }
62
-
63
- function getText(lastComponent: Component | undefined): TextLike {
64
- if (lastComponent && "setText" in lastComponent)
65
- return unframeToolResult(lastComponent as TextLike);
66
- // TextComponent is always present in the interactive TUI (pi-tui peer); the
67
- // require() guard only matters for headless/test contexts where renderers
68
- // are never invoked.
69
- if (TextComponent) return new TextComponent("", 0, 0);
70
- throw new Error("pi-tui Text component unavailable");
71
- }
72
-
73
- function allText(result: AgentToolResult<unknown>): string {
74
- return (result.content ?? [])
75
- .filter((block): block is { type: "text"; text: string } => block.type === "text")
76
- .map((block) => block.text)
77
- .join("\n");
78
- }
79
-
80
- function dimBody(body: string, theme: Theme, expanded: boolean): string {
81
- const lines = body.split("\n");
82
- const maxShow = expanded ? lines.length : MAX_PREVIEW_LINES;
83
- const shown = lines.slice(0, maxShow);
84
- const out = shown.map((line) => ` ${theme.fg("dim", line)}`);
85
- const remaining = lines.length - maxShow;
86
- if (remaining > 0) {
87
- out.push(` ${theme.fg("muted", `… ${remaining} more lines`)}`);
88
- }
89
- return out.join("\n");
90
- }
91
-
92
- /** Build a `renderCall` that shows `<title> <secondary arg>`. */
93
- export function makeRenderCall<TArgs>(title: string, pickArg: (args: TArgs) => string) {
94
- return (args: TArgs, theme: Theme, ctx: RenderCtx): Component => {
95
- const text = getText(ctx.lastComponent);
96
- if (
97
- hideCollapsedToolCall(ctx.state as CollapseState, ctx.expanded, (value) =>
98
- text.setText(value),
99
- )
100
- )
101
- return text;
102
- const arg = pickArg(args);
103
- text.setText(`${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("dim", arg)}`);
104
- return text;
105
- };
106
- }
107
-
108
- /** Build a `renderResult` with a bounded detail preview and optional compact terminal row. */
109
- export function makeRenderResult<TDetails>(config?: CompactRendererConfig<TDetails>) {
110
- return (
111
- result: AgentToolResult<unknown>,
112
- opts: ToolRenderResultOptions,
113
- theme: Theme,
114
- ctx: RenderCtx,
115
- ): Component => {
116
- const text = getText(ctx.lastComponent);
117
- const body = allText(result);
118
-
119
- if (ctx.isError) {
120
- text.setText(` ${theme.fg("error", body || "Error")}`);
121
- return opts.isPartial ? text : frameToolResult(text, theme, true);
122
- }
123
- if (!body.trim()) {
124
- text.setText(` ${theme.fg("muted", "(empty)")}`);
125
- return opts.isPartial ? text : frameToolResult(text, theme, false);
126
- }
127
-
128
- const details = result.details as TDetails | undefined;
129
- const isError = Boolean(details && config?.status?.(details) === "error");
130
- if (
131
- !opts.isPartial &&
132
- config &&
133
- details &&
134
- tickCollapse(config.tool, ctx.state as CollapseState, ctx.invalidate, opts.expanded)
135
- ) {
136
- text.setText(
137
- formatCollapsedToolRow(
138
- theme,
139
- config.tool,
140
- config.target(details),
141
- config.meta(details),
142
- config.status?.(details) ?? "success",
143
- ),
144
- );
145
- return text;
146
- }
147
-
148
- text.setText(dimBody(body, theme, opts.expanded));
149
- return opts.isPartial ? text : frameToolResult(text, theme, isError);
150
- };
151
- }
@@ -1,127 +0,0 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { matchesKey } from "@earendil-works/pi-tui";
3
- import {
4
- frameModal,
5
- modalOverlayOptions,
6
- modalWidth,
7
- terminalModalHeight,
8
- } from "@xynogen/pix-pretty/modal-frame";
9
- import { routerDefaults, saveDefaults } from "./defaults.js";
10
- import { microphoneDevices, type Recording, startRecording } from "./recorder.js";
11
- import { transcribeAudioFile } from "./transcribe.js";
12
-
13
- function levelBar(db: number | undefined): string {
14
- if (db === undefined) return "░".repeat(24);
15
- const count = Math.max(0, Math.min(24, Math.round(((db + 60) / 60) * 24)));
16
- return `${"█".repeat(count)}${"░".repeat(24 - count)}`;
17
- }
18
-
19
- async function record(
20
- ctx: ExtensionCommandContext,
21
- devices: string[],
22
- ): Promise<string | undefined> {
23
- return (
24
- (await ctx.ui.custom<string | null>(
25
- (tui, theme, _keybindings, done) => {
26
- let deviceIndex = Math.max(0, devices.indexOf(routerDefaults.sttDevice));
27
- let recording: Recording | undefined;
28
- let level: number | undefined;
29
- let error = "";
30
- let stopping = false;
31
-
32
- const stop = async () => {
33
- if (!recording || stopping) return;
34
- stopping = true;
35
- try {
36
- const current = recording;
37
- await current.stop();
38
- done(current.path);
39
- } catch (cause) {
40
- error = cause instanceof Error ? cause.message : String(cause);
41
- recording = undefined;
42
- stopping = false;
43
- tui.requestRender();
44
- }
45
- };
46
-
47
- return {
48
- render(width: number) {
49
- const device = devices[deviceIndex] ?? "default";
50
- return frameModal({
51
- width: modalWidth(width),
52
- maxHeight: terminalModalHeight(tui.terminal?.rows),
53
- title: "Microphone",
54
- titleColor: (text) => theme.fg("accent", theme.bold(text)),
55
- header: [""],
56
- body: [
57
- `${theme.fg("dim", "input")} ${theme.fg("success", device)}`,
58
- "",
59
- `${theme.fg("dim", "level")} ${theme.fg(level !== undefined && level > -12 ? "warning" : "success", levelBar(level))} ${level === undefined ? "" : `${level.toFixed(1)} dB`}`,
60
- "",
61
- theme.fg(error ? "error" : "text", error || (recording ? "Recording…" : "Ready")),
62
- ],
63
- footer: [
64
- "",
65
- theme.fg(
66
- "muted",
67
- recording
68
- ? "enter stop and transcribe · esc cancel"
69
- : "←→ input · enter record · esc close",
70
- ),
71
- ],
72
- color: (text) => theme.fg("accent", text),
73
- bg: (text) => theme.bg("customMessageBg", text),
74
- }).lines;
75
- },
76
- invalidate: () => {},
77
- handleInput(data: string) {
78
- if (matchesKey(data, "escape")) {
79
- if (recording) void recording.stop().finally(() => done(null));
80
- else done(null);
81
- } else if (matchesKey(data, "enter")) {
82
- if (recording) void stop();
83
- else {
84
- const device = devices[deviceIndex] ?? "default";
85
- try {
86
- recording = startRecording(device, (db) => {
87
- level = db;
88
- tui.requestRender();
89
- });
90
- routerDefaults.sttDevice = device;
91
- saveDefaults(routerDefaults);
92
- } catch (cause) {
93
- error = cause instanceof Error ? cause.message : String(cause);
94
- }
95
- }
96
- } else if (!recording && (matchesKey(data, "left") || matchesKey(data, "right"))) {
97
- const direction = matchesKey(data, "left") ? -1 : 1;
98
- deviceIndex = (deviceIndex + direction + devices.length) % devices.length;
99
- }
100
- tui.requestRender();
101
- },
102
- };
103
- },
104
- { overlay: true, overlayOptions: modalOverlayOptions() },
105
- )) ?? undefined
106
- );
107
- }
108
-
109
- export default function registerSttCommand(pi: ExtensionAPI): void {
110
- pi.registerCommand("stt", {
111
- description: "Record microphone audio and put its transcript in the prompt",
112
- handler: async (_args, ctx) => {
113
- try {
114
- const audio = await record(ctx, await microphoneDevices());
115
- if (!audio) return;
116
- ctx.ui.setStatus("9router-stt", "Transcribing microphone…");
117
- const transcript = await transcribeAudioFile(audio);
118
- ctx.ui.setEditorText(transcript);
119
- ctx.ui.notify("The transcript is in the prompt editor.", "info");
120
- } catch (error) {
121
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
122
- } finally {
123
- ctx.ui.setStatus("9router-stt", undefined);
124
- }
125
- },
126
- });
127
- }