@fastagent-sh/voicenote 0.21.0 → 0.22.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 CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
  import { cac } from 'cac'
3
- import { deriveNoProxy, envKeysToEmbed, hydrateFromFileEnv, parseFileEnv } from './envConfig'
3
+ import packageJson from '../package.json' with { type: 'json' }
4
4
  import { parseLockOwner } from './runLock'
5
+ import { tosObject, type TosConfig as VolcanoTosConfig } from './tos'
5
6
  import { applyOutcome, buildJobsView, classify, emptyState, localIso, MAX_ATTEMPTS, migrateLegacyState, ownsOutput, parseJobsLimit, parseStateFile, parseStrictJson, patchJob, pruneUnseen, reconcileInterrupted, startAttempt, SUMMARY_FAILED_STATUS, type CurrentJob, type JobRecord, type StateFile } from './jobs'
6
- import { createHash, createHmac, randomUUID } from 'node:crypto'
7
- import { appendFile, chmod, mkdir, readFile, writeFile, copyFile, rename, unlink, stat, readdir, rm } from 'node:fs/promises'
7
+ import { createHash, randomUUID } from 'node:crypto'
8
+ import { appendFile, chmod, mkdir, readFile, writeFile, copyFile, rename, unlink, stat, readdir } from 'node:fs/promises'
8
9
  import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, openSync, closeSync, statSync, readSync, unlinkSync, renameSync } from 'node:fs'
9
10
  import { dlopen, FFIType, suffix } from 'bun:ffi'
10
11
  import { basename, dirname, extname, join, resolve } from 'node:path'
@@ -12,7 +13,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
12
13
  import { spawn, spawnSync } from 'node:child_process'
13
14
  import os from 'node:os'
14
15
 
15
- const VERSION = '0.21.0'
16
+ const VERSION = packageJson.version
16
17
  const LAUNCH_AGENT_LABEL = 'sh.fastagent.voicenote'
17
18
  const LAUNCH_AGENT_LABEL_LEGACY = 'com.kid7st.voicenote' // pre-fastagent installs; cleaned up on install
18
19
  const TASK_NAME = 'VoiceNote' // Windows Task Scheduler name (mac uses LAUNCH_AGENT_LABEL)
@@ -29,17 +30,7 @@ const CONFIG_DIR = appConfigDir()
29
30
  const STATE_DIR = appStateDir()
30
31
  const LOG_DIR = join(STATE_DIR, 'logs')
31
32
  const LOCK_PATH = join(STATE_DIR, 'run.lock')
32
- const SPEAKERS_PATH = join(CONFIG_DIR, 'speakers.json')
33
33
  const CONFIG_ENV_PATH = join(CONFIG_DIR, 'config.json')
34
- // pi keeps credentials in its config dir, which PI_CODING_AGENT_DIR relocates.
35
- // Point it at a voicenote-owned directory to get an auth.json that only the
36
- // pipeline reads and refreshes: an interactive pi session rewrites its own
37
- // auth.json wholesale on exit and has already dropped entries that way.
38
- // Resolved per call — the env is hydrated from config.json after module load.
39
- function piAuthPath(): string {
40
- const dir = process.env.PI_CODING_AGENT_DIR || join(os.homedir(), '.pi', 'agent')
41
- return join(expandHome(dir), 'auth.json')
42
- }
43
34
 
44
35
  const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.wma', '.aac', '.flac'])
45
36
 
