dictum-cli 0.1.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/LICENSE +21 -0
- package/README.md +280 -0
- package/bin/dictum.js +7 -0
- package/package.json +41 -0
- package/src/cli.ts +399 -0
- package/src/config.ts +250 -0
- package/src/core/pipeline.ts +120 -0
- package/src/core/types.ts +102 -0
- package/src/doctor.ts +276 -0
- package/src/mcp/prompts.ts +186 -0
- package/src/mcp/server.ts +363 -0
- package/src/modules.d.ts +7 -0
- package/src/polisher/anthropic.ts +90 -0
- package/src/polisher/claude_cli.ts +128 -0
- package/src/polisher/factory.ts +89 -0
- package/src/polisher/layered.ts +43 -0
- package/src/polisher/openai_compat.ts +76 -0
- package/src/polisher/rules.ts +332 -0
- package/src/polisher/templates/agent-prompt.md +18 -0
- package/src/polisher/templates/commit.md +13 -0
- package/src/polisher/templates/decompose.md +20 -0
- package/src/polisher/templates/note.md +11 -0
- package/src/polisher/templates/spec.md +29 -0
- package/src/polisher/templates.ts +151 -0
- package/src/recorder/file.ts +35 -0
- package/src/recorder/sox.ts +165 -0
- package/src/recorder/vad.ts +100 -0
- package/src/recorder/wav.ts +150 -0
- package/src/sink/clipboard.ts +153 -0
- package/src/sink/factory.ts +31 -0
- package/src/sink/stdout.ts +17 -0
- package/src/stt/factory.ts +89 -0
- package/src/stt/local_http.ts +75 -0
- package/src/stt/openai_compat.ts +83 -0
- package/src/ui/choose.ts +95 -0
- package/src/ui/keys.ts +110 -0
- package/src/ui/status.ts +91 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sink/clipboard.ts — copy text to the system clipboard.
|
|
3
|
+
*
|
|
4
|
+
* Mechanism selection:
|
|
5
|
+
* - SSH session (SSH_CONNECTION / SSH_TTY) → OSC52 escape sequence to the
|
|
6
|
+
* terminal (works through SSH; the local terminal emulator receives it).
|
|
7
|
+
* - Wayland (WAYLAND_DISPLAY + wl-copy) → wl-copy
|
|
8
|
+
* - macOS (pbcopy) → pbcopy
|
|
9
|
+
* - X11 (xclip) → xclip -selection clipboard
|
|
10
|
+
* - otherwise → error with an actionable hint.
|
|
11
|
+
*
|
|
12
|
+
* tmux is handled by wrapping OSC52 in a DCS passthrough when $TMUX is set.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { closeSync, openSync, writeSync } from "node:fs"
|
|
16
|
+
import type { Sink } from "../core/types.ts"
|
|
17
|
+
|
|
18
|
+
type Which = (bin: string) => string | null
|
|
19
|
+
|
|
20
|
+
const ESC = "\x1b"
|
|
21
|
+
const BEL = "\x07"
|
|
22
|
+
|
|
23
|
+
export type ClipboardMechanism =
|
|
24
|
+
| { kind: "osc52"; label: string }
|
|
25
|
+
| { kind: "command"; label: string; argv: string[] }
|
|
26
|
+
| { kind: "none"; label: string }
|
|
27
|
+
|
|
28
|
+
function isSshSession(env: NodeJS.ProcessEnv): boolean {
|
|
29
|
+
return Boolean(env.SSH_CONNECTION || env.SSH_TTY)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Pick the clipboard mechanism for the current environment. */
|
|
33
|
+
export function detectClipboard(
|
|
34
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
35
|
+
which: Which = (b) => Bun.which(b),
|
|
36
|
+
hasTty: boolean = Boolean(process.stdout.isTTY || process.stderr.isTTY),
|
|
37
|
+
): ClipboardMechanism {
|
|
38
|
+
// OSC52 writes an escape sequence to the terminal — without an interactive
|
|
39
|
+
// TTY (cron, CI, `ssh host cmd`) there is nowhere to write. In that case
|
|
40
|
+
// fall through: X11 forwarding / a local display may still provide a
|
|
41
|
+
// clipboard tool that works.
|
|
42
|
+
const ssh = isSshSession(env)
|
|
43
|
+
if (ssh && hasTty) {
|
|
44
|
+
return { kind: "osc52", label: "OSC52 (SSH session)" }
|
|
45
|
+
}
|
|
46
|
+
if (env.WAYLAND_DISPLAY && which("wl-copy")) {
|
|
47
|
+
return { kind: "command", label: "wl-copy (wayland)", argv: ["wl-copy"] }
|
|
48
|
+
}
|
|
49
|
+
if (which("pbcopy")) {
|
|
50
|
+
return { kind: "command", label: "pbcopy (macOS)", argv: ["pbcopy"] }
|
|
51
|
+
}
|
|
52
|
+
if (env.DISPLAY && which("xclip")) {
|
|
53
|
+
return {
|
|
54
|
+
kind: "command",
|
|
55
|
+
label: "xclip (x11)",
|
|
56
|
+
argv: ["xclip", "-selection", "clipboard"],
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (ssh) {
|
|
60
|
+
return {
|
|
61
|
+
kind: "none",
|
|
62
|
+
label: "SSH session without an interactive terminal (OSC52 unavailable)",
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// A display-bound tool on PATH without a display would fail at copy time
|
|
66
|
+
// ("Can't open display") — report it as unavailable, not as ok.
|
|
67
|
+
if (which("wl-copy") || which("xclip")) {
|
|
68
|
+
return {
|
|
69
|
+
kind: "none",
|
|
70
|
+
label: "clipboard tool found, but no display (DISPLAY/WAYLAND_DISPLAY unset)",
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { kind: "none", label: "no clipboard tool found" }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build an OSC52 clipboard escape sequence for `text`. When `tmux` is true the
|
|
78
|
+
* sequence is wrapped in a tmux DCS passthrough so it reaches the outer terminal.
|
|
79
|
+
* Format: ESC ] 52 ; c ; <base64> BEL
|
|
80
|
+
*/
|
|
81
|
+
export function buildOsc52(text: string, opts: { tmux?: boolean } = {}): string {
|
|
82
|
+
const b64 = Buffer.from(text, "utf8").toString("base64")
|
|
83
|
+
const seq = `${ESC}]52;c;${b64}${BEL}`
|
|
84
|
+
if (opts.tmux) {
|
|
85
|
+
// tmux passthrough: ESC P tmux; <payload with ESC doubled> ESC \
|
|
86
|
+
const inner = seq.replaceAll(ESC, ESC + ESC)
|
|
87
|
+
return `${ESC}Ptmux;${inner}${ESC}\\`
|
|
88
|
+
}
|
|
89
|
+
return seq
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Write an OSC52 sequence to the controlling terminal. Prefers /dev/tty; falls
|
|
94
|
+
* back to stderr only when it's a TTY. Returns false when no real terminal is
|
|
95
|
+
* reachable (a pipe / cron) so the caller can advise --stdout instead of
|
|
96
|
+
* littering escape bytes into the output.
|
|
97
|
+
*/
|
|
98
|
+
function writeToTerminal(seq: string): boolean {
|
|
99
|
+
try {
|
|
100
|
+
const fd = openSync("/dev/tty", "w")
|
|
101
|
+
try {
|
|
102
|
+
writeSync(fd, seq)
|
|
103
|
+
} finally {
|
|
104
|
+
closeSync(fd)
|
|
105
|
+
}
|
|
106
|
+
return true
|
|
107
|
+
} catch {
|
|
108
|
+
if (process.stderr.isTTY) {
|
|
109
|
+
process.stderr.write(seq)
|
|
110
|
+
return true
|
|
111
|
+
}
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class ClipboardSink implements Sink {
|
|
117
|
+
constructor(
|
|
118
|
+
private readonly env: NodeJS.ProcessEnv = process.env,
|
|
119
|
+
private readonly which: Which = (b) => Bun.which(b),
|
|
120
|
+
) {}
|
|
121
|
+
|
|
122
|
+
async emit(text: string): Promise<void> {
|
|
123
|
+
const mech = detectClipboard(this.env, this.which)
|
|
124
|
+
if (mech.kind === "osc52") {
|
|
125
|
+
const ok = writeToTerminal(buildOsc52(text, { tmux: Boolean(this.env.TMUX) }))
|
|
126
|
+
if (!ok) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
"Clipboard over SSH (OSC52) needs an interactive terminal. " +
|
|
129
|
+
"Use --stdout to pipe the result (e.g. `dictum --stdout | claude`).",
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
if (mech.kind === "command") {
|
|
135
|
+
const proc = Bun.spawn(mech.argv, { stdin: "pipe", stdout: "ignore", stderr: "pipe" })
|
|
136
|
+
proc.stdin.write(text)
|
|
137
|
+
await proc.stdin.end()
|
|
138
|
+
const code = await proc.exited
|
|
139
|
+
if (code !== 0) {
|
|
140
|
+
const err = (await new Response(proc.stderr).text()).trim()
|
|
141
|
+
throw new Error(
|
|
142
|
+
`clipboard command '${mech.argv[0]}' failed (exit ${code})${err ? `: ${err}` : ""}`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
// Lead with the detector's diagnosis — "OSC52 needs an interactive
|
|
148
|
+
// terminal" must not be answered with "OSC52 is automatic".
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Clipboard unavailable: ${mech.label}. Use --stdout, or install xclip / wl-clipboard.`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sink/factory.ts — build a Sink from a target name.
|
|
3
|
+
*
|
|
4
|
+
* Validates the name (human-readable error on a bad config/env value) and
|
|
5
|
+
* constructs the matching sink. Imports only core/types.ts and sibling sink
|
|
6
|
+
* files (own module).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Sink } from "../core/types.ts"
|
|
10
|
+
import { ClipboardSink } from "./clipboard.ts"
|
|
11
|
+
import { StdoutSink } from "./stdout.ts"
|
|
12
|
+
|
|
13
|
+
export const VALID_SINKS = ["clipboard", "stdout"] as const
|
|
14
|
+
export type SinkName = (typeof VALID_SINKS)[number]
|
|
15
|
+
|
|
16
|
+
function isValidName(name: string): name is SinkName {
|
|
17
|
+
return (VALID_SINKS as readonly string[]).includes(name)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Construct the named Sink; throws on an unknown target. */
|
|
21
|
+
export function createSink(name: string): Sink {
|
|
22
|
+
if (!isValidName(name)) {
|
|
23
|
+
throw new Error(`Unknown sink '${name}'. Valid: ${VALID_SINKS.join(", ")}`)
|
|
24
|
+
}
|
|
25
|
+
switch (name) {
|
|
26
|
+
case "clipboard":
|
|
27
|
+
return new ClipboardSink()
|
|
28
|
+
case "stdout":
|
|
29
|
+
return new StdoutSink()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sink/stdout.ts — a Sink that writes the final text to stdout.
|
|
3
|
+
*
|
|
4
|
+
* Suited for shell pipes: `dictum --stdout | claude`. A trailing newline is
|
|
5
|
+
* added unless the text already ends with one.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Sink } from "../core/types.ts"
|
|
9
|
+
|
|
10
|
+
export class StdoutSink implements Sink {
|
|
11
|
+
async emit(text: string): Promise<void> {
|
|
12
|
+
const out = text.endsWith("\n") ? text : `${text}\n`
|
|
13
|
+
await new Promise<void>((resolve, reject) => {
|
|
14
|
+
process.stdout.write(out, (err) => (err ? reject(err) : resolve()))
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stt/factory.ts — build an STTProvider from configuration.
|
|
3
|
+
*
|
|
4
|
+
* Providers are tried in the configured order. transcribe() health-checks each
|
|
5
|
+
* candidate and falls back to the next when one is unhealthy or errors, so a
|
|
6
|
+
* dead primary (e.g. local server down) transparently fails over to a cloud
|
|
7
|
+
* backend. Imports only core/types.ts and sibling stt files (own module).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AudioData, STTProvider } from "../core/types.ts"
|
|
11
|
+
import { LocalHttpStt } from "./local_http.ts"
|
|
12
|
+
import { OpenAiCompatStt } from "./openai_compat.ts"
|
|
13
|
+
|
|
14
|
+
export const VALID_STT_PROVIDERS = ["local_http", "openai_compat"] as const
|
|
15
|
+
export type SttProviderName = (typeof VALID_STT_PROVIDERS)[number]
|
|
16
|
+
|
|
17
|
+
/** Structural config the factory needs (mirrors Config["stt"], no import). */
|
|
18
|
+
export type SttFactoryConfig = {
|
|
19
|
+
providers: string[]
|
|
20
|
+
local_http: { baseUrl: string }
|
|
21
|
+
openai_compat: { baseUrl: string; apiKey: string; model: string; language: string }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type NamedProvider = { name: SttProviderName; provider: STTProvider }
|
|
25
|
+
|
|
26
|
+
function isValidName(name: string): name is SttProviderName {
|
|
27
|
+
return (VALID_STT_PROVIDERS as readonly string[]).includes(name)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function instantiate(name: SttProviderName, cfg: SttFactoryConfig): STTProvider {
|
|
31
|
+
switch (name) {
|
|
32
|
+
case "local_http":
|
|
33
|
+
return new LocalHttpStt({ baseUrl: cfg.local_http.baseUrl })
|
|
34
|
+
case "openai_compat":
|
|
35
|
+
return new OpenAiCompatStt({
|
|
36
|
+
baseUrl: cfg.openai_compat.baseUrl,
|
|
37
|
+
apiKey: cfg.openai_compat.apiKey,
|
|
38
|
+
model: cfg.openai_compat.model,
|
|
39
|
+
language: cfg.openai_compat.language,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Build the ordered list of named providers, validating each name. */
|
|
45
|
+
export function buildSttProviders(cfg: SttFactoryConfig): NamedProvider[] {
|
|
46
|
+
if (cfg.providers.length === 0) {
|
|
47
|
+
throw new Error("No STT providers configured ([stt].providers is empty)")
|
|
48
|
+
}
|
|
49
|
+
return cfg.providers.map((name) => {
|
|
50
|
+
if (!isValidName(name)) {
|
|
51
|
+
throw new Error(`Unknown STT provider '${name}'. Valid: ${VALID_STT_PROVIDERS.join(", ")}`)
|
|
52
|
+
}
|
|
53
|
+
return { name, provider: instantiate(name, cfg) }
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** An STTProvider that fails over across an ordered list of backends. */
|
|
58
|
+
export class FallbackStt implements STTProvider {
|
|
59
|
+
constructor(private readonly providers: NamedProvider[]) {}
|
|
60
|
+
|
|
61
|
+
async health(): Promise<boolean> {
|
|
62
|
+
for (const p of this.providers) {
|
|
63
|
+
if (await p.provider.health()) return true
|
|
64
|
+
}
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async transcribe(audio: AudioData): Promise<string> {
|
|
69
|
+
const errors: string[] = []
|
|
70
|
+
for (const p of this.providers) {
|
|
71
|
+
const healthy = await p.provider.health()
|
|
72
|
+
if (!healthy) {
|
|
73
|
+
errors.push(`${p.name}: unhealthy`)
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
return await p.provider.transcribe(audio)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
errors.push(`${p.name}: ${err instanceof Error ? err.message : String(err)}`)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`All STT providers failed:\n - ${errors.join("\n - ")}`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Construct the composite STTProvider from config. */
|
|
87
|
+
export function createSttProvider(cfg: SttFactoryConfig): STTProvider {
|
|
88
|
+
return new FallbackStt(buildSttProviders(cfg))
|
|
89
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stt/local_http.ts — STTProvider for a local GigaAM-compatible HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Contract:
|
|
5
|
+
* GET {base}/health -> { loaded: boolean, ... }
|
|
6
|
+
* POST {base}/transcribe body { path: string } -> { text: string, duration?: number }
|
|
7
|
+
*
|
|
8
|
+
* The server reads audio by filesystem path, so we materialize the WAV to a
|
|
9
|
+
* temp file on this host before posting. Same-host only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
13
|
+
import { tmpdir } from "node:os"
|
|
14
|
+
import { join } from "node:path"
|
|
15
|
+
import type { AudioData, STTProvider } from "../core/types.ts"
|
|
16
|
+
|
|
17
|
+
export type LocalHttpOptions = {
|
|
18
|
+
baseUrl: string
|
|
19
|
+
/** Per-request timeout in milliseconds. */
|
|
20
|
+
timeoutMs?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class LocalHttpStt implements STTProvider {
|
|
24
|
+
private readonly baseUrl: string
|
|
25
|
+
private readonly timeoutMs: number
|
|
26
|
+
|
|
27
|
+
constructor(opts: LocalHttpOptions) {
|
|
28
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "")
|
|
29
|
+
this.timeoutMs = opts.timeoutMs ?? 60000
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async health(): Promise<boolean> {
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(`${this.baseUrl}/health`, {
|
|
35
|
+
signal: AbortSignal.timeout(Math.min(this.timeoutMs, 5000)),
|
|
36
|
+
})
|
|
37
|
+
if (!res.ok) return false
|
|
38
|
+
const body = (await res.json()) as { loaded?: boolean }
|
|
39
|
+
return body.loaded === true
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async transcribe(audio: AudioData): Promise<string> {
|
|
46
|
+
const dir = await mkdtemp(join(tmpdir(), "dictum-stt-"))
|
|
47
|
+
const wavPath = join(dir, "audio.wav")
|
|
48
|
+
try {
|
|
49
|
+
await writeFile(wavPath, audio.wav)
|
|
50
|
+
let res: Response
|
|
51
|
+
try {
|
|
52
|
+
res = await fetch(`${this.baseUrl}/transcribe`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { "content-type": "application/json" },
|
|
55
|
+
body: JSON.stringify({ path: wavPath }),
|
|
56
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
57
|
+
})
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
60
|
+
throw new Error(`local STT request to ${this.baseUrl} failed: ${reason}`)
|
|
61
|
+
}
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const detail = (await res.text()).trim()
|
|
64
|
+
throw new Error(`local STT returned ${res.status}${detail ? `: ${detail}` : ""}`)
|
|
65
|
+
}
|
|
66
|
+
const body = (await res.json()) as { text?: unknown }
|
|
67
|
+
if (typeof body.text !== "string") {
|
|
68
|
+
throw new Error("local STT response missing 'text' field")
|
|
69
|
+
}
|
|
70
|
+
return body.text.trim()
|
|
71
|
+
} finally {
|
|
72
|
+
await rm(dir, { recursive: true, force: true })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stt/openai_compat.ts — STTProvider for OpenAI-compatible transcription APIs.
|
|
3
|
+
*
|
|
4
|
+
* Works with OpenAI, Groq, and local whisper-servers exposing
|
|
5
|
+
* POST {base}/v1/audio/transcriptions (multipart: file, model, language)
|
|
6
|
+
* GET {base}/v1/models (used as a liveness/auth probe)
|
|
7
|
+
*
|
|
8
|
+
* Sends the WAV in-memory as multipart form data; no temp file needed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AudioData, STTProvider } from "../core/types.ts"
|
|
12
|
+
|
|
13
|
+
export type OpenAiCompatOptions = {
|
|
14
|
+
baseUrl: string
|
|
15
|
+
apiKey: string
|
|
16
|
+
model: string
|
|
17
|
+
/** Optional language hint (ISO code). Empty = let the server auto-detect. */
|
|
18
|
+
language: string
|
|
19
|
+
/** Per-request timeout in milliseconds. */
|
|
20
|
+
timeoutMs?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class OpenAiCompatStt implements STTProvider {
|
|
24
|
+
private readonly baseUrl: string
|
|
25
|
+
private readonly apiKey: string
|
|
26
|
+
private readonly model: string
|
|
27
|
+
private readonly language: string
|
|
28
|
+
private readonly timeoutMs: number
|
|
29
|
+
|
|
30
|
+
constructor(opts: OpenAiCompatOptions) {
|
|
31
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "")
|
|
32
|
+
this.apiKey = opts.apiKey
|
|
33
|
+
this.model = opts.model
|
|
34
|
+
this.language = opts.language
|
|
35
|
+
this.timeoutMs = opts.timeoutMs ?? 60000
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private authHeaders(): Record<string, string> {
|
|
39
|
+
return this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async health(): Promise<boolean> {
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(`${this.baseUrl}/v1/models`, {
|
|
45
|
+
headers: this.authHeaders(),
|
|
46
|
+
signal: AbortSignal.timeout(Math.min(this.timeoutMs, 5000)),
|
|
47
|
+
})
|
|
48
|
+
return res.ok
|
|
49
|
+
} catch {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async transcribe(audio: AudioData): Promise<string> {
|
|
55
|
+
const form = new FormData()
|
|
56
|
+
form.append("file", new Blob([audio.wav], { type: "audio/wav" }), "audio.wav")
|
|
57
|
+
form.append("model", this.model)
|
|
58
|
+
form.append("response_format", "json")
|
|
59
|
+
if (this.language) form.append("language", this.language)
|
|
60
|
+
|
|
61
|
+
let res: Response
|
|
62
|
+
try {
|
|
63
|
+
res = await fetch(`${this.baseUrl}/v1/audio/transcriptions`, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: this.authHeaders(),
|
|
66
|
+
body: form,
|
|
67
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
68
|
+
})
|
|
69
|
+
} catch (err) {
|
|
70
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
71
|
+
throw new Error(`OpenAI-compatible STT request to ${this.baseUrl} failed: ${reason}`)
|
|
72
|
+
}
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const detail = (await res.text()).trim()
|
|
75
|
+
throw new Error(`OpenAI-compatible STT returned ${res.status}${detail ? `: ${detail}` : ""}`)
|
|
76
|
+
}
|
|
77
|
+
const body = (await res.json()) as { text?: unknown }
|
|
78
|
+
if (typeof body.text !== "string") {
|
|
79
|
+
throw new Error("OpenAI-compatible STT response missing 'text' field")
|
|
80
|
+
}
|
|
81
|
+
return body.text.trim()
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/ui/choose.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ui/choose.ts — interactive selection between the user's original words and the
|
|
3
|
+
* polished candidate.
|
|
4
|
+
*
|
|
5
|
+
* Dictum proposes a polished prompt rather than blindly replacing the input: on
|
|
6
|
+
* an interactive terminal it shows both and asks which to emit, with the option
|
|
7
|
+
* to regenerate a fresh polish. Pipes (`dictum | claude`, `echo … | dictum`) and
|
|
8
|
+
* `--auto` skip this entirely and emit the polished text directly — cli.ts
|
|
9
|
+
* decides and only wires a chooser in when appropriate.
|
|
10
|
+
*
|
|
11
|
+
* Imported only by cli.ts (assembly layer). Depends only on core/types.ts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createInterface } from "node:readline/promises"
|
|
15
|
+
import type { ChoiceContext, Chooser } from "../core/types.ts"
|
|
16
|
+
|
|
17
|
+
export type ChoiceAction = "original" | "polished" | "regenerate" | "unknown"
|
|
18
|
+
|
|
19
|
+
/** Question shown when asking for a choice. */
|
|
20
|
+
export const CHOICE_PROMPT = "Keep [p]olished (default), [o]riginal, or [r]egenerate? "
|
|
21
|
+
|
|
22
|
+
/** Hint repeated after an unrecognized key. */
|
|
23
|
+
export const CHOICE_HINT = "Please answer o / p / r (or Enter for polished)."
|
|
24
|
+
|
|
25
|
+
/** Map a raw keypress / line to a choice action. Pure, for testing. */
|
|
26
|
+
export function parseChoiceKey(input: string): ChoiceAction {
|
|
27
|
+
const k = input.trim().toLowerCase()
|
|
28
|
+
if (k === "" || k === "p" || k === "polished") return "polished"
|
|
29
|
+
if (k === "o" || k === "original") return "original"
|
|
30
|
+
if (k === "r" || k === "regenerate") return "regenerate"
|
|
31
|
+
return "unknown"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Render the side-by-side preview block (original + polished). Pure, for testing. */
|
|
35
|
+
export function formatPreview(original: string, polished: string, note?: string): string {
|
|
36
|
+
const lines = ["", "── your words ──", original, "", "── polished ──", polished]
|
|
37
|
+
if (note) lines.push("", note)
|
|
38
|
+
lines.push("")
|
|
39
|
+
return lines.join("\n")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Injectable I/O so the interactive loop can be unit-tested without a real TTY. */
|
|
43
|
+
export type ChooserIo = {
|
|
44
|
+
/** Display a line/block (goes to stderr in production, keeping stdout clean). */
|
|
45
|
+
write: (s: string) => void
|
|
46
|
+
/** Prompt and read one line; resolves to null on EOF (Ctrl-D). */
|
|
47
|
+
readLine: (prompt: string) => Promise<string | null>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Drive the choose loop over an injectable I/O. Returns the text to emit.
|
|
52
|
+
* EOF (stdin closed) accepts the current polished default.
|
|
53
|
+
*/
|
|
54
|
+
export async function interactiveChoose(ctx: ChoiceContext, io: ChooserIo): Promise<string> {
|
|
55
|
+
let polished = ctx.polished
|
|
56
|
+
io.write(formatPreview(ctx.original, polished, ctx.annotate?.(polished)))
|
|
57
|
+
|
|
58
|
+
while (true) {
|
|
59
|
+
const line = await io.readLine(CHOICE_PROMPT)
|
|
60
|
+
if (line === null) return polished // EOF → accept the polished default
|
|
61
|
+
const action = parseChoiceKey(line)
|
|
62
|
+
if (action === "original") return ctx.original
|
|
63
|
+
if (action === "polished") return polished
|
|
64
|
+
if (action === "regenerate") {
|
|
65
|
+
io.write("\nRegenerating…\n")
|
|
66
|
+
polished = await ctx.regenerate()
|
|
67
|
+
io.write(formatPreview(ctx.original, polished, ctx.annotate?.(polished)))
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
io.write(`${CHOICE_HINT}\n`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build a Chooser bound to the real terminal: reads keys from stdin, renders the
|
|
76
|
+
* prompt on stderr so stdout stays clean for the result.
|
|
77
|
+
*/
|
|
78
|
+
export function terminalChooser(): Chooser {
|
|
79
|
+
return (ctx) => {
|
|
80
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
81
|
+
const io: ChooserIo = {
|
|
82
|
+
write: (s) => {
|
|
83
|
+
process.stderr.write(s)
|
|
84
|
+
},
|
|
85
|
+
readLine: async (prompt) => {
|
|
86
|
+
try {
|
|
87
|
+
return await rl.question(prompt)
|
|
88
|
+
} catch {
|
|
89
|
+
return null // stream closed / aborted
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
return interactiveChoose(ctx, io).finally(() => rl.close())
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/ui/keys.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ui/keys.ts — produce a "stop recording" AbortSignal from the keyboard.
|
|
3
|
+
*
|
|
4
|
+
* Modes:
|
|
5
|
+
* - enter : press Enter to stop (reliable everywhere; the default).
|
|
6
|
+
* - vad : recorder auto-stops on silence; Enter still stops early.
|
|
7
|
+
* - ptt : hold Space to talk, release to stop (raw stdin; release is
|
|
8
|
+
* inferred from the auto-repeat gap — terminal-dependent, so it's
|
|
9
|
+
* best-effort and verified manually).
|
|
10
|
+
*
|
|
11
|
+
* Ctrl-C always stops. This module is imported only by cli.ts (assembly layer);
|
|
12
|
+
* the recorder never depends on it — it just receives the AbortSignal.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type StopMode = "enter" | "vad" | "ptt"
|
|
16
|
+
|
|
17
|
+
export type StopController = {
|
|
18
|
+
signal: AbortSignal
|
|
19
|
+
dispose: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Release is inferred when no Space byte arrives within this window (ms). */
|
|
23
|
+
const PTT_RELEASE_MS = 400
|
|
24
|
+
|
|
25
|
+
/** The instruction line shown while recording. Pure, for display + tests. */
|
|
26
|
+
export function stopHint(mode: StopMode): string {
|
|
27
|
+
switch (mode) {
|
|
28
|
+
case "enter":
|
|
29
|
+
return "press Enter to stop"
|
|
30
|
+
case "vad":
|
|
31
|
+
return "speak — auto-stops on silence (Enter to stop now)"
|
|
32
|
+
case "ptt":
|
|
33
|
+
return "hold Space to talk, release to stop"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type StopControllerOptions = {
|
|
38
|
+
/** Whole-operation cancel (SIGINT); aborting it also stops recording. */
|
|
39
|
+
parent?: AbortSignal
|
|
40
|
+
/** Where to print the instruction hint (default stderr, keeps stdout clean). */
|
|
41
|
+
hintStream?: { write(s: string): unknown }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Wire stdin to an AbortController for the given stop mode. Returns the signal
|
|
46
|
+
* plus a dispose() that detaches listeners and restores the terminal. Safe to
|
|
47
|
+
* call in a non-interactive environment (it simply won't receive key events).
|
|
48
|
+
*/
|
|
49
|
+
export function createStopController(
|
|
50
|
+
mode: StopMode,
|
|
51
|
+
opts: StopControllerOptions = {},
|
|
52
|
+
): StopController {
|
|
53
|
+
const controller = new AbortController()
|
|
54
|
+
const stdin = process.stdin
|
|
55
|
+
const hint = opts.hintStream ?? process.stderr
|
|
56
|
+
let releaseTimer: ReturnType<typeof setTimeout> | undefined
|
|
57
|
+
|
|
58
|
+
const abort = () => {
|
|
59
|
+
if (!controller.signal.aborted) controller.abort()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (opts.parent) {
|
|
63
|
+
if (opts.parent.aborted) abort()
|
|
64
|
+
else opts.parent.addEventListener("abort", abort, { once: true })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const onData = (chunk: Buffer) => {
|
|
68
|
+
if (chunk.includes(0x03)) {
|
|
69
|
+
abort() // Ctrl-C
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
if (mode === "ptt") {
|
|
73
|
+
if (chunk.includes(0x20)) {
|
|
74
|
+
if (releaseTimer) clearTimeout(releaseTimer)
|
|
75
|
+
releaseTimer = setTimeout(abort, PTT_RELEASE_MS)
|
|
76
|
+
}
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
// enter / vad: stop on Enter (LF or CR)
|
|
80
|
+
if (chunk.includes(0x0a) || chunk.includes(0x0d)) abort()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let attached = false
|
|
84
|
+
try {
|
|
85
|
+
if (stdin.isTTY && mode === "ptt") stdin.setRawMode?.(true)
|
|
86
|
+
stdin.resume()
|
|
87
|
+
stdin.on("data", onData)
|
|
88
|
+
attached = true
|
|
89
|
+
} catch {
|
|
90
|
+
// stdin unavailable (non-interactive) — recorder still stops via VAD/cap.
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const dispose = () => {
|
|
94
|
+
if (releaseTimer) clearTimeout(releaseTimer)
|
|
95
|
+
if (attached) {
|
|
96
|
+
stdin.off("data", onData)
|
|
97
|
+
try {
|
|
98
|
+
if (stdin.isTTY) stdin.setRawMode?.(false)
|
|
99
|
+
} catch {
|
|
100
|
+
// ignore
|
|
101
|
+
}
|
|
102
|
+
stdin.pause()
|
|
103
|
+
}
|
|
104
|
+
if (opts.parent) opts.parent.removeEventListener("abort", abort)
|
|
105
|
+
}
|
|
106
|
+
controller.signal.addEventListener("abort", dispose, { once: true })
|
|
107
|
+
|
|
108
|
+
hint.write(`${stopHint(mode)}\n`)
|
|
109
|
+
return { signal: controller.signal, dispose }
|
|
110
|
+
}
|