@rol1n/pi-kit 0.0.0-stage → 0.1.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.
Files changed (120) hide show
  1. package/README.md +73 -2
  2. package/ask-u-questions/README.md +26 -0
  3. package/ask-u-questions/dialog-ui.ts +80 -0
  4. package/ask-u-questions/index.ts +92 -0
  5. package/ask-u-questions/multi-choice.ts +258 -0
  6. package/ask-u-questions/single-choice.ts +180 -0
  7. package/ask-u-questions/tool-renderers.ts +67 -0
  8. package/ask-u-questions/types.ts +192 -0
  9. package/continue-signal/README.md +20 -0
  10. package/continue-signal/index.ts +69 -0
  11. package/custom-providers/README.md +20 -0
  12. package/custom-providers/asr/handlers/dashscope.ts +154 -0
  13. package/custom-providers/asr/index.ts +37 -0
  14. package/custom-providers/asr/models.ts +18 -0
  15. package/custom-providers/asr/types.ts +20 -0
  16. package/custom-providers/chat/index.ts +33 -0
  17. package/custom-providers/chat/magpie.ts +258 -0
  18. package/custom-providers/chat/models.ts +111 -0
  19. package/custom-providers/classifier/index.ts +6 -0
  20. package/custom-providers/classifier/models.ts +1 -0
  21. package/custom-providers/image/handlers/dashscope.ts +240 -0
  22. package/custom-providers/image/handlers/openrouter.ts +185 -0
  23. package/custom-providers/image/index.ts +96 -0
  24. package/custom-providers/image/models.ts +71 -0
  25. package/custom-providers/image/types.ts +22 -0
  26. package/custom-providers/index.ts +64 -0
  27. package/custom-providers/types.ts +17 -0
  28. package/image-generate/README.md +25 -0
  29. package/image-generate/files.ts +49 -0
  30. package/image-generate/index.ts +220 -0
  31. package/image-generate/model-selector.ts +150 -0
  32. package/memory-log/README.md +54 -0
  33. package/memory-log/agent/consolidator/prompt.ts +47 -0
  34. package/memory-log/agent/consolidator/tools.ts +160 -0
  35. package/memory-log/agent/cost.ts +32 -0
  36. package/memory-log/agent/index.ts +42 -0
  37. package/memory-log/agent/observer/prompt.ts +73 -0
  38. package/memory-log/agent/observer/tool.ts +73 -0
  39. package/memory-log/commands/compact.ts +35 -0
  40. package/memory-log/commands/consolidate.ts +47 -0
  41. package/memory-log/commands/status.ts +50 -0
  42. package/memory-log/config.ts +253 -0
  43. package/memory-log/debug-log.ts +76 -0
  44. package/memory-log/hooks/compaction-hook.ts +161 -0
  45. package/memory-log/hooks/compaction-trigger.ts +160 -0
  46. package/memory-log/hooks/consolidator-trigger.ts +143 -0
  47. package/memory-log/hooks/observer-trigger.ts +176 -0
  48. package/memory-log/ids.ts +78 -0
  49. package/memory-log/index.ts +149 -0
  50. package/memory-log/ledger/fold.ts +77 -0
  51. package/memory-log/ledger/index.ts +7 -0
  52. package/memory-log/ledger/pool.ts +38 -0
  53. package/memory-log/ledger/progress.ts +181 -0
  54. package/memory-log/ledger/projection.ts +115 -0
  55. package/memory-log/ledger/render.ts +38 -0
  56. package/memory-log/ledger/serialize.ts +161 -0
  57. package/memory-log/ledger/types.ts +201 -0
  58. package/memory-log/memory/index-render.ts +45 -0
  59. package/memory-log/memory/paths.ts +120 -0
  60. package/memory-log/memory/session.ts +64 -0
  61. package/memory-log/runtime.ts +127 -0
  62. package/memory-log/spawn/launch.ts +133 -0
  63. package/memory-log/spawn/runs.ts +83 -0
  64. package/memory-log/tokens.ts +66 -0
  65. package/memory-log/ui/status-controller.ts +225 -0
  66. package/memory-log/ui/timeline.ts +103 -0
  67. package/model-vision/README.md +22 -0
  68. package/multi-harness/README.md +63 -0
  69. package/multi-harness/adapters.mjs +72 -0
  70. package/multi-harness/harness-done.mjs +14 -0
  71. package/multi-harness/index.ts +375 -0
  72. package/multi-harness/ipc.mjs +37 -0
  73. package/multi-harness/opencode-plugin.mjs +99 -0
  74. package/multi-harness/process-runner.mjs +115 -0
  75. package/multi-harness/turn-hook.mjs +29 -0
  76. package/multi-harness/worker-bridge.ts +52 -0
  77. package/oai-subquota/README.md +22 -0
  78. package/oai-subquota/index.ts +286 -0
  79. package/oai-subquota/snapshot.ts +293 -0
  80. package/oai-subquota/ui.ts +183 -0
  81. package/package.json +92 -4
  82. package/prompt-snipaste/README.md +27 -0
  83. package/prompt-snipaste/index.ts +143 -0
  84. package/prompt-snipaste/menu.ts +159 -0
  85. package/prompt-snipaste/prompts/ask-questions.md +8 -0
  86. package/prompt-snipaste/prompts/delegate-exploration.md +7 -0
  87. package/prompt-snipaste/prompts/diagnose-report.md +7 -0
  88. package/prompt-snipaste/prompts/frontend-tech-preset.md +18 -0
  89. package/prompt-snipaste/prompts/orchestrator-mode.md +7 -0
  90. package/prompt-snipaste/prompts/session-kickoff.md +7 -0
  91. package/prompt-snipaste/prompts/verify-not-assume.md +7 -0
  92. package/prompt-snipaste/renderers.ts +87 -0
  93. package/prompt-snipaste/snippets.ts +206 -0
  94. package/prompt-snipaste/types.ts +18 -0
  95. package/screenshot/README.md +14 -0
  96. package/srp-theme/README.md +18 -0
  97. package/srp-theme/footer.ts +193 -0
  98. package/srp-theme/index.ts +172 -0
  99. package/srp-theme/tps-meter.ts +441 -0
  100. package/subagent/README.md +35 -0
  101. package/subagent/bridge.ts +171 -0
  102. package/subagent/index.ts +590 -0
  103. package/subagent/mux.ts +621 -0
  104. package/subagent/session.ts +166 -0
  105. package/subagent/ui.ts +100 -0
  106. package/tui-asr/README.md +21 -0
  107. package/tui-asr/dictation.ts +272 -0
  108. package/tui-asr/index.ts +181 -0
  109. package/tui-asr/input.ts +87 -0
  110. package/tui-asr/model-selector.ts +163 -0
  111. package/tui-asr/status.ts +79 -0
  112. package/web-reach/README.md +19 -0
  113. package/web-reach/html.ts +83 -0
  114. package/web-reach/index.ts +82 -0
  115. package/web-reach/page.ts +214 -0
  116. package/web-reach/rsc.ts +161 -0
  117. package/web-reach/search.ts +85 -0
  118. package/web-reach/transport.ts +35 -0
  119. package/windows-wheel-fix/README.md +12 -0
  120. package/windows-wheel-fix/index.ts +44 -0