@@ -76,15 +67,6 @@ type SpeakerKnown = { name: string; aliases: string[]; relationship?: string | n
76
67
  type SpeakersConfig = { self: SpeakerSelf; known: SpeakerKnown[] }
77
68
 
78
69
 
79
- type VolcanoTosConfig = {
80
- endpoint: string
81
- region: string
82
- bucket: string
83
- accessKey: string
84
- secretKey: string
85
- keep: boolean
86
- }
87
-
88
70
  type VolcanoConfig = {
89
71
  apiKey: string // X-Api-Key (new Volcano console)
90
72
  resourceId: string
@@ -92,8 +74,21 @@ type VolcanoConfig = {
92
74
  tos: VolcanoTosConfig
93
75
  }
94
76
 
77
+ /** How this install runs pi: which binary, which model, what it may read. */
78
+ type PiConfig = {
79
+ bin: string
80
+ /** Set when pi ships as plain JS next to a bundled bun: `<bin> <cli> <args>`. */
81
+ cli: string | null
82
+ model: string | null
83
+ thinking: string
84
+ /** Comma-separated tool list; empty = run the summary without tools. */
85
+ tools: string
86
+ contextDir: string
87
+ retries: number
88
+ authPath: string
89
+ }
90
+
95
91
  type Config = {
96
- deviceVolume: string
97
92
  recordDir: string
98
93
  workspace: string
99
94
  minBytes: number
@@ -101,20 +96,24 @@ type Config = {
101
96
  maxAgeHours: number
102
97
  speakers: SpeakersConfig
103
98
  volcano: VolcanoConfig | null
99
+ ffprobeBin: string
100
+ pi: PiConfig
101
+ /** Added to the environment of every process vn spawns. */
102
+ childEnv: Record<string, string>
104
103
  }
105
104
 
106
105
  // ────────────────────────────────────────────────────────────────────────────
107
- // Env loading
106
+ // Settings → Config
107
+ //
108
+ // config.json is the only persisted source; the inherited environment overrides
109
+ // it for this process only. Everything the program needs is resolved once, in
110
+ // getConfig(), and passed down as a frozen Config — no code below reads a
111
+ // business setting out of process.env, so behaviour can never depend on whether
112
+ // some earlier call happened to hydrate it.
108
113
  // ────────────────────────────────────────────────────────────────────────────
109
114
 
110
- // Single source of truth for every env var the pipeline reads. Both consumers
111
- // derive from this list so they can never drift:
112
- // - loadEnvConfig() hydrates these from config.json / ~/.zshrc for non-interactive runs
113
- // - launchAgentEnv() embeds the REAL-environment subset into the LaunchAgent
114
- // plist (file-sourced values are skipped — vn run re-reads the files at
115
- // startup, and plist env overrides config.json, so embedding a file value
116
- // would freeze it: later GUI edits would silently never reach the agent)
117
- // Anything documented in the README as a configurable knob MUST live here.
115
+ // Config keys accepted by `vn config set` and loaded from config.json when the
116
+ // inherited environment does not already define them.
118
117
  const ENV_KEYS = [
119
118
  'VOICENOTE_DEVICE_VOLUME',
120
119
  'VOICENOTE_RECORD_DIR',
@@ -136,6 +135,7 @@ const ENV_KEYS = [
136
135
  'PI_CODING_AGENT_DIR',
137
136
  'VOICENOTE_FFPROBE_BIN',
138
137
  'VOICENOTE_PI_MODEL',
138
+ 'VOICENOTE_PI_RETRIES',
139
139
  'VOICENOTE_PI_THINKING',
140
140
  'VOICENOTE_PI_SUMMARY_TOOLS',
141
141
  'VOICENOTE_CONTEXT_DIR',
@@ -152,26 +152,6 @@ const ENV_KEYS = [
152
152
  // 2) routing China-mainland Volcano APIs through an overseas proxy is slower / unreliable
153
153
  const VOLCANO_NO_PROXY_HOSTS = ['.volces.com', '.volcengineapi.com', 'openspeech.bytedance.com']
154
154
 
155
- // Provenance: ENV_KEYS this process synthesized — hydrated from config.json /
156
- // .zshrc, or derived (http_proxy from LOCAL_PROXY_HOST or the macOS system
157
- // proxy; no_proxy seeded/merged below) — as opposed to inherited from the
158
- // real environment. Two consumers:
159
- // - reloadEnvConfig() deletes exactly these before re-hydrating, so the
160
- // long-lived `vn serve` picks up GUI config edits immediately;
161
- // - launchAgentEnv() skips them when embedding env into the scheduler
162
- // (they are recoverable at run time; real env values are not).
163
- const hydratedEnvKeys = new Set<string>()
164
-
165
- // Real-environment no_proxy/NO_PROXY values captured BEFORE the volcano-hosts
166
- // merge below. The merged value is partly synthesized and must never be
167
- // embedded into the scheduler (vn run re-merges at startup); launchAgentEnv
168
- // substitutes these originals when deciding what to embed.
169
- const premergeRealNoProxy: Record<string, string> = {}
170
-
171
- // Node/Bun fetch doesn't read the macOS system proxy — only http_proxy env. Read
172
- // the active SCDynamicStore proxy so users whose proxy app sets the system proxy
173
- // (Clash/Surge “system proxy” mode) don't have to type host/port. Prefer HTTPS
174
- // (OpenAI is https); ignore PAC/auth setups. Returns http://host:port or null.
175
155
  function systemProxyUrl(): string | null {
176
156
  if (process.platform !== 'darwin') return null
177
157
  try {
@@ -184,105 +164,52 @@ function systemProxyUrl(): string | null {
184
164
  } catch { return null }
185
165
  }
186
166
 
187
- // Bun's child_process does NOT hand a child the proxy variables this process set
188
- // on process.env (http_proxy/https_proxy/no_proxy and their uppercase forms are
189
- // special-cased internally; only all_proxy survives). Every spawn that must reach
190
- // the network through the proxy has to pass them explicitly, so record them here.
191
- // Without this, a scheduler run whose proxy comes from config.json rather than a
192
- // real shell env leaves pi with no proxy at all — it fails with `fetch failed`.
193
- const childProxyEnv: Record<string, string> = {}
194
-
195
- function applyDerivedProxy(): void {
196
- const host = process.env.LOCAL_PROXY_HOST
197
- const port = process.env.LOCAL_PROXY_PORT
198
- // Source precedence: explicit http_proxy > LOCAL_PROXY_HOST/PORT > macOS system
199
- // proxy. (Volcano always bypasses, below.)
200
- let url: string | null = host && port ? `http://${host}:${port}` : null
201
- if (!url) {
202
- const cur = process.env.http_proxy || process.env.HTTP_PROXY
203
- if (!cur || cur.includes('${')) url = systemProxyUrl()
204
- }
205
- if (url) {
206
- // Set when unset, OR when a config/.zshrc value came in with unexpanded shell
207
- // vars (e.g. "http://${LOCAL_PROXY_HOST}:...") — those are never valid as-is.
208
- const needs = (k: string) => !process.env[k] || process.env[k]!.includes('${')
209
- for (const k of ['http_proxy', 'https_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY']) {
210
- if (needs(k)) { process.env[k] = url; hydratedEnvKeys.add(k) } // derived, not real env
211
- childProxyEnv[k] = process.env[k]!
212
- }
167
+ type Settings = Record<string, string>
168
+
169
+ /** This run's settings: config.json, overridden by the inherited environment. */
170
+ function readSettings(file: Record<string, unknown>): Settings {
171
+ const settings: Settings = {}
172
+ for (const key of ENV_KEYS) {
173
+ const inherited = process.env[key]
174
+ if (inherited !== undefined) settings[key] = inherited
175
+ else if (typeof file[key] === 'string') settings[key] = file[key] as string
213
176
  }
214
- // no_proxy/NO_PROXY: seed a base when a proxy is active, then always merge
215
- // the Volcano bypass hosts. Provenance bookkeeping (capture pre-merge real
216
- // original vs mark synthesized-hydrated) lives in deriveNoProxy — pure and
217
- // tested; see envConfig.ts.
218
- const baseNoProxy = process.env.LOCAL_NO_PROXY || 'localhost,127.0.0.1,::1'
219
- for (const k of ['no_proxy', 'NO_PROXY'] as const) {
220
- const r = deriveNoProxy(process.env[k], premergeRealNoProxy[k], hydratedEnvKeys.has(k), !!url, baseNoProxy, VOLCANO_NO_PROXY_HOSTS)
221
- process.env[k] = r.runtime
222
- childProxyEnv[k] = r.runtime
223
- if (r.hydrate) hydratedEnvKeys.add(k)
224
- if (r.capture !== undefined) premergeRealNoProxy[k] = r.capture
225
- }
226
- }
227
-
228
- // File values for each ENV_KEY. Hydration passes the current environment for
229
- // variable references; scheduler comparison uses files alone. Primary source is
230
- // ~/.config/voicenote/config.json
231
- // (ENV-style runtime keys at the top level; identity under `speakers`); the
232
- // legacy fallback is `export KEY=...` lines in ~/.zshrc, for CLI installs
233
- // that predate config.json. Precedence/expansion logic lives in envConfig.ts
234
- // (pure + tested). Two consumers: loadEnvConfig() hydrates these into
235
- // process.env for keys the real environment doesn't set, and launchAgentEnv()
236
- // uses them to decide which values are recoverable at run time.
237
- function fileProvidedEnv(environment: Record<string, string | undefined> = {}): Record<string, string> {
238
- const data = loadJsonSync<Record<string, unknown>>(CONFIG_ENV_PATH, {})
239
- let zshrc: string | null = null
240
- const zshrcPath = join(os.homedir(), '.zshrc')
241
- if (existsSync(zshrcPath)) { try { zshrc = readFileSync(zshrcPath, 'utf8') } catch { zshrc = null } }
242
- return parseFileEnv(ENV_KEYS, data, zshrc, os.homedir(), environment)
243
- }
244
-
245
- let envConfigLoaded = false
246
- function loadEnvConfig(): void {
247
- if (envConfigLoaded) return
248
- envConfigLoaded = true
249
- // Precedence: process.env > config.json (GUI) > ~/.zshrc (legacy); an
250
- // explicit empty string in the environment is never overridden.
251
- const toApply = hydrateFromFileEnv(ENV_KEYS, process.env, fileProvidedEnv(process.env))
252
- for (const [key, v] of Object.entries(toApply)) { process.env[key] = v; hydratedEnvKeys.add(key) }
253
- // Derive http_proxy etc. from LOCAL_PROXY_HOST/PORT regardless of source, and
254
- // always keep Volcano hosts on NO_PROXY. (Runs even with no config files.)
255
- applyDerivedProxy()
256
- }
257
-
258
- // Re-hydrate after config.json changes. Needed by the long-lived `vn serve`:
259
- // without this, a GUI config edit only reaches OTHER processes (vn run reads
260
- // the file fresh each start), while serve's own doctor kept reporting stale
261
- // values and ensure_agent re-embedded them into the scheduler env on
262
- // reinstall. Only hydrated/derived keys are dropped — real environment
263
- // variables keep their precedence (a real-env no_proxy stays merged in place;
264
- // its pre-merge original survives in premergeRealNoProxy, and the volcano
265
- // merge is idempotent on the next pass).
266
- function reloadEnvConfig(): void {
267
- for (const k of hydratedEnvKeys) delete process.env[k]
268
- hydratedEnvKeys.clear()
269
- envConfigLoaded = false
270
- loadEnvConfig()
271
- }
272
-
273
- function getVolcanoConfigFromEnv(): VolcanoConfig | null {
274
- const apiKey = process.env.VOLCANO_ASR_KEY || ''
275
- const tosAccess = process.env.VOLCANO_TOS_ACCESS_KEY
276
- const tosSecret = process.env.VOLCANO_TOS_SECRET_KEY
277
- const bucket = process.env.VOLCANO_TOS_BUCKET
177
+ return settings
178
+ }
179
+
180
+ /**
181
+ * Proxy variables, resolved from settings or the macOS system proxy. Returned as
182
+ * a map instead of being pushed onto process.env alone because Bun does not hand
183
+ * a child the variables this process added after startup — every spawn site
184
+ * passes them explicitly (covered by summary.test.ts).
185
+ */
186
+ function proxyEnv(s: Settings): Record<string, string> {
187
+ const url = s.https_proxy || s.HTTPS_PROXY || s.http_proxy || s.HTTP_PROXY || s.all_proxy || s.ALL_PROXY
188
+ || (s.LOCAL_PROXY_HOST && s.LOCAL_PROXY_PORT ? `http://${s.LOCAL_PROXY_HOST}:${s.LOCAL_PROXY_PORT}` : '')
189
+ || systemProxyUrl()
190
+ if (!url) return {}
191
+ const env: Record<string, string> = {}
192
+ for (const key of ['http_proxy', 'https_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY']) env[key] = s[key] || url
193
+ const base = s.LOCAL_NO_PROXY || s.no_proxy || s.NO_PROXY || 'localhost,127.0.0.1,::1'
194
+ const bypass = [...new Set([...base.split(',').map(v => v.trim()).filter(Boolean), ...VOLCANO_NO_PROXY_HOSTS])].join(',')
195
+ env.no_proxy = bypass
196
+ env.NO_PROXY = bypass
197
+ return env
198
+ }
199
+
200
+ function volcanoFrom(s: Settings): VolcanoConfig | null {
201
+ const apiKey = s.VOLCANO_ASR_KEY || ''
202
+ const tosAccess = s.VOLCANO_TOS_ACCESS_KEY
203
+ const tosSecret = s.VOLCANO_TOS_SECRET_KEY
204
+ const bucket = s.VOLCANO_TOS_BUCKET
278
205
  if (!apiKey || !tosAccess || !tosSecret || !bucket) return null
279
- const region = process.env.VOLCANO_TOS_REGION || 'cn-hongkong'
280
- const endpoint = process.env.VOLCANO_TOS_ENDPOINT || `tos-s3-${region}.volces.com`
281
- const keep = ['1', 'true', 'yes'].includes((process.env.VOLCANO_TOS_KEEP || '0').toLowerCase())
206
+ const region = s.VOLCANO_TOS_REGION || 'cn-guangzhou'
207
+ const endpoint = s.VOLCANO_TOS_ENDPOINT || `tos-s3-${region}.volces.com`
208
+ const keep = ['1', 'true', 'yes'].includes((s.VOLCANO_TOS_KEEP || '0').toLowerCase())
282
209
  return {
283
210
  apiKey,
284
- resourceId: process.env.VOLCANO_ASR_RESOURCE_ID || 'volc.seedasr.auc',
285
- language: process.env.VOLCANO_ASR_LANGUAGE || undefined,
211
+ resourceId: s.VOLCANO_ASR_RESOURCE_ID || 'volc.seedasr.auc',
212
+ language: s.VOLCANO_ASR_LANGUAGE || undefined,
286
213
  tos: { endpoint, region, bucket, accessKey: tosAccess, secretKey: tosSecret, keep },
287
214
  }
288
215
  }
@@ -298,22 +225,67 @@ function volcanoAuthHeaders(volc: VolcanoConfig, taskId: string, includeSequence
298
225
  return base
299
226
  }
300
227
 
228
+ function settingNumber(s: Settings, key: string, fallback: number): number {
229
+ const raw = s[key]
230
+ const value = raw === undefined || raw === '' ? fallback : Number(raw)
231
+ if (!Number.isFinite(value) || value < 0) throw new Error(`Invalid ${key}: expected a non-negative number, got '${raw}'`)
232
+ return value
233
+ }
234
+
235
+ let configCache: Config | null = null
236
+
301
237
  function getConfig(): Config {
302
- loadEnvConfig()
303
- const deviceVolume = process.env.VOICENOTE_DEVICE_VOLUME || 'VTR6500'
304
- const recordDir = process.env.VOICENOTE_RECORD_DIR || `/Volumes/${deviceVolume}/RECORD`
305
- return {
306
- deviceVolume,
307
- recordDir,
308
- workspace: expandHome(process.env.VOICENOTE_WORKSPACE || '~/Documents/meetings'),
309
- minBytes: Number(process.env.VOICENOTE_MIN_BYTES || 100000),
310
- minDurationSeconds: Number(process.env.VOICENOTE_MIN_DURATION_SECONDS || 60),
238
+ if (configCache) return configCache
239
+ const file = loadConfigJson()
240
+ const s = readSettings(file)
241
+ const proxy = proxyEnv(s)
242
+ // vn's own fetch (the ChatGPT OAuth flow) reads the proxy from the process
243
+ // environment, so the derived values have to land there as well.
244
+ for (const [key, value] of Object.entries(proxy)) process.env[key] = value
245
+ // Passed to every child: the proxy, plus the credentials and config dir that
246
+ // pi — not vn — resolves for itself.
247
+ const childEnv = { ...proxy }
248
+ for (const key of ['PI_CODING_AGENT_DIR', 'OPENAI_API_KEY', 'DEEPSEEK_API_KEY']) if (s[key]) childEnv[key] = s[key]!
249
+
250
+ const deviceVolume = s.VOICENOTE_DEVICE_VOLUME || 'VTR6500'
251
+ const workspace = expandHome(s.VOICENOTE_WORKSPACE || '~/Documents/meetings')
252
+ // pi keeps credentials in its config dir, which PI_CODING_AGENT_DIR relocates.
253
+ // Point it at a voicenote-owned directory to get an auth.json that only the
254
+ // pipeline reads and refreshes: an interactive pi session rewrites its own
255
+ // auth.json wholesale on exit and has already dropped entries that way.
256
+ const piAgentDir = expandHome(s.PI_CODING_AGENT_DIR || join(os.homedir(), '.pi', 'agent'))
257
+ configCache = Object.freeze({
258
+ recordDir: expandHome(s.VOICENOTE_RECORD_DIR || `/Volumes/${deviceVolume}/RECORD`),
259
+ workspace,
260
+ minBytes: settingNumber(s, 'VOICENOTE_MIN_BYTES', 100000),
261
+ minDurationSeconds: settingNumber(s, 'VOICENOTE_MIN_DURATION_SECONDS', 60),
311
262
  // Only recordings from the last N hours are picked up (0 = no limit), so a
312
263
  // fresh install doesn't drain the recorder's entire history.
313
- maxAgeHours: Number(process.env.VOICENOTE_MAX_AGE_HOURS || 48),
314
- volcano: getVolcanoConfigFromEnv(),
315
- speakers: loadSpeakers(),
316
- }
264
+ maxAgeHours: settingNumber(s, 'VOICENOTE_MAX_AGE_HOURS', 48),
265
+ volcano: volcanoFrom(s),
266
+ speakers: normalizeSpeakers(file.speakers ?? DEFAULT_SPEAKERS),
267
+ // ffprobe is the only ffmpeg-suite binary the pipeline uses (duration
268
+ // detection); a configurable path lets the GUI point at its bundled copy.
269
+ ffprobeBin: expandHome(s.VOICENOTE_FFPROBE_BIN || 'ffprobe'),
270
+ pi: {
271
+ bin: expandHome(s.VOICENOTE_PI_BIN || 'pi'),
272
+ cli: s.VOICENOTE_PI_CLI ? expandHome(s.VOICENOTE_PI_CLI) : null,
273
+ // pi's --model accepts "provider/id" (e.g. openai-codex/gpt-5.6-sol), so
274
+ // this one setting pins both. Null = whatever pi is configured to use.
275
+ model: (s.VOICENOTE_PI_MODEL || '').trim() || null,
276
+ thinking: s.VOICENOTE_PI_THINKING || 'high',
277
+ // Default ON: let the summary model read/grep prior notes for cross-reference
278
+ // consistency. Set VOICENOTE_PI_SUMMARY_TOOLS='' to disable.
279
+ tools: s.VOICENOTE_PI_SUMMARY_TOOLS === undefined ? 'read,grep' : s.VOICENOTE_PI_SUMMARY_TOOLS.trim(),
280
+ // Directory the summary model may read/grep. The published default must not
281
+ // reach outside the configured workspace.
282
+ contextDir: expandHome(s.VOICENOTE_CONTEXT_DIR || workspace),
283
+ retries: Math.max(1, Math.floor(settingNumber(s, 'VOICENOTE_PI_RETRIES', 3))),
284
+ authPath: join(piAgentDir, 'auth.json'),
285
+ },
286
+ childEnv,
287
+ })
288
+ return configCache
317
289
  }
318
290
 
319
291
  // ────────────────────────────────────────────────────────────────────────────
@@ -322,12 +294,6 @@ function getConfig(): Config {
322
294
 
323
295
  const DEFAULT_SPEAKERS: SpeakersConfig = { self: { name: null, aliases: [] }, known: [] }
324
296
 
325
-
326
- function loadJsonSync<T>(path: string, fallback: T): T {
327
- if (!existsSync(path)) return fallback
328
- try { return JSON.parse(readFileSync(path, 'utf8')) as T } catch (e) { warnSideEffect(`parse ${path}`, e); return fallback }
329
- }
330
-
331
297
  function normalizeSpeakers(data: unknown): SpeakersConfig {
332
298
  const raw = (data && typeof data === 'object') ? data as Partial<SpeakersConfig> : {}
333
299
  return {
@@ -344,34 +310,13 @@ function normalizeSpeakers(data: unknown): SpeakersConfig {
344
310
  }
345
311
 
346
312
  function loadConfigJson(): Record<string, unknown> {
347
- return loadJsonSync<Record<string, unknown>>(CONFIG_ENV_PATH, {})
348
- }
349
-
350
- function loadSpeakers(): SpeakersConfig {
351
- ensureConfigSeed()
352
- const config = loadConfigJson()
353
- if (config.speakers) return normalizeSpeakers(config.speakers)
354
- // Backward compatibility for installs created before speakers moved into config.json.
355
- return normalizeSpeakers(loadJsonSync<unknown>(SPEAKERS_PATH, DEFAULT_SPEAKERS))
356
- }
357
-
358
-
359
- let configSeeded = false
360
- function ensureConfigSeed(): void {
361
- if (configSeeded) return
362
- configSeeded = true
363
- try {
364
- if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true })
365
-
366
- const current = loadConfigJson()
367
- if (!current.speakers) {
368
- const legacy = existsSync(SPEAKERS_PATH) ? loadJsonSync<unknown>(SPEAKERS_PATH, DEFAULT_SPEAKERS) : DEFAULT_SPEAKERS
369
- current.speakers = normalizeSpeakers(legacy)
370
- writeFileSync(CONFIG_ENV_PATH, JSON.stringify(current, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
371
- }
372
- } catch {
373
- // Don't crash if we can't seed; commands still work with defaults in memory.
313
+ if (!existsSync(CONFIG_ENV_PATH)) return {}
314
+ let value: unknown
315
+ try { value = JSON.parse(readFileSync(CONFIG_ENV_PATH, 'utf8')) } catch (e: any) {
316
+ throw new Error(`${CONFIG_ENV_PATH} is invalid JSON: ${e?.message || e}`)
374
317
  }
318
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${CONFIG_ENV_PATH} must contain a JSON object`)
319
+ return value as Record<string, unknown>
375
320
  }
376
321
 
377
322
  // ────────────────────────────────────────────────────────────────────────────
@@ -379,9 +324,7 @@ function ensureConfigSeed(): void {
379
324
  // ────────────────────────────────────────────────────────────────────────────
380
325
 
381
326
  function expandHome(path: string): string {
382
- if (path === '~') return os.homedir()
383
- if (path.startsWith('~/')) return join(os.homedir(), path.slice(2))
384
- return path
327
+ return path.replace(/^(?:~|\$\{?HOME\}?)(?=\/|$)/, os.homedir())
385
328
  }
386
329
 
387
330
  function nowIso(): string { return new Date().toISOString() }
@@ -417,11 +360,6 @@ async function ensureDirs(config: Config): Promise<void> {
417
360
  }
418
361
  }
419
362
 
420
- async function readJson<T>(path: string, fallback: T): Promise<T> {
421
- if (!existsSync(path)) return fallback
422
- try { return JSON.parse(await readFile(path, 'utf8')) as T } catch (e) { warnSideEffect(`parse ${path}`, e); return fallback }
423
- }
424
-
425
363
  // Write via tmp+rename so readers only ever see a complete file. Anything whose
426
364
  // mere existence is later treated as a signal MUST go through this: a half
427
365
  // written file that still parses is worse than no file at all.
@@ -554,9 +492,8 @@ function normalizeRunMode(opts: any): RunMode {
554
492
  // Single-instance mutual exclusion via an OS advisory lock (flock) held on an open
555
493
  // fd. The kernel releases it automatically when the process exits — including
556
494
  // SIGKILL/crash — so there is NO pid / mtime / heartbeat / stale-steal logic to
557
- // race on. flock is loaded from libSystem (macOS). On Windows we instead use a
558
- // pid+timestamp lockfile (acquireRunLockWindows); on Linux flock is unavailable
559
- // via this path and we degrade to no cross-process lock with a warning.
495
+ // race on. flock is loaded from libSystem, so it is macOS-only; every other
496
+ // platform uses the pid+timestamp lockfile below.
560
497
  const flockFn = (() => {
561
498
  try {
562
499
  const lib = dlopen(`libSystem.${suffix}`, { flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 } })
@@ -566,9 +503,9 @@ const flockFn = (() => {
566
503
  const FLOCK_EX_NB = 2 | 4 // LOCK_EX | LOCK_NB
567
504
  const FLOCK_UN = 8
568
505
 
569
- // Windows lock: no flock here. A pid+timestamp lockfile, created atomically with
570
- // 'wx'. We only reclaim an existing lock when its owner pid is dead OR the lock is
571
- // stale (older than STALE_MS). The holder refreshes its timestamp every 5 minutes
506
+ // Lockfile used wherever flock is not available (Windows, Linux). A pid+timestamp
507
+ // file, created atomically with 'wx'. We only reclaim an existing lock when its
508
+ // owner pid is dead OR the lock is stale (older than STALE_MS). The holder refreshes its timestamp every 5 minutes
572
509
  // (heartbeat below), so a legitimately long RUNNING job — ASR on a multi-hour
573
510
  // recording — never looks stale. The staleness escape exists for the pid-reuse
574
511
  // false positive (owner died, an unrelated process now has its pid, the aliveness
@@ -580,7 +517,7 @@ const FLOCK_UN = 8
580
517
  // to cover a manual `vn run` racing the scheduled one. The tiny create/reclaim
581
518
  // window is acceptable: its failure mode is conservatively skipping one run (same
582
519
  // as mac when flock is already held).
583
- async function acquireRunLockWindows(): Promise<{ release: () => Promise<void> } | null> {
520
+ async function acquireRunLockFile(): Promise<{ release: () => Promise<void> } | null> {
584
521
  await mkdir(dirname(LOCK_PATH), { recursive: true })
585
522
  const STALE_MS = 30 * 60 * 1000
586
523
  const tryCreate = (): number | null => {
@@ -593,7 +530,7 @@ async function acquireRunLockWindows(): Promise<{ release: () => Promise<void> }
593
530
  try {
594
531
  const data = JSON.parse(readFileSync(LOCK_PATH, 'utf8'))
595
532
  const pid = Number(data.pid), ts = Number(data.ts)
596
- const alive = pid > 0 && (() => { try { process.kill(pid, 0); return true } catch (e: any) { return e?.code === 'EPERM' } })()
533
+ const alive = pidAlive(pid)
597
534
  const fresh = Number.isFinite(ts) && (Date.now() - ts) < STALE_MS
598
535
  reclaim = !alive || !fresh
599
536
  } catch { reclaim = true } // unreadable/corrupt lock -> reclaim
@@ -625,12 +562,12 @@ async function acquireRunLockWindows(): Promise<{ release: () => Promise<void> }
625
562
  console.error('Run lock was reclaimed by another process (machine slept >30min?); this run continues but is no longer protected against overlap.')
626
563
  return
627
564
  }
628
- if (owner === 'unknown') { warnSideEffect('windows lock heartbeat read', new Error('lock unreadable this tick; will retry')); return }
565
+ if (owner === 'unknown') { warnSideEffect('run lock heartbeat read', new Error('lock unreadable this tick; will retry')); return }
629
566
  try {
630
567
  const tmp = `${LOCK_PATH}.hb-${process.pid}`
631
568
  writeFileSync(tmp, JSON.stringify({ pid: process.pid, ts: Date.now() }))
632
569
  renameSync(tmp, LOCK_PATH) // atomic replace, also on Windows
633
- } catch (e) { warnSideEffect('windows lock heartbeat', e) }
570
+ } catch (e) { warnSideEffect('run lock heartbeat', e) }
634
571
  }, 5 * 60 * 1000)
635
572
  ;(heartbeat as any).unref?.()
636
573
  let released = false
@@ -653,12 +590,8 @@ async function acquireRunLockWindows(): Promise<{ release: () => Promise<void> }
653
590
  }
654
591
 
655
592
  async function acquireRunLock(): Promise<{ release: () => Promise<void> } | null> {
656
- if (IS_WINDOWS) return acquireRunLockWindows()
593
+ if (!flockFn) return acquireRunLockFile()
657
594
  await mkdir(dirname(LOCK_PATH), { recursive: true })
658
- if (!flockFn) {
659
- console.error('Warning: flock unavailable on this runtime; proceeding without cross-process locking.')
660
- return { release: async () => {} }
661
- }
662
595
  // The lock is a regular file we keep open. Builds ≤ 0.15.2 used a *directory*
663
596
  // here, held purely by its existence, with no pid or refreshed mtime inside — so
664
597
  // a leftover legacy dir carries NO reliable signal about whether an old `vn run`
@@ -780,7 +713,7 @@ async function tailFiles(files: string[], lines: number, follow: boolean): Promi
780
713
  } else if (size < prev) {
781
714
  sizes.set(f, size) // rotated/truncated
782
715
  }
783
- } catch {}
716
+ } catch (e) { warnSideEffect(`follow ${f}`, e) }
784
717
  }
785
718
  if (!stop) setTimeout(poll, 1000)
786
719
  }
@@ -788,13 +721,8 @@ async function tailFiles(files: string[], lines: number, follow: boolean): Promi
788
721
  })
789
722
  }
790
723
 
791
- // ffprobe is the only ffmpeg-suite binary the pipeline actually uses (duration
792
- // detection). Resolve a configurable path so a bundled binary (GUI .app sidecar)
793
- // can be used without relying on PATH — mirrors the VOICENOTE_PI_BIN convention.
794
- function ffprobeBin(): string { return process.env.VOICENOTE_FFPROBE_BIN || 'ffprobe' }
795
-
796
- async function ffprobeDuration(path: string): Promise<number | null> {
797
- const result = await runCommand(ffprobeBin(), ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', path])
724
+ async function ffprobeDuration(config: Config, path: string): Promise<number | null> {
725
+ const result = await runCommand(config.ffprobeBin, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', path])
798
726
  if (result.code !== 0) return null
799
727
  const v = Number(result.stdout.trim())
800
728
  return Number.isFinite(v) ? v : null
@@ -816,13 +744,13 @@ function isCandidateFile(path: string): boolean {
816
744
  * treating a half-read device as authoritative would delete live queue entries
817
745
  * along with their retry counters.
818
746
  */
819
- async function toRecording(file: string): Promise<Recording> {
747
+ async function toRecording(config: Config, file: string): Promise<Recording> {
820
748
  const st = await stat(file)
821
749
  return {
822
750
  sourcePath: file,
823
751
  sizeBytes: st.size,
824
752
  modifiedAt: st.mtime.toISOString(),
825
- durationSeconds: await ffprobeDuration(file),
753
+ durationSeconds: await ffprobeDuration(config, file),
826
754
  sourceId: await sourceIdFor(file),
827
755
  recordedAt: parseRecordedAt(file),
828
756
  }
@@ -841,7 +769,7 @@ async function scanRecordings(config: Config): Promise<{ recordings: Recording[]
841
769
  if (!st) { complete = false; continue }
842
770
  if (!st.isFile()) continue
843
771
  try {
844
- recordings.push(await toRecording(file))
772
+ recordings.push(await toRecording(config, file))
845
773
  } catch (e) { complete = false; warnSideEffect(`read ${basename(file)} during scan`, e) }
846
774
  }
847
775
  } catch (e) {
@@ -854,30 +782,24 @@ async function scanRecordings(config: Config): Promise<{ recordings: Recording[]
854
782
  return { recordings, complete }
855
783
  }
856
784
 
857
- const limitsOf = (config: Config) => ({ maxAgeHours: config.maxAgeHours, minBytes: config.minBytes, minDurationSeconds: config.minDurationSeconds })
858
-
859
785
  // ────────────────────────────────────────────────────────────────────────────
860
786
  // File path planning
861
787
  // ────────────────────────────────────────────────────────────────────────────
862
788
 
863
- function initialLocalFiles(config: Config, rec: Recording): LocalFiles {
789
+ /**
790
+ * The one place the output layout is written down. A job starts out untitled
791
+ * (timestamp only) and moves to its titled names once the summary produces a
792
+ * title; pass `title` — including a null/empty one — for the titled form.
793
+ */
794
+ function layout(config: Config, rec: Recording, title?: string | null): LocalFiles {
864
795
  const { month, prefix } = dateParts(rec.recordedAt)
796
+ const untitled = title === undefined
797
+ const base = untitled ? prefix : `${prefix}-${safeSlug(title || 'note')}`
865
798
  return {
866
- audio: join(config.workspace, '_audio', month, `${prefix}-original${extname(rec.sourcePath).toLowerCase()}`),
867
- transcript: join(config.workspace, '_transcripts', month, `${prefix}-transcript.md`),
868
- notes: join(config.workspace, month, `${prefix}-note.md`),
869
- metadata: join(config.workspace, '_metadata', month, `${prefix}-metadata.json`),
870
- }
871
- }
872
-
873
- function localFilesFromState(config: Config, rec: Recording, entry: JobRecord | undefined): LocalFiles {
874
- const fallback = initialLocalFiles(config, rec)
875
- const paths = entry?.paths || {}
876
- return {
877
- audio: typeof paths.audio === 'string' ? paths.audio : fallback.audio,
878
- transcript: typeof paths.transcript === 'string' ? paths.transcript : fallback.transcript,
879
- notes: typeof paths.notes === 'string' ? paths.notes : fallback.notes,
880
- metadata: typeof paths.metadata === 'string' ? paths.metadata : fallback.metadata,
799
+ audio: join(config.workspace, '_audio', month, `${base}-original${extname(rec.sourcePath).toLowerCase()}`),
800
+ transcript: join(config.workspace, '_transcripts', month, `${base}-transcript.md`),
801
+ notes: join(config.workspace, month, untitled ? `${base}-note.md` : `${base}.md`),
802
+ metadata: join(config.workspace, '_metadata', month, `${base}-metadata.json`),
881
803
  }
882
804
  }
883
805
 
@@ -887,7 +809,13 @@ function localFilesFromState(config: Config, rec: Recording, entry: JobRecord |
887
809
  // even though the transcript was still sitting there.
888
810
  function resumableTranscriptFiles(config: Config, rec: Recording, store: StateFile, mode: RunMode, force: boolean): LocalFiles | null {
889
811
  if (force || mode !== 'notes') return null
890
- const files = localFilesFromState(config, rec, store.jobs[rec.sourceId])
812
+ // Paths recorded by an earlier attempt win: that attempt may already have
813
+ // moved its outputs to titled names.
814
+ const fallback = layout(config, rec)
815
+ const recorded = store.jobs[rec.sourceId]?.paths || {}
816
+ const files = Object.fromEntries(
817
+ Object.entries(fallback).map(([key, path]) => [key, typeof recorded[key] === 'string' ? recorded[key] : path]),
818
+ ) as LocalFiles
891
819
  return existsSync(files.transcript) ? files : null
892
820
  }
893
821
 
@@ -908,121 +836,29 @@ async function removeFailedSummaryStub(path: string): Promise<void> {
908
836
  } catch (e) { warnSideEffect(`remove failed-summary stub ${path}`, e) }
909
837
  }
910
838
 
911
- async function titledLocalFiles(config: Config, rec: Recording, meta: Json, files: LocalFiles): Promise<LocalFiles> {
912
- const { month, prefix } = dateParts(rec.recordedAt)
913
- const base = `${prefix}-${safeSlug(meta.title || 'note')}`
914
- const targets: LocalFiles = {
915
- audio: join(config.workspace, '_audio', month, `${base}-original${extname(rec.sourcePath).toLowerCase()}`),
916
- transcript: join(config.workspace, '_transcripts', month, `${base}-transcript.md`),
917
- notes: join(config.workspace, month, `${base}.md`),
918
- metadata: join(config.workspace, '_metadata', month, `${base}-metadata.json`),
839
+ /**
840
+ * Move a job's existing outputs onto their titled paths. Audio and the
841
+ * transcript written before the summary ran move together — they used to be
842
+ * renamed in two different places, and the one left behind became an orphan.
843
+ * Notes and metadata are rewritten by the caller, so their stale copies from a
844
+ * failed attempt are dropped instead of moved.
845
+ */
846
+ async function promoteOutputs(from: LocalFiles, to: LocalFiles): Promise<void> {
847
+ for (const key of ['audio', 'transcript'] as const) {
848
+ if (from[key] === to[key] || !existsSync(from[key])) continue
849
+ await mkdir(dirname(to[key]), { recursive: true })
850
+ if (existsSync(to[key])) await unlink(to[key])
851
+ await rename(from[key], to[key])
919
852
  }
920
- for (const p of Object.values(targets)) await mkdir(dirname(p), { recursive: true })
921
- if (existsSync(files.audio) && files.audio !== targets.audio) {
922
- if (existsSync(targets.audio)) await unlink(targets.audio)
923
- await rename(files.audio, targets.audio)
853
+ if (from.metadata !== to.metadata && existsSync(from.metadata)) {
854
+ await unlink(from.metadata).catch(e => warnSideEffect(`remove orphaned metadata ${from.metadata}`, e))
924
855
  }
925
- return targets
926
856
  }
927
857
 
928
858
  // ───────────────────────────────────────────────────────────────────────
929
859
  // Volcano (Doubao ASR + TOS upload)
930
860
  // ───────────────────────────────────────────────────────────────────────
931
861
 
932
- function sha256Hex(data: Buffer | string): string {
933
- return createHash('sha256').update(data).digest('hex')
934
- }
935
-
936
- function hmacSha256(key: Buffer | string, data: string): Buffer {
937
- return createHmac('sha256', key).update(data).digest()
938
- }
939
-
940
- function tosCanonicalUri(key: string): string {
941
- // S3 SigV4: encode each path segment, keep '/' as separator.
942
- return '/' + key.split('/').map(s => encodeURIComponent(s)).join('/')
943
- }
944
-
945
- function tosAmzDate(now: Date = new Date()): { amzDate: string; dateStamp: string } {
946
- const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
947
- return { amzDate, dateStamp: amzDate.slice(0, 8) }
948
- }
949
-
950
- function tosSigningKey(secretKey: string, dateStamp: string, region: string): Buffer {
951
- const kDate = hmacSha256('AWS4' + secretKey, dateStamp)
952
- const kRegion = hmacSha256(kDate, region)
953
- const kService = hmacSha256(kRegion, 's3')
954
- return hmacSha256(kService, 'aws4_request')
955
- }
956
-
957
- function tosSignRequest(tos: VolcanoTosConfig, method: 'PUT' | 'DELETE', key: string, payloadHash: string, contentType?: string): { url: string; headers: Record<string, string> } {
958
- const host = `${tos.bucket}.${tos.endpoint}`
959
- const { amzDate, dateStamp } = tosAmzDate()
960
- const canonicalUri = tosCanonicalUri(key)
961
- const headers: Record<string, string> = {
962
- host,
963
- 'x-amz-content-sha256': payloadHash,
964
- 'x-amz-date': amzDate,
965
- }
966
- if (contentType) headers['content-type'] = contentType
967
- const sortedNames = Object.keys(headers).sort()
968
- const canonicalHeaders = sortedNames.map(h => `${h}:${headers[h]}\n`).join('')
969
- const signedHeaders = sortedNames.join(';')
970
- const canonicalRequest = [method, canonicalUri, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
971
- const credentialScope = `${dateStamp}/${tos.region}/s3/aws4_request`
972
- const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n')
973
- const signingKey = tosSigningKey(tos.secretKey, dateStamp, tos.region)
974
- const signature = hmacSha256(signingKey, stringToSign).toString('hex')
975
- const authorization = `AWS4-HMAC-SHA256 Credential=${tos.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
976
- return { url: `https://${host}${canonicalUri}`, headers: { ...headers, Authorization: authorization } }
977
- }
978
-
979
- function tosPresignedGet(tos: VolcanoTosConfig, key: string, expiresSeconds = 3600): string {
980
- const host = `${tos.bucket}.${tos.endpoint}`
981
- const { amzDate, dateStamp } = tosAmzDate()
982
- const canonicalUri = tosCanonicalUri(key)
983
- const credentialScope = `${dateStamp}/${tos.region}/s3/aws4_request`
984
- const params: Record<string, string> = {
985
- 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
986
- 'X-Amz-Credential': `${tos.accessKey}/${credentialScope}`,
987
- 'X-Amz-Date': amzDate,
988
- 'X-Amz-Expires': String(expiresSeconds),
989
- 'X-Amz-SignedHeaders': 'host',
990
- }
991
- const canonicalQuery = Object.keys(params).sort().map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k]!)}`).join('&')
992
- const canonicalHeaders = `host:${host}\n`
993
- const canonicalRequest = ['GET', canonicalUri, canonicalQuery, canonicalHeaders, 'host', 'UNSIGNED-PAYLOAD'].join('\n')
994
- const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n')
995
- const signature = hmacSha256(tosSigningKey(tos.secretKey, dateStamp, tos.region), stringToSign).toString('hex')
996
- return `https://${host}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`
997
- }
998
-
999
- async function tosUploadObject(tos: VolcanoTosConfig, localPath: string, key: string, contentType: string): Promise<void> {
1000
- const body = await readFile(localPath)
1001
- const payloadHash = sha256Hex(body)
1002
- const { url, headers } = tosSignRequest(tos, 'PUT', key, payloadHash, contentType)
1003
- const res = await fetch(url, { method: 'PUT', body, headers })
1004
- if (!res.ok) {
1005
- const text = await res.text().catch(() => '')
1006
- throw new Error(`TOS upload failed: ${res.status} ${text.slice(0, 500)}`)
1007
- }
1008
- }
1009
-
1010
- async function tosDeleteObject(tos: VolcanoTosConfig, key: string): Promise<void> {
1011
- const { url, headers } = tosSignRequest(tos, 'DELETE', key, sha256Hex(''))
1012
- const res = await fetch(url, { method: 'DELETE', headers })
1013
- if (!res.ok && res.status !== 204 && res.status !== 404) {
1014
- const text = await res.text().catch(() => '')
1015
- console.log(`Warn: TOS delete returned ${res.status}: ${text.slice(0, 200)}`)
1016
- }
1017
- }
1018
-
1019
- function volcanoFormatFromExt(ext: string): string {
1020
- const e = ext.replace(/^\./, '').toLowerCase()
1021
- if (e === 'mp3') return 'mp3'
1022
- if (e === 'wav') return 'wav'
1023
- return e || 'mp3'
1024
- }
1025
-
1026
862
  function volcanoContentTypeFromExt(ext: string): string {
1027
863
  const e = ext.replace(/^\./, '').toLowerCase()
1028
864
  switch (e) {
@@ -1117,20 +953,21 @@ function volcanoFormatTranscript(result: { text?: string; utterances?: VolcanoUt
1117
953
 
1118
954
  async function volcanoTranscribeAudio(volc: VolcanoConfig, audioPath: string, rec: Recording): Promise<string> {
1119
955
  const ext = extname(audioPath).toLowerCase() || '.mp3'
1120
- const format = volcanoFormatFromExt(ext)
956
+ const format = ext.replace(/^\./, '')
1121
957
  const contentType = volcanoContentTypeFromExt(ext)
1122
958
  const { month } = dateParts(rec.recordedAt)
1123
959
  const key = `voicenote/${month}/${rec.sourceId}-${Date.now()}${ext}`
960
+ const object = tosObject(volc.tos, key)
1124
961
  console.log(`Volcano: upload audio to TOS as ${key}`)
1125
- await withHeartbeat('upload audio to TOS', () => tosUploadObject(volc.tos, audioPath, key, contentType), 30)
962
+ await withHeartbeat('upload audio to TOS', () => object.write(Bun.file(audioPath), { type: contentType }), 30)
1126
963
  let cleanedUp = false
1127
964
  const cleanup = async () => {
1128
965
  if (cleanedUp || volc.tos.keep) return
1129
966
  cleanedUp = true
1130
- await tosDeleteObject(volc.tos, key).catch(() => {})
967
+ await object.delete().catch(e => warnSideEffect(`delete TOS object ${key}`, e))
1131
968
  }
1132
969
  try {
1133
- const audioUrl = tosPresignedGet(volc.tos, key, 6 * 3600)
970
+ const audioUrl = object.presign({ method: 'GET', expiresIn: 6 * 3600 })
1134
971
  const taskId = randomUUID()
1135
972
  console.log(`Volcano: submit ASR task ${taskId} (resource=${volc.resourceId}, format=${format})`)
1136
973
  await volcanoSubmitTask(volc, taskId, audioUrl, format)
@@ -1192,7 +1029,7 @@ async function volcanoTranscribeAudio(volc: VolcanoConfig, audioPath: string, re
1192
1029
  }
1193
1030
 
1194
1031
  async function transcribeAudio(config: Config, audioPath: string, rec: Recording): Promise<string> {
1195
- if (!config.volcano) throw new Error('Volcano ASR not configured. Set VOLCANO_ASR_KEY / VOLCANO_TOS_* in ~/.zshrc.')
1032
+ if (!config.volcano) throw new Error('Volcano ASR not configured. Set VOLCANO_ASR_KEY / VOLCANO_TOS_* in config.json.')
1196
1033
  return volcanoTranscribeAudio(config.volcano, audioPath, rec)
1197
1034
  }
1198
1035
 
@@ -1296,39 +1133,30 @@ ${transcript}`
1296
1133
  }
1297
1134
 
1298
1135
  // ───────────────────────────────────────────────────────────────────────
1299
- // Summary via pi. Provider, model and credentials are pi's own configuration:
1300
- // we invoke `pi -p` with no --provider/--model and never fall back elsewhere,
1301
- // so whatever the user selected in pi is what writes the notes.
1136
+ // Summary via pi. Provider and credentials are pi's own configuration. The
1137
+ // optional VOICENOTE_PI_MODEL pins a model; otherwise pi's selected model writes
1138
+ // the notes. VoiceNote does not implement a provider fallback chain.
1302
1139
  // ───────────────────────────────────────────────────────────────────────
1303
1140
 
1304
- function piCodexBin(): string {
1305
- return process.env.VOICENOTE_PI_BIN || 'pi'
1306
- }
1307
-
1308
1141
  // pi can't be `bun build --compile`'d (it reads data files from disk), so the
1309
1142
  // bundled GUI ships pi as plain JS and runs it under a bundled bun. When
1310
- // VOICENOTE_PI_CLI is set, piCodexBin() is the runtime (bun) and the cli.js is
1311
- // prepended to pi's args — `<bun> <cli.js> <args>`, no wrapper script and no
1312
- // shell (critical on Windows, where pi args include a huge --system-prompt that
1313
- // a .cmd/%* wrapper would mangle). CLI users with a real `pi` on PATH leave
1314
- // PI_CLI unset and pi is invoked directly.
1315
- function piInvocation(args: string[]): { bin: string; args: string[] } {
1316
- const cli = process.env.VOICENOTE_PI_CLI
1317
- const bin = piCodexBin()
1318
- return cli ? { bin, args: [cli, ...args] } : { bin, args }
1143
+ // `pi.cli` is set, `pi.bin` is the runtime (bun) and the cli.js is prepended to
1144
+ // pi's args — `<bun> <cli.js> <args>`, no wrapper script and no shell (critical
1145
+ // on Windows, where pi args include a huge --system-prompt that a .cmd/%*
1146
+ // wrapper would mangle). CLI users with a real `pi` on PATH leave it unset.
1147
+ function piInvocation(pi: PiConfig, args: string[]): { bin: string; args: string[] } {
1148
+ return pi.cli ? { bin: pi.bin, args: [pi.cli, ...args] } : { bin: pi.bin, args }
1319
1149
  }
1320
1150
 
1321
1151
  // ───────────────────────────────────────────────────────────────────────
1322
- // ChatGPT (OpenAI Codex) OAuth login — headless device-code flow.
1323
- // Today the only way to authenticate the pi summary backend is to open pi's
1324
- // interactive TUI and run `/login`. This exposes the same flow as a plain
1325
- // command so non-TUI users (and the GUI client, via --json) can sign in.
1152
+ // ChatGPT (OpenAI Codex) OAuth login. The browser callback is the default;
1153
+ // --device-code is available for accounts that opted into that flow. This
1154
+ // exposes pi's login as a plain command for non-TUI and GUI users.
1326
1155
  // We reuse pi's own OAuth implementation (@earendil-works/pi-ai) and persist
1327
1156
  // to pi's auth.json in the exact shape it reads: { type: 'oauth', ...creds }.
1328
1157
  // ───────────────────────────────────────────────────────────────────────
1329
1158
 
1330
- async function persistPiOAuth(providerId: string, creds: Record<string, unknown>): Promise<void> {
1331
- const authPath = piAuthPath()
1159
+ async function persistPiOAuth(authPath: string, providerId: string, creds: Record<string, unknown>): Promise<void> {
1332
1160
  await mkdir(dirname(authPath), { recursive: true })
1333
1161
  let existing: Json = {}
1334
1162
  if (existsSync(authPath)) {
@@ -1341,9 +1169,9 @@ async function persistPiOAuth(providerId: string, creds: Record<string, unknown>
1341
1169
  }
1342
1170
 
1343
1171
  async function loginChatGPT(opts: { json?: boolean; deviceCode?: boolean; emit?: (o: Record<string, unknown>) => void }): Promise<void> {
1344
- // OpenAI's OAuth endpoint is geo-blocked in some regions; hydrate the proxy
1345
- // env (LOCAL_PROXY_HOST/PORT -> http_proxy) before any request goes out.
1346
- loadEnvConfig()
1172
+ // OpenAI's OAuth endpoint is geo-blocked in some regions; getConfig() resolves
1173
+ // the proxy into this process's env before any request goes out.
1174
+ const authPath = getConfig().pi.authPath
1347
1175
  const json = !!opts.json
1348
1176
  const emit = opts.emit ?? ((o: Record<string, unknown>) => { if (json) console.log(JSON.stringify(o)) })
1349
1177
  try {
@@ -1385,9 +1213,9 @@ async function loginChatGPT(opts: { json?: boolean; deviceCode?: boolean; emit?:
1385
1213
  },
1386
1214
  }) as Record<string, unknown>
1387
1215
  }
1388
- await persistPiOAuth(oauth.openaiCodexOAuthProvider.id, creds)
1216
+ await persistPiOAuth(authPath, oauth.openaiCodexOAuthProvider.id, creds)
1389
1217
  if (json) emit({ event: 'success', provider: oauth.openaiCodexOAuthProvider.id })
1390
- else console.log(`\n✓ Signed in. Credentials saved to ${piAuthPath()}. Verify with: vn doctor`)
1218
+ else console.log(`\n✓ Signed in. Credentials saved to ${authPath}. Verify with: vn doctor`)
1391
1219
  } catch (e: any) {
1392
1220
  let message = String(e?.message || e)
1393
1221
  if (/unsupported_country_region_territory|\b403\b/.test(message)) {
@@ -1415,18 +1243,18 @@ function readStdin(): Promise<string> {
1415
1243
  })
1416
1244
  }
1417
1245
 
1418
- function configFileEnv(): Record<string, string> {
1419
- const raw = loadConfigJson()
1246
+ function configFileEnv(raw = loadConfigJson()): Record<string, string> {
1420
1247
  const env: Record<string, string> = {}
1421
1248
  for (const k of ENV_KEYS) if (typeof raw[k] === 'string') env[k] = raw[k] as string
1422
1249
  return env
1423
1250
  }
1424
1251
 
1425
1252
  function configGetData(): { path: string; env: Record<string, string>; self: { name: string | null; aliases: string[] } } {
1426
- const speakers = loadSpeakers()
1253
+ const current = loadConfigJson()
1254
+ const speakers = normalizeSpeakers(current.speakers ?? DEFAULT_SPEAKERS)
1427
1255
  return {
1428
1256
  path: CONFIG_ENV_PATH,
1429
- env: configFileEnv(),
1257
+ env: configFileEnv(current),
1430
1258
  self: { name: speakers.self.name, aliases: speakers.self.aliases },
1431
1259
  }
1432
1260
  }
@@ -1435,37 +1263,39 @@ function configGet(): void { console.log(JSON.stringify(configGetData(), null, 2
1435
1263
 
1436
1264
  type ConfigSetPayload = { env?: Record<string, unknown>; self?: { name?: string | null; aliases?: string[] } }
1437
1265
 
1438
- async function configSetData(payload: ConfigSetPayload): Promise<{ ok: true; path: string; ignoredKeys?: string[] }> {
1266
+ async function writeConfigJson(value: Record<string, unknown>): Promise<void> {
1439
1267
  await mkdir(CONFIG_DIR, { recursive: true })
1268
+ const tmp = `${CONFIG_ENV_PATH}.tmp-${process.pid}`
1269
+ await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 })
1270
+ await rename(tmp, CONFIG_ENV_PATH)
1271
+ }
1440
1272
 
1441
- // Merge env into config.json (only known ENV_KEYS; null deletes a key).
1273
+ async function configSetData(payload: ConfigSetPayload): Promise<{ ok: true; path: string; ignoredKeys?: string[] }> {
1274
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('Config payload must be a JSON object')
1442
1275
  const current = loadConfigJson()
1443
1276
  const known = ENV_KEYS as readonly string[]
1444
1277
  const ignored: string[] = []
1445
1278
  if (payload.env) {
1446
- for (const [k, v] of Object.entries(payload.env)) {
1447
- if (!known.includes(k)) { ignored.push(k); continue }
1448
- if (v === null) delete current[k]
1449
- else if (typeof v === 'string') current[k] = v
1279
+ for (const [key, value] of Object.entries(payload.env)) {
1280
+ if (!known.includes(key)) { ignored.push(key); continue }
1281
+ if (value === null) delete current[key]
1282
+ else if (typeof value === 'string') current[key] = value
1283
+ else throw new Error(`Config value ${key} must be a string or null`)
1450
1284
  }
1451
1285
  }
1452
- const tmp = `${CONFIG_ENV_PATH}.tmp-${process.pid}`
1453
- await writeFile(tmp, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 })
1454
- await rename(tmp, CONFIG_ENV_PATH)
1455
-
1456
- // Identity lives in config.json too; speakers.json is read only as a legacy fallback.
1457
1286
  if (payload.self) {
1458
- const speakers = normalizeSpeakers(current.speakers ?? loadSpeakers())
1459
- if (payload.self.name !== undefined) speakers.self.name = payload.self.name
1460
- if (Array.isArray(payload.self.aliases)) speakers.self.aliases = payload.self.aliases
1287
+ const speakers = normalizeSpeakers(current.speakers ?? DEFAULT_SPEAKERS)
1288
+ if (payload.self.name !== undefined) {
1289
+ if (payload.self.name !== null && typeof payload.self.name !== 'string') throw new Error('self.name must be a string or null')
1290
+ speakers.self.name = payload.self.name
1291
+ }
1292
+ if (payload.self.aliases !== undefined) {
1293
+ if (!Array.isArray(payload.self.aliases) || payload.self.aliases.some(alias => typeof alias !== 'string')) throw new Error('self.aliases must contain only strings')
1294
+ speakers.self.aliases = payload.self.aliases
1295
+ }
1461
1296
  current.speakers = speakers
1462
- await writeFile(tmp, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 })
1463
- await rename(tmp, CONFIG_ENV_PATH)
1464
1297
  }
1465
-
1466
- // Make the new values visible to THIS process immediately (see reloadEnvConfig).
1467
- reloadEnvConfig()
1468
-
1298
+ await writeConfigJson(current)
1469
1299
  return { ok: true, path: CONFIG_ENV_PATH, ...(ignored.length ? { ignoredKeys: ignored } : {}) }
1470
1300
  }
1471
1301
 
@@ -1488,15 +1318,10 @@ async function configSet(): Promise<void> {
1488
1318
  }
1489
1319
  }
1490
1320
 
1491
- function stripJsonFences(text: string): string {
1492
- const trimmed = text.trim()
1493
- const fence = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```\s*$/i)
1494
- if (fence) return fence[1]!.trim()
1495
- return trimmed
1496
- }
1497
-
1498
1321
  function extractFirstJsonObject(text: string): string {
1499
- const trimmed = stripJsonFences(text)
1322
+ const raw = text.trim()
1323
+ // Models often wrap JSON in a ```json fence; strip it before looking inside.
1324
+ const trimmed = raw.match(/^```(?:json)?\s*([\s\S]*?)\s*```\s*$/i)?.[1]?.trim() ?? raw
1500
1325
  if (trimmed.startsWith('{') && trimmed.endsWith('}')) return trimmed
1501
1326
  // Find the first balanced {...}
1502
1327
  let depth = 0, start = -1, inString = false, escape = false
@@ -1515,7 +1340,7 @@ function extractFirstJsonObject(text: string): string {
1515
1340
  return trimmed
1516
1341
  }
1517
1342
 
1518
- async function runPi(opts: {
1343
+ type PiRunOptions = {
1519
1344
  systemPrompt: string
1520
1345
  userPrompt: string
1521
1346
  timeoutMs?: number
@@ -1523,7 +1348,9 @@ async function runPi(opts: {
1523
1348
  tools?: string // e.g. 'read,grep'; empty/undefined = --no-tools
1524
1349
  appendSystemPrompt?: string
1525
1350
  cwd?: string // agent working dir: the knowledge base, so read/grep/find default there
1526
- }): Promise<string> {
1351
+ }
1352
+
1353
+ async function runPi(config: Config, opts: PiRunOptions): Promise<string> {
1527
1354
  const args = [
1528
1355
  '-p',
1529
1356
  '--mode', 'text',
@@ -1532,15 +1359,14 @@ async function runPi(opts: {
1532
1359
  ]
1533
1360
  // Unset means pi's own default model and provider. There is no second
1534
1361
  // provider to fall back to either way.
1535
- const model = piSummaryModel()
1536
- if (model) args.push('--model', model)
1362
+ if (config.pi.model) args.push('--model', config.pi.model)
1537
1363
  if (opts.thinking) args.push('--thinking', opts.thinking)
1538
1364
  if (opts.tools && opts.tools.trim()) args.push('--tools', opts.tools.trim())
1539
1365
  else args.push('--no-tools')
1540
1366
  if (opts.appendSystemPrompt) args.push('--append-system-prompt', opts.appendSystemPrompt)
1541
1367
  return new Promise<string>((resolve, reject) => {
1542
- const inv = piInvocation(args)
1543
- const child = spawn(inv.bin, inv.args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: opts.cwd, windowsHide: true, env: { ...process.env, ...childProxyEnv } })
1368
+ const inv = piInvocation(config.pi, args)
1369
+ const child = spawn(inv.bin, inv.args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: opts.cwd, windowsHide: true, env: { ...process.env, ...config.childEnv } })
1544
1370
  let stdout = '', stderr = ''
1545
1371
  const timer = opts.timeoutMs ? setTimeout(() => child.kill('SIGKILL'), opts.timeoutMs) : null
1546
1372
  child.stdout.on('data', d => stdout += String(d))
@@ -1572,11 +1398,11 @@ function isTransientPiError(e: any): boolean {
1572
1398
  return /socket connection was closed|socket hang up|econnreset|etimedout|esockettimedout|enetunreach|econnrefused|eai_again|fetch failed|network error|timed ?out|temporarily|overloaded|\b(429|500|502|503|504)\b/.test(msg)
1573
1399
  }
1574
1400
 
1575
- async function chatCompleteViaPi(opts: Parameters<typeof runPi>[0]): Promise<string> {
1576
- const maxAttempts = Math.max(1, Number(process.env.VOICENOTE_PI_RETRIES || 3))
1401
+ async function chatCompleteViaPi(config: Config, opts: PiRunOptions): Promise<string> {
1402
+ const maxAttempts = config.pi.retries
1577
1403
  for (let attempt = 1; ; attempt++) {
1578
1404
  try {
1579
- return await runPi(opts)
1405
+ return await runPi(config, opts)
1580
1406
  } catch (e: any) {
1581
1407
  if (attempt >= maxAttempts || !isTransientPiError(e)) throw e
1582
1408
  const backoffMs = Math.min(30000, 2000 * 2 ** (attempt - 1))
@@ -1586,32 +1412,6 @@ async function chatCompleteViaPi(opts: Parameters<typeof runPi>[0]): Promise<str
1586
1412
  }
1587
1413
  }
1588
1414
 
1589
- // pi's --model accepts "provider/id" (e.g. openai-codex/gpt-5.6-sol), so this one
1590
- // setting pins both. Empty/unset = whatever pi is configured to use.
1591
- function piSummaryModel(): string {
1592
- return (process.env.VOICENOTE_PI_MODEL || '').trim()
1593
- }
1594
-
1595
- function piThinkingLevel(): string {
1596
- return process.env.VOICENOTE_PI_THINKING || 'high'
1597
- }
1598
-
1599
- function piSummaryTools(): string {
1600
- // Default ON: let the summary model read/grep prior notes for cross-reference consistency.
1601
- // Set VOICENOTE_PI_SUMMARY_TOOLS='' (empty) to disable.
1602
- const v = process.env.VOICENOTE_PI_SUMMARY_TOOLS
1603
- if (v === undefined) return 'read,grep'
1604
- return v.trim()
1605
- }
1606
-
1607
- function summaryContextDir(config: Config): string {
1608
- // Directory the summary model may read/grep for cross-reference consistency.
1609
- // Defaults to the workspace itself. Users can opt into a wider notes/vault
1610
- // directory with VOICENOTE_CONTEXT_DIR, but the published default must not
1611
- // read outside the configured workspace.
1612
- return expandHome(process.env.VOICENOTE_CONTEXT_DIR || config.workspace)
1613
- }
1614
-
1615
1415
  function piSummaryToolsHint(contextDir: string): string {
1616
1416
  return `Before writing the notes you have two read-only tools: read and grep. Your current working directory (cwd) is \`${contextDir}\` (the configured notes/reference directory); use relative paths for grep/read.\n\nGoal: use existing context to align names, speakers, client/project names, product names, and domain terms in this note; do not maintain or assume a separate glossary.\n\nSuggested flow:\n- First extract the most likely client/project/product keywords from the title, filename, and transcript.\n- If a clear topic matches, prefer grep/read on related index pages, project docs, status records, or the 3-5 most recent related notes in the same directory; use them to identify Speaker B/C/F etc., common aliases, product names, and term spellings.\n- If no clear topic matches, grep the current directory with keywords and read only the few most relevant files.\n- Before output, do one names/terms lint pass: eliminate leftover Speaker A/B/C, obviously misheard names, product-name variants, and outdated names; when context is insufficient, keep the uncertainty — never guess.\n\nConstraints:\n- At most 10 tool calls total; if the transcript alone is sufficient, make none.\n- Read only within \`${contextDir}\`; skip directories that clearly involve personal privacy/credentials/finance (e.g. identity / credentials / finance).\n- Found information is only for consistency and background calibration; never write content absent from this transcript into the notes as new meeting facts.\n- Do not attempt to write files or call bash (those tools are not enabled).`
1617
1417
  }
@@ -1621,17 +1421,17 @@ function piSummaryToolsHint(contextDir: string): string {
1621
1421
  // missing, say so loudly and run without tools rather than searching the wrong
1622
1422
  // tree (tools, the cwd hint, and the spawn cwd move together).
1623
1423
  async function chatComplete(opts: { systemPrompt: string; userPrompt: string; config: Config }): Promise<string> {
1624
- const wantTools = !!piSummaryTools()
1625
- const ctx = wantTools ? summaryContextDir(opts.config) : undefined
1424
+ const { pi } = opts.config
1425
+ const ctx = pi.tools ? pi.contextDir : undefined
1626
1426
  const ctxExists = ctx ? existsSync(ctx) : false
1627
1427
  if (ctx && !ctxExists) console.error(`Warning: context dir ${ctx} does not exist; summary agent runs WITHOUT read/grep cross-reference.`)
1628
- const toolsActive = wantTools && ctxExists
1629
- return chatCompleteViaPi({
1428
+ const toolsActive = !!ctx && ctxExists
1429
+ return chatCompleteViaPi(opts.config, {
1630
1430
  systemPrompt: opts.systemPrompt,
1631
1431
  userPrompt: opts.userPrompt,
1632
1432
  timeoutMs: 60 * 60 * 1000,
1633
- thinking: piThinkingLevel(),
1634
- tools: toolsActive ? piSummaryTools() : undefined,
1433
+ thinking: pi.thinking,
1434
+ tools: toolsActive ? pi.tools : undefined,
1635
1435
  appendSystemPrompt: toolsActive ? piSummaryToolsHint(ctx!) : undefined,
1636
1436
  cwd: toolsActive ? ctx : undefined,
1637
1437
  })
@@ -1671,14 +1471,14 @@ function normalizeMetadata(meta: Json, rec: Recording): Json {
1671
1471
  }
1672
1472
 
1673
1473
  const SOURCE_MARKER = '<!-- voicenote:source -->'
1674
- function sourceDetails(meta: Json, audioPath: string, transcriptPath: string): string {
1474
+ function sourceDetails(audioPath: string, transcriptPath: string): string {
1675
1475
  return `${SOURCE_MARKER}\n<details>\n<summary>Source</summary>\n\n- Generated by: voicenote automatic transcription\n- Original audio: \`${audioPath}\`\n- Full transcript: \`${transcriptPath}\`\n\n</details>`
1676
1476
  }
1677
1477
 
1678
1478
  function markdownNotes(meta: Json, audioPath: string, transcriptPath: string): string {
1679
1479
  let body = typeof meta.markdown === 'string' && meta.markdown.trim() ? meta.markdown.trim() : `# ${meta.title || 'Untitled recording notes'}\n`
1680
1480
  if (!body.startsWith('#')) body = `# ${meta.title || 'Untitled recording notes'}\n\n${body}`
1681
- if (!body.includes(SOURCE_MARKER)) body = `${body.trim()}\n\n${sourceDetails(meta, audioPath, transcriptPath)}`
1481
+ if (!body.includes(SOURCE_MARKER)) body = `${body.trim()}\n\n${sourceDetails(audioPath, transcriptPath)}`
1682
1482
  return `${body.trim()}\n`
1683
1483
  }
1684
1484
 
@@ -1730,7 +1530,7 @@ function transcriptMarkdown(config: Config, rec: Recording, transcript: string,
1730
1530
 
1731
1531
  async function processRecording(config: Config, rec: Recording, opts: any): Promise<Json> {
1732
1532
  const jobStarted = Date.now()
1733
- let files = (opts.resumeFromTranscriptFiles as LocalFiles | null) || initialLocalFiles(config, rec)
1533
+ let files = (opts.resumeFromTranscriptFiles as LocalFiles | null) || layout(config, rec)
1734
1534
  const mode = normalizeRunMode(opts)
1735
1535
  const needsNotes = mode === 'notes'
1736
1536
  const resumeSummary = needsNotes && Boolean(opts.resumeFromTranscriptFiles)
@@ -1788,7 +1588,7 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1788
1588
 
1789
1589
  let summaryError: any = null
1790
1590
  if (needsNotes) {
1791
- progressStep(nextStep(), totalSteps, 'Generate integrated semantic notes', `via pi, model=${piSummaryModel() || "pi's own default"}`)
1591
+ progressStep(nextStep(), totalSteps, 'Generate integrated semantic notes', `via pi, model=${config.pi.model || "pi's own default"}`)
1792
1592
  try {
1793
1593
  meta = await withHeartbeat('generate integrated semantic notes', () => summarizeTranscript(config, transcript, rec, files.audio), 60)
1794
1594
  } catch (e: any) {
@@ -1816,22 +1616,13 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1816
1616
  progressStep(nextStep(), totalSteps, 'Write outputs and index')
1817
1617
  let failedStubPathToRemove: string | null = null
1818
1618
  if (needsNotes && !summaryError) {
1819
- const previousNotes = files.notes
1820
- const previousMetadata = files.metadata
1821
- const titled = await titledLocalFiles(config, rec, meta, files)
1822
- // titledLocalFiles renames the audio; manually move our already-written transcript too.
1823
- if (titled.transcript !== files.transcript && existsSync(files.transcript)) {
1824
- await mkdir(dirname(titled.transcript), { recursive: true })
1825
- if (existsSync(titled.transcript)) await unlink(titled.transcript)
1826
- await rename(files.transcript, titled.transcript)
1827
- }
1619
+ const titled = layout(config, rec, meta.title)
1620
+ await promoteOutputs(files, titled)
1621
+ // The stub note of a failed attempt is removed only after the real note is
1622
+ // written, so a failure in between still leaves the user a pointer to the
1623
+ // saved transcript.
1624
+ if (files.notes !== titled.notes) failedStubPathToRemove = files.notes
1828
1625
  files = titled
1829
- if (previousNotes !== files.notes) failedStubPathToRemove = previousNotes
1830
- // Resuming a failed run whose title changed leaves the old untitled metadata
1831
- // from the failed attempt orphaned (note stub is handled above; metadata was not).
1832
- if (previousMetadata !== files.metadata && existsSync(previousMetadata)) {
1833
- await unlink(previousMetadata).catch(e => warnSideEffect(`remove orphaned metadata ${previousMetadata}`, e))
1834
- }
1835
1626
  }
1836
1627
  await mkdir(dirname(files.notes), { recursive: true })
1837
1628
  await mkdir(dirname(files.metadata), { recursive: true })
@@ -1911,7 +1702,7 @@ async function loadState(config: Config): Promise<StateFile> {
1911
1702
  return migrateLegacyState(await readLegacyState(config), nowIso())
1912
1703
  }
1913
1704
  const store = existsSync(path) ? parseStateFile(await readFile(path, 'utf8'), path) : emptyState()
1914
- lastSavedState.set(path, JSON.stringify(store))
1705
+ lastSavedState = JSON.stringify(store)
1915
1706
  return store
1916
1707
  }
1917
1708
 
@@ -1925,23 +1716,19 @@ async function migrateStateOnDisk(config: Config): Promise<void> {
1925
1716
  if (existsSync(path) || !existsSync(legacy)) return
1926
1717
  const store = migrateLegacyState(await readLegacyState(config), nowIso())
1927
1718
  await writeJson(path, store)
1928
- lastSavedState.set(path, JSON.stringify(store))
1719
+ lastSavedState = JSON.stringify(store)
1929
1720
  await rename(legacy, `${legacy}.v1.bak`).catch(e => warnSideEffect('archive pre-0.18 state', e))
1930
1721
  console.log(`Converted ${basename(legacy)} → ${basename(path)} (${Object.keys(store.jobs).length} records; old file kept as .v1.bak)`)
1931
1722
  }
1932
1723
 
1933
- // The scheduler ticks every 60s and the workspace is often a synced folder
1934
- // (iCloud/Dropbox). Rewriting an unchanged state file on every tick would be
1935
- // pure sync noise, so writes are content-gated. Keyed by path, not a single
1936
- // value: `vn serve` handles config.set (which can move the workspace) and runs
1937
- // in one process, and a shared key could skip the first write to a new path.
1938
- const lastSavedState = new Map<string, string>()
1724
+ // Workspaces are often synced folders; skip writes when a run did not change
1725
+ // the state.
1726
+ let lastSavedState = ''
1939
1727
  async function saveState(config: Config, store: StateFile): Promise<void> {
1940
- const path = statePathFor(config)
1941
1728
  const serialized = JSON.stringify(store)
1942
- if (serialized === lastSavedState.get(path)) return
1943
- await writeJson(path, store)
1944
- lastSavedState.set(path, serialized)
1729
+ if (serialized === lastSavedState) return
1730
+ await writeJson(statePathFor(config), store)
1731
+ lastSavedState = serialized
1945
1732
  }
1946
1733
 
1947
1734
  /** Upsert the scan-time facts; never touches lifecycle fields. */
@@ -1959,11 +1746,6 @@ function recordFor(store: StateFile, rec: Recording): JobRecord {
1959
1746
  return next
1960
1747
  }
1961
1748
 
1962
- function setJobState(store: StateFile, id: string, patch: Partial<JobRecord>): void {
1963
- const entry = store.jobs[id]
1964
- if (entry) patchJob(entry, patch, nowIso())
1965
- }
1966
-
1967
1749
  // The live job, declared by the run itself. Lives next to run.lock (machine
1968
1750
  // state, not workspace data) and carries the pid so a reader can tell a live
1969
1751
  // job from one whose process was killed.
@@ -1995,10 +1777,19 @@ function reportStep(step: string): void {
1995
1777
  }
1996
1778
 
1997
1779
  function readCurrent(): CurrentJob | null {
1780
+ let raw: string
1781
+ try { raw = readFileSync(CURRENT_PATH, 'utf8') } catch (e: any) {
1782
+ if (e?.code !== 'ENOENT') warnSideEffect('read current job', e)
1783
+ return null
1784
+ }
1785
+ // A damaged file means a live job shows up as queued; treating it as "no job"
1786
+ // is the safe read, but it must not be silent.
1998
1787
  try {
1999
- const c = JSON.parse(readFileSync(CURRENT_PATH, 'utf8'))
2000
- return Number.isFinite(c?.pid) && typeof c?.source_id === 'string' ? c : null
2001
- } catch { return null }
1788
+ const c = JSON.parse(raw)
1789
+ if (Number.isFinite(c?.pid) && typeof c?.source_id === 'string') return c
1790
+ warnSideEffect('read current job', new Error(`${CURRENT_PATH} has no pid/source_id`))
1791
+ } catch (e) { warnSideEffect('read current job', e) }
1792
+ return null
2002
1793
  }
2003
1794
 
2004
1795
  function pidAlive(pid: number): boolean {
@@ -2047,7 +1838,7 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2047
1838
  return
2048
1839
  }
2049
1840
  const { recordings, complete: scanComplete } = single
2050
- ? { recordings: [await toRecording(single)], complete: false }
1841
+ ? { recordings: [await toRecording(config, single)], complete: false }
2051
1842
  : await scanRecordings(config)
2052
1843
  const mode = normalizeRunMode(opts)
2053
1844
  const force = Boolean(opts.force)
@@ -2058,7 +1849,9 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2058
1849
  // idle-suppressed silence meant for the 60s scheduler tick.
2059
1850
  const verboseSkips = Boolean(opts.verbose || opts.dryRun || single)
2060
1851
  const seen = new Set<string>()
2061
- const limits = single ? { maxAgeHours: 0, minBytes: 0, minDurationSeconds: 0 } : limitsOf(config)
1852
+ const limits = single
1853
+ ? { maxAgeHours: 0, minBytes: 0, minDurationSeconds: 0 }
1854
+ : { maxAgeHours: config.maxAgeHours, minBytes: config.minBytes, minDurationSeconds: config.minDurationSeconds }
2062
1855
  for (const rec of recordings) {
2063
1856
  seen.add(rec.sourceId)
2064
1857
  const entry = recordFor(store, rec)
@@ -2066,7 +1859,7 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2066
1859
  if (verdict.run) { eligible.push(rec); continue }
2067
1860
  skipCounts[verdict.code] = (skipCounts[verdict.code] || 0) + 1
2068
1861
  ;(skipSamples[verdict.code] ||= []).push(entry.name)
2069
- if (verdict.persist) setJobState(store, rec.sourceId, { state: 'filtered', code: verdict.code, detail: verdict.detail })
1862
+ if (verdict.persist) patchJob(entry, { state: 'filtered', code: verdict.code, detail: verdict.detail }, nowIso())
2070
1863
  if (verboseSkips) console.log(` Skip: ${entry.name} (${verdict.code}${verdict.detail ? `: ${verdict.detail}` : ''})`)
2071
1864
  }
2072
1865
  // Only prune against a listing we believe to be complete: if the recorder went
@@ -2167,7 +1960,7 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2167
1960
  // Bun standalone executables embed source in a virtual FS, so import.meta.url is
2168
1961
  // NOT a real on-disk path: "/$bunfs/..." on mac/Linux, "B:\~BUN\root\..." on
2169
1962
  // Windows. Either marker means we're the compiled exe (run it directly via
2170
- // process.execPath); otherwise we're bun + cli.mjs on disk. NOTE: matching only
1963
+ // process.execPath); otherwise we're bun + cli.ts on disk. NOTE: matching only
2171
1964
  // $bunfs (the old check) misfired on Windows and leaked the virtual path into the
2172
1965
  // scheduled task's arguments.
2173
1966
  function resolveCli(): { cliPath: string; compiled: boolean } {
@@ -2183,47 +1976,26 @@ function xmlEscape(s: string): string {
2183
1976
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;')
2184
1977
  }
2185
1978
 
2186
- // Pulled in for `vn install-launch-agent`. launchd does NOT inherit your zsh
2187
- // environment, so anything the pipeline needs that lives ONLY in the real
2188
- // environment (e.g. exported from a non-zsh shell, or injected by the GUI)
2189
- // has to be written into the plist's EnvironmentVariables. Values that came
2190
- // from config.json/.zshrc are deliberately NOT embedded: vn run re-reads
2191
- // those files at startup, and since plist env outranks config.json, embedding
2192
- // them would freeze the values — later GUI edits would silently never reach
2193
- // the background agent.
2194
- async function launchAgentEnv(): Promise<Record<string, string>> {
2195
- loadEnvConfig()
1979
+ // Scheduled runs read all business settings from config.json. The plist only
1980
+ // carries a fixed PATH and desktop-bundled runtime paths that do not exist in
1981
+ // that file.
1982
+ async function launchAgentEnv(config: Config): Promise<Record<string, string>> {
2196
1983
  const env: Record<string, string> = {
2197
1984
  PATH: `${os.homedir()}/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`,
2198
1985
  }
2199
- // Embed only what is NOT recoverable from the config files at run time —
2200
- // see envConfig.ts (invariants 3+4) for the full matrix. A real-env value
2201
- // that differs from the file value is embedded as an override but warned
2202
- // about: it may equally be a stale shell session, and it will keep
2203
- // overriding config edits until the scheduler is reinstalled.
2204
- //
2205
- // no_proxy/NO_PROXY carry a volcano-hosts merge we added; substitute the
2206
- // pre-merge real-env original (or drop it entirely if we synthesized the
2207
- // whole value) so the scheduler never freezes our merge over config edits.
2208
- const embedEnv: Record<string, string | undefined> = { ...process.env }
2209
- for (const k of ['no_proxy', 'NO_PROXY'] as const) embedEnv[k] = premergeRealNoProxy[k]
2210
- const fileEnv = fileProvidedEnv()
2211
- const { embed, frozenOverrides } = envKeysToEmbed(ENV_KEYS, embedEnv, hydratedEnvKeys, fileEnv)
2212
- Object.assign(env, embed)
2213
- for (const k of frozenOverrides) {
2214
- console.error(`Warning: environment ${k} overrides the config file. Update or unset it, then re-run \`vn install-launch-agent --load\` to apply the intended value.`)
2215
- }
2216
- // Embed pi's ABSOLUTE path so launchd resolves it regardless of the fixed plist
2217
- // PATH (npm global bin can live outside it under nvm / custom prefixes). Resolve
2218
- // the configured name (including the documented relative `VOICENOTE_PI_BIN="pi"`
2219
- // and a file-sourced relative name); only an absolute override is left as-is.
2220
- // The resolved path is regenerated on every (re)install, so config edits that
2221
- // change VOICENOTE_PI_BIN take effect via ensure_agent's forced reinstall.
2222
- const configuredPi = env.VOICENOTE_PI_BIN ?? process.env.VOICENOTE_PI_BIN
2223
- if (!configuredPi?.startsWith('/')) {
2224
- const w = await runCommand(IS_WINDOWS ? 'where' : 'which', [configuredPi || 'pi'], 5000)
2225
- const p = w.code === 0 ? (w.stdout.trim().split(/\r?\n/)[0] || '') : ''
2226
- if (p && existsSync(p)) env.VOICENOTE_PI_BIN = p
1986
+ // Provenance matters here, so this reads the raw sources rather than Config:
1987
+ // only paths the GUI injected into our environment (and that config.json does
1988
+ // not already carry) have to be written into the plist.
1989
+ const fileEnv = configFileEnv()
1990
+ for (const key of ['VOICENOTE_PI_CLI', 'VOICENOTE_FFPROBE_BIN'] as const) {
1991
+ if (process.env[key] && process.env[key] !== fileEnv[key]) env[key] = process.env[key]!
1992
+ }
1993
+ const configuredPi = config.pi.bin
1994
+ if (configuredPi.startsWith('/')) env.VOICENOTE_PI_BIN = configuredPi
1995
+ else {
1996
+ const found = await runCommand(IS_WINDOWS ? 'where' : 'which', [configuredPi], 5000)
1997
+ const path = found.code === 0 ? (found.stdout.trim().split(/\r?\n/)[0] || '') : ''
1998
+ if (path && existsSync(path)) env.VOICENOTE_PI_BIN = path
2227
1999
  }
2228
2000
  return env
2229
2001
  }
@@ -2237,7 +2009,7 @@ async function installLaunchAgent(opts: { load?: boolean } = {}): Promise<void>
2237
2009
  const plist = plistPath()
2238
2010
  await mkdir(dirname(plist), { recursive: true })
2239
2011
  await mkdir(LOG_DIR, { recursive: true })
2240
- const env = await launchAgentEnv()
2012
+ const env = await launchAgentEnv(getConfig())
2241
2013
  const envEntries = Object.entries(env)
2242
2014
  .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`).join('\n')
2243
2015
  const content = `<?xml version="1.0" encoding="UTF-8"?>
@@ -2268,9 +2040,7 @@ ${envEntries}
2268
2040
  </plist>
2269
2041
  `
2270
2042
  await writeFile(plist, content, 'utf8')
2271
- // The plist may embed real-env secrets (proxy credentials, exported keys);
2272
- // chmod explicitly — writeFile's mode only applies on creation, and existing
2273
- // plists from older installs are 0644.
2043
+ // Keep scheduler details private and tighten permissions on older plists.
2274
2044
  await chmod(plist, 0o600)
2275
2045
  const summary = Object.keys(env).join(', ')
2276
2046
  console.log(`LaunchAgent written: ${plist}`)
@@ -2319,7 +2089,7 @@ async function installScheduledTask(opts: { load?: boolean } = {}): Promise<void
2319
2089
  await mkdir(STATE_DIR, { recursive: true })
2320
2090
  await mkdir(LOG_DIR, { recursive: true })
2321
2091
  // The task carries no env (Task Scheduler has no per-task env block), so the
2322
- // bundled-engine paths the GUI injected via process env (pi runtime + cli.js +
2092
+ // bundled CLI paths the GUI injected via process env (pi runtime + cli.js +
2323
2093
  // ffprobe) must be persisted to config.json, which `vn run` reads on startup.
2324
2094
  // (On mac these ride in the LaunchAgent plist instead.)
2325
2095
  const persist: Record<string, string> = {}
@@ -2328,9 +2098,9 @@ async function installScheduledTask(opts: { load?: boolean } = {}): Promise<void
2328
2098
  }
2329
2099
  if (Object.keys(persist).length) {
2330
2100
  await mkdir(CONFIG_DIR, { recursive: true })
2331
- const current = loadJsonSync<Record<string, unknown>>(CONFIG_ENV_PATH, {})
2101
+ const current = loadConfigJson()
2332
2102
  Object.assign(current, persist)
2333
- await writeFile(CONFIG_ENV_PATH, JSON.stringify(current, null, 2) + '\n')
2103
+ await writeConfigJson(current)
2334
2104
  }
2335
2105
  const { command, argLine } = schedulerProgramArgs()
2336
2106
  // bun.exe / vn.exe are console-subsystem: an InteractiveToken task flashes a
@@ -2418,7 +2188,11 @@ async function uninstallScheduledTask(): Promise<void> {
2418
2188
  // leftover copy could make schedulerIsCurrent misjudge a future install.
2419
2189
  // Only when the task is actually gone — deleting the VBS while the task is
2420
2190
  // still registered would turn every tick into a silent wscript failure.
2421
- if (r.code === 0) for (const p of [taskVbsPath(), taskXmlPath()]) { try { unlinkSync(p) } catch {} }
2191
+ if (r.code === 0) {
2192
+ for (const p of [taskVbsPath(), taskXmlPath()]) {
2193
+ try { unlinkSync(p) } catch (e: any) { if (e?.code !== 'ENOENT') warnSideEffect(`remove scheduler artifact ${p}`, e) }
2194
+ }
2195
+ }
2422
2196
  console.log(r.code === 0 ? `Scheduled task '${TASK_NAME}' removed.` : `schtasks /delete: ${(r.stderr || r.stdout).trim()}`)
2423
2197
  }
2424
2198
 
@@ -2466,7 +2240,7 @@ async function listMeetings(opts: { month?: string }): Promise<void> {
2466
2240
  async function notesIndexPath(config: Config): Promise<string> {
2467
2241
  const p = join(config.workspace, '_index', 'notes.jsonl')
2468
2242
  const legacy = join(config.workspace, '_index', 'meetings.jsonl')
2469
- if (!existsSync(p) && existsSync(legacy)) await rename(legacy, p).catch(() => {})
2243
+ if (!existsSync(p) && existsSync(legacy)) await rename(legacy, p).catch(e => warnSideEffect(`rename ${legacy}`, e))
2470
2244
  return p
2471
2245
  }
2472
2246
 
@@ -2575,16 +2349,22 @@ async function showErrors(opts: { lines?: number }): Promise<void> {
2575
2349
  }
2576
2350
 
2577
2351
  async function upgradeSelf(): Promise<void> {
2578
- const cmd = IS_WINDOWS ? 'bun' : (existsSync('/opt/homebrew/bin/bun') ? '/opt/homebrew/bin/bun' : 'bun')
2352
+ // The registry fetch needs the configured proxy: `bun add -g` only sees it if
2353
+ // we pass it, because the proxy lives in config.json, not in the shell.
2354
+ const env = { ...process.env, ...getConfig().childEnv }
2355
+ // Plain `bun` from PATH: vn is started by bun (`#!/usr/bin/env bun`), so an
2356
+ // interactive upgrade always has it. If it is somehow missing, the spawn error
2357
+ // below says so instead of the command silently "failing".
2579
2358
  // `bun add -g` upgrades in place: verified no dependency loop on npm→npm re-add
2580
2359
  // (the steady-state upgrade path) nor on replacing an old git-ref install. No
2581
2360
  // remove-first, so a failed add leaves the running vn intact.
2582
- console.log(`$ ${cmd} add -g @fastagent-sh/voicenote`)
2361
+ console.log('$ bun add -g @fastagent-sh/voicenote')
2583
2362
  const addCode = await new Promise<number>(res =>
2584
- spawn(cmd, ['add', '-g', '@fastagent-sh/voicenote'], { stdio: 'inherit', shell: IS_WINDOWS })
2585
- .on('close', c => res(c ?? 1)).on('error', () => res(1)))
2363
+ spawn('bun', ['add', '-g', '@fastagent-sh/voicenote'], { stdio: 'inherit', shell: IS_WINDOWS, env })
2364
+ .on('close', c => res(c ?? 1))
2365
+ .on('error', (e: Error) => { console.error(`Cannot run bun: ${e.message}`); res(1) }))
2586
2366
  if (addCode !== 0) {
2587
- console.error(`Upgrade failed: \`${cmd} add -g @fastagent-sh/voicenote\` exited ${addCode}. Your current install is unchanged; retry later.`)
2367
+ console.error(`Upgrade failed: \`bun add -g @fastagent-sh/voicenote\` exited ${addCode}. Your current install is unchanged; retry later.`)
2588
2368
  process.exitCode = 1
2589
2369
  return
2590
2370
  }
@@ -2696,11 +2476,11 @@ async function collectDoctor() {
2696
2476
  const config = getConfig()
2697
2477
  // pi is a bun-based CLI; cold start (esp. behind a proxy) can take >5s, so
2698
2478
  // give --version a generous timeout to avoid a false 'missing' on a healthy pi.
2699
- const piInv = piInvocation(['--version'])
2479
+ const piInv = piInvocation(config.pi, ['--version'])
2700
2480
  const piCheck = await runCommand(piInv.bin, piInv.args, 15000)
2701
- const ff = await runCommand(ffprobeBin(), ['-version'], 5000)
2481
+ const ff = await runCommand(config.ffprobeBin, ['-version'], 5000)
2702
2482
  const v = config.volcano
2703
- const tools = piSummaryTools()
2483
+ const { pi } = config
2704
2484
  return {
2705
2485
  version: VERSION,
2706
2486
  bun: process.versions.bun || null,
@@ -2718,12 +2498,11 @@ async function collectDoctor() {
2718
2498
  : { configured: false as const },
2719
2499
  // Provider/model/credentials are pi's own configuration; `pi.available` is
2720
2500
  // all we can honestly report about whether a summary can run.
2721
- summary: { backend: 'pi', model: piSummaryModel() || null, thinking: piThinkingLevel(), tools: tools || null, contextDir: tools ? summaryContextDir(config) : null },
2722
- pi: { bin: piCodexBin(), version: piCheck.code === 0 ? (piCheck.stdout.trim() || piCheck.stderr.trim() || null) : null, available: piCheck.code === 0, auth: existsSync(piAuthPath()), authPath: piAuthPath() },
2723
- // Outbound proxy for HTTPS endpoints (updater/GitHub): honor the standard
2724
- // env chain, not just lowercase http_proxy — an https_proxy-only setup must
2725
- // still route the updater.
2726
- proxy: { url: process.env.https_proxy || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.HTTP_PROXY || null },
2501
+ summary: { backend: 'pi', model: pi.model, thinking: pi.thinking, tools: pi.tools || null, contextDir: pi.tools ? pi.contextDir : null },
2502
+ pi: { bin: pi.bin, version: piCheck.code === 0 ? (piCheck.stdout.trim() || piCheck.stderr.trim() || null) : null, available: piCheck.code === 0, auth: existsSync(pi.authPath), authPath: pi.authPath },
2503
+ // Outbound proxy for HTTPS endpoints (updater/GitHub). The GUI reads this to
2504
+ // route its own update check, so it reports the resolved value.
2505
+ proxy: { url: config.childEnv.https_proxy ?? null },
2727
2506
  identity: { self: config.speakers.self.name || null, aliases: config.speakers.self.aliases, knownCount: config.speakers.known.length },
2728
2507
  // The thresholds that silently decide what never gets processed. Without
2729
2508
  // them here, confirming a change to VOICENOTE_MAX_AGE_HOURS meant planting
@@ -2802,24 +2581,12 @@ async function doctor(opts: { json?: boolean } = {}): Promise<void> {
2802
2581
  console.log(`ffprobe=${s.deps.ffprobe ? 'ok' : 'missing'}`)
2803
2582
  }
2804
2583
 
2805
- // ────────────────────────────────────────────────────────────────────────────
2806
- // serve — persistent JSON-RPC engine over stdio (the desktop GUI's client)
2807
- // ────────────────────────────────────────────────────────────────────────────
2808
- // One long-lived process the GUI talks to instead of spawning `vn` per call, so
2809
- // Bun cold start (and on Windows the AV scan + console flash) is paid ONCE.
2810
- // Protocol (newline-delimited JSON on stdio):
2811
- // client→: {"type":"req","id":N,"method":M,"params":P}
2812
- // →client: {"type":"res","id":N,"result":R} | {"type":"res","id":N,"error":E}
2813
- // →client: {"type":"event","event":"login-event","payload":{...}} (login stream)
2814
-
2815
- // Is the background scheduler already installed AND pointing at THIS binary?
2816
- // (Mirrors what the GUI's ensure_agent used to check in Rust; kept here so the
2817
- // staleness logic lives in one place.)
2584
+ // Is the background scheduler installed and pointing at this binary?
2818
2585
  async function schedulerIsCurrent(): Promise<boolean> {
2819
2586
  const exe = process.execPath
2820
2587
  if (IS_WINDOWS) {
2821
2588
  if ((await runCommand('schtasks', ['/query', '/tn', TASK_NAME], 10000)).code !== 0) return false
2822
- // The task XML points at wscript; the actual engine path lives in the VBS.
2589
+ // The task XML points at wscript; the actual CLI path lives in the VBS.
2823
2590
  try { return readFileSync(taskVbsPath(), 'utf16le').includes(exe) } catch { return false }
2824
2591
  }
2825
2592
  try { return readFileSync(plistPath(), 'utf8').includes(exe) } catch { return false }
@@ -2831,99 +2598,6 @@ async function ensureScheduler(force: boolean): Promise<{ ok: true; skipped?: bo
2831
2598
  return { ok: true }
2832
2599
  }
2833
2600
 
2834
- async function dispatchServe(req: any, send: (o: unknown) => void): Promise<void> {
2835
- const { id, method, params } = req || {}
2836
- try {
2837
- let result: unknown
2838
- switch (method) {
2839
- case 'config.get': result = configGetData(); break
2840
- case 'config.set': result = await configSetData(params || {}); break
2841
- case 'doctor': result = await collectDoctor(); break
2842
- case 'jobs': result = await jobsListData(parseJobsLimit(params?.limit, 40)); break
2843
- case 'ensure_agent': result = await ensureScheduler(!!params?.force); break
2844
- case 'run': {
2845
- // Long-running (minutes) like login: ack immediately so the GUI's 60s
2846
- // request timeout can't misread it as a wedged engine. Progress shows
2847
- // via the jobs poll; acquireRunLock inside runPipeline dedupes against
2848
- // the scheduler tick and a double-click.
2849
- void runPipeline(undefined, {}).catch(e => console.error('manual run failed:', e?.message || e))
2850
- result = { started: true }
2851
- break
2852
- }
2853
- case 'login': {
2854
- // Ack immediately: the OAuth round-trip takes minutes (user in browser),
2855
- // and the GUI client times requests out after 60s — a long-lived login
2856
- // response would be misread as a wedged engine. Progress and outcome
2857
- // ride entirely on login-event; the response carries nothing.
2858
- void (async () => {
2859
- let ok = false
2860
- // Attempt latch: once this attempt settles (timeout or completion),
2861
- // late events from a still-dangling OAuth flow must not reach the
2862
- // UI — a stale success/error would clobber a NEWER login attempt's
2863
- // state (the closed for this attempt has already been sent).
2864
- let settled = false
2865
- const sendEvent = (o: Record<string, unknown>) => { if (!settled) send({ type: 'event', event: 'login-event', payload: o }) }
2866
- try {
2867
- // Bound the OAuth wait: if the user closes the browser without
2868
- // authorizing, the callback never arrives and the flow would hang
2869
- // forever — with the GUI's login button locked until app restart.
2870
- // True cancellation is not available (the browser flow of
2871
- // @earendil-works/pi-ai takes no AbortSignal), so on timeout the
2872
- // abandoned flow keeps running muted (settled latch above). Two
2873
- // consequences, both surfaced in the timeout message: a LATE
2874
- // authorization still persists credentials silently (login may
2875
- // actually have succeeded — hence “refresh to confirm”), and the dangling
2876
- // localhost callback server may hold its port until serve exits,
2877
- // so an immediate retry can fail fast with a port-busy error.
2878
- const timeout = new Promise<never>((_, rej) => {
2879
- const t = setTimeout(() => rej(new Error('Login timed out: authorization was not completed within 10 minutes. If you just authorized in the browser, click Refresh to confirm login status; otherwise retry')), 10 * 60 * 1000)
2880
- ;(t as any).unref?.()
2881
- })
2882
- await Promise.race([
2883
- loginChatGPT({ json: true, deviceCode: !!params?.deviceCode, emit: (o) => { if (o.event === 'success') ok = true; sendEvent(o) } }),
2884
- timeout,
2885
- ])
2886
- } catch (e: any) {
2887
- // loginChatGPT handles its own errors; this catches the timeout
2888
- // above plus anything it lets escape (fail visibly).
2889
- sendEvent({ event: 'error', message: String(e?.message || e) })
2890
- }
2891
- sendEvent({ event: 'closed', code: ok ? 0 : 1 })
2892
- settled = true
2893
- })()
2894
- result = { started: true }
2895
- break
2896
- }
2897
- default: throw new Error(`unknown method: ${method}`)
2898
- }
2899
- send({ type: 'res', id, result })
2900
- } catch (e: any) {
2901
- send({ type: 'res', id, error: String(e?.message || e) })
2902
- }
2903
- }
2904
-
2905
- async function serve(): Promise<void> {
2906
- loadEnvConfig()
2907
- // The protocol owns stdout; route any stray console.log from reused helpers
2908
- // (e.g. installScheduler) to stderr so it can't corrupt the JSONL stream.
2909
- console.log = (...args: any[]) => { console.error(...args) }
2910
- const send = (o: unknown) => process.stdout.write(JSON.stringify(o) + '\n')
2911
- let buf = ''
2912
- process.stdin.setEncoding('utf8')
2913
- process.stdin.on('data', (chunk: string) => {
2914
- buf += chunk
2915
- let nl: number
2916
- while ((nl = buf.indexOf('\n')) >= 0) {
2917
- const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1)
2918
- if (!line) continue
2919
- let req: any
2920
- try { req = JSON.parse(line) } catch { continue }
2921
- void dispatchServe(req, send)
2922
- }
2923
- })
2924
- await new Promise<void>((resolve) => { process.stdin.on('end', resolve); process.stdin.on('close', resolve) })
2925
- }
2926
-
2927
2601
  // ────────────────────────────────────────────────────────────────────────────
2928
2602
  // CLI commands
2929
2603
  // ────────────────────────────────────────────────────────────────────────────
@@ -2968,7 +2642,6 @@ cli.command('upgrade', 'Upgrade to the latest published version via bun add -g')
2968
2642
  cli.command('doctor', 'Check environment')
2969
2643
  .option('--json', 'Output structured status as JSON (for the GUI)')
2970
2644
  .action((opts: { json?: boolean }) => doctor(opts))
2971
- cli.command('serve', 'Run a persistent JSON-RPC engine over stdio (used by the desktop GUI)').action(serve)
2972
2645
  cli.command('login', 'Sign in to ChatGPT (Codex OAuth) for the pi summary backend')
2973
2646
  .option('--json', 'Emit machine-readable JSON events (for the GUI client)')
2974
2647
  .option('--device-code', 'Use the device-code flow instead of the browser callback (needs the ChatGPT security-settings opt-in)')
@@ -2983,9 +2656,27 @@ cli.command('config <action>', 'Read/write file-based config. action: get (print
2983
2656
  cli.command('install-launch-agent', 'Install background scheduler (mac LaunchAgent / Windows Task Scheduler)')
2984
2657
  .option('--load', 'Also (re)load/start it immediately')
2985
2658
  .action((opts: { load?: boolean }) => installScheduler(opts))
2659
+ cli.command('ensure-launch-agent', 'Install the background scheduler when missing or stale')
2660
+ .option('--force', 'Reinstall even when the scheduler is current')
2661
+ .action((opts: { force?: boolean }) => ensureScheduler(!!opts.force))
2986
2662
  cli.command('uninstall-launch-agent', 'Remove the background scheduler').action(uninstallScheduler)
2987
2663
  cli.command('status', 'Print background scheduler status').action(printSchedulerStatus)
2988
2664
 
2989
2665
  cli.help()
2990
2666
  cli.version(VERSION)
2991
- cli.parse()
2667
+ // Run the command ourselves so a thrown error (bad config, unreadable state
2668
+ // file) reaches the user as the one line it is, not as a bun stack trace.
2669
+ const parsed = cli.parse(process.argv, { run: false })
2670
+ // cac prints --help/--version itself and then reports no matched command; any
2671
+ // OTHER unmatched invocation is a typo, which it would ignore in silence.
2672
+ if (!cli.matchedCommand && !parsed.options.help && !parsed.options.version) {
2673
+ if (parsed.args.length) console.error(`vn: unknown command '${parsed.args[0]}'`)
2674
+ cli.outputHelp()
2675
+ process.exit(parsed.args.length ? 1 : 0)
2676
+ }
2677
+ try {
2678
+ await cli.runMatchedCommand()
2679
+ } catch (e: any) {
2680
+ console.error(`vn: ${e?.message || e}`)
2681
+ process.exit(1)
2682
+ }