Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"test:work-context": "tsx --tsconfig tsconfig.web.json scripts/test-work-context.ts",
"test:worktree-activity": "tsx --tsconfig tsconfig.node.json scripts/test-worktree-activity-index.ts",
"test:ghost-fallback": "tsx --tsconfig tsconfig.web.json scripts/test-ghost-fallback.ts",
"test:dictation": "tsx --tsconfig tsconfig.node.json scripts/test-dictation-imports.ts",
"proxy-demo-bootstrap": "npm --prefix packages/claude-code-headless run proxy-test-bootstrap",
"proxy-demo": "npm --prefix packages/claude-code-headless run build && electron experiments/claude-proxy-stream-app/main.mjs",
"postinstall": "electron-rebuild -f -w node-pty"
Expand All @@ -35,6 +36,7 @@
"@xterm/headless": "^5.5.0",
"@xterm/xterm": "^6.0.0",
"agent-transcript-parser": "file:./packages/agent-transcript-parser",
"agent-voice-dictation": "file:../agent-voice-dictation",
"chokidar": "^5.0.0",
"highlight.js": "^11.11.1",
"monaco-editor": "^0.52.2",
Expand Down
106 changes: 106 additions & 0 deletions scripts/test-dictation-imports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Phase 1 smoke test: prove cc-shell can resolve and use the
// agent-voice-dictation package without any actual network round-trip.
//
// Why a structural smoke test instead of a real Deepgram call:
// - No API key in this dev sandbox, no fixture audio in cc-shell's repo.
// - We want a fast, deterministic gate that catches the breakage modes
// this integration is most likely to hit: misnamed exports, broken
// subpath resolution, types that drift between the two repos. Real
// network tests belong in flow-electron's live-providers suite, not
// here — cc-shell is a consumer, not a provider gateway.
//
// What this verifies:
// 1. cc-shell's main-process tsx can import 'agent-voice-dictation'
// and the controller wrapping it.
// 2. Both subpath exports we rely on resolve (composer + env).
// 3. listSelectableProviders() agrees with the package's support map
// (catches the case where the package quietly disables a provider
// and cc-shell's UI keeps offering it).
// 4. The Deepgram streaming singleton constructs without throwing.
//
// Run with: npm run test:dictation

import { wrapWithSttTag } from 'agent-voice-dictation/composer'
import { parseDotEnv } from 'agent-voice-dictation/env'
import {
STT_PROVIDER_SUPPORT,
type SpeechProviderId,
} from 'agent-voice-dictation'

import {
deepgramStreaming,
listSelectableProviders,
transcribeBatch,
type DictationBatchInput,
type DictationBatchOutcome,
} from '../src/main/dictation/index.js'

let failures = 0

function check(label: string, condition: boolean, detail?: unknown): void {
if (condition) {
console.log(` ok ${label}`)
} else {
failures += 1
console.log(` FAIL ${label}`, detail ?? '')
}
}

console.log('\nagent-voice-dictation integration smoke test\n')

console.log('package subpath exports:')
check('composer subpath: wrapWithSttTag is callable', typeof wrapWithSttTag === 'function')
check('composer subpath: wraps as expected', wrapWithSttTag('hello').includes('<stt'))
check('env subpath: parseDotEnv is callable', typeof parseDotEnv === 'function')
const parsed = parseDotEnv('FOO=bar\n# comment\nBAZ="qux"')
check('env subpath: parses simple kv', parsed.FOO === 'bar' && parsed.BAZ === 'qux')

console.log('\ncontroller surface:')
check('transcribeBatch is exported', typeof transcribeBatch === 'function')
check('deepgramStreaming is exported', typeof deepgramStreaming === 'function')
check('listSelectableProviders is exported', typeof listSelectableProviders === 'function')

console.log('\nprovider registry agreement:')
const exposedByController = new Set(listSelectableProviders())
const expectedFromPackage = new Set(
(Object.keys(STT_PROVIDER_SUPPORT) as SpeechProviderId[])
.filter(id => STT_PROVIDER_SUPPORT[id].selectable),
)
check(
'controller and package agree on selectable providers',
exposedByController.size === expectedFromPackage.size
&& [...exposedByController].every(id => expectedFromPackage.has(id)),
{ controller: [...exposedByController], package: [...expectedFromPackage] },
)
check(
'at least one provider is selectable',
exposedByController.size >= 1,
)