@@ -0,0 +1,166 @@
1
+ import { openSync, readFileSync, readSync, closeSync } from "node:fs";
2
+
3
+ export interface SessionEntry {
4
+ type: string;
5
+ id: string;
6
+ parentId?: string | null;
7
+ [key: string]: unknown;
8
+ }
9
+
10
+ export interface AssistantMessage {
11
+ role: "assistant";
12
+ content?: unknown;
13
+ stopReason?: string;
14
+ errorMessage?: string;
15
+ model?: string;
16
+ usage?: Record<string, unknown>;
17
+ }
18
+
19
+ export interface SessionUsage {
20
+ model?: string;
21
+ toolCount: number;
22
+ input: number;
23
+ output: number;
24
+ cacheRead: number;
25
+ cacheWrite: number;
26
+ contextTokens: number;
27
+ cost: number;
28
+ }
29
+
30
+ export function readEntries(sessionFile: string): SessionEntry[] {
31
+ let content: string;
32
+ try {
33
+ content = readFileSync(sessionFile, "utf8");
34
+ } catch {
35
+ return [];
36
+ }
37
+ const entries: SessionEntry[] = [];
38
+ for (const line of content.split("\n")) {
39
+ if (!line.trim()) continue;
40
+ try {
41
+ entries.push(JSON.parse(line));
42
+ } catch {
43
+ // ignore malformed or mid-append lines
44
+ }
45
+ }
46
+ return entries;
47
+ }
48
+
49
+ export function getSessionId(sessionFile: string): string | null {
50
+ let fd: number | undefined;
51
+ try {
52
+ fd = openSync(sessionFile, "r");
53
+ const buf = Buffer.allocUnsafe(4096);
54
+ const bytes = readSync(fd, buf, 0, 4096, 0);
55
+ if (bytes <= 0) return null;
56
+ const nl = buf.indexOf(0x0a);
57
+ const end = nl === -1 || nl >= bytes ? bytes : nl;
58
+ const firstLine = buf.toString("utf8", 0, end).trim();
59
+ if (!firstLine) return null;
60
+ const parsed = JSON.parse(firstLine);
61
+ return parsed.type === "session" && typeof parsed.id === "string" ? parsed.id : null;
62
+ } catch {
63
+ return null;
64
+ } finally {
65
+ if (fd !== undefined) {
66
+ try {
67
+ closeSync(fd);
68
+ } catch {}
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Extracts the last assistant response text from a session JSONL file.
75
+ * Handles auto-retry error messages as fallback.
76
+ */
77
+ export function findLastAssistantMessage(sessionFile: string): string | null {
78
+ const entries = readEntries(sessionFile);
79
+ for (let i = entries.length - 1; i >= 0; i--) {
80
+ const entry = entries[i];
81
+ if (entry.type !== "message" || typeof entry.message !== "object" || entry.message === null) continue;
82
+ const msg = entry.message as AssistantMessage;
83
+ if (msg.role !== "assistant") continue;
84
+
85
+ if (Array.isArray(msg.content)) {
86
+ const texts: string[] = [];
87
+ for (const block of msg.content) {
88
+ if (typeof block === "object" && block !== null && (block as any).type === "text" && typeof (block as any).text === "string") {
89
+ texts.push((block as any).text);
90
+ }
91
+ }
92
+ const combined = texts.join("\n").trim();
93
+ if (combined) return combined;
94
+ }
95
+
96
+ if (msg.stopReason === "error" && typeof msg.errorMessage === "string" && msg.errorMessage.trim()) {
97
+ return `Worker error: ${msg.errorMessage.trim()}`;
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Summarize token usage and cost across all assistant turns.
105
+ */
106
+ export function readSessionUsage(sessionFile: string): SessionUsage | undefined {
107
+ let content: string;
108
+ try {
109
+ content = readFileSync(sessionFile, "utf8");
110
+ } catch {
111
+ return undefined;
112
+ }
113
+
114
+ const usage: SessionUsage = {
115
+ toolCount: 0,
116
+ input: 0,
117
+ output: 0,
118
+ cacheRead: 0,
119
+ cacheWrite: 0,
120
+ contextTokens: 0,
121
+ cost: 0,
122
+ };
123
+
124
+ const num = (value: unknown): number =>
125
+ typeof value === "number" && Number.isFinite(value) ? value : 0;
126
+
127
+ for (const line of content.split("\n")) {
128
+ if (!line.trim()) continue;
129
+ let entry: Record<string, unknown>;
130
+ try {
131
+ entry = JSON.parse(line);
132
+ } catch {
133
+ continue;
134
+ }
135
+
136
+ if (entry.type === "model_change" && typeof entry.modelId === "string" && entry.modelId) {
137
+ usage.model = entry.modelId;
138
+ continue;
139
+ }
140
+ if (entry.type !== "message") continue;
141
+ const message = entry.message as AssistantMessage | undefined;
142
+ if (!message || message.role !== "assistant") continue;
143
+
144
+ if (typeof message.model === "string" && message.model) usage.model = message.model;
145
+ if (Array.isArray(message.content)) {
146
+ for (const block of message.content) {
147
+ if (typeof block === "object" && block !== null && (block as any).type === "toolCall") {
148
+ usage.toolCount++;
149
+ }
150
+ }
151
+ }
152
+
153
+ const record = message.usage as Record<string, unknown> | undefined;
154
+ if (!record || typeof record !== "object") continue;
155
+ usage.input += num(record.input);
156
+ usage.output += num(record.output);
157
+ usage.cacheRead += num(record.cacheRead);
158
+ usage.cacheWrite += num(record.cacheWrite);
159
+ const total = num(record.totalTokens);
160
+ if (total > 0) usage.contextTokens = total;
161
+ const cost = record.cost as Record<string, unknown> | undefined;
162
+ if (cost && typeof cost === "object") usage.cost += num(cost.total);
163
+ }
164
+
165
+ return usage;
166
+ }
package/subagent/ui.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * subagent/ui.ts — Status widget for active subagent workers above the editor.
3
+ *
4
+ * Compact, borderless, multi-worker friendly layout matching the SRP theme suite.
5
+ */
6
+
7
+ import type { Theme } from "@earendil-works/pi-coding-agent";
8
+ import { readSessionUsage, type SessionUsage } from "./session.ts";
9
+
10
+ export type WorkerState = "running" | "waiting" | "done" | "error";
11
+
12
+ export interface WorkerStatus {
13
+ name: string;
14
+ state: WorkerState;
15
+ startTime: number;
16
+ sessionFile: string;
17
+ question?: string;
18
+ error?: string;
19
+ backend?: string;
20
+ }
21
+
22
+ export function formatTokenCount(value: number): string {
23
+ if (value < 1000) return `${value}`;
24
+ if (value < 1_000_000) return `${(value / 1000).toFixed(1)}k`;
25
+ return `${(value / 1_000_000).toFixed(2)}M`;
26
+ }
27
+
28
+ export function formatElapsed(seconds: number): string {
29
+ const total = Math.max(0, Math.floor(seconds));
30
+ const m = Math.floor(total / 60);
31
+ const s = total % 60;
32
+ if (m > 0) return `${m}m${String(s).padStart(2, "0")}s`;
33
+ return `${s}s`;
34
+ }
35
+
36
+ function usageSegments(usage: SessionUsage): string[] {
37
+ const parts: string[] = [];
38
+ if (usage.toolCount > 0) parts.push(`${usage.toolCount} calls`);
39
+ if (usage.input > 0) parts.push(`↑${formatTokenCount(usage.input)}`);
40
+ if (usage.output > 0) parts.push(`↓${formatTokenCount(usage.output)}`);
41
+ if (usage.cost > 0) parts.push(`$${usage.cost < 0.1 ? usage.cost.toFixed(4) : usage.cost.toFixed(2)}`);
42
+ return parts;
43
+ }
44
+
45
+ function stateStyle(state: WorkerState, theme: Theme): { icon: string; label: string } {
46
+ switch (state) {
47
+ case "running":
48
+ return { icon: theme.fg("accent", "●"), label: theme.fg("accent", "running") };
49
+ case "waiting":
50
+ return { icon: theme.fg("warning", "◆"), label: theme.fg("warning", "waiting: ask") };
51
+ case "done":
52
+ return { icon: theme.fg("success", "✔"), label: theme.fg("success", "done") };
53
+ case "error":
54
+ return { icon: theme.fg("error", "✖"), label: theme.fg("error", "error") };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Render multi-worker status lines for ctx.ui.setWidget.
60
+ */
61
+ export function renderWorkersWidget(workers: WorkerStatus[], theme: Theme, maxRows = 4): string[] {
62
+ if (workers.length === 0) return [];
63
+
64
+ const waitingCount = workers.filter((w) => w.state === "waiting").length;
65
+ const statusNote = waitingCount > 0 ? theme.fg("warning", ` · ${waitingCount} awaiting reply`) : "";
66
+ const header = `${theme.fg("accent", "◆")} ${theme.bold("Subagents")} ${theme.fg("dim", `· ${workers.length} active`)}${statusNote}`;
67
+
68
+ const visible = workers.slice(0, maxRows);
69
+ const overflow = workers.length - visible.length;
70
+
71
+ const rows = visible.map((worker) => {
72
+ const elapsed = formatElapsed((Date.now() - worker.startTime) / 1000);
73
+ const { icon, label } = stateStyle(worker.state, theme);
74
+
75
+ let detail = "";
76
+ if (worker.state === "waiting" && worker.question) {
77
+ const q = worker.question.length > 40 ? `${worker.question.slice(0, 37)}...` : worker.question;
78
+ detail = ` ${theme.fg("warning", `[Q: "${q}"]`)}`;
79
+ } else {
80
+ const usage = readSessionUsage(worker.sessionFile);
81
+ if (usage) {
82
+ const segments = usageSegments(usage).map((seg) => theme.fg("dim", seg));
83
+ if (segments.length > 0) detail = ` ${segments.join(theme.fg("dim", " · "))}`;
84
+ }
85
+ }
86
+
87
+ if (worker.state === "error" && worker.error) {
88
+ detail = ` ${theme.fg("error", worker.error.split("\n")[0])}`;
89
+ }
90
+
91
+ const backendTag = worker.backend === "process" ? theme.fg("dim", " [bg]") : "";
92
+ return ` ${icon} ${theme.bold(worker.name)}${backendTag} ${label} ${theme.fg("dim", elapsed)}${detail}`;
93
+ });
94
+
95
+ if (overflow > 0) {
96
+ rows.push(` ${theme.fg("muted", `+${overflow} more workers running...`)}`);
97
+ }
98
+
99
+ return [header, ...rows];
100
+ }
@@ -0,0 +1,21 @@
1
+ # tui-asr
2
+
3
+ Streaming voice dictation for Pi's terminal editor using Aliyun DashScope Paraformer.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pi install npm:@rol1n/tui-asr
9
+ ```
10
+
11
+ Also included in `@rol1n/pi-kit`. Installing this package automatically installs `@rol1n/custom-providers`.
12
+
13
+ ## Commands and shortcuts
14
+
15
+ | Action | Trigger |
16
+ |---|---|
17
+ | Start / stop dictation | `/asr` or `Alt+M` |
18
+ | Cancel dictation | `Alt+N` |
19
+ | Switch ASR model | `/asr model [name]` |
20
+
21
+ Requires `sox` (`rec` on POSIX, `sox -t waveaudio` on Windows) on `PATH` and DashScope credentials via `/login dashscope`, `DASHSCOPE_API_KEY`, or `ALIYUN_API_KEY`.
@@ -0,0 +1,272 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { spawn, type ChildProcessByStdio } from "node:child_process";
3
+ import type { Readable } from "node:stream";
4
+ import {
5
+ ALL_ASR_MODELS,
6
+ createAsrProvider,
7
+ ensureCustomProviders,
8
+ type AsrModelDefinition,
9
+ type StreamingAsrProvider,
10
+ } from "../custom-providers/index.ts";
11
+ import { insertTranscript } from "./input.ts";
12
+ import { createStatusDisplay } from "./status.ts";
13
+
14
+ export { ALL_ASR_MODELS, createAsrProvider, ensureCustomProviders, type AsrModelDefinition, type StreamingAsrProvider };
15
+
16
+ export async function resolveProviderApiKey(provider: string, ctx?: ExtensionContext | null): Promise<string> {
17
+ if (ctx?.modelRegistry) {
18
+ const candidates = provider === "dashscope" ? ["dashscope", "aliyun"] : [provider];
19
+ for (const id of candidates) {
20
+ try {
21
+ const key = await ctx.modelRegistry.getApiKeyForProvider(id);
22
+ if (key?.trim()) return key.trim();
23
+ } catch {}
24
+ try {
25
+ const auth = await ctx.modelRegistry.getProviderAuth(id);
26
+ if (auth?.auth?.apiKey?.trim()) return auth.auth.apiKey.trim();
27
+ } catch {}
28
+ }
29
+ }
30
+ const envKey = process.env[`${provider.toUpperCase()}_API_KEY`];
31
+ if (envKey?.trim()) return envKey.trim();
32
+ if (provider === "dashscope") {
33
+ return process.env.ALIYUN_API_KEY?.trim() || "";
34
+ }
35
+ return "";
36
+ }
37
+
38
+ export function rmsFromPcm16(buf: Buffer): number {
39
+ if (buf.length < 2) return 0;
40
+ const sampleCount = Math.floor(buf.length / 2);
41
+ let sumSquares = 0;
42
+ for (let i = 0; i < sampleCount; i++) {
43
+ const sample = buf.readInt16LE(i * 2);
44
+ sumSquares += sample * sample;
45
+ }
46
+ const meanSquare = sumSquares / sampleCount;
47
+ return Math.sqrt(meanSquare);
48
+ }
49
+
50
+ /** Spawn the existing 16 kHz mono PCM recorder for the current platform. */
51
+ export function spawnRecorder(): ChildProcessByStdio<null, Readable, Readable> {
52
+ const commonAudioArgs = [
53
+ "-q",
54
+ "--buffer", "512",
55
+ "-r", "16000",
56
+ "-c", "1",
57
+ "-b", "16",
58
+ "-e", "signed-integer",
59
+ "-t", "raw",
60
+ "-",
61
+ ];
62
+
63
+ let proc: ChildProcessByStdio<null, Readable, Readable>;
64
+ if (process.platform === "win32") {
65
+ proc = spawn("sox", ["-t", "waveaudio", "default", ...commonAudioArgs], { stdio: ["ignore", "pipe", "pipe"] });
66
+ } else {
67
+ try {
68
+ proc = spawn("rec", commonAudioArgs, { stdio: ["ignore", "pipe", "pipe"] });
69
+ } catch {
70
+ proc = spawn("sox", ["-d", ...commonAudioArgs], { stdio: ["ignore", "pipe", "pipe"] });
71
+ }
72
+ }
73
+ return proc;
74
+ }
75
+
76
+ type State = "idle" | "recording" | "stopping";
77
+
78
+ /** Keep recording state, cancellation guards, and cleanup in one session-local controller. */
79
+ export function createDictationController(getActiveModel: () => string, getTuiHandle: () => any) {
80
+ let state: State = "idle";
81
+ let rec: ChildProcessByStdio<null, Readable, Readable> | null = null;
82
+ let activeProvider: StreamingAsrProvider | null = null;
83
+ let finals: string[] = [];
84
+ let activeCtx: ExtensionContext | null = null;
85
+ let flushed = false;
86
+ let cancelled = false;
87
+ let stopTimeout: NodeJS.Timeout | null = null;
88
+ let generation = 0;
89
+
90
+ const status = createStatusDisplay(() => activeCtx);
91
+ const { setStatus, startMeter, stopMeter, startSpinner, stopSpinner } = status;
92
+
93
+ /** Flush transcribed text into the focused component once per recording. */
94
+ const flush = () => {
95
+ if (flushed || !activeCtx) return;
96
+ flushed = true;
97
+ if (cancelled) return;
98
+
99
+ const text = finals.join(" ").replace(/\s+/g, " ").trim();
100
+ if (!text) return;
101
+ insertTranscript(activeCtx, getTuiHandle(), text);
102
+ };
103
+
104
+ const cleanup = () => {
105
+ generation++;
106
+ flush();
107
+ stopSpinner();
108
+ stopMeter();
109
+ if (stopTimeout) {
110
+ clearTimeout(stopTimeout);
111
+ stopTimeout = null;
112
+ }
113
+ if (rec) {
114
+ try {
115
+ rec.kill("SIGTERM");
116
+ } catch {}
117
+ rec = null;
118
+ }
119
+ if (activeProvider) {
120
+ try {
121
+ activeProvider.cancel();
122
+ } catch {}
123
+ activeProvider = null;
124
+ }
125
+ finals = [];
126
+ state = "idle";
127
+ setStatus(undefined);
128
+ activeCtx = null;
129
+ flushed = false;
130
+ cancelled = false;
131
+ };
132
+
133
+ const startDictation = async (ctx: ExtensionContext) => {
134
+ const modelDef = ALL_ASR_MODELS.find((m) => m.id === getActiveModel()) ?? ALL_ASR_MODELS[0];
135
+ const apiKey = await resolveProviderApiKey(modelDef.provider, ctx);
136
+ if (!apiKey) {
137
+ ctx.ui.notify(
138
+ `API Key not found for ${modelDef.provider}.\nPlease run /login ${modelDef.provider} in terminal or set ${modelDef.provider.toUpperCase()}_API_KEY.`,
139
+ "error",
140
+ );
141
+ return;
142
+ }
143
+
144
+ activeCtx = ctx;
145
+ finals = [];
146
+ flushed = false;
147
+ cancelled = false;
148
+ state = "recording";
149
+
150
+ const provider = createAsrProvider(modelDef.provider, modelDef.id, apiKey);
151
+ activeProvider = provider;
152
+ const myGeneration = ++generation;
153
+ startMeter();
154
+
155
+ // Spawn cross-platform audio recorder (sox / rec)
156
+ let proc: ChildProcessByStdio<null, Readable, Readable>;
157
+ let recorderStderr = "";
158
+ try {
159
+ proc = spawnRecorder();
160
+ } catch {
161
+ ctx.ui.notify("Failed to spawn audio recorder. Please ensure sox or rec is installed (e.g. sudo pacman -S sox).", "error");
162
+ cleanup();
163
+ return;
164
+ }
165
+ rec = proc;
166
+
167
+ proc.stderr?.on("data", (chunk: Buffer) => {
168
+ recorderStderr += chunk.toString();
169
+ });
170
+
171
+ proc.on("error", (err) => {
172
+ if (myGeneration !== generation) return;
173
+ ctx.ui.notify(`Audio recording error: ${err.message}`, "error");
174
+ cleanup();
175
+ });
176
+
177
+ proc.on("exit", (code) => {
178
+ if (myGeneration !== generation) return;
179
+ if (state === "recording" && code !== null && code !== 0) {
180
+ if (activeCtx) {
181
+ const detail = recorderStderr.trim() ? `: ${recorderStderr.trim()}` : "";
182
+ activeCtx.ui.notify(`Audio recorder exited unexpectedly (code ${code})${detail}`, "warning");
183
+ }
184
+ cleanup();
185
+ }
186
+ });
187
+
188
+ // Start provider streaming connection
189
+ try {
190
+ await provider.start({
191
+ onFinal: (t) => {
192
+ if (myGeneration !== generation || cancelled) return;
193
+ if (t) finals.push(t);
194
+ },
195
+ onError: (err) => {
196
+ if (myGeneration !== generation || cancelled) return;
197
+ if (activeCtx) activeCtx.ui.notify(`DashScope ASR error: ${err.message}`, "error");
198
+ cleanup();
199
+ },
200
+ onClose: () => {
201
+ if (myGeneration !== generation || cancelled) return;
202
+ if (state === "recording" || state === "stopping") {
203
+ cleanup();
204
+ }
205
+ },
206
+ });
207
+ } catch (err: any) {
208
+ if (myGeneration !== generation || cancelled) return;
209
+ ctx.ui.notify(`Failed to connect to DashScope: ${err.message}`, "error");
210
+ cleanup();
211
+ return;
212
+ }
213
+
214
+ if (myGeneration !== generation || !rec) return;
215
+
216
+ rec.stdout.on("data", (chunk: Buffer) => {
217
+ status.setLevel(rmsFromPcm16(chunk));
218
+ if (provider && state === "recording") {
219
+ provider.sendAudio(chunk);
220
+ }
221
+ });
222
+ };
223
+
224
+ const stopDictation = async () => {
225
+ if (state !== "recording") return;
226
+ state = "stopping";
227
+ stopMeter();
228
+ startSpinner("finalizing…");
229
+
230
+ if (rec) {
231
+ try {
232
+ rec.kill("SIGTERM");
233
+ } catch {}
234
+ }
235
+
236
+ if (activeProvider) {
237
+ try {
238
+ await activeProvider.stop();
239
+ } catch {
240
+ cleanup();
241
+ return;
242
+ }
243
+
244
+ stopTimeout = setTimeout(() => {
245
+ if (state === "stopping") cleanup();
246
+ }, 3000);
247
+ } else {
248
+ cleanup();
249
+ }
250
+ };
251
+
252
+ const cancelDictation = () => {
253
+ if (state !== "recording" && state !== "stopping") return;
254
+ cancelled = true;
255
+ finals = [];
256
+ cleanup();
257
+ if (activeCtx) activeCtx.ui.notify("Voice dictation cancelled", "info");
258
+ };
259
+
260
+ const toggleDictation = async (ctx: ExtensionContext) => {
261
+ if (state === "idle") {
262
+ await startDictation(ctx);
263
+ } else if (state === "recording") {
264
+ await stopDictation();
265
+ }
266
+ };
267
+
268
+ return {
269
+ get state() { return state; },
270
+ startDictation, stopDictation, cancelDictation, toggleDictation, cleanup,
271
+ };
272
+ }