@oh-my-pi/pi-coding-agent 6.8.5 → 6.9.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/core/voice.ts DELETED
@@ -1,314 +0,0 @@
1
- import { unlinkSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { completeSimple, type Model } from "@oh-my-pi/pi-ai";
5
- import { logger } from "@oh-my-pi/pi-utils";
6
- import { nanoid } from "nanoid";
7
- import voiceSummaryPrompt from "../prompts/voice-summary.md" with { type: "text" };
8
- import type { ModelRegistry } from "./model-registry";
9
- import { findSmolModel } from "./model-resolver";
10
- import { renderPromptTemplate } from "./prompt-templates";
11
- import type { VoiceSettings } from "./settings-manager";
12
-
13
- const DEFAULT_SAMPLE_RATE = 16000;
14
- const DEFAULT_CHANNELS = 1;
15
- const DEFAULT_BITS = 16;
16
- const SUMMARY_MAX_CHARS = 6000;
17
- const VOICE_SUMMARY_PROMPT = renderPromptTemplate(voiceSummaryPrompt);
18
-
19
- export interface VoiceRecordingHandle {
20
- filePath: string;
21
- stop: () => Promise<void>;
22
- cancel: () => Promise<void>;
23
- cleanup: () => void;
24
- }
25
-
26
- export class VoiceRecording implements VoiceRecordingHandle {
27
- readonly filePath: string;
28
- private proc: ReturnType<typeof Bun.spawn>;
29
-
30
- constructor(_settings: VoiceSettings) {
31
- const sampleRate = DEFAULT_SAMPLE_RATE;
32
- const channels = DEFAULT_CHANNELS;
33
- this.filePath = join(tmpdir(), `omp-voice-${nanoid()}.wav`);
34
- const command = buildRecordingCommand(this.filePath, sampleRate, channels);
35
- if (!command) {
36
- throw new Error("No audio recorder found (install sox, arecord, or ffmpeg).");
37
- }
38
-
39
- logger.debug("voice: starting recorder", { command });
40
- this.proc = Bun.spawn(command, {
41
- stdin: "ignore",
42
- stdout: "ignore",
43
- stderr: "pipe",
44
- });
45
- }
46
-
47
- async stop(): Promise<void> {
48
- try {
49
- this.proc.kill();
50
- } catch {
51
- // ignore
52
- }
53
- await this.proc.exited;
54
- }
55
-
56
- cleanup(): void {
57
- try {
58
- unlinkSync(this.filePath);
59
- } catch {
60
- // ignore cleanup errors
61
- }
62
- }
63
-
64
- async cancel(): Promise<void> {
65
- await this.stop();
66
- this.cleanup();
67
- }
68
- }
69
-
70
- export interface VoiceTranscriptionResult {
71
- text: string;
72
- }
73
-
74
- export interface VoiceSynthesisResult {
75
- audio: Uint8Array;
76
- format: "wav" | "mp3" | "opus" | "aac" | "flac";
77
- }
78
-
79
- function buildRecordingCommand(filePath: string, sampleRate: number, channels: number): string[] | null {
80
- const soxPath = Bun.which("sox") ?? Bun.which("rec");
81
- if (soxPath) {
82
- return [soxPath, "-d", "-r", String(sampleRate), "-c", String(channels), "-b", String(DEFAULT_BITS), filePath];
83
- }
84
-
85
- const arecordPath = Bun.which("arecord");
86
- if (arecordPath) {
87
- return [arecordPath, "-f", "S16_LE", "-r", String(sampleRate), "-c", String(channels), filePath];
88
- }
89
-
90
- const ffmpegPath = Bun.which("ffmpeg");
91
- if (ffmpegPath) {
92
- const platform = process.platform;
93
- if (platform === "darwin") {
94
- // avfoundation default input device; users can override by installing sox for reliability.
95
- return [
96
- ffmpegPath,
97
- "-f",
98
- "avfoundation",
99
- "-i",
100
- ":0",
101
- "-ac",
102
- String(channels),
103
- "-ar",
104
- String(sampleRate),
105
- "-y",
106
- filePath,
107
- ];
108
- }
109
- if (platform === "linux") {
110
- // alsa default input device (commonly "default").
111
- return [
112
- ffmpegPath,
113
- "-f",
114
- "alsa",
115
- "-i",
116
- "default",
117
- "-ac",
118
- String(channels),
119
- "-ar",
120
- String(sampleRate),
121
- "-y",
122
- filePath,
123
- ];
124
- }
125
- if (platform === "win32") {
126
- // dshow default input device name varies; "audio=default" is a best-effort fallback.
127
- return [
128
- ffmpegPath,
129
- "-f",
130
- "dshow",
131
- "-i",
132
- "audio=default",
133
- "-ac",
134
- String(channels),
135
- "-ar",
136
- String(sampleRate),
137
- "-y",
138
- filePath,
139
- ];
140
- }
141
- }
142
-
143
- return null;
144
- }
145
-
146
- export async function transcribeAudio(
147
- filePath: string,
148
- apiKey: string,
149
- settings: VoiceSettings,
150
- ): Promise<VoiceTranscriptionResult> {
151
- const file = Bun.file(filePath);
152
- const buffer = await file.arrayBuffer();
153
- const blob = new File([buffer], "speech.wav", { type: "audio/wav" });
154
- const form = new FormData();
155
- form.append("file", blob);
156
- form.append("model", settings.transcriptionModel ?? "whisper-1");
157
- if (settings.transcriptionLanguage) {
158
- form.append("language", settings.transcriptionLanguage);
159
- }
160
-
161
- const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
162
- method: "POST",
163
- headers: { Authorization: `Bearer ${apiKey}` },
164
- body: form,
165
- });
166
-
167
- if (!response.ok) {
168
- const errText = await response.text();
169
- throw new Error(`Whisper transcription failed: ${response.status} ${errText}`);
170
- }
171
-
172
- const data = (await response.json()) as { text?: string };
173
- return { text: (data.text ?? "").trim() };
174
- }
175
-
176
- export async function synthesizeSpeech(
177
- text: string,
178
- apiKey: string,
179
- settings: VoiceSettings,
180
- ): Promise<VoiceSynthesisResult> {
181
- const format = settings.ttsFormat ?? "wav";
182
- const response = await fetch("https://api.openai.com/v1/audio/speech", {
183
- method: "POST",
184
- headers: {
185
- Authorization: `Bearer ${apiKey}`,
186
- "Content-Type": "application/json",
187
- },
188
- body: JSON.stringify({
189
- model: settings.ttsModel ?? "tts-1",
190
- voice: settings.ttsVoice ?? "alloy",
191
- format,
192
- input: text,
193
- }),
194
- });
195
-
196
- if (!response.ok) {
197
- const errText = await response.text();
198
- throw new Error(`TTS synthesis failed: ${response.status} ${errText}`);
199
- }
200
-
201
- const audio = new Uint8Array(await response.arrayBuffer());
202
- return { audio, format };
203
- }
204
-
205
- function getPlayerCommand(filePath: string, format: VoiceSynthesisResult["format"]): string[] | null {
206
- const platform = process.platform;
207
- if (platform === "darwin") {
208
- const afplay = Bun.which("afplay");
209
- if (afplay) return [afplay, filePath];
210
- }
211
-
212
- if (platform === "linux") {
213
- const paplay = Bun.which("paplay");
214
- if (paplay) return [paplay, filePath];
215
- const aplay = Bun.which("aplay");
216
- if (aplay) return [aplay, filePath];
217
- const ffplay = Bun.which("ffplay");
218
- if (ffplay) return [ffplay, "-autoexit", "-nodisp", filePath];
219
- const play = Bun.which("play");
220
- if (play) return [play, filePath];
221
- }
222
-
223
- if (platform === "win32") {
224
- if (format !== "wav") {
225
- return null;
226
- }
227
- const ps = Bun.which("powershell");
228
- if (ps) {
229
- return [
230
- ps,
231
- "-NoProfile",
232
- "-Command",
233
- `(New-Object Media.SoundPlayer '${filePath.replace(/'/g, "''")}').PlaySync()`,
234
- ];
235
- }
236
- }
237
-
238
- return null;
239
- }
240
-
241
- export async function playAudio(audio: Uint8Array, format: VoiceSynthesisResult["format"]): Promise<void> {
242
- const filePath = join(tmpdir(), `omp-tts-${nanoid()}.${format}`);
243
- await Bun.write(filePath, audio);
244
-
245
- const command = getPlayerCommand(filePath, format);
246
- if (!command) {
247
- throw new Error("No audio player available for playback.");
248
- }
249
-
250
- const proc = Bun.spawn(command, {
251
- stdin: "ignore",
252
- stdout: "ignore",
253
- stderr: "pipe",
254
- });
255
- await proc.exited;
256
-
257
- try {
258
- unlinkSync(filePath);
259
- } catch {
260
- // ignore cleanup errors
261
- }
262
- }
263
-
264
- function extractTextFromResponse(response: { content: Array<{ type: string; text?: string }> }): string {
265
- let text = "";
266
- for (const content of response.content) {
267
- if (content.type === "text" && content.text) {
268
- text += content.text;
269
- }
270
- }
271
- return text.trim();
272
- }
273
-
274
- export async function summarizeForVoice(
275
- text: string,
276
- registry: ModelRegistry,
277
- savedSmolModel?: string,
278
- ): Promise<string | null> {
279
- const model = await findSmolModel(registry, savedSmolModel);
280
- if (!model) {
281
- logger.debug("voice: no smol model found for summary");
282
- return null;
283
- }
284
-
285
- const apiKey = await registry.getApiKey(model);
286
- if (!apiKey) {
287
- logger.debug("voice: no API key for summary model", { provider: model.provider, id: model.id });
288
- return null;
289
- }
290
-
291
- const truncated = text.length > SUMMARY_MAX_CHARS ? `${text.slice(0, SUMMARY_MAX_CHARS)}...` : text;
292
- const request = {
293
- model: `${model.provider}/${model.id}`,
294
- systemPrompt: VOICE_SUMMARY_PROMPT,
295
- userMessage: `<assistant_response>\n${truncated}\n</assistant_response>`,
296
- };
297
- logger.debug("voice: summary request", request);
298
-
299
- try {
300
- const response = await completeSimple(
301
- model as Model<any>,
302
- {
303
- systemPrompt: request.systemPrompt,
304
- messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
305
- },
306
- { apiKey, maxTokens: 120 },
307
- );
308
- const summary = extractTextFromResponse(response);
309
- return summary || null;
310
- } catch (error) {
311
- logger.debug("voice: summary error", { error: error instanceof Error ? error.message : String(error) });
312
- return null;
313
- }
314
- }
@@ -1,180 +0,0 @@
1
- import { nanoid } from "nanoid";
2
- import { WorktreeError, WorktreeErrorCode } from "./errors";
3
- import { git, gitWithInput } from "./git";
4
- import { find, remove, type Worktree } from "./operations";
5
-
6
- export type CollapseStrategy = "simple" | "merge-base" | "rebase";
7
-
8
- export interface CollapseOptions {
9
- strategy?: CollapseStrategy;
10
- keepSource?: boolean;
11
- }
12
-
13
- export interface CollapseResult {
14
- filesChanged: number;
15
- insertions: number;
16
- deletions: number;
17
- }
18
-
19
- function diffStats(diff: string): CollapseResult {
20
- let filesChanged = 0;
21
- let insertions = 0;
22
- let deletions = 0;
23
-
24
- for (const line of diff.split("\n")) {
25
- if (line.startsWith("diff --git ")) {
26
- filesChanged += 1;
27
- continue;
28
- }
29
- if (line.startsWith("+++") || line.startsWith("---")) continue;
30
- if (line.startsWith("+")) {
31
- insertions += 1;
32
- continue;
33
- }
34
- if (line.startsWith("-")) {
35
- deletions += 1;
36
- }
37
- }
38
-
39
- return { filesChanged, insertions, deletions };
40
- }
41
-
42
- async function requireGitSuccess(result: { code: number; stderr: string }, message: string): Promise<void> {
43
- if (result.code !== 0) {
44
- throw new WorktreeError(
45
- message + (result.stderr ? `\n${result.stderr.trim()}` : ""),
46
- WorktreeErrorCode.COLLAPSE_FAILED,
47
- );
48
- }
49
- }
50
-
51
- async function ensureHasChanges(result: { stdout: string }): Promise<string> {
52
- const diff = result.stdout;
53
- if (!diff.trim()) {
54
- throw new WorktreeError("No changes to collapse", WorktreeErrorCode.NO_CHANGES);
55
- }
56
- return diff;
57
- }
58
-
59
- async function collapseSimple(src: Worktree): Promise<string> {
60
- await requireGitSuccess(await git(["add", "-A"], src.path), "Failed to stage changes");
61
- return ensureHasChanges(await git(["diff", "HEAD"], src.path));
62
- }
63
-
64
- async function collapseMergeBase(src: Worktree, dst: Worktree): Promise<string> {
65
- await requireGitSuccess(await git(["add", "-A"], src.path), "Failed to stage changes");
66
-
67
- const baseResult = await git(["merge-base", "HEAD", dst.branch ?? "HEAD"], src.path);
68
- if (baseResult.code !== 0) {
69
- throw new WorktreeError("Could not find merge base", WorktreeErrorCode.COLLAPSE_FAILED);
70
- }
71
-
72
- const base = baseResult.stdout.trim();
73
- if (!base) {
74
- throw new WorktreeError("Could not find merge base", WorktreeErrorCode.COLLAPSE_FAILED);
75
- }
76
-
77
- return ensureHasChanges(await git(["diff", base], src.path));
78
- }
79
-
80
- async function collapseRebase(src: Worktree, dst: Worktree): Promise<string> {
81
- await requireGitSuccess(await git(["add", "-A"], src.path), "Failed to stage changes");
82
-
83
- const stagedResult = await git(["diff", "--cached", "--name-only"], src.path);
84
- if (!stagedResult.stdout.trim()) {
85
- throw new WorktreeError("No changes to collapse", WorktreeErrorCode.NO_CHANGES);
86
- }
87
-
88
- const headResult = await git(["rev-parse", "HEAD"], src.path);
89
- if (headResult.code !== 0) {
90
- throw new WorktreeError("Failed to resolve HEAD", WorktreeErrorCode.COLLAPSE_FAILED);
91
- }
92
- const originalHead = headResult.stdout.trim();
93
- const tempBranch = `wt-collapse-${nanoid()}`;
94
-
95
- await requireGitSuccess(await git(["checkout", "-b", tempBranch], src.path), "Failed to create temp branch");
96
-
97
- const commitResult = await git(["commit", "--allow-empty-message", "-m", ""], src.path);
98
- if (commitResult.code !== 0) {
99
- await git(["checkout", originalHead], src.path);
100
- await git(["branch", "-D", tempBranch], src.path);
101
- throw new WorktreeError("Failed to commit changes", WorktreeErrorCode.COLLAPSE_FAILED);
102
- }
103
-
104
- const rebaseResult = await git(["rebase", dst.branch ?? "HEAD"], src.path);
105
- if (rebaseResult.code !== 0) {
106
- await git(["rebase", "--abort"], src.path);
107
- await git(["checkout", originalHead], src.path);
108
- await git(["branch", "-D", tempBranch], src.path);
109
- throw new WorktreeError(
110
- `Rebase conflicts:${rebaseResult.stderr ? `\n${rebaseResult.stderr.trim()}` : ""}`,
111
- WorktreeErrorCode.REBASE_CONFLICTS,
112
- );
113
- }
114
-
115
- const diffResult = await git(["diff", `${dst.branch ?? "HEAD"}..HEAD`], src.path);
116
-
117
- await git(["checkout", originalHead], src.path);
118
- await git(["branch", "-D", tempBranch], src.path);
119
-
120
- return ensureHasChanges(diffResult);
121
- }
122
-
123
- async function applyDiff(diff: string, targetPath: string): Promise<void> {
124
- let result = await gitWithInput(["apply"], diff, targetPath);
125
- if (result.code === 0) return;
126
-
127
- result = await gitWithInput(["apply", "--3way"], diff, targetPath);
128
- if (result.code === 0) return;
129
-
130
- throw new WorktreeError(
131
- `Failed to apply diff:${result.stderr ? `\n${result.stderr.trim()}` : ""}`,
132
- WorktreeErrorCode.APPLY_FAILED,
133
- );
134
- }
135
-
136
- /**
137
- * Collapse changes from source worktree into destination.
138
- */
139
- export async function collapse(
140
- source: string,
141
- destination: string,
142
- options?: CollapseOptions,
143
- ): Promise<CollapseResult> {
144
- const src = await find(source);
145
- const dst = await find(destination);
146
-
147
- if (src.path === dst.path) {
148
- throw new WorktreeError("Source and destination are the same", WorktreeErrorCode.COLLAPSE_FAILED);
149
- }
150
-
151
- if (!options?.keepSource && src.isMain) {
152
- throw new WorktreeError("Cannot remove main worktree", WorktreeErrorCode.CANNOT_MODIFY_MAIN);
153
- }
154
-
155
- const strategy = options?.strategy ?? "rebase";
156
- let diff: string;
157
-
158
- switch (strategy) {
159
- case "simple":
160
- diff = await collapseSimple(src);
161
- break;
162
- case "merge-base":
163
- diff = await collapseMergeBase(src, dst);
164
- break;
165
- case "rebase":
166
- diff = await collapseRebase(src, dst);
167
- break;
168
- default:
169
- throw new WorktreeError(`Unknown strategy: ${strategy}`, WorktreeErrorCode.COLLAPSE_FAILED);
170
- }
171
-
172
- const stats = diffStats(diff);
173
- await applyDiff(diff, dst.path);
174
-
175
- if (!options?.keepSource) {
176
- await remove(src.path, { force: true });
177
- }
178
-
179
- return stats;
180
- }
@@ -1,14 +0,0 @@
1
- import { accessSync, constants } from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
-
5
- function getWorktreeBase(): string {
6
- try {
7
- accessSync("/work", constants.W_OK);
8
- return "/work/.tree";
9
- } catch {
10
- return path.join(os.tmpdir(), ".tree");
11
- }
12
- }
13
-
14
- export const WORKTREE_BASE = getWorktreeBase();
@@ -1,23 +0,0 @@
1
- export enum WorktreeErrorCode {
2
- NOT_GIT_REPO = "NOT_GIT_REPO",
3
- WORKTREE_NOT_FOUND = "WORKTREE_NOT_FOUND",
4
- WORKTREE_EXISTS = "WORKTREE_EXISTS",
5
- CANNOT_MODIFY_MAIN = "CANNOT_MODIFY_MAIN",
6
- NO_CHANGES = "NO_CHANGES",
7
- COLLAPSE_FAILED = "COLLAPSE_FAILED",
8
- REBASE_CONFLICTS = "REBASE_CONFLICTS",
9
- APPLY_FAILED = "APPLY_FAILED",
10
- OVERLAPPING_SCOPES = "OVERLAPPING_SCOPES",
11
- }
12
-
13
- export class WorktreeError extends Error {
14
- readonly code: WorktreeErrorCode;
15
- readonly cause?: Error;
16
-
17
- constructor(message: string, code: WorktreeErrorCode, cause?: Error) {
18
- super(message);
19
- this.name = "WorktreeError";
20
- this.code = code;
21
- this.cause = cause;
22
- }
23
- }
@@ -1,60 +0,0 @@
1
- import * as path from "node:path";
2
- import { ptree } from "@oh-my-pi/pi-utils";
3
- import { execCommand } from "../../core/exec";
4
- import { WorktreeError, WorktreeErrorCode } from "./errors";
5
-
6
- export interface GitResult {
7
- code: number;
8
- stdout: string;
9
- stderr: string;
10
- }
11
-
12
- /**
13
- * Execute a git command.
14
- * @param args - Command arguments (excluding 'git')
15
- * @param cwd - Working directory (optional)
16
- * @returns Promise<GitResult>
17
- */
18
- export async function git(args: string[], cwd?: string): Promise<GitResult> {
19
- const result = await execCommand("git", args, cwd ?? process.cwd());
20
- return { code: result.code, stdout: result.stdout, stderr: result.stderr };
21
- }
22
-
23
- /**
24
- * Execute git command with stdin input.
25
- * Used for piping diffs to `git apply`.
26
- */
27
- export async function gitWithInput(args: string[], stdin: string, cwd?: string): Promise<GitResult> {
28
- const proc = ptree.cspawn(["git", ...args], {
29
- cwd: cwd ?? process.cwd(),
30
- stdin: Buffer.from(stdin),
31
- });
32
-
33
- const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text()]);
34
-
35
- return { code: proc.exitCode ?? 0, stdout, stderr };
36
- }
37
-
38
- /**
39
- * Get repository root directory.
40
- * @throws Error if not in a git repository
41
- */
42
- export async function getRepoRoot(cwd?: string): Promise<string> {
43
- const result = await git(["rev-parse", "--show-toplevel"], cwd ?? process.cwd());
44
- if (result.code !== 0) {
45
- throw new WorktreeError("Not a git repository", WorktreeErrorCode.NOT_GIT_REPO);
46
- }
47
- const root = result.stdout.trim();
48
- if (!root) {
49
- throw new WorktreeError("Not a git repository", WorktreeErrorCode.NOT_GIT_REPO);
50
- }
51
- return path.resolve(root);
52
- }
53
-
54
- /**
55
- * Get repository name (directory basename of repo root).
56
- */
57
- export async function getRepoName(cwd?: string): Promise<string> {
58
- const root = await getRepoRoot(cwd);
59
- return path.basename(root);
60
- }
@@ -1,15 +0,0 @@
1
- export { type CollapseOptions, type CollapseResult, type CollapseStrategy, collapse } from "./collapse";
2
- export { WORKTREE_BASE } from "./constants";
3
- export { WorktreeError, WorktreeErrorCode } from "./errors";
4
- export { getRepoName, getRepoRoot, git, gitWithInput as gitWithStdin } from "./git";
5
- export { create, find, list, prune, remove, type Worktree, which } from "./operations";
6
- export {
7
- cleanupSessions,
8
- createSession,
9
- getSession,
10
- listSessions,
11
- type SessionStatus,
12
- updateSession,
13
- type WorktreeSession,
14
- } from "./session";
15
- export { formatStats, getStats, type WorktreeStats } from "./stats";