console.log('\nstreaming provider construction:')
let streaming: ReturnType<typeof deepgramStreaming>
try {
streaming = deepgramStreaming()
check('Deepgram streaming singleton constructed', true)
} catch (err) {
check('Deepgram streaming singleton constructed', false, err)
process.exit(1)
}
check('streaming.start is callable', typeof streaming.start === 'function')
check('streaming.stop is callable', typeof streaming.stop === 'function')
check('streaming.cancel is callable', typeof streaming.cancel === 'function')

// Type-level check: confirm the discriminated-union outcome shape compiles.
// This block never runs at the type level if the union has changed
// incompatibly; tsx will refuse to load the file.
const _typeCheck: DictationBatchOutcome = { kind: 'no-speech' }
void _typeCheck
const _inputCheck: DictationBatchInput = {
provider: 'deepgram',
apiKey: 'placeholder',
audio: new ArrayBuffer(0),
}
void _inputCheck

console.log(`\n${failures === 0 ? 'PASS' : `FAIL — ${failures} check(s) failed`}\n`)
process.exit(failures === 0 ? 0 : 1)
14 changes: 14 additions & 0 deletions scripts/test-settings-coerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ assert(defaults.showStatusMode === true, 'showStatusMode should default on')
assert(defaults.showWorktreeBadges === true, 'showWorktreeBadges should default on')
assert(defaults.dangerousAgentsEnabled === false, 'dangerousAgentsEnabled should default off')
assert(defaults.defaultWorkspaceMode === 'grid', 'defaultWorkspaceMode should default to grid')
assert(defaults.dictationShortcut === 'Fn', 'dictation shortcut should default to Fn')

