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
package/src/cli.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* cli.ts — entry point. Parses argv with util.parseArgs and dispatches to a
|
|
4
|
+
* command. Pipeline assembly lives here and in core/pipeline.ts; nothing else
|
|
5
|
+
* imports sibling modules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { parseArgs } from "node:util"
|
|
9
|
+
import pkg from "../package.json" with { type: "json" }
|
|
10
|
+
import { loadConfig, templatesDir } from "./config.ts"
|
|
11
|
+
import { runPipeline } from "./core/pipeline.ts"
|
|
12
|
+
import type { ChoiceContext, Recorder, Sink } from "./core/types.ts"
|
|
13
|
+
import { doctorExitCode, formatDoctorTable, runDoctorChecks } from "./doctor.ts"
|
|
14
|
+
import { startMcpServer } from "./mcp/server.ts"
|
|
15
|
+
import { createPolisher } from "./polisher/factory.ts"
|
|
16
|
+
import { type PromptDimension, analyzePrompt } from "./polisher/rules.ts"
|
|
17
|
+
import { resolveTemplate } from "./polisher/templates.ts"
|
|
18
|
+
import { FileRecorder } from "./recorder/file.ts"
|
|
19
|
+
import { SoxRecorder } from "./recorder/sox.ts"
|
|
20
|
+
import { createSink } from "./sink/factory.ts"
|
|
21
|
+
import { createSttProvider } from "./stt/factory.ts"
|
|
22
|
+
import { terminalChooser } from "./ui/choose.ts"
|
|
23
|
+
import { type StopController, createStopController } from "./ui/keys.ts"
|
|
24
|
+
import { StatusReporter } from "./ui/status.ts"
|
|
25
|
+
|
|
26
|
+
const VERSION: string = pkg.version
|
|
27
|
+
|
|
28
|
+
export type CliCommand = "run" | "doctor" | "mcp" | "help" | "version"
|
|
29
|
+
|
|
30
|
+
export type OutputFormat = "text" | "json"
|
|
31
|
+
|
|
32
|
+
export type CliOptions = {
|
|
33
|
+
command: CliCommand
|
|
34
|
+
/** Read audio from this WAV file instead of recording. */
|
|
35
|
+
input: string | undefined
|
|
36
|
+
/** Polish this text directly instead of recording (text input). */
|
|
37
|
+
text: string | undefined
|
|
38
|
+
/** Force stdout sink. */
|
|
39
|
+
stdout: boolean
|
|
40
|
+
/** Skip polishing, emit the raw transcript. */
|
|
41
|
+
raw: boolean
|
|
42
|
+
/** Skip the interactive choice; emit the polished text directly. */
|
|
43
|
+
auto: boolean
|
|
44
|
+
/** Template / mode name (-m / --mode / --template). */
|
|
45
|
+
mode: string | undefined
|
|
46
|
+
/** Output format: plain polished text (default) or a JSON envelope. */
|
|
47
|
+
format: OutputFormat
|
|
48
|
+
/** Unknown / error message produced while parsing, if any. */
|
|
49
|
+
error: string | undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const HELP = `dictum — dictate (or type) a thought, get a polished prompt.
|
|
53
|
+
|
|
54
|
+
Usage:
|
|
55
|
+
dictum [options] Record (or read --input/--text/stdin), polish, emit
|
|
56
|
+
dictum doctor Check microphone, STT, polisher and clipboard
|
|
57
|
+
dictum mcp Run as an MCP server (host-brain prompts + tools) over stdio
|
|
58
|
+
dictum --help Show this help
|
|
59
|
+
dictum --version Show version
|
|
60
|
+
|
|
61
|
+
Input (pick one; default is the microphone):
|
|
62
|
+
-i, --input <file> Transcribe a WAV file instead of recording
|
|
63
|
+
-t, --text <text> Polish this text instead of recording
|
|
64
|
+
(piped stdin is read as text too: echo … | dictum)
|
|
65
|
+
|
|
66
|
+
Options:
|
|
67
|
+
-m, --mode <name> Polishing template: agent-prompt | commit | note | spec | decompose
|
|
68
|
+
--template <name> Alias for --mode
|
|
69
|
+
--raw Skip polishing; output the raw transcript
|
|
70
|
+
--auto Skip the choice prompt; emit the polished text
|
|
71
|
+
--stdout Print result to stdout instead of the clipboard
|
|
72
|
+
--format <text|json> Output format. json emits a stable envelope:
|
|
73
|
+
{v, original, polished, template, score, rationale}
|
|
74
|
+
-h, --help Show help
|
|
75
|
+
-v, --version Show version
|
|
76
|
+
|
|
77
|
+
On an interactive terminal Dictum shows the polished result and lets you keep it,
|
|
78
|
+
your original, or regenerate. In a pipe (or with --auto) it emits automatically.
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
dictum Push-to-talk, choose, polished prompt to clipboard
|
|
82
|
+
dictum --text "fix the bug in auth" -m agent-prompt
|
|
83
|
+
echo "draft commit message" | dictum -m commit --stdout
|
|
84
|
+
dictum --input note.wav --stdout
|
|
85
|
+
dictum -m commit --auto --stdout | git commit -F -
|
|
86
|
+
dictum --text "draft" --auto --stdout --format json | jq .polished
|
|
87
|
+
`
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Pure argument parser — no side effects, safe to unit-test. Returns the
|
|
91
|
+
* resolved command and options, or an `error` string for invalid input.
|
|
92
|
+
*/
|
|
93
|
+
export function parseCliArgs(argv: string[]): CliOptions {
|
|
94
|
+
const base: CliOptions = {
|
|
95
|
+
command: "run",
|
|
96
|
+
input: undefined,
|
|
97
|
+
text: undefined,
|
|
98
|
+
stdout: false,
|
|
99
|
+
raw: false,
|
|
100
|
+
auto: false,
|
|
101
|
+
mode: undefined,
|
|
102
|
+
format: "text",
|
|
103
|
+
error: undefined,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let parsed: ReturnType<typeof parseArgs>
|
|
107
|
+
try {
|
|
108
|
+
parsed = parseArgs({
|
|
109
|
+
args: argv,
|
|
110
|
+
allowPositionals: true,
|
|
111
|
+
strict: true,
|
|
112
|
+
options: {
|
|
113
|
+
version: { type: "boolean", short: "v" },
|
|
114
|
+
help: { type: "boolean", short: "h" },
|
|
115
|
+
input: { type: "string", short: "i" },
|
|
116
|
+
text: { type: "string", short: "t" },
|
|
117
|
+
mode: { type: "string", short: "m" },
|
|
118
|
+
template: { type: "string" },
|
|
119
|
+
raw: { type: "boolean" },
|
|
120
|
+
auto: { type: "boolean" },
|
|
121
|
+
stdout: { type: "boolean" },
|
|
122
|
+
format: { type: "string" },
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
} catch (err) {
|
|
126
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
127
|
+
return { ...base, error: reason }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const { values, positionals } = parsed
|
|
131
|
+
|
|
132
|
+
if (values.version === true) return { ...base, command: "version" }
|
|
133
|
+
if (values.help === true) return { ...base, command: "help" }
|
|
134
|
+
|
|
135
|
+
let command: CliCommand = "run"
|
|
136
|
+
if (positionals.length > 0) {
|
|
137
|
+
const cmd = positionals[0]
|
|
138
|
+
if (cmd === "doctor") {
|
|
139
|
+
command = "doctor"
|
|
140
|
+
} else if (cmd === "mcp") {
|
|
141
|
+
command = "mcp"
|
|
142
|
+
} else if (cmd === "run") {
|
|
143
|
+
command = "run"
|
|
144
|
+
} else {
|
|
145
|
+
return { ...base, error: `Unknown command: ${cmd}` }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined)
|
|
150
|
+
|
|
151
|
+
const input = str(values.input)
|
|
152
|
+
const text = str(values.text)
|
|
153
|
+
if (input !== undefined && text !== undefined) {
|
|
154
|
+
return { ...base, error: "Use either --input or --text, not both" }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const format = str(values.format) ?? "text"
|
|
158
|
+
if (format !== "text" && format !== "json") {
|
|
159
|
+
return { ...base, error: `Unknown format '${format}'. Valid: text, json` }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
command,
|
|
164
|
+
input,
|
|
165
|
+
text,
|
|
166
|
+
stdout: values.stdout === true,
|
|
167
|
+
raw: values.raw === true,
|
|
168
|
+
auto: values.auto === true,
|
|
169
|
+
mode: str(values.mode) ?? str(values.template),
|
|
170
|
+
format,
|
|
171
|
+
error: undefined,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Stable machine-readable result envelope (`--format json`, v1). Consumed by
|
|
177
|
+
* scripts and integrations (e.g. Hail's shell-out). Scores come from the
|
|
178
|
+
* deterministic analyzer: `score.before`/`dimensions`/`rationale` describe the
|
|
179
|
+
* original draft (why polishing was needed), `score.after` the emitted text.
|
|
180
|
+
*/
|
|
181
|
+
export type JsonEnvelope = {
|
|
182
|
+
v: 1
|
|
183
|
+
original: string
|
|
184
|
+
polished: string
|
|
185
|
+
template: string
|
|
186
|
+
score: { before: number; after: number; dimensions: Record<PromptDimension, number> }
|
|
187
|
+
rationale: string[]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Build the --format json envelope from a pipeline result. Pure, for testing. */
|
|
191
|
+
export function buildJsonEnvelope(
|
|
192
|
+
original: string,
|
|
193
|
+
polished: string,
|
|
194
|
+
templateName: string,
|
|
195
|
+
): JsonEnvelope {
|
|
196
|
+
const before = analyzePrompt(original)
|
|
197
|
+
const after = analyzePrompt(polished)
|
|
198
|
+
return {
|
|
199
|
+
v: 1,
|
|
200
|
+
original,
|
|
201
|
+
polished,
|
|
202
|
+
template: templateName,
|
|
203
|
+
score: { before: before.score, after: after.score, dimensions: before.dimensions },
|
|
204
|
+
rationale: before.issues,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* One-line score annotation for the interactive chooser: total delta plus the
|
|
210
|
+
* two most-improved dimensions, e.g. "score 34 → 78 · structure +5, clarity +4".
|
|
211
|
+
*/
|
|
212
|
+
export function scoreLine(original: string, polished: string): string {
|
|
213
|
+
const before = analyzePrompt(original)
|
|
214
|
+
const after = analyzePrompt(polished)
|
|
215
|
+
const gains = (Object.keys(after.dimensions) as PromptDimension[])
|
|
216
|
+
.map((d) => ({ d, delta: after.dimensions[d] - before.dimensions[d] }))
|
|
217
|
+
.filter((g) => g.delta > 0)
|
|
218
|
+
.sort((a, b) => b.delta - a.delta)
|
|
219
|
+
.slice(0, 2)
|
|
220
|
+
.map((g) => `${g.d} +${g.delta}`)
|
|
221
|
+
const tail = gains.length > 0 ? ` · ${gains.join(", ")}` : ""
|
|
222
|
+
return `score ${before.score} → ${after.score}${tail}`
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Run environment diagnostics and print a report. */
|
|
226
|
+
async function runDoctor(): Promise<number> {
|
|
227
|
+
const config = await loadConfig()
|
|
228
|
+
const results = await runDoctorChecks(config)
|
|
229
|
+
process.stdout.write(formatDoctorTable(results))
|
|
230
|
+
return doctorExitCode(results)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Resolve the text input, if any. Explicit --text wins; otherwise piped stdin
|
|
235
|
+
* (non-TTY, and no --input) is read as text. Returns undefined for the mic/file
|
|
236
|
+
* audio path. Reading stdin here is safe because an interactive terminal keeps
|
|
237
|
+
* stdin a TTY (so `dictum | claude` still records from the mic).
|
|
238
|
+
*/
|
|
239
|
+
async function resolveTextInput(opts: CliOptions): Promise<string | undefined> {
|
|
240
|
+
if (opts.text !== undefined) return opts.text
|
|
241
|
+
if (!opts.input && !process.stdin.isTTY) return await Bun.stdin.text()
|
|
242
|
+
return undefined
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Assemble concrete stages from config + CLI options and run the pipeline. */
|
|
246
|
+
async function runCommand(opts: CliOptions): Promise<number> {
|
|
247
|
+
const config = await loadConfig()
|
|
248
|
+
const fail = (err: unknown): void => {
|
|
249
|
+
process.stderr.write(`dictum: ${err instanceof Error ? err.message : String(err)}\n`)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Input source: explicit --text / piped stdin take the text path (no STT).
|
|
253
|
+
const textInput = await resolveTextInput(opts)
|
|
254
|
+
|
|
255
|
+
// Polisher selected by config ([polisher].provider / DICTUM_POLISHER).
|
|
256
|
+
let polisher: ReturnType<typeof createPolisher>
|
|
257
|
+
try {
|
|
258
|
+
polisher = createPolisher(config.polisher)
|
|
259
|
+
} catch (err) {
|
|
260
|
+
fail(err)
|
|
261
|
+
return 2
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Sink: --stdout forces stdout (for pipes); otherwise the configured target.
|
|
265
|
+
const sinkName = opts.stdout ? "stdout" : config.sink.target
|
|
266
|
+
let sink: ReturnType<typeof createSink>
|
|
267
|
+
try {
|
|
268
|
+
sink = createSink(sinkName)
|
|
269
|
+
} catch (err) {
|
|
270
|
+
fail(err)
|
|
271
|
+
return 2
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Template from -m/--mode or config; user overrides in ~/.config/dictum/templates.
|
|
275
|
+
let template: Awaited<ReturnType<typeof resolveTemplate>>
|
|
276
|
+
try {
|
|
277
|
+
template = await resolveTemplate(opts.mode ?? config.polisher.template, templatesDir())
|
|
278
|
+
} catch (err) {
|
|
279
|
+
fail(err)
|
|
280
|
+
return 2
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// STT only on the audio path.
|
|
284
|
+
let stt: ReturnType<typeof createSttProvider> | undefined
|
|
285
|
+
if (textInput === undefined) {
|
|
286
|
+
try {
|
|
287
|
+
stt = createSttProvider(config.stt)
|
|
288
|
+
} catch (err) {
|
|
289
|
+
fail(err)
|
|
290
|
+
return 2
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Interactive choice: only on a fully-interactive terminal, never with --auto
|
|
295
|
+
// or --raw (nothing to choose). Pipes (dictum | claude, echo | dictum) auto-emit.
|
|
296
|
+
// The chooser is annotated with the analyzer's score delta so the user sees
|
|
297
|
+
// *why* the polished candidate is (or is not) an improvement.
|
|
298
|
+
const interactive =
|
|
299
|
+
!opts.auto && Boolean(process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY)
|
|
300
|
+
const chooseBase = interactive && !opts.raw ? terminalChooser() : undefined
|
|
301
|
+
const choose = chooseBase
|
|
302
|
+
? (ctx: ChoiceContext) =>
|
|
303
|
+
chooseBase({ ...ctx, annotate: (polished) => scoreLine(ctx.original, polished) })
|
|
304
|
+
: undefined
|
|
305
|
+
|
|
306
|
+
// Whole-operation cancel (Ctrl-C).
|
|
307
|
+
const cancel = new AbortController()
|
|
308
|
+
const onSigint = () => cancel.abort()
|
|
309
|
+
process.on("SIGINT", onSigint)
|
|
310
|
+
|
|
311
|
+
// Recorder + recording-stop signal (audio path only). --input reads a file
|
|
312
|
+
// (no keys); live capture stops via Enter / silence (VAD) / push-to-talk.
|
|
313
|
+
let recorder: Recorder | undefined
|
|
314
|
+
let stop: StopController | undefined
|
|
315
|
+
if (textInput === undefined) {
|
|
316
|
+
if (opts.input) {
|
|
317
|
+
recorder = new FileRecorder(opts.input)
|
|
318
|
+
} else {
|
|
319
|
+
recorder = new SoxRecorder({
|
|
320
|
+
maxDuration: config.recorder.maxDuration,
|
|
321
|
+
vad: config.recorder.stopMode === "vad",
|
|
322
|
+
vadOptions: {
|
|
323
|
+
energyThreshold: config.recorder.energyThreshold,
|
|
324
|
+
silenceTimeoutSec: config.recorder.silenceTimeout,
|
|
325
|
+
},
|
|
326
|
+
})
|
|
327
|
+
stop = createStopController(config.recorder.stopMode, { parent: cancel.signal })
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Status line (● recording / ◌ transcribing / ✦ polishing / ✓) → stderr.
|
|
332
|
+
const status = new StatusReporter(process.stderr, {
|
|
333
|
+
emitLabel: sinkName === "stdout" ? "emitted" : "copied",
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
// --format json: capture the pipeline's emit, then wrap the result in the
|
|
337
|
+
// stable envelope and send *that* through the real sink (use with --stdout).
|
|
338
|
+
let sinkForPipeline: Sink = sink
|
|
339
|
+
if (opts.format === "json") {
|
|
340
|
+
sinkForPipeline = { emit: async () => {} }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
const result = await runPipeline({
|
|
345
|
+
recorder,
|
|
346
|
+
stt,
|
|
347
|
+
text: textInput,
|
|
348
|
+
polisher,
|
|
349
|
+
sink: sinkForPipeline,
|
|
350
|
+
template,
|
|
351
|
+
raw: opts.raw,
|
|
352
|
+
signal: stop ? stop.signal : cancel.signal,
|
|
353
|
+
onStage: status.onStage,
|
|
354
|
+
choose,
|
|
355
|
+
})
|
|
356
|
+
if (opts.format === "json") {
|
|
357
|
+
const envelope = buildJsonEnvelope(result.transcript, result.output, template.name)
|
|
358
|
+
await sink.emit(JSON.stringify(envelope))
|
|
359
|
+
}
|
|
360
|
+
return 0
|
|
361
|
+
} catch (err) {
|
|
362
|
+
fail(err)
|
|
363
|
+
return 1
|
|
364
|
+
} finally {
|
|
365
|
+
stop?.dispose()
|
|
366
|
+
process.off("SIGINT", onSigint)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function main(argv: string[]): Promise<number> {
|
|
371
|
+
const opts = parseCliArgs(argv)
|
|
372
|
+
|
|
373
|
+
if (opts.error) {
|
|
374
|
+
process.stderr.write(`dictum: ${opts.error}\n\nRun 'dictum --help' for usage.\n`)
|
|
375
|
+
return 2
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
switch (opts.command) {
|
|
379
|
+
case "version":
|
|
380
|
+
process.stdout.write(`${VERSION}\n`)
|
|
381
|
+
return 0
|
|
382
|
+
case "help":
|
|
383
|
+
process.stdout.write(HELP)
|
|
384
|
+
return 0
|
|
385
|
+
case "doctor":
|
|
386
|
+
return runDoctor()
|
|
387
|
+
case "mcp":
|
|
388
|
+
await startMcpServer()
|
|
389
|
+
return 0
|
|
390
|
+
case "run":
|
|
391
|
+
return runCommand(opts)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Only run when executed directly (not when imported by tests).
|
|
396
|
+
if (import.meta.main) {
|
|
397
|
+
const code = await main(Bun.argv.slice(2))
|
|
398
|
+
process.exit(code)
|
|
399
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.ts — load, merge and validate Dictum configuration.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order (lowest → highest priority):
|
|
5
|
+
* 1. built-in defaults (DEFAULT_CONFIG)
|
|
6
|
+
* 2. ~/.config/dictum/config.toml (or $DICTUM_CONFIG)
|
|
7
|
+
* 3. environment variable overrides
|
|
8
|
+
*
|
|
9
|
+
* Depends only on Node/Bun built-ins and `smol-toml`. No project imports.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { homedir } from "node:os"
|
|
13
|
+
import { join } from "node:path"
|
|
14
|
+
import { parse as parseToml } from "smol-toml"
|
|
15
|
+
|
|
16
|
+
export type SttProviderName = "local_http" | "openai_compat"
|
|
17
|
+
export type PolisherName = "claude_cli" | "anthropic" | "openai_compat"
|
|
18
|
+
export type PolisherMode = "llm" | "rules" | "layered"
|
|
19
|
+
export type SinkName = "clipboard" | "stdout"
|
|
20
|
+
// Live capture is sox (`rec`); the file recorder is chosen automatically by
|
|
21
|
+
// --input. There is deliberately no `backend` config field: cli.ts never read
|
|
22
|
+
// it, so it only promised a choice that did not exist. Reintroduce together
|
|
23
|
+
// with a recorder factory that actually honors it.
|
|
24
|
+
export type StopMode = "enter" | "vad" | "ptt"
|
|
25
|
+
|
|
26
|
+
export type Config = {
|
|
27
|
+
recorder: {
|
|
28
|
+
/** How recording stops: Enter key, silence auto-stop (VAD), or push-to-talk. */
|
|
29
|
+
stopMode: StopMode
|
|
30
|
+
/** Stop recording after this many seconds of trailing silence (VAD). */
|
|
31
|
+
silenceTimeout: number
|
|
32
|
+
/** Voicing threshold for VAD as normalized RMS in [0, 1]. */
|
|
33
|
+
energyThreshold: number
|
|
34
|
+
/** Hard safety cap on a single recording, in seconds. */
|
|
35
|
+
maxDuration: number
|
|
36
|
+
}
|
|
37
|
+
stt: {
|
|
38
|
+
/** Provider order; the factory tries each in turn, falling back on failure. */
|
|
39
|
+
providers: SttProviderName[]
|
|
40
|
+
local_http: {
|
|
41
|
+
/** Base URL of the local GigaAM-compatible HTTP server. */
|
|
42
|
+
baseUrl: string
|
|
43
|
+
}
|
|
44
|
+
openai_compat: {
|
|
45
|
+
/** Base URL, e.g. https://api.openai.com or a Groq/whisper endpoint. */
|
|
46
|
+
baseUrl: string
|
|
47
|
+
/** API key (often supplied via env). */
|
|
48
|
+
apiKey: string
|
|
49
|
+
/** Model id, e.g. "whisper-1". */
|
|
50
|
+
model: string
|
|
51
|
+
/** Language hint, e.g. "ru". Empty string lets the server auto-detect. */
|
|
52
|
+
language: string
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
polisher: {
|
|
56
|
+
/** Active polisher. */
|
|
57
|
+
provider: PolisherName
|
|
58
|
+
/**
|
|
59
|
+
* Polishing mode: "llm" (provider only, default), "rules" (offline
|
|
60
|
+
* deterministic cleanup, no LLM), "layered" (rule-based gate — the LLM
|
|
61
|
+
* runs only when the draft scores below scoreThreshold).
|
|
62
|
+
*/
|
|
63
|
+
mode: PolisherMode
|
|
64
|
+
/** Layered mode: skip the LLM when the draft scores ≥ this (0–100). */
|
|
65
|
+
scoreThreshold: number
|
|
66
|
+
/** Default template name when none is given on the CLI. */
|
|
67
|
+
template: string
|
|
68
|
+
claude_cli: {
|
|
69
|
+
/** Path/name of the claude binary. */
|
|
70
|
+
bin: string
|
|
71
|
+
/** Optional model override passed to claude (empty = CLI default). */
|
|
72
|
+
model: string
|
|
73
|
+
/** Timeout in seconds. */
|
|
74
|
+
timeout: number
|
|
75
|
+
}
|
|
76
|
+
anthropic: {
|
|
77
|
+
apiKey: string
|
|
78
|
+
model: string
|
|
79
|
+
baseUrl: string
|
|
80
|
+
}
|
|
81
|
+
openai_compat: {
|
|
82
|
+
apiKey: string
|
|
83
|
+
model: string
|
|
84
|
+
baseUrl: string
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
sink: {
|
|
88
|
+
/** Where the final text goes. */
|
|
89
|
+
target: SinkName
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const DEFAULT_CONFIG: Config = {
|
|
94
|
+
recorder: {
|
|
95
|
+
stopMode: "enter",
|
|
96
|
+
silenceTimeout: 2.0,
|
|
97
|
+
energyThreshold: 0.015,
|
|
98
|
+
maxDuration: 120,
|
|
99
|
+
},
|
|
100
|
+
stt: {
|
|
101
|
+
providers: ["local_http", "openai_compat"],
|
|
102
|
+
local_http: {
|
|
103
|
+
baseUrl: "http://127.0.0.1:5500",
|
|
104
|
+
},
|
|
105
|
+
openai_compat: {
|
|
106
|
+
baseUrl: "https://api.openai.com",
|
|
107
|
+
apiKey: "",
|
|
108
|
+
model: "whisper-1",
|
|
109
|
+
language: "",
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
polisher: {
|
|
113
|
+
provider: "claude_cli",
|
|
114
|
+
mode: "llm",
|
|
115
|
+
scoreThreshold: 80,
|
|
116
|
+
template: "agent-prompt",
|
|
117
|
+
claude_cli: {
|
|
118
|
+
bin: "claude",
|
|
119
|
+
model: "",
|
|
120
|
+
timeout: 60,
|
|
121
|
+
},
|
|
122
|
+
anthropic: {
|
|
123
|
+
apiKey: "",
|
|
124
|
+
model: "claude-opus-4-8",
|
|
125
|
+
baseUrl: "https://api.anthropic.com",
|
|
126
|
+
},
|
|
127
|
+
openai_compat: {
|
|
128
|
+
apiKey: "",
|
|
129
|
+
model: "gpt-4o-mini",
|
|
130
|
+
baseUrl: "https://api.openai.com",
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
sink: {
|
|
134
|
+
target: "clipboard",
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Absolute path to the config file, honoring $DICTUM_CONFIG. */
|
|
139
|
+
export function configPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
140
|
+
if (env.DICTUM_CONFIG && env.DICTUM_CONFIG.length > 0) return env.DICTUM_CONFIG
|
|
141
|
+
const base =
|
|
142
|
+
env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.length > 0
|
|
143
|
+
? env.XDG_CONFIG_HOME
|
|
144
|
+
: join(homedir(), ".config")
|
|
145
|
+
return join(base, "dictum", "config.toml")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Directory holding user template overrides. */
|
|
149
|
+
export function templatesDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
150
|
+
const base =
|
|
151
|
+
env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.length > 0
|
|
152
|
+
? env.XDG_CONFIG_HOME
|
|
153
|
+
: join(homedir(), ".config")
|
|
154
|
+
return join(base, "dictum", "templates")
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type PlainObject = Record<string, unknown>
|
|
158
|
+
|
|
159
|
+
function isPlainObject(value: unknown): value is PlainObject {
|
|
160
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Recursively merge `source` onto a shallow clone of `target`. Only keys already
|
|
165
|
+
* present in `target` are considered, so unknown TOML keys are ignored and the
|
|
166
|
+
* result keeps the exact shape of the defaults. Arrays are replaced wholesale.
|
|
167
|
+
* `target` is always a plain object here (arrays are leaf values, never recursed).
|
|
168
|
+
*/
|
|
169
|
+
function deepMerge<T extends PlainObject>(target: T, source: unknown): T {
|
|
170
|
+
if (!isPlainObject(source)) return target
|
|
171
|
+
const out: PlainObject = { ...target }
|
|
172
|
+
for (const key of Object.keys(target)) {
|
|
173
|
+
const tVal = target[key]
|
|
174
|
+
const sVal = source[key]
|
|
175
|
+
if (sVal === undefined) continue
|
|
176
|
+
if (isPlainObject(tVal)) {
|
|
177
|
+
out[key] = deepMerge(tVal, sVal)
|
|
178
|
+
} else if (Array.isArray(tVal)) {
|
|
179
|
+
out[key] = Array.isArray(sVal) ? sVal : tVal
|
|
180
|
+
} else {
|
|
181
|
+
// primitive: take the override as-is
|
|
182
|
+
out[key] = sVal
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out as T
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Apply environment-variable overrides in place and return the config. */
|
|
189
|
+
export function applyEnvOverrides(config: Config, env: NodeJS.ProcessEnv = process.env): Config {
|
|
190
|
+
const c = config
|
|
191
|
+
|
|
192
|
+
// Polisher: Anthropic
|
|
193
|
+
if (env.ANTHROPIC_API_KEY) c.polisher.anthropic.apiKey = env.ANTHROPIC_API_KEY
|
|
194
|
+
if (env.DICTUM_ANTHROPIC_MODEL) c.polisher.anthropic.model = env.DICTUM_ANTHROPIC_MODEL
|
|
195
|
+
|
|
196
|
+
// OpenAI key is shared between STT and polisher unless overridden explicitly.
|
|
197
|
+
if (env.OPENAI_API_KEY) {
|
|
198
|
+
c.stt.openai_compat.apiKey = env.OPENAI_API_KEY
|
|
199
|
+
c.polisher.openai_compat.apiKey = env.OPENAI_API_KEY
|
|
200
|
+
}
|
|
201
|
+
if (env.DICTUM_STT_OPENAI_API_KEY) c.stt.openai_compat.apiKey = env.DICTUM_STT_OPENAI_API_KEY
|
|
202
|
+
if (env.DICTUM_POLISHER_OPENAI_API_KEY)
|
|
203
|
+
c.polisher.openai_compat.apiKey = env.DICTUM_POLISHER_OPENAI_API_KEY
|
|
204
|
+
|
|
205
|
+
// STT endpoints / models
|
|
206
|
+
if (env.DICTUM_STT_LOCAL_URL) c.stt.local_http.baseUrl = env.DICTUM_STT_LOCAL_URL
|
|
207
|
+
if (env.DICTUM_STT_OPENAI_URL) c.stt.openai_compat.baseUrl = env.DICTUM_STT_OPENAI_URL
|
|
208
|
+
if (env.DICTUM_STT_OPENAI_MODEL) c.stt.openai_compat.model = env.DICTUM_STT_OPENAI_MODEL
|
|
209
|
+
if (env.DICTUM_STT_LANGUAGE) c.stt.openai_compat.language = env.DICTUM_STT_LANGUAGE
|
|
210
|
+
|
|
211
|
+
// Polisher selection / mode / template
|
|
212
|
+
if (env.DICTUM_POLISHER) c.polisher.provider = env.DICTUM_POLISHER as PolisherName
|
|
213
|
+
if (env.DICTUM_POLISHER_MODE) c.polisher.mode = env.DICTUM_POLISHER_MODE as PolisherMode
|
|
214
|
+
if (env.DICTUM_SCORE_THRESHOLD) {
|
|
215
|
+
const n = Number(env.DICTUM_SCORE_THRESHOLD)
|
|
216
|
+
if (Number.isFinite(n) && n >= 0 && n <= 100) c.polisher.scoreThreshold = n
|
|
217
|
+
}
|
|
218
|
+
if (env.DICTUM_TEMPLATE) c.polisher.template = env.DICTUM_TEMPLATE
|
|
219
|
+
if (env.DICTUM_CLAUDE_BIN) c.polisher.claude_cli.bin = env.DICTUM_CLAUDE_BIN
|
|
220
|
+
|
|
221
|
+
// Recorder
|
|
222
|
+
if (env.DICTUM_STOP_MODE) c.recorder.stopMode = env.DICTUM_STOP_MODE as StopMode
|
|
223
|
+
|
|
224
|
+
// Sink
|
|
225
|
+
if (env.DICTUM_SINK) c.sink.target = env.DICTUM_SINK as SinkName
|
|
226
|
+
|
|
227
|
+
return c
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Load configuration from disk (if present), merge over defaults, then apply
|
|
232
|
+
* environment overrides. Never throws on a missing file; throws with a clear
|
|
233
|
+
* message on malformed TOML.
|
|
234
|
+
*/
|
|
235
|
+
export async function loadConfig(env: NodeJS.ProcessEnv = process.env): Promise<Config> {
|
|
236
|
+
const path = configPath(env)
|
|
237
|
+
let parsed: unknown = {}
|
|
238
|
+
const file = Bun.file(path)
|
|
239
|
+
if (await file.exists()) {
|
|
240
|
+
const text = await file.text()
|
|
241
|
+
try {
|
|
242
|
+
parsed = parseToml(text)
|
|
243
|
+
} catch (err) {
|
|
244
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
245
|
+
throw new Error(`Failed to parse config at ${path}: ${reason}`)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const merged = deepMerge(structuredClone(DEFAULT_CONFIG), parsed)
|
|
249
|
+
return applyEnvOverrides(merged, env)
|
|
250
|
+
}
|