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/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
+ }