const coerced = coerceSettings({
mode: 'not-a-theme',
Expand All @@ -29,6 +30,19 @@ assert(coerced.showStatusMode === false, 'showStatusMode should preserve explici
assert(coerced.showWorktreeBadges === false, 'showWorktreeBadges should preserve explicit false')
assert(coerced.dangerousAgentsEnabled === true, 'dangerousAgentsEnabled should accept true')
assert(coerced.useProxyStreaming === true, 'useProxyStreaming should accept true')
assert(coerced.dictationShortcut === 'Fn', 'missing dictation shortcut should fall back to Fn')

const customDictationShortcut = coerceSettings({ dictationShortcut: 'Ctrl+Shift+SPACE' })
assert(
customDictationShortcut.dictationShortcut === 'Ctrl+Shift+SPACE',
'dictation shortcut should accept arbitrary captured bindings',
)

const legacyDictationShortcut = coerceSettings({ dictationShortcut: 'mod-shift-d' })
assert(
legacyDictationShortcut.dictationShortcut === 'Cmd+Shift+D',
'legacy dictation shortcut ids should migrate to captured binding strings',
)

const dispatchPick = coerceSettings({ defaultWorkspaceMode: 'dispatch' })
assert(
Expand Down
142 changes: 142 additions & 0 deletions src/main/dictation/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Phase 1: minimal main-process dictation controller for cc-shell.
//
// Purpose of this file is narrow on purpose. It proves cc-shell can call
// `agent-voice-dictation` from its main process and shape the outputs into
// the discriminated-union outcome type the renderer will eventually consume.
// No IPC handlers, no global hotkey, no UI yet — those land in Phase 2 with
// the composer-integrated pill. Keeping this file backend-only means the
// integration shape (provider selection, key resolution, trace forwarding,
// outcome typing) is settled BEFORE we have to make UI commitments that
// would otherwise lock us into the wrong contract.
//
// Why mirror flow-electron's controller shape? Two consumers of the same
// package converging on the same orchestration vocabulary makes it easier
// to spot drift later — if the package gains a new primitive (say,
// per-session diarisation), both hosts adopt it the same way. Every place
// where cc-shell's needs *intentionally* differ from flow-electron's gets
// a comment explaining why.

import {
createDeepgramStreamingProvider,
isSpeechProviderSelectable,
polishTranscriptWithOpenRouter,
STT_PROVIDER_SUPPORT,
transcribeAssemblyAi,
transcribeDeepgram,
transcribeElevenLabs,
transcribeGladia,
transcribeOpenAi,
type SpeechProviderId,
type SpeechTraceEvent,
type SpeechTranscript,
} from 'agent-voice-dictation'

export type DictationProvider = SpeechProviderId

export type DictationBatchInput = {
provider: DictationProvider
apiKey: string
audio: ArrayBuffer
mimeType?: string
/** ISO 639-1 / BCP-47 short code. v1 is English-only by product
* decision (see agent-voice-dictation settings store comment) — leaving
* this configurable here keeps the option available for cc-shell to
* expose multilingual dictation later without a controller refactor. */
language?: string
/** Optional polish step. cc-shell will eventually feed this with
* per-project glossary context (the MCP feedback loop), but Phase 1
* keeps it as a plain on/off toggle to stay scoped. */
polish?: { openRouterApiKey: string; model?: string }
onTrace?: (event: SpeechTraceEvent) => void
}

// Discriminated union, same shape as the cleanup we landed on the
// agent-voice-dictation app side (PR #1, "treat empty transcripts as a
// normal outcome"). Adopted here from the start so cc-shell never has the
// "no speech detected = thrown exception spamming the terminal" phase
// flow-electron had to refactor out of.
export type DictationBatchOutcome =
| { kind: 'success'; raw: string; polished: string | null; transcript: SpeechTranscript }
| { kind: 'no-speech' }

export async function transcribeBatch(input: DictationBatchInput): Promise<DictationBatchOutcome> {
if (!isSpeechProviderSelectable(input.provider)) {
// Mirrors the package's product-level gate: the renderer should never
// reach this branch because Settings won't expose unselectable
// providers, but the controller refuses to silently accept one if a
// bad settings file does end up requesting it.
throw new Error(`Provider "${input.provider}" is not selectable in v1`)
}

const transcript = await runProvider(input)
if (!transcript.text.trim()) return { kind: 'no-speech' }

if (!input.polish) {
return { kind: 'success', raw: transcript.text, polished: null, transcript }
}

// Polish failures must NOT swallow the raw transcript — STT is the
// irreplaceable artifact, polish is a nice-to-have. flow-electron had
// a real bug where a transient OpenRouter outage made the user lose
// the whole dictation; we surface raw on polish failure here.
try {
const result = await polishTranscriptWithOpenRouter({
apiKey: input.polish.openRouterApiKey,
rawTranscript: transcript.text,
...(input.polish.model ? { model: input.polish.model } : {}),
})
return { kind: 'success', raw: transcript.text, polished: result.text || null, transcript }
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[dictation] polish failed; surfacing raw transcript:', err)
return { kind: 'success', raw: transcript.text, polished: null, transcript }
}
}

function runProvider(input: DictationBatchInput): Promise<SpeechTranscript> {
// Provider-specific request construction stays in the package. The
// controller's job is to route the call and forward traces — never to
// know the shape of any one provider's request body.
const audio = {
data: input.audio,
...(input.mimeType ? { mimeType: input.mimeType } : {}),
}
const opts = {
apiKey: input.apiKey,
audio,
...(input.language ? { language: input.language } : {}),
...(input.onTrace ? { onTrace: input.onTrace } : {}),
}
switch (input.provider) {
case 'deepgram': return transcribeDeepgram({}, opts)
case 'assemblyai': return transcribeAssemblyAi({}, opts)
case 'openai': return transcribeOpenAi({}, opts)
case 'gladia': return transcribeGladia({}, opts)
case 'elevenlabs': return transcribeElevenLabs({}, opts)
}
}

// Singleton streaming provider. The package's createDeepgramStreamingProvider
// is intentionally session-keyed: one instance manages many concurrent
// sessions identified by id. We construct it lazily on first use because
// the cc-shell main process should not pay the WebSocket-helper cost
// during boot — most users will never trigger dictation.
let deepgramStreamingSingleton: ReturnType<typeof createDeepgramStreamingProvider> | null = null

export function deepgramStreaming(): ReturnType<typeof createDeepgramStreamingProvider> {
if (!deepgramStreamingSingleton) {
deepgramStreamingSingleton = createDeepgramStreamingProvider()
}
return deepgramStreamingSingleton
}

export function listSelectableProviders(): DictationProvider[] {
// Source-of-truth for "which providers the UI should expose" is the
// package, not cc-shell. Re-exporting through the controller (rather
// than letting renderer code import the package directly) keeps the
// boundary clean: renderer talks to controller, controller talks to
// package. Future additions like a cc-shell-only "DEMO" provider
// would slot in here without touching the package.
return (Object.keys(STT_PROVIDER_SUPPORT) as DictationProvider[])
.filter(id => isSpeechProviderSelectable(id))
}
5 changes: 5 additions & 0 deletions src/main/dictation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Public surface for the cc-shell dictation module. Phase 1 only exposes
// the controller — IPC registration, settings/secrets wiring, and UI
// integration (the composer-embedded pill) all land in Phase 2 and will
// add their own modules under this directory.
export * from './controller.js'
Loading