diff --git a/apps/cli/src/commands.ts b/apps/cli/src/commands.ts index af8a5e1..b7aab71 100644 --- a/apps/cli/src/commands.ts +++ b/apps/cli/src/commands.ts @@ -128,6 +128,9 @@ export interface SessionContext { credsStore?: CredentialsStore; /** User settings.json path (REPL-injected, honors --home) โ€” backs /config set. */ userSettingsPath?: string; + /** Home dir override (REPL-injected from --home) โ€” backs default-path lookups + * like /voice's `~/.deepcode/models/...` model probe. Defaults to os.homedir(). */ + home?: string; sessionId: string; sessions: SessionManager; usage: { @@ -1169,6 +1172,57 @@ export const TasksCommand: SlashCommand = { }, }; +export const VoiceCommand: SlashCommand = { + name: '/voice', + description: 'Check local voice-input (whisper.cpp) setup; `/voice setup` shows install steps.', + async run(args, ctx) { + const { detectVoice } = await import('@deepcode/core'); + const status = await detectVoice(ctx.settings.voice, { home: ctx.home }); + const forceSetup = (args[0] ?? '').toLowerCase() === 'setup'; + + if (status.ready && !forceSetup) { + return [ + '๐ŸŽ™ Voice input is ready โ€” whisper.cpp, fully local (no audio leaves your machine).', + ` binary: ${status.binPath}`, + ` model: ${status.modelPath}`, + '', + 'Dictate from the REPL with the voice key (default Ctrl+V; remap in keybindings.json).', + 'Note: live mic capture lands in a follow-up โ€” this step ships setup + detection.', + ]; + } + + const lines: string[] = [ + status.ready + ? '๐ŸŽ™ Voice input is ready. Setup reference below.' + : '๐ŸŽ™ Voice input is not set up yet. Enable local dictation (whisper.cpp โ€” no cloud):', + '', + 'Detected:', + ` ${status.binPath ? 'โœ“' : 'โœ—'} whisper binary ${status.binPath ?? '(not found)'}`, + ` ${status.modelPath ? 'โœ“' : 'โœ—'} model ${status.modelPath ?? '(not found)'}`, + ]; + if (status.problems.length) { + lines.push('', 'Issues:'); + for (const p of status.problems) lines.push(` โ€ข ${p}`); + } + lines.push( + '', + 'Setup:', + ' 1. Install whisper.cpp', + ' macOS: brew install whisper-cpp', + ' Linux: build https://github.com/ggerganov/whisper.cpp, put `whisper` on PATH', + ' 2. Download a model (base.en โ‰ˆ 140 MB is a good default) and save it:', + ' mkdir -p ~/.deepcode/models', + ' cp ggml-base.en.bin ~/.deepcode/models/whisper-base.en.bin', + ' 3. (optional) Point DeepCode at custom paths in ~/.deepcode/settings.json:', + ' { "voice": { "binPath": "/opt/homebrew/bin/whisper-cli",', + ' "modelPath": "~/.deepcode/models/whisper-base.en.bin" } }', + '', + 'Full guide: docs/VOICE_INPUT.md', + ); + return lines; + }, +}; + export const BackgroundCommand: SlashCommand = { name: '/background', aliases: ['/bg'], @@ -1229,6 +1283,7 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [ BtwCommand, TasksCommand, BackgroundCommand, + VoiceCommand, ]; // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 3e49860..5b109c5 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -437,6 +437,7 @@ export async function startRepl(opts: ReplOpts): Promise { creds, credsStore, userSettingsPath: settingsPaths({ cwd, home: opts.home }).userPath, + home: opts.home, sessionId: session.id, sessions, usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0 }, diff --git a/apps/cli/src/voice-cmd.test.ts b/apps/cli/src/voice-cmd.test.ts new file mode 100644 index 0000000..bbdac47 --- /dev/null +++ b/apps/cli/src/voice-cmd.test.ts @@ -0,0 +1,85 @@ +// Tests for the /voice slash command messaging. Detection logic itself is +// unit-tested in core (voice/detect.test.ts); here we drive the command end to +// end with real temp files so the "ready" path is deterministic, and bogus +// configured paths so the "not set up" path never depends on the host's PATH. + +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SessionManager } from '@deepcode/core'; +import { CommandRegistry, type SessionContext } from './commands.js'; + +const reg = new CommandRegistry(); +const tmps: string[] = []; +async function tmpDir(): Promise { + const d = await mkdtemp(join(tmpdir(), 'dc-voice-')); + tmps.push(d); + return d; +} +afterEach(async () => { + await Promise.all(tmps.splice(0).map((d) => rm(d, { recursive: true, force: true }))); +}); + +function ctx(overrides: Partial = {}): SessionContext { + return { + cwd: '/tmp/x', + model: 'deepseek-chat', + mode: 'default', + effort: 'medium', + settings: {}, + creds: { apiKey: 'sk-test' }, + sessionId: 's1', + sessions: new SessionManager({ root: '/tmp/x' }), + usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0 }, + ...overrides, + }; +} + +const run = (args: string[], c: SessionContext) => reg.match('/voice')!.cmd.run(args, c); + +describe('/voice', () => { + it('reports ready when configured binary + model both exist', async () => { + const dir = await tmpDir(); + const binPath = join(dir, 'whisper-cli'); + const modelPath = join(dir, 'model.bin'); + await writeFile(binPath, '#!/bin/sh\n'); + await writeFile(modelPath, 'GGML'); + const out = (await run([], ctx({ settings: { voice: { binPath, modelPath } } }))).join('\n'); + expect(out).toMatch(/ready/i); + expect(out).toContain(binPath); + expect(out).toContain(modelPath); + expect(out).toMatch(/Ctrl\+V/); + }); + + it('prints setup steps + issues when configured paths are missing', async () => { + const out = ( + await run( + [], + ctx({ settings: { voice: { binPath: '/no/such/whisper', modelPath: '/no/such/m.bin' } } }), + ) + ).join('\n'); + expect(out).toMatch(/not set up yet/i); + expect(out).toMatch(/brew install whisper-cpp/); + expect(out).toMatch(/docs\/VOICE_INPUT\.md/); + // The specific configured-but-missing problems surface under "Issues:". + expect(out).toMatch(/Issues:/); + expect(out).toContain('Configured voice.binPath not found: /no/such/whisper'); + expect(out).toContain('Configured voice.modelPath not found: /no/such/m.bin'); + }); + + it('`/voice setup` always shows install steps, even when ready', async () => { + const dir = await tmpDir(); + const binPath = join(dir, 'whisper-cli'); + const modelPath = join(dir, 'model.bin'); + await writeFile(binPath, ''); + await writeFile(modelPath, ''); + const out = (await run(['setup'], ctx({ settings: { voice: { binPath, modelPath } } }))).join( + '\n', + ); + expect(out).toMatch(/Setup:/); + expect(out).toMatch(/brew install whisper-cpp/); + // Still acknowledges it's already ready. + expect(out).toMatch(/ready/i); + }); +}); diff --git a/docs/BEHAVIOR_PARITY.md b/docs/BEHAVIOR_PARITY.md index eb5f547..c5c3821 100644 --- a/docs/BEHAVIOR_PARITY.md +++ b/docs/BEHAVIOR_PARITY.md @@ -21,55 +21,55 @@ Legend: `โœ…` matches ยท `๐ŸŸก` matches with caveats ยท `๐Ÿ”„` deferred ยท `โš  ## Slash commands (30+ in Claude Code, ~32 shipped in DeepCode) -| Command | Claude Code | DeepCode | Status | -| -------------------------- | ----------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | -| `/help` | โœ“ | โœ“ | โœ… | -| `/clear` | โœ“ | โœ“ | โœ… | -| `/exit` / `/quit` | โœ“ | โœ“ | โœ… | -| `/status` / `/doctor` | โœ“ | โœ“ | โœ… | -| `/model` | โœ“ | โœ“ | โœ… DeepCode constrains to deepseek-\* (model picker doesn't show foreign providers) | -| `/mode` | โœ“ | โœ“ | โœ… | -| `/effort` | โœ“ | โœ“ | ๐ŸŸก โ€” CLI prints the tier table (numbers from `EFFORT_PARAMS` SSOT); switch via `/effort `; arrow-key selector is GUI-only (M6) | -| `/cost` / `/usage` | โœ“ | โœ“ | โœ… | -| `/context` | โœ“ | โœ“ | โœ… | -| `/config` | โœ“ | โœ“ | ๐ŸŸก โ€” dumps merged settings + `/config set ` (dotted keys, JSON values) writes user settings; no full arrow-key editor | -| `/resume` | โœ“ | โœ“ | โœ… โ€” lists recent sessions; `/resume ` switches the live session in-REPL; `--resume ` / `-r` at launch | -| `/init` | โœ“ | โœ“ | โœ… โ€” interactive 3-phase REPL flow (scan โ†’ draft โ†’ approve-write `AGENTS.md`) | -| `/mcp` | โœ“ | โœ“ | โœ… | -| `/add-dir` | โœ“ | โœ“ (records intent) | ๐ŸŸก โ€” M3 will enforce | -| `/todos` | โœ“ | โœ“ | โœ… โ€” reads `/todos.json` written by TodoWrite tool | -| `/plugins` | โœ“ | โœ“ | โœ… โ€” lists wired plugins + contributed hook events + warnings (M5.2) | -| `/compact` | โœ“ | โœ“ | โœ… โ€” manual `/compact` + automatic threshold trigger in the agent loop | -| `/diff` | โœ“ | โœ“ | โœ… โ€” git diff + untracked files in the working tree (PR #150) | -| `/btw` | โœ“ | โœ“ | ๐ŸŸก โ€” queues a "by the way" context note the agent sees with your next message (no turn fired); exact Claude Code behavior may differ | -| `/recap` | โœ“ | โœ“ | โœ… โ€” provider-summarized recap of the session so far | -| `/rewind` | โœ“ | โœ“ | โœ… โ€” 5 ops (code/conversation/both/summarize-from/up-to); `Esc Esc` bound | -| `/voice` | โœ“ | โœ— | ๐Ÿ”„ M8 | -| `/teleport` | โœ“ | โœ— | ๐Ÿ”„ M8 | -| `/desktop` | โœ“ | โœ— | ๐Ÿ”„ M6 | -| `/background` | โœ“ | โœ“ | โœ… โ€” runs a prompt as a background sub-agent via the session TaskManager (alias `/bg`); agent-started TaskCreate tasks appear too | -| `/batch` | โœ“ | โœ— | ๐Ÿ”„ โ€” batch-of-prompts not yet wired (use `/background` per prompt) | -| `/tasks` | โœ“ | โœ“ | โœ… โ€” lists this session's background tasks; `/tasks ` shows one's status + output | -| `/plan` | โœ“ | โœ— | ๐Ÿ”„ โ€” set via `/mode plan` in DeepCode | -| `/login` / `/logout` | โœ“ | โœ“ | โœ… โ€” /logout clears creds + exits; /login stores a new key (next launch) | -| `/export` | โœ“ | โœ“ | โœ… โ€” writes the conversation to a markdown file | -| `/bug` (alias `/feedback`) | โœ“ | โœ“ | โœ… โ€” prints a prefilled GitHub issue link (model/mode/effort in the body) | -| `/upgrade` | โœ“ | โœ“ | โœ… โ€” prints version + `npm i -g deepcode-cli@latest` (also the `deepcode upgrade` subcommand) | -| `/pr_comments` | โœ“ | โœ“ | โœ… โ€” `gh pr view` comments for the current branch's PR | -| `/review` | โœ“ | โœ— (skill avail) | ๐ŸŸก โ€” via Skill tool | -| `/security-review` | โœ“ | โœ— (skill avail) | ๐ŸŸก โ€” via Skill tool | -| `/schedule` | โœ“ | โœ— (skill avail) | ๐ŸŸก | -| `/loop` | โœ“ | โœ— (skill avail) | ๐ŸŸก | -| `/terminal-setup` | โœ“ | โœ— | ๐Ÿ”„ | -| `/vim` | โœ“ | โœ“ | โœ… โ€” toggles Vim mode (persists to `~/.deepcode/keybindings.json`) | -| `/keybindings` | โœ“ | โœ“ (read-only) | ๐ŸŸก โ€” Claude Code opens/creates the keybindings config; ours lists bindings (edit `~/.deepcode/keybindings.json` manually) | -| `/agents` | โœ“ | โœ“ | โœ… โ€” lists sub-agents from `.deepcode/agents/` | -| `/hooks` | โœ“ | โœ“ | โœ… โ€” lists hooks configured in settings.json | -| `/skills` | โœ“ | โœ“ | โœ… โ€” lists built-in + user + project skills | -| `/permissions` | โœ“ | โœ“ (read-only) | ๐ŸŸก โ€” shows rules + default mode (interactive editor deferred) | -| `/privacy-settings` | โœ“ | โœ“ | โœ… โ€” summarizes local data locations + what's sent to the DeepSeek API (read-only) | -| `/migrate-installer` | โœ“ | โœ— | ๐Ÿ”„ | -| `/release-notes` | โœ“ | โœ“ | โœ… โ€” prints the latest `CHANGELOG.md` entry | +| Command | Claude Code | DeepCode | Status | +| -------------------------- | ----------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/help` | โœ“ | โœ“ | โœ… | +| `/clear` | โœ“ | โœ“ | โœ… | +| `/exit` / `/quit` | โœ“ | โœ“ | โœ… | +| `/status` / `/doctor` | โœ“ | โœ“ | โœ… | +| `/model` | โœ“ | โœ“ | โœ… DeepCode constrains to deepseek-\* (model picker doesn't show foreign providers) | +| `/mode` | โœ“ | โœ“ | โœ… | +| `/effort` | โœ“ | โœ“ | ๐ŸŸก โ€” CLI prints the tier table (numbers from `EFFORT_PARAMS` SSOT); switch via `/effort `; arrow-key selector is GUI-only (M6) | +| `/cost` / `/usage` | โœ“ | โœ“ | โœ… | +| `/context` | โœ“ | โœ“ | โœ… | +| `/config` | โœ“ | โœ“ | ๐ŸŸก โ€” dumps merged settings + `/config set ` (dotted keys, JSON values) writes user settings; no full arrow-key editor | +| `/resume` | โœ“ | โœ“ | โœ… โ€” lists recent sessions; `/resume ` switches the live session in-REPL; `--resume ` / `-r` at launch | +| `/init` | โœ“ | โœ“ | โœ… โ€” interactive 3-phase REPL flow (scan โ†’ draft โ†’ approve-write `AGENTS.md`) | +| `/mcp` | โœ“ | โœ“ | โœ… | +| `/add-dir` | โœ“ | โœ“ (records intent) | ๐ŸŸก โ€” M3 will enforce | +| `/todos` | โœ“ | โœ“ | โœ… โ€” reads `/todos.json` written by TodoWrite tool | +| `/plugins` | โœ“ | โœ“ | โœ… โ€” lists wired plugins + contributed hook events + warnings (M5.2) | +| `/compact` | โœ“ | โœ“ | โœ… โ€” manual `/compact` + automatic threshold trigger in the agent loop | +| `/diff` | โœ“ | โœ“ | โœ… โ€” git diff + untracked files in the working tree (PR #150) | +| `/btw` | โœ“ | โœ“ | ๐ŸŸก โ€” queues a "by the way" context note the agent sees with your next message (no turn fired); exact Claude Code behavior may differ | +| `/recap` | โœ“ | โœ“ | โœ… โ€” provider-summarized recap of the session so far | +| `/rewind` | โœ“ | โœ“ | โœ… โ€” 5 ops (code/conversation/both/summarize-from/up-to); `Esc Esc` bound | +| `/voice` | โœ“ | โœ“ | ๐ŸŸก โ€” `/voice` detects whisper.cpp + a model and prints setup steps (docs/VOICE_INPUT.md); core `WhisperCppProvider` is wired; live mic capture lands in a follow-up slice | +| `/teleport` | โœ“ | โœ— | ๐Ÿ”„ M8 | +| `/desktop` | โœ“ | โœ— | ๐Ÿ”„ M6 | +| `/background` | โœ“ | โœ“ | โœ… โ€” runs a prompt as a background sub-agent via the session TaskManager (alias `/bg`); agent-started TaskCreate tasks appear too | +| `/batch` | โœ“ | โœ— | ๐Ÿ”„ โ€” batch-of-prompts not yet wired (use `/background` per prompt) | +| `/tasks` | โœ“ | โœ“ | โœ… โ€” lists this session's background tasks; `/tasks ` shows one's status + output | +| `/plan` | โœ“ | โœ— | ๐Ÿ”„ โ€” set via `/mode plan` in DeepCode | +| `/login` / `/logout` | โœ“ | โœ“ | โœ… โ€” /logout clears creds + exits; /login stores a new key (next launch) | +| `/export` | โœ“ | โœ“ | โœ… โ€” writes the conversation to a markdown file | +| `/bug` (alias `/feedback`) | โœ“ | โœ“ | โœ… โ€” prints a prefilled GitHub issue link (model/mode/effort in the body) | +| `/upgrade` | โœ“ | โœ“ | โœ… โ€” prints version + `npm i -g deepcode-cli@latest` (also the `deepcode upgrade` subcommand) | +| `/pr_comments` | โœ“ | โœ“ | โœ… โ€” `gh pr view` comments for the current branch's PR | +| `/review` | โœ“ | โœ— (skill avail) | ๐ŸŸก โ€” via Skill tool | +| `/security-review` | โœ“ | โœ— (skill avail) | ๐ŸŸก โ€” via Skill tool | +| `/schedule` | โœ“ | โœ— (skill avail) | ๐ŸŸก | +| `/loop` | โœ“ | โœ— (skill avail) | ๐ŸŸก | +| `/terminal-setup` | โœ“ | โœ— | ๐Ÿ”„ | +| `/vim` | โœ“ | โœ“ | โœ… โ€” toggles Vim mode (persists to `~/.deepcode/keybindings.json`) | +| `/keybindings` | โœ“ | โœ“ (read-only) | ๐ŸŸก โ€” Claude Code opens/creates the keybindings config; ours lists bindings (edit `~/.deepcode/keybindings.json` manually) | +| `/agents` | โœ“ | โœ“ | โœ… โ€” lists sub-agents from `.deepcode/agents/` | +| `/hooks` | โœ“ | โœ“ | โœ… โ€” lists hooks configured in settings.json | +| `/skills` | โœ“ | โœ“ | โœ… โ€” lists built-in + user + project skills | +| `/permissions` | โœ“ | โœ“ (read-only) | ๐ŸŸก โ€” shows rules + default mode (interactive editor deferred) | +| `/privacy-settings` | โœ“ | โœ“ | โœ… โ€” summarizes local data locations + what's sent to the DeepSeek API (read-only) | +| `/migrate-installer` | โœ“ | โœ— | ๐Ÿ”„ | +| `/release-notes` | โœ“ | โœ“ | โœ… โ€” prints the latest `CHANGELOG.md` entry | --- diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 02500ee..32bf474 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -15,6 +15,7 @@ export type { UpdateConfig, WorktreeConfig, AutoModeConfig, + VoiceConfig, } from './types.js'; export { diff --git a/packages/core/src/config/schema.test.ts b/packages/core/src/config/schema.test.ts index 3ee0774..a84fd80 100644 --- a/packages/core/src/config/schema.test.ts +++ b/packages/core/src/config/schema.test.ts @@ -51,6 +51,12 @@ describe('validateSettingsShallow', () => { expect(errs[0]).toMatch(/OnEverything/); }); + it('flags unknown voice provider but accepts whisper.cpp', () => { + expect(validateSettingsShallow({ voice: { provider: 'whisper.cpp' } })).toEqual([]); + const errs = validateSettingsShallow({ voice: { provider: 'azure' } }); + expect(errs[0]).toMatch(/voice\.provider "azure"/); + }); + it('returns no errors on empty config', () => { expect(validateSettingsShallow({})).toEqual([]); }); diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 3c7d233..d648032 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -89,5 +89,11 @@ export function validateSettingsShallow(settings: Record): stri } } + const voiceProviderEnum = ['whisper.cpp', 'stub']; + const voice = settings['voice'] as { provider?: string } | undefined; + if (voice?.provider && !voiceProviderEnum.includes(voice.provider)) { + errors.push(`voice.provider "${voice.provider}" not in ${voiceProviderEnum.join(' | ')}`); + } + return errors; } diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 6fa1a47..f77af1e 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -108,6 +108,18 @@ export interface AutoModeConfig { fallback?: 'ask' | 'deny'; } +export interface VoiceConfig { + /** + * Speech-to-text engine. Only 'whisper.cpp' is a real local engine; 'stub' + * returns an empty transcript (tests / "disabled"). Spec: docs/VOICE_INPUT.md. + */ + provider?: 'whisper.cpp' | 'stub'; + /** Path to the whisper CLI binary. Defaults to `whisper-cli`/`whisper` on PATH. */ + binPath?: string; + /** Path to the ggml model file (e.g. ~/.deepcode/models/whisper-base.en.bin). */ + modelPath?: string; +} + export interface DeepCodeSettings { // Identity model?: string; @@ -166,6 +178,9 @@ export interface DeepCodeSettings { // Worktree worktree?: WorktreeConfig; + // Voice input (M8 โ€” local whisper.cpp ASR; see docs/VOICE_INPUT.md) + voice?: VoiceConfig; + // Plugins (M5) plugins?: { globalEnabled?: boolean; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 344b3d7..757713a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -104,6 +104,7 @@ export { type UpdateConfig, type WorktreeConfig, type AutoModeConfig, + type VoiceConfig, } from './config/index.js'; // Credentials (M2; M3c adds ApiKeyHelperRefresher) @@ -334,15 +335,18 @@ export { type AgentStreamEvent, } from './ipc/protocol.js'; -// Voice input (M8 โ€” whisper.cpp wrapper + stub provider) +// Voice input (M8 โ€” whisper.cpp wrapper + stub provider + setup detection) export { WhisperCppProvider, StubVoiceProvider, parseWhisperOutput, + detectVoice, type VoiceProvider, type VoiceTranscript, type TranscribeOpts, type WhisperCppOpts, + type VoiceProbe, + type VoiceStatus, } from './voice/index.js'; // Auto-mode classifier (M3c-rest โ€” LLM-judged tool gate when mode === 'auto') diff --git a/packages/core/src/voice/detect.test.ts b/packages/core/src/voice/detect.test.ts new file mode 100644 index 0000000..321da90 --- /dev/null +++ b/packages/core/src/voice/detect.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { join } from 'node:path'; +import { detectVoice, expandHome, type VoiceProbe } from './detect.js'; +import type { VoiceConfig } from '../config/types.js'; + +const HOME = '/home/u'; + +/** Build a fake probe where `present` is the set of paths/bins that "exist". */ +function probe( + present: Iterable, + overrides: Partial = {}, +): Partial { + const set = new Set(present); + return { + home: HOME, + fileExists: async (p) => set.has(p), + which: async (name) => (set.has(name) ? `/usr/bin/${name}` : null), + ...overrides, + }; +} + +describe('expandHome', () => { + it('expands ~ and ~/path, leaves others alone', () => { + expect(expandHome('~', HOME)).toBe(HOME); + expect(expandHome('~/m/x.bin', HOME)).toBe(join(HOME, 'm/x.bin')); + expect(expandHome('/abs/x.bin', HOME)).toBe('/abs/x.bin'); + expect(expandHome('rel/x.bin', HOME)).toBe('rel/x.bin'); + }); +}); + +describe('detectVoice', () => { + it('is ready when configured binPath + modelPath both exist', async () => { + const voice: VoiceConfig = { binPath: '/opt/whisper-cli', modelPath: '/models/base.bin' }; + const s = await detectVoice(voice, probe(['/opt/whisper-cli', '/models/base.bin'])); + expect(s.ready).toBe(true); + expect(s.binPath).toBe('/opt/whisper-cli'); + expect(s.modelPath).toBe('/models/base.bin'); + expect(s.problems).toEqual([]); + }); + + it('finds the binary on PATH when binPath is unset', async () => { + // 'whisper-cli' is the first candidate; PATH has it. + const def = join(HOME, '.deepcode', 'models', 'whisper-base.en.bin'); + const s = await detectVoice({ modelPath: def }, probe(['whisper-cli', def])); + expect(s.ready).toBe(true); + expect(s.binPath).toBe('/usr/bin/whisper-cli'); + }); + + it('falls back to the second PATH candidate (whisper)', async () => { + const s = await detectVoice( + { modelPath: '/m.bin' }, + probe(['whisper', '/m.bin']), // no whisper-cli, but whisper exists + ); + expect(s.binPath).toBe('/usr/bin/whisper'); + expect(s.ready).toBe(true); + }); + + it('uses the default ~/.deepcode model path when modelPath is unset', async () => { + const def = join(HOME, '.deepcode', 'models', 'whisper-base.en.bin'); + const s = await detectVoice({ binPath: '/b' }, probe(['/b', def])); + expect(s.ready).toBe(true); + expect(s.modelPath).toBe(def); + }); + + it('reports both missing pieces when nothing is installed', async () => { + const s = await detectVoice(undefined, probe([])); // empty PATH + fs + expect(s.ready).toBe(false); + expect(s.binPath).toBeUndefined(); + expect(s.modelPath).toBeUndefined(); + expect(s.problems.join('\n')).toMatch(/binary not found on PATH/); + expect(s.problems.join('\n')).toMatch(/no model at the default/); + }); + + it('flags a configured binPath / modelPath that does not exist', async () => { + const s = await detectVoice( + { binPath: '/nope/whisper', modelPath: '/nope/model.bin' }, + probe([]), + ); + expect(s.ready).toBe(false); + expect(s.problems).toContain('Configured voice.binPath not found: /nope/whisper'); + expect(s.problems).toContain('Configured voice.modelPath not found: /nope/model.bin'); + }); + + it('expands ~ in configured paths against the probe home', async () => { + const bin = join(HOME, 'bin', 'whisper'); + const model = join(HOME, 'm', 'x.bin'); + const s = await detectVoice( + { binPath: '~/bin/whisper', modelPath: '~/m/x.bin' }, + probe([bin, model]), + ); + expect(s.ready).toBe(true); + expect(s.binPath).toBe(bin); + expect(s.modelPath).toBe(model); + }); + + it('is not ready with an unknown provider even if bin + model resolve', async () => { + const s = await detectVoice( + { provider: 'azure' as unknown as VoiceConfig['provider'], binPath: '/b', modelPath: '/m' }, + probe(['/b', '/m']), + ); + expect(s.ready).toBe(false); + expect(s.provider).toBe('azure'); + expect(s.problems.join('\n')).toMatch(/Unknown voice provider/); + }); +}); diff --git a/packages/core/src/voice/detect.ts b/packages/core/src/voice/detect.ts new file mode 100644 index 0000000..d70d5ef --- /dev/null +++ b/packages/core/src/voice/detect.ts @@ -0,0 +1,137 @@ +// Voice setup detection โ€” resolves the whisper.cpp binary + model so the +// `/voice` command (and, later, the desktop client) can report readiness and +// print actionable setup steps. Pure logic over injectable probes so it is +// unit-testable without touching the real PATH / filesystem. +// Spec: docs/VOICE_INPUT.md + +import { access, stat } from 'node:fs/promises'; +import { constants as FS } from 'node:fs'; +import { homedir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import type { VoiceConfig } from '../config/types.js'; + +/** Binary names searched on PATH when `voice.binPath` is unset, in order. */ +export const WHISPER_BIN_CANDIDATES = ['whisper-cli', 'whisper'] as const; + +/** Default model location probed when `voice.modelPath` is unset (under home). */ +export const DEFAULT_MODEL_RELPATH = ['.deepcode', 'models', 'whisper-base.en.bin'] as const; + +/** Filesystem / PATH probes โ€” injectable so detection is deterministic in tests. */ +export interface VoiceProbe { + /** Resolve an executable `name` on PATH to an absolute path, or null. */ + which(name: string): Promise; + /** True if a readable regular file exists at `path`. */ + fileExists(path: string): Promise; + /** Home dir, for ~ expansion + the default model path. */ + home: string; +} + +export interface VoiceStatus { + /** True iff a supported provider, a binary, and a model were all resolved. */ + ready: boolean; + /** Resolved provider name (defaults to 'whisper.cpp'). */ + provider: string; + /** Resolved whisper binary (absolute path), if found. */ + binPath?: string; + /** Resolved model file (absolute path), if found. */ + modelPath?: string; + /** Human-readable reasons it is not ready (empty when ready). */ + problems: string[]; +} + +/** Expand a leading `~` / `~/` to the home dir. Other paths pass through. */ +export function expandHome(p: string, home: string): string { + if (p === '~') return home; + if (p.startsWith('~/')) return join(home, p.slice(2)); + return p; +} + +/** Real PATH lookup โ€” first dir in $PATH holding an executable `name`. */ +async function whichOnPath(name: string): Promise { + const dirs = (process.env['PATH'] ?? '').split(delimiter).filter(Boolean); + for (const dir of dirs) { + const candidate = join(dir, name); + try { + await access(candidate, FS.X_OK); + return candidate; + } catch { + /* not here, or not executable */ + } + } + return null; +} + +/** Real existence check โ€” true only for a regular file. */ +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +} + +/** + * Detect whether local voice input (whisper.cpp) is ready to use. + * + * Resolution order: + * - binary: `voice.binPath` (if set) else the first of + * {@link WHISPER_BIN_CANDIDATES} found on PATH. + * - model: `voice.modelPath` (if set) else the documented default + * `~/.deepcode/models/whisper-base.en.bin`. + * + * Never throws โ€” every missing/invalid piece becomes a `problems` entry. + */ +export async function detectVoice( + voice: VoiceConfig | undefined, + probe?: Partial, +): Promise { + const home = probe?.home ?? homedir(); + const which = probe?.which ?? whichOnPath; + const fileExists = probe?.fileExists ?? isFile; + + const provider = voice?.provider ?? 'whisper.cpp'; + const problems: string[] = []; + + if (provider !== 'whisper.cpp' && provider !== 'stub') { + problems.push(`Unknown voice provider "${provider}" โ€” expected "whisper.cpp".`); + } + + // Resolve the binary. + let binPath: string | undefined; + if (voice?.binPath) { + const p = expandHome(voice.binPath, home); + if (await fileExists(p)) binPath = p; + else problems.push(`Configured voice.binPath not found: ${voice.binPath}`); + } else { + for (const name of WHISPER_BIN_CANDIDATES) { + const found = await which(name); + if (found) { + binPath = found; + break; + } + } + if (!binPath) { + problems.push( + `whisper.cpp binary not found on PATH (looked for ${WHISPER_BIN_CANDIDATES.join(', ')}).`, + ); + } + } + + // Resolve the model. + let modelPath: string | undefined; + if (voice?.modelPath) { + const p = expandHome(voice.modelPath, home); + if (await fileExists(p)) modelPath = p; + else problems.push(`Configured voice.modelPath not found: ${voice.modelPath}`); + } else { + const def = join(home, ...DEFAULT_MODEL_RELPATH); + if (await fileExists(def)) modelPath = def; + else + problems.push( + `No voice.modelPath set, and no model at the default ~/${DEFAULT_MODEL_RELPATH.join('/')}.`, + ); + } + + const ready = problems.length === 0 && !!binPath && !!modelPath; + return { ready, provider, binPath, modelPath, problems }; +} diff --git a/packages/core/src/voice/index.ts b/packages/core/src/voice/index.ts index 793a1d3..6efe668 100644 --- a/packages/core/src/voice/index.ts +++ b/packages/core/src/voice/index.ts @@ -137,3 +137,16 @@ export class StubVoiceProvider implements VoiceProvider { return { text: '', latencyMs: 0 }; } } + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Setup detection โ€” is whisper.cpp + a model installed and configured? +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export { + detectVoice, + expandHome, + WHISPER_BIN_CANDIDATES, + DEFAULT_MODEL_RELPATH, + type VoiceProbe, + type VoiceStatus, +} from './detect.js';