@fastagent-sh/voicenote 0.20.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.20.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,9 +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
- const PI_AUTH_PATH = join(os.homedir(), '.pi', 'agent', 'auth.json')
35
34
 
36
35
  const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.wma', '.aac', '.flac'])
37
36
 
@@ -68,15 +67,6 @@ type SpeakerKnown = { name: string; aliases: string[]; relationship?: string | n
68
67
  type SpeakersConfig = { self: SpeakerSelf; known: SpeakerKnown[] }
69
68
 
70
69
 
71
- type VolcanoTosConfig = {
72
- endpoint: string
73
- region: string
74
- bucket: string
75
- accessKey: string
76
- secretKey: string
77
- keep: boolean
78
- }
79
-
80
70
  type VolcanoConfig = {
81
71
  apiKey: string // X-Api-Key (new Volcano console)
82
72
  resourceId: string
@@ -84,8 +74,21 @@ type VolcanoConfig = {
84
74
  tos: VolcanoTosConfig
85
75
  }
86
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
+
87
91
  type Config = {
88
- deviceVolume: string
89
92
  recordDir: string
90
93
  workspace: string
91
94
  minBytes: number
@@ -93,20 +96,24 @@ type Config = {
93
96
  maxAgeHours: number
94
97
  speakers: SpeakersConfig
95
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>
96
103
  }
97
104
 
98
105
  // ────────────────────────────────────────────────────────────────────────────
99
- // 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.
100
113
  // ────────────────────────────────────────────────────────────────────────────
101
114
 
102
- // Single source of truth for every env var the pipeline reads. Both consumers
103
- // derive from this list so they can never drift:
104
- // - loadEnvConfig() hydrates these from config.json / ~/.zshrc for non-interactive runs
105
- // - launchAgentEnv() embeds the REAL-environment subset into the LaunchAgent
106
- // plist (file-sourced values are skipped — vn run re-reads the files at
107
- // startup, and plist env overrides config.json, so embedding a file value
108
- // would freeze it: later GUI edits would silently never reach the agent)
109
- // 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.
110
117
  const ENV_KEYS = [
111
118
  'VOICENOTE_DEVICE_VOLUME',
112
119
  'VOICENOTE_RECORD_DIR',
@@ -125,8 +132,10 @@ const ENV_KEYS = [
125
132
  'VOLCANO_TOS_KEEP',
126
133
  'VOICENOTE_PI_BIN',
127
134
  'VOICENOTE_PI_CLI',
135
+ 'PI_CODING_AGENT_DIR',
128
136
  'VOICENOTE_FFPROBE_BIN',
129
137
  'VOICENOTE_PI_MODEL',
138
+ 'VOICENOTE_PI_RETRIES',
130
139
  'VOICENOTE_PI_THINKING',
131
140
  'VOICENOTE_PI_SUMMARY_TOOLS',
132
141
  'VOICENOTE_CONTEXT_DIR',
@@ -143,26 +152,6 @@ const ENV_KEYS = [
143
152
  // 2) routing China-mainland Volcano APIs through an overseas proxy is slower / unreliable
144
153
  const VOLCANO_NO_PROXY_HOSTS = ['.volces.com', '.volcengineapi.com', 'openspeech.bytedance.com']
145
154
 
146
- // Provenance: ENV_KEYS this process synthesized — hydrated from config.json /
147
- // .zshrc, or derived (http_proxy from LOCAL_PROXY_HOST or the macOS system
148
- // proxy; no_proxy seeded/merged below) — as opposed to inherited from the
149
- // real environment. Two consumers:
150
- // - reloadEnvConfig() deletes exactly these before re-hydrating, so the
151
- // long-lived `vn serve` picks up GUI config edits immediately;
152
- // - launchAgentEnv() skips them when embedding env into the scheduler
153
- // (they are recoverable at run time; real env values are not).
154
- const hydratedEnvKeys = new Set<string>()
155
-
156
- // Real-environment no_proxy/NO_PROXY values captured BEFORE the volcano-hosts
157
- // merge below. The merged value is partly synthesized and must never be
158
- // embedded into the scheduler (vn run re-merges at startup); launchAgentEnv
159
- // substitutes these originals when deciding what to embed.
160
- const premergeRealNoProxy: Record<string, string> = {}
161
-
162
- // Node/Bun fetch doesn't read the macOS system proxy — only http_proxy env. Read
163
- // the active SCDynamicStore proxy so users whose proxy app sets the system proxy
164
- // (Clash/Surge “system proxy” mode) don't have to type host/port. Prefer HTTPS
165
- // (OpenAI is https); ignore PAC/auth setups. Returns http://host:port or null.
166
155
  function systemProxyUrl(): string | null {
167
156
  if (process.platform !== 'darwin') return null
168
157
  try {
@@ -175,95 +164,52 @@ function systemProxyUrl(): string | null {
175
164
  } catch { return null }
176
165
  }
177
166
 
178
- function applyDerivedProxy(): void {
179
- const host = process.env.LOCAL_PROXY_HOST
180
- const port = process.env.LOCAL_PROXY_PORT
181
- // Source precedence: explicit http_proxy > LOCAL_PROXY_HOST/PORT > macOS system
182
- // proxy. (Volcano always bypasses, below.)
183
- let url: string | null = host && port ? `http://${host}:${port}` : null
184
- if (!url) {
185
- const cur = process.env.http_proxy || process.env.HTTP_PROXY
186
- if (!cur || cur.includes('${')) url = systemProxyUrl()
187
- }
188
- if (url) {
189
- // Set when unset, OR when a config/.zshrc value came in with unexpanded shell
190
- // vars (e.g. "http://${LOCAL_PROXY_HOST}:...") — those are never valid as-is.
191
- const needs = (k: string) => !process.env[k] || process.env[k]!.includes('${')
192
- for (const k of ['http_proxy', 'https_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY']) {
193
- if (needs(k)) { process.env[k] = url; hydratedEnvKeys.add(k) } // derived, not real env
194
- }
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
195
176
  }
196
- // no_proxy/NO_PROXY: seed a base when a proxy is active, then always merge
197
- // the Volcano bypass hosts. Provenance bookkeeping (capture pre-merge real
198
- // original vs mark synthesized-hydrated) lives in deriveNoProxy — pure and
199
- // tested; see envConfig.ts.
200
- const baseNoProxy = process.env.LOCAL_NO_PROXY || 'localhost,127.0.0.1,::1'
201
- for (const k of ['no_proxy', 'NO_PROXY'] as const) {
202
- const r = deriveNoProxy(process.env[k], premergeRealNoProxy[k], hydratedEnvKeys.has(k), !!url, baseNoProxy, VOLCANO_NO_PROXY_HOSTS)
203
- process.env[k] = r.runtime
204
- if (r.hydrate) hydratedEnvKeys.add(k)
205
- if (r.capture !== undefined) premergeRealNoProxy[k] = r.capture
206
- }
207
- }
208
-
209
- // File values for each ENV_KEY. Hydration passes the current environment for
210
- // variable references; scheduler comparison uses files alone. Primary source is
211
- // ~/.config/voicenote/config.json
212
- // (ENV-style runtime keys at the top level; identity under `speakers`); the
213
- // legacy fallback is `export KEY=...` lines in ~/.zshrc, for CLI installs
214
- // that predate config.json. Precedence/expansion logic lives in envConfig.ts
215
- // (pure + tested). Two consumers: loadEnvConfig() hydrates these into
216
- // process.env for keys the real environment doesn't set, and launchAgentEnv()
217
- // uses them to decide which values are recoverable at run time.
218
- function fileProvidedEnv(environment: Record<string, string | undefined> = {}): Record<string, string> {
219
- const data = loadJsonSync<Record<string, unknown>>(CONFIG_ENV_PATH, {})
220
- let zshrc: string | null = null
221
- const zshrcPath = join(os.homedir(), '.zshrc')
222
- if (existsSync(zshrcPath)) { try { zshrc = readFileSync(zshrcPath, 'utf8') } catch { zshrc = null } }
223
- return parseFileEnv(ENV_KEYS, data, zshrc, os.homedir(), environment)
224
- }
225
-
226
- let envConfigLoaded = false
227
- function loadEnvConfig(): void {
228
- if (envConfigLoaded) return
229
- envConfigLoaded = true
230
- // Precedence: process.env > config.json (GUI) > ~/.zshrc (legacy); an
231
- // explicit empty string in the environment is never overridden.
232
- const toApply = hydrateFromFileEnv(ENV_KEYS, process.env, fileProvidedEnv(process.env))
233
- for (const [key, v] of Object.entries(toApply)) { process.env[key] = v; hydratedEnvKeys.add(key) }
234
- // Derive http_proxy etc. from LOCAL_PROXY_HOST/PORT regardless of source, and
235
- // always keep Volcano hosts on NO_PROXY. (Runs even with no config files.)
236
- applyDerivedProxy()
237
- }
238
-
239
- // Re-hydrate after config.json changes. Needed by the long-lived `vn serve`:
240
- // without this, a GUI config edit only reaches OTHER processes (vn run reads
241
- // the file fresh each start), while serve's own doctor kept reporting stale
242
- // values and ensure_agent re-embedded them into the scheduler env on
243
- // reinstall. Only hydrated/derived keys are dropped — real environment
244
- // variables keep their precedence (a real-env no_proxy stays merged in place;
245
- // its pre-merge original survives in premergeRealNoProxy, and the volcano
246
- // merge is idempotent on the next pass).
247
- function reloadEnvConfig(): void {
248
- for (const k of hydratedEnvKeys) delete process.env[k]
249
- hydratedEnvKeys.clear()
250
- envConfigLoaded = false
251
- loadEnvConfig()
252
- }
253
-
254
- function getVolcanoConfigFromEnv(): VolcanoConfig | null {
255
- const apiKey = process.env.VOLCANO_ASR_KEY || ''
256
- const tosAccess = process.env.VOLCANO_TOS_ACCESS_KEY
257
- const tosSecret = process.env.VOLCANO_TOS_SECRET_KEY
258
- 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
259
205
  if (!apiKey || !tosAccess || !tosSecret || !bucket) return null
260
- const region = process.env.VOLCANO_TOS_REGION || 'cn-hongkong'
261
- const endpoint = process.env.VOLCANO_TOS_ENDPOINT || `tos-s3-${region}.volces.com`
262
- 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())
263
209
  return {
264
210
  apiKey,
265
- resourceId: process.env.VOLCANO_ASR_RESOURCE_ID || 'volc.seedasr.auc',
266
- language: process.env.VOLCANO_ASR_LANGUAGE || undefined,
211
+ resourceId: s.VOLCANO_ASR_RESOURCE_ID || 'volc.seedasr.auc',
212
+ language: s.VOLCANO_ASR_LANGUAGE || undefined,
267
213
  tos: { endpoint, region, bucket, accessKey: tosAccess, secretKey: tosSecret, keep },
268
214
  }
269
215
  }
@@ -279,22 +225,67 @@ function volcanoAuthHeaders(volc: VolcanoConfig, taskId: string, includeSequence
279
225
  return base
280
226
  }
281
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
+
282
237
  function getConfig(): Config {
283
- loadEnvConfig()
284
- const deviceVolume = process.env.VOICENOTE_DEVICE_VOLUME || 'VTR6500'
285
- const recordDir = process.env.VOICENOTE_RECORD_DIR || `/Volumes/${deviceVolume}/RECORD`
286
- return {
287
- deviceVolume,
288
- recordDir,
289
- workspace: expandHome(process.env.VOICENOTE_WORKSPACE || '~/Documents/meetings'),
290
- minBytes: Number(process.env.VOICENOTE_MIN_BYTES || 100000),
291
- 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),
292
262
  // Only recordings from the last N hours are picked up (0 = no limit), so a
293
263
  // fresh install doesn't drain the recorder's entire history.
294
- maxAgeHours: Number(process.env.VOICENOTE_MAX_AGE_HOURS || 48),
295
- volcano: getVolcanoConfigFromEnv(),
296
- speakers: loadSpeakers(),
297
- }
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
298
289
  }
299
290
 
300
291
  // ────────────────────────────────────────────────────────────────────────────
@@ -303,12 +294,6 @@ function getConfig(): Config {
303
294
 
304
295
  const DEFAULT_SPEAKERS: SpeakersConfig = { self: { name: null, aliases: [] }, known: [] }
305
296
 
306
-
307
- function loadJsonSync<T>(path: string, fallback: T): T {
308
- if (!existsSync(path)) return fallback
309
- try { return JSON.parse(readFileSync(path, 'utf8')) as T } catch (e) { warnSideEffect(`parse ${path}`, e); return fallback }
310
- }
311
-
312
297
  function normalizeSpeakers(data: unknown): SpeakersConfig {
313
298
  const raw = (data && typeof data === 'object') ? data as Partial<SpeakersConfig> : {}
314
299
  return {
@@ -325,34 +310,13 @@ function normalizeSpeakers(data: unknown): SpeakersConfig {
325
310
  }
326
311
 
327
312
  function loadConfigJson(): Record<string, unknown> {
328
- return loadJsonSync<Record<string, unknown>>(CONFIG_ENV_PATH, {})
329
- }
330
-
331
- function loadSpeakers(): SpeakersConfig {
332
- ensureConfigSeed()
333
- const config = loadConfigJson()
334
- if (config.speakers) return normalizeSpeakers(config.speakers)
335
- // Backward compatibility for installs created before speakers moved into config.json.
336
- return normalizeSpeakers(loadJsonSync<unknown>(SPEAKERS_PATH, DEFAULT_SPEAKERS))
337
- }
338
-
339
-
340
- let configSeeded = false
341
- function ensureConfigSeed(): void {
342
- if (configSeeded) return
343
- configSeeded = true
344
- try {
345
- if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true })
346
-
347
- const current = loadConfigJson()
348
- if (!current.speakers) {
349
- const legacy = existsSync(SPEAKERS_PATH) ? loadJsonSync<unknown>(SPEAKERS_PATH, DEFAULT_SPEAKERS) : DEFAULT_SPEAKERS
350
- current.speakers = normalizeSpeakers(legacy)
351
- writeFileSync(CONFIG_ENV_PATH, JSON.stringify(current, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
352
- }
353
- } catch {
354
- // 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}`)
355
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>
356
320
  }
357
321
 
358
322
  // ────────────────────────────────────────────────────────────────────────────
@@ -360,9 +324,7 @@ function ensureConfigSeed(): void {
360
324
  // ────────────────────────────────────────────────────────────────────────────
361
325
 
362
326
  function expandHome(path: string): string {
363
- if (path === '~') return os.homedir()
364
- if (path.startsWith('~/')) return join(os.homedir(), path.slice(2))
365
- return path
327
+ return path.replace(/^(?:~|\$\{?HOME\}?)(?=\/|$)/, os.homedir())
366
328
  }
367
329
 
368
330
  function nowIso(): string { return new Date().toISOString() }
@@ -398,11 +360,6 @@ async function ensureDirs(config: Config): Promise<void> {
398
360
  }
399
361
  }
400
362
 
401
- async function readJson<T>(path: string, fallback: T): Promise<T> {
402
- if (!existsSync(path)) return fallback
403
- try { return JSON.parse(await readFile(path, 'utf8')) as T } catch (e) { warnSideEffect(`parse ${path}`, e); return fallback }
404
- }
405
-
406
363
  // Write via tmp+rename so readers only ever see a complete file. Anything whose
407
364
  // mere existence is later treated as a signal MUST go through this: a half
408
365
  // written file that still parses is worse than no file at all.
@@ -535,9 +492,8 @@ function normalizeRunMode(opts: any): RunMode {
535
492
  // Single-instance mutual exclusion via an OS advisory lock (flock) held on an open
536
493
  // fd. The kernel releases it automatically when the process exits — including
537
494
  // SIGKILL/crash — so there is NO pid / mtime / heartbeat / stale-steal logic to
538
- // race on. flock is loaded from libSystem (macOS). On Windows we instead use a
539
- // pid+timestamp lockfile (acquireRunLockWindows); on Linux flock is unavailable
540
- // 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.
541
497
  const flockFn = (() => {
542
498
  try {
543
499
  const lib = dlopen(`libSystem.${suffix}`, { flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 } })
@@ -547,9 +503,9 @@ const flockFn = (() => {
547
503
  const FLOCK_EX_NB = 2 | 4 // LOCK_EX | LOCK_NB
548
504
  const FLOCK_UN = 8
549
505
 
550
- // Windows lock: no flock here. A pid+timestamp lockfile, created atomically with
551
- // 'wx'. We only reclaim an existing lock when its owner pid is dead OR the lock is
552
- // 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
553
509
  // (heartbeat below), so a legitimately long RUNNING job — ASR on a multi-hour
554
510
  // recording — never looks stale. The staleness escape exists for the pid-reuse
555
511
  // false positive (owner died, an unrelated process now has its pid, the aliveness
@@ -561,7 +517,7 @@ const FLOCK_UN = 8
561
517
  // to cover a manual `vn run` racing the scheduled one. The tiny create/reclaim
562
518
  // window is acceptable: its failure mode is conservatively skipping one run (same
563
519
  // as mac when flock is already held).
564
- async function acquireRunLockWindows(): Promise<{ release: () => Promise<void> } | null> {
520
+ async function acquireRunLockFile(): Promise<{ release: () => Promise<void> } | null> {
565
521
  await mkdir(dirname(LOCK_PATH), { recursive: true })
566
522
  const STALE_MS = 30 * 60 * 1000
567
523
  const tryCreate = (): number | null => {
@@ -574,7 +530,7 @@ async function acquireRunLockWindows(): Promise<{ release: () => Promise<void> }
574
530
  try {
575
531
  const data = JSON.parse(readFileSync(LOCK_PATH, 'utf8'))
576
532
  const pid = Number(data.pid), ts = Number(data.ts)
577
- const alive = pid > 0 && (() => { try { process.kill(pid, 0); return true } catch (e: any) { return e?.code === 'EPERM' } })()
533
+ const alive = pidAlive(pid)
578
534
  const fresh = Number.isFinite(ts) && (Date.now() - ts) < STALE_MS
579
535
  reclaim = !alive || !fresh
580
536
  } catch { reclaim = true } // unreadable/corrupt lock -> reclaim
@@ -606,12 +562,12 @@ async function acquireRunLockWindows(): Promise<{ release: () => Promise<void> }
606
562
  console.error('Run lock was reclaimed by another process (machine slept >30min?); this run continues but is no longer protected against overlap.')
607
563
  return
608
564
  }
609
- 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 }
610
566
  try {
611
567
  const tmp = `${LOCK_PATH}.hb-${process.pid}`
612
568
  writeFileSync(tmp, JSON.stringify({ pid: process.pid, ts: Date.now() }))
613
569
  renameSync(tmp, LOCK_PATH) // atomic replace, also on Windows
614
- } catch (e) { warnSideEffect('windows lock heartbeat', e) }
570
+ } catch (e) { warnSideEffect('run lock heartbeat', e) }
615
571
  }, 5 * 60 * 1000)
616
572
  ;(heartbeat as any).unref?.()
617
573
  let released = false
@@ -634,12 +590,8 @@ async function acquireRunLockWindows(): Promise<{ release: () => Promise<void> }
634
590
  }
635
591
 
636
592
  async function acquireRunLock(): Promise<{ release: () => Promise<void> } | null> {
637
- if (IS_WINDOWS) return acquireRunLockWindows()
593
+ if (!flockFn) return acquireRunLockFile()
638
594
  await mkdir(dirname(LOCK_PATH), { recursive: true })
639
- if (!flockFn) {
640
- console.error('Warning: flock unavailable on this runtime; proceeding without cross-process locking.')
641
- return { release: async () => {} }
642
- }
643
595
  // The lock is a regular file we keep open. Builds ≤ 0.15.2 used a *directory*
644
596
  // here, held purely by its existence, with no pid or refreshed mtime inside — so
645
597
  // a leftover legacy dir carries NO reliable signal about whether an old `vn run`
@@ -761,7 +713,7 @@ async function tailFiles(files: string[], lines: number, follow: boolean): Promi
761
713
  } else if (size < prev) {
762
714
  sizes.set(f, size) // rotated/truncated
763
715
  }
764
- } catch {}
716
+ } catch (e) { warnSideEffect(`follow ${f}`, e) }
765
717
  }
766
718
  if (!stop) setTimeout(poll, 1000)
767
719
  }
@@ -769,13 +721,8 @@ async function tailFiles(files: string[], lines: number, follow: boolean): Promi
769
721
  })
770
722
  }
771
723
 
772
- // ffprobe is the only ffmpeg-suite binary the pipeline actually uses (duration
773
- // detection). Resolve a configurable path so a bundled binary (GUI .app sidecar)
774
- // can be used without relying on PATH — mirrors the VOICENOTE_PI_BIN convention.
775
- function ffprobeBin(): string { return process.env.VOICENOTE_FFPROBE_BIN || 'ffprobe' }
776
-
777
- async function ffprobeDuration(path: string): Promise<number | null> {
778
- 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])
779
726
  if (result.code !== 0) return null
780
727
  const v = Number(result.stdout.trim())
781
728
  return Number.isFinite(v) ? v : null
@@ -797,13 +744,13 @@ function isCandidateFile(path: string): boolean {
797
744
  * treating a half-read device as authoritative would delete live queue entries
798
745
  * along with their retry counters.
799
746
  */
800
- async function toRecording(file: string): Promise<Recording> {
747
+ async function toRecording(config: Config, file: string): Promise<Recording> {
801
748
  const st = await stat(file)
802
749
  return {
803
750
  sourcePath: file,
804
751
  sizeBytes: st.size,
805
752
  modifiedAt: st.mtime.toISOString(),
806
- durationSeconds: await ffprobeDuration(file),
753
+ durationSeconds: await ffprobeDuration(config, file),
807
754
  sourceId: await sourceIdFor(file),
808
755
  recordedAt: parseRecordedAt(file),
809
756
  }
@@ -822,7 +769,7 @@ async function scanRecordings(config: Config): Promise<{ recordings: Recording[]
822
769
  if (!st) { complete = false; continue }
823
770
  if (!st.isFile()) continue
824
771
  try {
825
- recordings.push(await toRecording(file))
772
+ recordings.push(await toRecording(config, file))
826
773
  } catch (e) { complete = false; warnSideEffect(`read ${basename(file)} during scan`, e) }
827
774
  }
828
775
  } catch (e) {
@@ -835,30 +782,24 @@ async function scanRecordings(config: Config): Promise<{ recordings: Recording[]
835
782
  return { recordings, complete }
836
783
  }
837
784
 
838
- const limitsOf = (config: Config) => ({ maxAgeHours: config.maxAgeHours, minBytes: config.minBytes, minDurationSeconds: config.minDurationSeconds })
839
-
840
785
  // ────────────────────────────────────────────────────────────────────────────
841
786
  // File path planning
842
787
  // ────────────────────────────────────────────────────────────────────────────
843
788
 
844
- 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 {
845
795
  const { month, prefix } = dateParts(rec.recordedAt)
796
+ const untitled = title === undefined
797
+ const base = untitled ? prefix : `${prefix}-${safeSlug(title || 'note')}`
846
798
  return {
847
- audio: join(config.workspace, '_audio', month, `${prefix}-original${extname(rec.sourcePath).toLowerCase()}`),
848
- transcript: join(config.workspace, '_transcripts', month, `${prefix}-transcript.md`),
849
- notes: join(config.workspace, month, `${prefix}-note.md`),
850
- metadata: join(config.workspace, '_metadata', month, `${prefix}-metadata.json`),
851
- }
852
- }
853
-
854
- function localFilesFromState(config: Config, rec: Recording, entry: JobRecord | undefined): LocalFiles {
855
- const fallback = initialLocalFiles(config, rec)
856
- const paths = entry?.paths || {}
857
- return {
858
- audio: typeof paths.audio === 'string' ? paths.audio : fallback.audio,
859
- transcript: typeof paths.transcript === 'string' ? paths.transcript : fallback.transcript,
860
- notes: typeof paths.notes === 'string' ? paths.notes : fallback.notes,
861
- 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`),
862
803
  }
863
804
  }
864
805
 
@@ -868,7 +809,13 @@ function localFilesFromState(config: Config, rec: Recording, entry: JobRecord |
868
809
  // even though the transcript was still sitting there.
869
810
  function resumableTranscriptFiles(config: Config, rec: Recording, store: StateFile, mode: RunMode, force: boolean): LocalFiles | null {
870
811
  if (force || mode !== 'notes') return null
871
- 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
872
819
  return existsSync(files.transcript) ? files : null
873
820
  }
874
821
 
@@ -889,121 +836,29 @@ async function removeFailedSummaryStub(path: string): Promise<void> {
889
836
  } catch (e) { warnSideEffect(`remove failed-summary stub ${path}`, e) }
890
837
  }
891
838
 
892
- async function titledLocalFiles(config: Config, rec: Recording, meta: Json, files: LocalFiles): Promise<LocalFiles> {
893
- const { month, prefix } = dateParts(rec.recordedAt)
894
- const base = `${prefix}-${safeSlug(meta.title || 'note')}`
895
- const targets: LocalFiles = {
896
- audio: join(config.workspace, '_audio', month, `${base}-original${extname(rec.sourcePath).toLowerCase()}`),
897
- transcript: join(config.workspace, '_transcripts', month, `${base}-transcript.md`),
898
- notes: join(config.workspace, month, `${base}.md`),
899
- 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])
900
852
  }
901
- for (const p of Object.values(targets)) await mkdir(dirname(p), { recursive: true })
902
- if (existsSync(files.audio) && files.audio !== targets.audio) {
903
- if (existsSync(targets.audio)) await unlink(targets.audio)
904
- 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))
905
855
  }
906
- return targets
907
856
  }
908
857
 
909
858
  // ───────────────────────────────────────────────────────────────────────
910
859
  // Volcano (Doubao ASR + TOS upload)
911
860
  // ───────────────────────────────────────────────────────────────────────
912
861
 
913
- function sha256Hex(data: Buffer | string): string {
914
- return createHash('sha256').update(data).digest('hex')
915
- }
916
-
917
- function hmacSha256(key: Buffer | string, data: string): Buffer {
918
- return createHmac('sha256', key).update(data).digest()
919
- }
920
-
921
- function tosCanonicalUri(key: string): string {
922
- // S3 SigV4: encode each path segment, keep '/' as separator.
923
- return '/' + key.split('/').map(s => encodeURIComponent(s)).join('/')
924
- }
925
-
926
- function tosAmzDate(now: Date = new Date()): { amzDate: string; dateStamp: string } {
927
- const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
928
- return { amzDate, dateStamp: amzDate.slice(0, 8) }
929
- }
930
-
931
- function tosSigningKey(secretKey: string, dateStamp: string, region: string): Buffer {
932
- const kDate = hmacSha256('AWS4' + secretKey, dateStamp)
933
- const kRegion = hmacSha256(kDate, region)
934
- const kService = hmacSha256(kRegion, 's3')
935
- return hmacSha256(kService, 'aws4_request')
936
- }
937
-
938
- function tosSignRequest(tos: VolcanoTosConfig, method: 'PUT' | 'DELETE', key: string, payloadHash: string, contentType?: string): { url: string; headers: Record<string, string> } {
939
- const host = `${tos.bucket}.${tos.endpoint}`
940
- const { amzDate, dateStamp } = tosAmzDate()
941
- const canonicalUri = tosCanonicalUri(key)
942
- const headers: Record<string, string> = {
943
- host,
944
- 'x-amz-content-sha256': payloadHash,
945
- 'x-amz-date': amzDate,
946
- }
947
- if (contentType) headers['content-type'] = contentType
948
- const sortedNames = Object.keys(headers).sort()
949
- const canonicalHeaders = sortedNames.map(h => `${h}:${headers[h]}\n`).join('')
950
- const signedHeaders = sortedNames.join(';')
951
- const canonicalRequest = [method, canonicalUri, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
952
- const credentialScope = `${dateStamp}/${tos.region}/s3/aws4_request`
953
- const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n')
954
- const signingKey = tosSigningKey(tos.secretKey, dateStamp, tos.region)
955
- const signature = hmacSha256(signingKey, stringToSign).toString('hex')
956
- const authorization = `AWS4-HMAC-SHA256 Credential=${tos.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
957
- return { url: `https://${host}${canonicalUri}`, headers: { ...headers, Authorization: authorization } }
958
- }
959
-
960
- function tosPresignedGet(tos: VolcanoTosConfig, key: string, expiresSeconds = 3600): string {
961
- const host = `${tos.bucket}.${tos.endpoint}`
962
- const { amzDate, dateStamp } = tosAmzDate()
963
- const canonicalUri = tosCanonicalUri(key)
964
- const credentialScope = `${dateStamp}/${tos.region}/s3/aws4_request`
965
- const params: Record<string, string> = {
966
- 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
967
- 'X-Amz-Credential': `${tos.accessKey}/${credentialScope}`,
968
- 'X-Amz-Date': amzDate,
969
- 'X-Amz-Expires': String(expiresSeconds),
970
- 'X-Amz-SignedHeaders': 'host',
971
- }
972
- const canonicalQuery = Object.keys(params).sort().map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k]!)}`).join('&')
973
- const canonicalHeaders = `host:${host}\n`
974
- const canonicalRequest = ['GET', canonicalUri, canonicalQuery, canonicalHeaders, 'host', 'UNSIGNED-PAYLOAD'].join('\n')
975
- const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n')
976
- const signature = hmacSha256(tosSigningKey(tos.secretKey, dateStamp, tos.region), stringToSign).toString('hex')
977
- return `https://${host}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`
978
- }
979
-
980
- async function tosUploadObject(tos: VolcanoTosConfig, localPath: string, key: string, contentType: string): Promise<void> {
981
- const body = await readFile(localPath)
982
- const payloadHash = sha256Hex(body)
983
- const { url, headers } = tosSignRequest(tos, 'PUT', key, payloadHash, contentType)
984
- const res = await fetch(url, { method: 'PUT', body, headers })
985
- if (!res.ok) {
986
- const text = await res.text().catch(() => '')
987
- throw new Error(`TOS upload failed: ${res.status} ${text.slice(0, 500)}`)
988
- }
989
- }
990
-
991
- async function tosDeleteObject(tos: VolcanoTosConfig, key: string): Promise<void> {
992
- const { url, headers } = tosSignRequest(tos, 'DELETE', key, sha256Hex(''))
993
- const res = await fetch(url, { method: 'DELETE', headers })
994
- if (!res.ok && res.status !== 204 && res.status !== 404) {
995
- const text = await res.text().catch(() => '')
996
- console.log(`Warn: TOS delete returned ${res.status}: ${text.slice(0, 200)}`)
997
- }
998
- }
999
-
1000
- function volcanoFormatFromExt(ext: string): string {
1001
- const e = ext.replace(/^\./, '').toLowerCase()
1002
- if (e === 'mp3') return 'mp3'
1003
- if (e === 'wav') return 'wav'
1004
- return e || 'mp3'
1005
- }
1006
-
1007
862
  function volcanoContentTypeFromExt(ext: string): string {
1008
863
  const e = ext.replace(/^\./, '').toLowerCase()
1009
864
  switch (e) {
@@ -1098,20 +953,21 @@ function volcanoFormatTranscript(result: { text?: string; utterances?: VolcanoUt
1098
953
 
1099
954
  async function volcanoTranscribeAudio(volc: VolcanoConfig, audioPath: string, rec: Recording): Promise<string> {
1100
955
  const ext = extname(audioPath).toLowerCase() || '.mp3'
1101
- const format = volcanoFormatFromExt(ext)
956
+ const format = ext.replace(/^\./, '')
1102
957
  const contentType = volcanoContentTypeFromExt(ext)
1103
958
  const { month } = dateParts(rec.recordedAt)
1104
959
  const key = `voicenote/${month}/${rec.sourceId}-${Date.now()}${ext}`
960
+ const object = tosObject(volc.tos, key)
1105
961
  console.log(`Volcano: upload audio to TOS as ${key}`)
1106
- 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)
1107
963
  let cleanedUp = false
1108
964
  const cleanup = async () => {
1109
965
  if (cleanedUp || volc.tos.keep) return
1110
966
  cleanedUp = true
1111
- await tosDeleteObject(volc.tos, key).catch(() => {})
967
+ await object.delete().catch(e => warnSideEffect(`delete TOS object ${key}`, e))
1112
968
  }
1113
969
  try {
1114
- const audioUrl = tosPresignedGet(volc.tos, key, 6 * 3600)
970
+ const audioUrl = object.presign({ method: 'GET', expiresIn: 6 * 3600 })
1115
971
  const taskId = randomUUID()
1116
972
  console.log(`Volcano: submit ASR task ${taskId} (resource=${volc.resourceId}, format=${format})`)
1117
973
  await volcanoSubmitTask(volc, taskId, audioUrl, format)
@@ -1173,7 +1029,7 @@ async function volcanoTranscribeAudio(volc: VolcanoConfig, audioPath: string, re
1173
1029
  }
1174
1030
 
1175
1031
  async function transcribeAudio(config: Config, audioPath: string, rec: Recording): Promise<string> {
1176
- 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.')
1177
1033
  return volcanoTranscribeAudio(config.volcano, audioPath, rec)
1178
1034
  }
1179
1035
 
@@ -1277,53 +1133,45 @@ ${transcript}`
1277
1133
  }
1278
1134
 
1279
1135
  // ───────────────────────────────────────────────────────────────────────
1280
- // Summary via pi. Provider, model and credentials are pi's own configuration:
1281
- // we invoke `pi -p` with no --provider/--model and never fall back elsewhere,
1282
- // 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.
1283
1139
  // ───────────────────────────────────────────────────────────────────────
1284
1140
 
1285
- function piCodexBin(): string {
1286
- return process.env.VOICENOTE_PI_BIN || 'pi'
1287
- }
1288
-
1289
1141
  // pi can't be `bun build --compile`'d (it reads data files from disk), so the
1290
1142
  // bundled GUI ships pi as plain JS and runs it under a bundled bun. When
1291
- // VOICENOTE_PI_CLI is set, piCodexBin() is the runtime (bun) and the cli.js is
1292
- // prepended to pi's args — `<bun> <cli.js> <args>`, no wrapper script and no
1293
- // shell (critical on Windows, where pi args include a huge --system-prompt that
1294
- // a .cmd/%* wrapper would mangle). CLI users with a real `pi` on PATH leave
1295
- // PI_CLI unset and pi is invoked directly.
1296
- function piInvocation(args: string[]): { bin: string; args: string[] } {
1297
- const cli = process.env.VOICENOTE_PI_CLI
1298
- const bin = piCodexBin()
1299
- 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 }
1300
1149
  }
1301
1150
 
1302
1151
  // ───────────────────────────────────────────────────────────────────────
1303
- // ChatGPT (OpenAI Codex) OAuth login — headless device-code flow.
1304
- // Today the only way to authenticate the pi summary backend is to open pi's
1305
- // interactive TUI and run `/login`. This exposes the same flow as a plain
1306
- // 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.
1307
1155
  // We reuse pi's own OAuth implementation (@earendil-works/pi-ai) and persist
1308
1156
  // to pi's auth.json in the exact shape it reads: { type: 'oauth', ...creds }.
1309
1157
  // ───────────────────────────────────────────────────────────────────────
1310
1158
 
1311
- async function persistPiOAuth(providerId: string, creds: Record<string, unknown>): Promise<void> {
1312
- await mkdir(dirname(PI_AUTH_PATH), { recursive: true })
1159
+ async function persistPiOAuth(authPath: string, providerId: string, creds: Record<string, unknown>): Promise<void> {
1160
+ await mkdir(dirname(authPath), { recursive: true })
1313
1161
  let existing: Json = {}
1314
- if (existsSync(PI_AUTH_PATH)) {
1315
- try { existing = JSON.parse(await readFile(PI_AUTH_PATH, 'utf8')) as Json } catch (e) { warnSideEffect(`parse ${PI_AUTH_PATH}`, e) }
1162
+ if (existsSync(authPath)) {
1163
+ try { existing = JSON.parse(await readFile(authPath, 'utf8')) as Json } catch (e) { warnSideEffect(`parse ${authPath}`, e) }
1316
1164
  }
1317
1165
  existing[providerId] = { type: 'oauth', ...creds }
1318
- const tmp = `${PI_AUTH_PATH}.tmp-${process.pid}`
1166
+ const tmp = `${authPath}.tmp-${process.pid}`
1319
1167
  await writeFile(tmp, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 })
1320
- await rename(tmp, PI_AUTH_PATH)
1168
+ await rename(tmp, authPath)
1321
1169
  }
1322
1170
 
1323
1171
  async function loginChatGPT(opts: { json?: boolean; deviceCode?: boolean; emit?: (o: Record<string, unknown>) => void }): Promise<void> {
1324
- // OpenAI's OAuth endpoint is geo-blocked in some regions; hydrate the proxy
1325
- // env (LOCAL_PROXY_HOST/PORT -> http_proxy) before any request goes out.
1326
- 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
1327
1175
  const json = !!opts.json
1328
1176
  const emit = opts.emit ?? ((o: Record<string, unknown>) => { if (json) console.log(JSON.stringify(o)) })
1329
1177
  try {
@@ -1365,9 +1213,9 @@ async function loginChatGPT(opts: { json?: boolean; deviceCode?: boolean; emit?:
1365
1213
  },
1366
1214
  }) as Record<string, unknown>
1367
1215
  }
1368
- await persistPiOAuth(oauth.openaiCodexOAuthProvider.id, creds)
1216
+ await persistPiOAuth(authPath, oauth.openaiCodexOAuthProvider.id, creds)
1369
1217
  if (json) emit({ event: 'success', provider: oauth.openaiCodexOAuthProvider.id })
1370
- else console.log(`\n✓ Signed in. Credentials saved to ${PI_AUTH_PATH}. Verify with: vn doctor`)
1218
+ else console.log(`\n✓ Signed in. Credentials saved to ${authPath}. Verify with: vn doctor`)
1371
1219
  } catch (e: any) {
1372
1220
  let message = String(e?.message || e)
1373
1221
  if (/unsupported_country_region_territory|\b403\b/.test(message)) {
@@ -1395,18 +1243,18 @@ function readStdin(): Promise<string> {
1395
1243
  })
1396
1244
  }
1397
1245
 
1398
- function configFileEnv(): Record<string, string> {
1399
- const raw = loadConfigJson()
1246
+ function configFileEnv(raw = loadConfigJson()): Record<string, string> {
1400
1247
  const env: Record<string, string> = {}
1401
1248
  for (const k of ENV_KEYS) if (typeof raw[k] === 'string') env[k] = raw[k] as string
1402
1249
  return env
1403
1250
  }
1404
1251
 
1405
1252
  function configGetData(): { path: string; env: Record<string, string>; self: { name: string | null; aliases: string[] } } {
1406
- const speakers = loadSpeakers()
1253
+ const current = loadConfigJson()
1254
+ const speakers = normalizeSpeakers(current.speakers ?? DEFAULT_SPEAKERS)
1407
1255
  return {
1408
1256
  path: CONFIG_ENV_PATH,
1409
- env: configFileEnv(),
1257
+ env: configFileEnv(current),
1410
1258
  self: { name: speakers.self.name, aliases: speakers.self.aliases },
1411
1259
  }
1412
1260
  }
@@ -1415,37 +1263,39 @@ function configGet(): void { console.log(JSON.stringify(configGetData(), null, 2
1415
1263
 
1416
1264
  type ConfigSetPayload = { env?: Record<string, unknown>; self?: { name?: string | null; aliases?: string[] } }
1417
1265
 
1418
- async function configSetData(payload: ConfigSetPayload): Promise<{ ok: true; path: string; ignoredKeys?: string[] }> {
1266
+ async function writeConfigJson(value: Record<string, unknown>): Promise<void> {
1419
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
+ }
1420
1272
 
1421
- // 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')
1422
1275
  const current = loadConfigJson()
1423
1276
  const known = ENV_KEYS as readonly string[]
1424
1277
  const ignored: string[] = []
1425
1278
  if (payload.env) {
1426
- for (const [k, v] of Object.entries(payload.env)) {
1427
- if (!known.includes(k)) { ignored.push(k); continue }
1428
- if (v === null) delete current[k]
1429
- 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`)
1430
1284
  }
1431
1285
  }
1432
- const tmp = `${CONFIG_ENV_PATH}.tmp-${process.pid}`
1433
- await writeFile(tmp, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 })
1434
- await rename(tmp, CONFIG_ENV_PATH)
1435
-
1436
- // Identity lives in config.json too; speakers.json is read only as a legacy fallback.
1437
1286
  if (payload.self) {
1438
- const speakers = normalizeSpeakers(current.speakers ?? loadSpeakers())
1439
- if (payload.self.name !== undefined) speakers.self.name = payload.self.name
1440
- 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
+ }
1441
1296
  current.speakers = speakers
1442
- await writeFile(tmp, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 })
1443
- await rename(tmp, CONFIG_ENV_PATH)
1444
1297
  }
1445
-
1446
- // Make the new values visible to THIS process immediately (see reloadEnvConfig).
1447
- reloadEnvConfig()
1448
-
1298
+ await writeConfigJson(current)
1449
1299
  return { ok: true, path: CONFIG_ENV_PATH, ...(ignored.length ? { ignoredKeys: ignored } : {}) }
1450
1300
  }
1451
1301
 
@@ -1468,15 +1318,10 @@ async function configSet(): Promise<void> {
1468
1318
  }
1469
1319
  }
1470
1320
 
1471
- function stripJsonFences(text: string): string {
1472
- const trimmed = text.trim()
1473
- const fence = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```\s*$/i)
1474
- if (fence) return fence[1]!.trim()
1475
- return trimmed
1476
- }
1477
-
1478
1321
  function extractFirstJsonObject(text: string): string {
1479
- 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
1480
1325
  if (trimmed.startsWith('{') && trimmed.endsWith('}')) return trimmed
1481
1326
  // Find the first balanced {...}
1482
1327
  let depth = 0, start = -1, inString = false, escape = false
@@ -1495,7 +1340,7 @@ function extractFirstJsonObject(text: string): string {
1495
1340
  return trimmed
1496
1341
  }
1497
1342
 
1498
- async function runPi(opts: {
1343
+ type PiRunOptions = {
1499
1344
  systemPrompt: string
1500
1345
  userPrompt: string
1501
1346
  timeoutMs?: number
@@ -1503,7 +1348,9 @@ async function runPi(opts: {
1503
1348
  tools?: string // e.g. 'read,grep'; empty/undefined = --no-tools
1504
1349
  appendSystemPrompt?: string
1505
1350
  cwd?: string // agent working dir: the knowledge base, so read/grep/find default there
1506
- }): Promise<string> {
1351
+ }
1352
+
1353
+ async function runPi(config: Config, opts: PiRunOptions): Promise<string> {
1507
1354
  const args = [
1508
1355
  '-p',
1509
1356
  '--mode', 'text',
@@ -1512,15 +1359,14 @@ async function runPi(opts: {
1512
1359
  ]
1513
1360
  // Unset means pi's own default model and provider. There is no second
1514
1361
  // provider to fall back to either way.
1515
- const model = piSummaryModel()
1516
- if (model) args.push('--model', model)
1362
+ if (config.pi.model) args.push('--model', config.pi.model)
1517
1363
  if (opts.thinking) args.push('--thinking', opts.thinking)
1518
1364
  if (opts.tools && opts.tools.trim()) args.push('--tools', opts.tools.trim())
1519
1365
  else args.push('--no-tools')
1520
1366
  if (opts.appendSystemPrompt) args.push('--append-system-prompt', opts.appendSystemPrompt)
1521
1367
  return new Promise<string>((resolve, reject) => {
1522
- const inv = piInvocation(args)
1523
- const child = spawn(inv.bin, inv.args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: opts.cwd, windowsHide: true })
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 } })
1524
1370
  let stdout = '', stderr = ''
1525
1371
  const timer = opts.timeoutMs ? setTimeout(() => child.kill('SIGKILL'), opts.timeoutMs) : null
1526
1372
  child.stdout.on('data', d => stdout += String(d))
@@ -1552,11 +1398,11 @@ function isTransientPiError(e: any): boolean {
1552
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)
1553
1399
  }
1554
1400
 
1555
- async function chatCompleteViaPi(opts: Parameters<typeof runPi>[0]): Promise<string> {
1556
- 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
1557
1403
  for (let attempt = 1; ; attempt++) {
1558
1404
  try {
1559
- return await runPi(opts)
1405
+ return await runPi(config, opts)
1560
1406
  } catch (e: any) {
1561
1407
  if (attempt >= maxAttempts || !isTransientPiError(e)) throw e
1562
1408
  const backoffMs = Math.min(30000, 2000 * 2 ** (attempt - 1))
@@ -1566,32 +1412,6 @@ async function chatCompleteViaPi(opts: Parameters<typeof runPi>[0]): Promise<str
1566
1412
  }
1567
1413
  }
1568
1414
 
1569
- // pi's --model accepts "provider/id" (e.g. openai-codex/gpt-5.6-sol), so this one
1570
- // setting pins both. Empty/unset = whatever pi is configured to use.
1571
- function piSummaryModel(): string {
1572
- return (process.env.VOICENOTE_PI_MODEL || '').trim()
1573
- }
1574
-
1575
- function piThinkingLevel(): string {
1576
- return process.env.VOICENOTE_PI_THINKING || 'high'
1577
- }
1578
-
1579
- function piSummaryTools(): string {
1580
- // Default ON: let the summary model read/grep prior notes for cross-reference consistency.
1581
- // Set VOICENOTE_PI_SUMMARY_TOOLS='' (empty) to disable.
1582
- const v = process.env.VOICENOTE_PI_SUMMARY_TOOLS
1583
- if (v === undefined) return 'read,grep'
1584
- return v.trim()
1585
- }
1586
-
1587
- function summaryContextDir(config: Config): string {
1588
- // Directory the summary model may read/grep for cross-reference consistency.
1589
- // Defaults to the workspace itself. Users can opt into a wider notes/vault
1590
- // directory with VOICENOTE_CONTEXT_DIR, but the published default must not
1591
- // read outside the configured workspace.
1592
- return expandHome(process.env.VOICENOTE_CONTEXT_DIR || config.workspace)
1593
- }
1594
-
1595
1415
  function piSummaryToolsHint(contextDir: string): string {
1596
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).`
1597
1417
  }
@@ -1601,17 +1421,17 @@ function piSummaryToolsHint(contextDir: string): string {
1601
1421
  // missing, say so loudly and run without tools rather than searching the wrong
1602
1422
  // tree (tools, the cwd hint, and the spawn cwd move together).
1603
1423
  async function chatComplete(opts: { systemPrompt: string; userPrompt: string; config: Config }): Promise<string> {
1604
- const wantTools = !!piSummaryTools()
1605
- const ctx = wantTools ? summaryContextDir(opts.config) : undefined
1424
+ const { pi } = opts.config
1425
+ const ctx = pi.tools ? pi.contextDir : undefined
1606
1426
  const ctxExists = ctx ? existsSync(ctx) : false
1607
1427
  if (ctx && !ctxExists) console.error(`Warning: context dir ${ctx} does not exist; summary agent runs WITHOUT read/grep cross-reference.`)
1608
- const toolsActive = wantTools && ctxExists
1609
- return chatCompleteViaPi({
1428
+ const toolsActive = !!ctx && ctxExists
1429
+ return chatCompleteViaPi(opts.config, {
1610
1430
  systemPrompt: opts.systemPrompt,
1611
1431
  userPrompt: opts.userPrompt,
1612
1432
  timeoutMs: 60 * 60 * 1000,
1613
- thinking: piThinkingLevel(),
1614
- tools: toolsActive ? piSummaryTools() : undefined,
1433
+ thinking: pi.thinking,
1434
+ tools: toolsActive ? pi.tools : undefined,
1615
1435
  appendSystemPrompt: toolsActive ? piSummaryToolsHint(ctx!) : undefined,
1616
1436
  cwd: toolsActive ? ctx : undefined,
1617
1437
  })
@@ -1651,14 +1471,14 @@ function normalizeMetadata(meta: Json, rec: Recording): Json {
1651
1471
  }
1652
1472
 
1653
1473
  const SOURCE_MARKER = '<!-- voicenote:source -->'
1654
- function sourceDetails(meta: Json, audioPath: string, transcriptPath: string): string {
1474
+ function sourceDetails(audioPath: string, transcriptPath: string): string {
1655
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>`
1656
1476
  }
1657
1477
 
1658
1478
  function markdownNotes(meta: Json, audioPath: string, transcriptPath: string): string {
1659
1479
  let body = typeof meta.markdown === 'string' && meta.markdown.trim() ? meta.markdown.trim() : `# ${meta.title || 'Untitled recording notes'}\n`
1660
1480
  if (!body.startsWith('#')) body = `# ${meta.title || 'Untitled recording notes'}\n\n${body}`
1661
- 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)}`
1662
1482
  return `${body.trim()}\n`
1663
1483
  }
1664
1484
 
@@ -1710,7 +1530,7 @@ function transcriptMarkdown(config: Config, rec: Recording, transcript: string,
1710
1530
 
1711
1531
  async function processRecording(config: Config, rec: Recording, opts: any): Promise<Json> {
1712
1532
  const jobStarted = Date.now()
1713
- let files = (opts.resumeFromTranscriptFiles as LocalFiles | null) || initialLocalFiles(config, rec)
1533
+ let files = (opts.resumeFromTranscriptFiles as LocalFiles | null) || layout(config, rec)
1714
1534
  const mode = normalizeRunMode(opts)
1715
1535
  const needsNotes = mode === 'notes'
1716
1536
  const resumeSummary = needsNotes && Boolean(opts.resumeFromTranscriptFiles)
@@ -1768,7 +1588,7 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1768
1588
 
1769
1589
  let summaryError: any = null
1770
1590
  if (needsNotes) {
1771
- 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"}`)
1772
1592
  try {
1773
1593
  meta = await withHeartbeat('generate integrated semantic notes', () => summarizeTranscript(config, transcript, rec, files.audio), 60)
1774
1594
  } catch (e: any) {
@@ -1796,22 +1616,13 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1796
1616
  progressStep(nextStep(), totalSteps, 'Write outputs and index')
1797
1617
  let failedStubPathToRemove: string | null = null
1798
1618
  if (needsNotes && !summaryError) {
1799
- const previousNotes = files.notes
1800
- const previousMetadata = files.metadata
1801
- const titled = await titledLocalFiles(config, rec, meta, files)
1802
- // titledLocalFiles renames the audio; manually move our already-written transcript too.
1803
- if (titled.transcript !== files.transcript && existsSync(files.transcript)) {
1804
- await mkdir(dirname(titled.transcript), { recursive: true })
1805
- if (existsSync(titled.transcript)) await unlink(titled.transcript)
1806
- await rename(files.transcript, titled.transcript)
1807
- }
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
1808
1625
  files = titled
1809
- if (previousNotes !== files.notes) failedStubPathToRemove = previousNotes
1810
- // Resuming a failed run whose title changed leaves the old untitled metadata
1811
- // from the failed attempt orphaned (note stub is handled above; metadata was not).
1812
- if (previousMetadata !== files.metadata && existsSync(previousMetadata)) {
1813
- await unlink(previousMetadata).catch(e => warnSideEffect(`remove orphaned metadata ${previousMetadata}`, e))
1814
- }
1815
1626
  }
1816
1627
  await mkdir(dirname(files.notes), { recursive: true })
1817
1628
  await mkdir(dirname(files.metadata), { recursive: true })
@@ -1891,7 +1702,7 @@ async function loadState(config: Config): Promise<StateFile> {
1891
1702
  return migrateLegacyState(await readLegacyState(config), nowIso())
1892
1703
  }
1893
1704
  const store = existsSync(path) ? parseStateFile(await readFile(path, 'utf8'), path) : emptyState()
1894
- lastSavedState.set(path, JSON.stringify(store))
1705
+ lastSavedState = JSON.stringify(store)
1895
1706
  return store
1896
1707
  }
1897
1708
 
@@ -1905,23 +1716,19 @@ async function migrateStateOnDisk(config: Config): Promise<void> {
1905
1716
  if (existsSync(path) || !existsSync(legacy)) return
1906
1717
  const store = migrateLegacyState(await readLegacyState(config), nowIso())
1907
1718
  await writeJson(path, store)
1908
- lastSavedState.set(path, JSON.stringify(store))
1719
+ lastSavedState = JSON.stringify(store)
1909
1720
  await rename(legacy, `${legacy}.v1.bak`).catch(e => warnSideEffect('archive pre-0.18 state', e))
1910
1721
  console.log(`Converted ${basename(legacy)} → ${basename(path)} (${Object.keys(store.jobs).length} records; old file kept as .v1.bak)`)
1911
1722
  }
1912
1723
 
1913
- // The scheduler ticks every 60s and the workspace is often a synced folder
1914
- // (iCloud/Dropbox). Rewriting an unchanged state file on every tick would be
1915
- // pure sync noise, so writes are content-gated. Keyed by path, not a single
1916
- // value: `vn serve` handles config.set (which can move the workspace) and runs
1917
- // in one process, and a shared key could skip the first write to a new path.
1918
- 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 = ''
1919
1727
  async function saveState(config: Config, store: StateFile): Promise<void> {
1920
- const path = statePathFor(config)
1921
1728
  const serialized = JSON.stringify(store)
1922
- if (serialized === lastSavedState.get(path)) return
1923
- await writeJson(path, store)
1924
- lastSavedState.set(path, serialized)
1729
+ if (serialized === lastSavedState) return
1730
+ await writeJson(statePathFor(config), store)
1731
+ lastSavedState = serialized
1925
1732
  }
1926
1733
 
1927
1734
  /** Upsert the scan-time facts; never touches lifecycle fields. */
@@ -1939,11 +1746,6 @@ function recordFor(store: StateFile, rec: Recording): JobRecord {
1939
1746
  return next
1940
1747
  }
1941
1748
 
1942
- function setJobState(store: StateFile, id: string, patch: Partial<JobRecord>): void {
1943
- const entry = store.jobs[id]
1944
- if (entry) patchJob(entry, patch, nowIso())
1945
- }
1946
-
1947
1749
  // The live job, declared by the run itself. Lives next to run.lock (machine
1948
1750
  // state, not workspace data) and carries the pid so a reader can tell a live
1949
1751
  // job from one whose process was killed.
@@ -1975,10 +1777,19 @@ function reportStep(step: string): void {
1975
1777
  }
1976
1778
 
1977
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.
1978
1787
  try {
1979
- const c = JSON.parse(readFileSync(CURRENT_PATH, 'utf8'))
1980
- return Number.isFinite(c?.pid) && typeof c?.source_id === 'string' ? c : null
1981
- } 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
1982
1793
  }
1983
1794
 
1984
1795
  function pidAlive(pid: number): boolean {
@@ -2027,7 +1838,7 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2027
1838
  return
2028
1839
  }
2029
1840
  const { recordings, complete: scanComplete } = single
2030
- ? { recordings: [await toRecording(single)], complete: false }
1841
+ ? { recordings: [await toRecording(config, single)], complete: false }
2031
1842
  : await scanRecordings(config)
2032
1843
  const mode = normalizeRunMode(opts)
2033
1844
  const force = Boolean(opts.force)
@@ -2038,7 +1849,9 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2038
1849
  // idle-suppressed silence meant for the 60s scheduler tick.
2039
1850
  const verboseSkips = Boolean(opts.verbose || opts.dryRun || single)
2040
1851
  const seen = new Set<string>()
2041
- 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 }
2042
1855
  for (const rec of recordings) {
2043
1856
  seen.add(rec.sourceId)
2044
1857
  const entry = recordFor(store, rec)
@@ -2046,7 +1859,7 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2046
1859
  if (verdict.run) { eligible.push(rec); continue }
2047
1860
  skipCounts[verdict.code] = (skipCounts[verdict.code] || 0) + 1
2048
1861
  ;(skipSamples[verdict.code] ||= []).push(entry.name)
2049
- 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())
2050
1863
  if (verboseSkips) console.log(` Skip: ${entry.name} (${verdict.code}${verdict.detail ? `: ${verdict.detail}` : ''})`)
2051
1864
  }
2052
1865
  // Only prune against a listing we believe to be complete: if the recorder went
@@ -2147,7 +1960,7 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2147
1960
  // Bun standalone executables embed source in a virtual FS, so import.meta.url is
2148
1961
  // NOT a real on-disk path: "/$bunfs/..." on mac/Linux, "B:\~BUN\root\..." on
2149
1962
  // Windows. Either marker means we're the compiled exe (run it directly via
2150
- // 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
2151
1964
  // $bunfs (the old check) misfired on Windows and leaked the virtual path into the
2152
1965
  // scheduled task's arguments.
2153
1966
  function resolveCli(): { cliPath: string; compiled: boolean } {
@@ -2163,47 +1976,26 @@ function xmlEscape(s: string): string {
2163
1976
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;')
2164
1977
  }
2165
1978
 
2166
- // Pulled in for `vn install-launch-agent`. launchd does NOT inherit your zsh
2167
- // environment, so anything the pipeline needs that lives ONLY in the real
2168
- // environment (e.g. exported from a non-zsh shell, or injected by the GUI)
2169
- // has to be written into the plist's EnvironmentVariables. Values that came
2170
- // from config.json/.zshrc are deliberately NOT embedded: vn run re-reads
2171
- // those files at startup, and since plist env outranks config.json, embedding
2172
- // them would freeze the values — later GUI edits would silently never reach
2173
- // the background agent.
2174
- async function launchAgentEnv(): Promise<Record<string, string>> {
2175
- 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>> {
2176
1983
  const env: Record<string, string> = {
2177
1984
  PATH: `${os.homedir()}/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`,
2178
1985
  }
2179
- // Embed only what is NOT recoverable from the config files at run time —
2180
- // see envConfig.ts (invariants 3+4) for the full matrix. A real-env value
2181
- // that differs from the file value is embedded as an override but warned
2182
- // about: it may equally be a stale shell session, and it will keep
2183
- // overriding config edits until the scheduler is reinstalled.
2184
- //
2185
- // no_proxy/NO_PROXY carry a volcano-hosts merge we added; substitute the
2186
- // pre-merge real-env original (or drop it entirely if we synthesized the
2187
- // whole value) so the scheduler never freezes our merge over config edits.
2188
- const embedEnv: Record<string, string | undefined> = { ...process.env }
2189
- for (const k of ['no_proxy', 'NO_PROXY'] as const) embedEnv[k] = premergeRealNoProxy[k]
2190
- const fileEnv = fileProvidedEnv()
2191
- const { embed, frozenOverrides } = envKeysToEmbed(ENV_KEYS, embedEnv, hydratedEnvKeys, fileEnv)
2192
- Object.assign(env, embed)
2193
- for (const k of frozenOverrides) {
2194
- 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.`)
2195
- }
2196
- // Embed pi's ABSOLUTE path so launchd resolves it regardless of the fixed plist
2197
- // PATH (npm global bin can live outside it under nvm / custom prefixes). Resolve
2198
- // the configured name (including the documented relative `VOICENOTE_PI_BIN="pi"`
2199
- // and a file-sourced relative name); only an absolute override is left as-is.
2200
- // The resolved path is regenerated on every (re)install, so config edits that
2201
- // change VOICENOTE_PI_BIN take effect via ensure_agent's forced reinstall.
2202
- const configuredPi = env.VOICENOTE_PI_BIN ?? process.env.VOICENOTE_PI_BIN
2203
- if (!configuredPi?.startsWith('/')) {
2204
- const w = await runCommand(IS_WINDOWS ? 'where' : 'which', [configuredPi || 'pi'], 5000)
2205
- const p = w.code === 0 ? (w.stdout.trim().split(/\r?\n/)[0] || '') : ''
2206
- 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
2207
1999
  }
2208
2000
  return env
2209
2001
  }
@@ -2217,7 +2009,7 @@ async function installLaunchAgent(opts: { load?: boolean } = {}): Promise<void>
2217
2009
  const plist = plistPath()
2218
2010
  await mkdir(dirname(plist), { recursive: true })
2219
2011
  await mkdir(LOG_DIR, { recursive: true })
2220
- const env = await launchAgentEnv()
2012
+ const env = await launchAgentEnv(getConfig())
2221
2013
  const envEntries = Object.entries(env)
2222
2014
  .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`).join('\n')
2223
2015
  const content = `<?xml version="1.0" encoding="UTF-8"?>
@@ -2248,9 +2040,7 @@ ${envEntries}
2248
2040
  </plist>
2249
2041
  `
2250
2042
  await writeFile(plist, content, 'utf8')
2251
- // The plist may embed real-env secrets (proxy credentials, exported keys);
2252
- // chmod explicitly — writeFile's mode only applies on creation, and existing
2253
- // plists from older installs are 0644.
2043
+ // Keep scheduler details private and tighten permissions on older plists.
2254
2044
  await chmod(plist, 0o600)
2255
2045
  const summary = Object.keys(env).join(', ')
2256
2046
  console.log(`LaunchAgent written: ${plist}`)
@@ -2299,7 +2089,7 @@ async function installScheduledTask(opts: { load?: boolean } = {}): Promise<void
2299
2089
  await mkdir(STATE_DIR, { recursive: true })
2300
2090
  await mkdir(LOG_DIR, { recursive: true })
2301
2091
  // The task carries no env (Task Scheduler has no per-task env block), so the
2302
- // 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 +
2303
2093
  // ffprobe) must be persisted to config.json, which `vn run` reads on startup.
2304
2094
  // (On mac these ride in the LaunchAgent plist instead.)
2305
2095
  const persist: Record<string, string> = {}
@@ -2308,9 +2098,9 @@ async function installScheduledTask(opts: { load?: boolean } = {}): Promise<void
2308
2098
  }
2309
2099
  if (Object.keys(persist).length) {
2310
2100
  await mkdir(CONFIG_DIR, { recursive: true })
2311
- const current = loadJsonSync<Record<string, unknown>>(CONFIG_ENV_PATH, {})
2101
+ const current = loadConfigJson()
2312
2102
  Object.assign(current, persist)
2313
- await writeFile(CONFIG_ENV_PATH, JSON.stringify(current, null, 2) + '\n')
2103
+ await writeConfigJson(current)
2314
2104
  }
2315
2105
  const { command, argLine } = schedulerProgramArgs()
2316
2106
  // bun.exe / vn.exe are console-subsystem: an InteractiveToken task flashes a
@@ -2398,7 +2188,11 @@ async function uninstallScheduledTask(): Promise<void> {
2398
2188
  // leftover copy could make schedulerIsCurrent misjudge a future install.
2399
2189
  // Only when the task is actually gone — deleting the VBS while the task is
2400
2190
  // still registered would turn every tick into a silent wscript failure.
2401
- 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
+ }
2402
2196
  console.log(r.code === 0 ? `Scheduled task '${TASK_NAME}' removed.` : `schtasks /delete: ${(r.stderr || r.stdout).trim()}`)
2403
2197
  }
2404
2198
 
@@ -2446,7 +2240,7 @@ async function listMeetings(opts: { month?: string }): Promise<void> {
2446
2240
  async function notesIndexPath(config: Config): Promise<string> {
2447
2241
  const p = join(config.workspace, '_index', 'notes.jsonl')
2448
2242
  const legacy = join(config.workspace, '_index', 'meetings.jsonl')
2449
- 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))
2450
2244
  return p
2451
2245
  }
2452
2246
 
@@ -2555,16 +2349,22 @@ async function showErrors(opts: { lines?: number }): Promise<void> {
2555
2349
  }
2556
2350
 
2557
2351
  async function upgradeSelf(): Promise<void> {
2558
- 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".
2559
2358
  // `bun add -g` upgrades in place: verified no dependency loop on npm→npm re-add
2560
2359
  // (the steady-state upgrade path) nor on replacing an old git-ref install. No
2561
2360
  // remove-first, so a failed add leaves the running vn intact.
2562
- console.log(`$ ${cmd} add -g @fastagent-sh/voicenote`)
2361
+ console.log('$ bun add -g @fastagent-sh/voicenote')
2563
2362
  const addCode = await new Promise<number>(res =>
2564
- spawn(cmd, ['add', '-g', '@fastagent-sh/voicenote'], { stdio: 'inherit', shell: IS_WINDOWS })
2565
- .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) }))
2566
2366
  if (addCode !== 0) {
2567
- 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.`)
2568
2368
  process.exitCode = 1
2569
2369
  return
2570
2370
  }
@@ -2676,11 +2476,11 @@ async function collectDoctor() {
2676
2476
  const config = getConfig()
2677
2477
  // pi is a bun-based CLI; cold start (esp. behind a proxy) can take >5s, so
2678
2478
  // give --version a generous timeout to avoid a false 'missing' on a healthy pi.
2679
- const piInv = piInvocation(['--version'])
2479
+ const piInv = piInvocation(config.pi, ['--version'])
2680
2480
  const piCheck = await runCommand(piInv.bin, piInv.args, 15000)
2681
- const ff = await runCommand(ffprobeBin(), ['-version'], 5000)
2481
+ const ff = await runCommand(config.ffprobeBin, ['-version'], 5000)
2682
2482
  const v = config.volcano
2683
- const tools = piSummaryTools()
2483
+ const { pi } = config
2684
2484
  return {
2685
2485
  version: VERSION,
2686
2486
  bun: process.versions.bun || null,
@@ -2698,12 +2498,11 @@ async function collectDoctor() {
2698
2498
  : { configured: false as const },
2699
2499
  // Provider/model/credentials are pi's own configuration; `pi.available` is
2700
2500
  // all we can honestly report about whether a summary can run.
2701
- summary: { backend: 'pi', model: piSummaryModel() || null, thinking: piThinkingLevel(), tools: tools || null, contextDir: tools ? summaryContextDir(config) : null },
2702
- pi: { bin: piCodexBin(), version: piCheck.code === 0 ? (piCheck.stdout.trim() || piCheck.stderr.trim() || null) : null, available: piCheck.code === 0, auth: existsSync(PI_AUTH_PATH) },
2703
- // Outbound proxy for HTTPS endpoints (updater/GitHub): honor the standard
2704
- // env chain, not just lowercase http_proxy — an https_proxy-only setup must
2705
- // still route the updater.
2706
- 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 },
2707
2506
  identity: { self: config.speakers.self.name || null, aliases: config.speakers.self.aliases, knownCount: config.speakers.known.length },
2708
2507
  // The thresholds that silently decide what never gets processed. Without
2709
2508
  // them here, confirming a change to VOICENOTE_MAX_AGE_HOURS meant planting
@@ -2773,7 +2572,7 @@ async function doctor(opts: { json?: boolean } = {}): Promise<void> {
2773
2572
  console.log(`pi.version=${s.pi.version || 'missing'}`)
2774
2573
  // Neutral fact, not an instruction: an API-key user has no auth.json and needs
2775
2574
  // nothing fixed.
2776
- console.log(`pi.auth=${s.pi.auth ? 'logged-in (auth.json present)' : 'no ~/.pi/agent/auth.json (fine if a provider API key is set)'}`)
2575
+ console.log(`pi.auth=${s.pi.authPath} ${s.pi.auth ? '(present)' : '(missing — fine if a provider API key is set)'}`)
2777
2576
  console.log(`defaultMode=notes`)
2778
2577
  console.log(`proxy=${s.proxy.url || '<unset>'}`)
2779
2578
  console.log(`speakers.self=${s.identity.self || '<unset>'}`)
@@ -2782,24 +2581,12 @@ async function doctor(opts: { json?: boolean } = {}): Promise<void> {
2782
2581
  console.log(`ffprobe=${s.deps.ffprobe ? 'ok' : 'missing'}`)
2783
2582
  }
2784
2583
 
2785
- // ────────────────────────────────────────────────────────────────────────────
2786
- // serve — persistent JSON-RPC engine over stdio (the desktop GUI's client)
2787
- // ────────────────────────────────────────────────────────────────────────────
2788
- // One long-lived process the GUI talks to instead of spawning `vn` per call, so
2789
- // Bun cold start (and on Windows the AV scan + console flash) is paid ONCE.
2790
- // Protocol (newline-delimited JSON on stdio):
2791
- // client→: {"type":"req","id":N,"method":M,"params":P}
2792
- // →client: {"type":"res","id":N,"result":R} | {"type":"res","id":N,"error":E}
2793
- // →client: {"type":"event","event":"login-event","payload":{...}} (login stream)
2794
-
2795
- // Is the background scheduler already installed AND pointing at THIS binary?
2796
- // (Mirrors what the GUI's ensure_agent used to check in Rust; kept here so the
2797
- // staleness logic lives in one place.)
2584
+ // Is the background scheduler installed and pointing at this binary?
2798
2585
  async function schedulerIsCurrent(): Promise<boolean> {
2799
2586
  const exe = process.execPath
2800
2587
  if (IS_WINDOWS) {
2801
2588
  if ((await runCommand('schtasks', ['/query', '/tn', TASK_NAME], 10000)).code !== 0) return false
2802
- // 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.
2803
2590
  try { return readFileSync(taskVbsPath(), 'utf16le').includes(exe) } catch { return false }
2804
2591
  }
2805
2592
  try { return readFileSync(plistPath(), 'utf8').includes(exe) } catch { return false }
@@ -2811,99 +2598,6 @@ async function ensureScheduler(force: boolean): Promise<{ ok: true; skipped?: bo
2811
2598
  return { ok: true }
2812
2599
  }
2813
2600
 
2814
- async function dispatchServe(req: any, send: (o: unknown) => void): Promise<void> {
2815
- const { id, method, params } = req || {}
2816
- try {
2817
- let result: unknown
2818
- switch (method) {
2819
- case 'config.get': result = configGetData(); break
2820
- case 'config.set': result = await configSetData(params || {}); break
2821
- case 'doctor': result = await collectDoctor(); break
2822
- case 'jobs': result = await jobsListData(parseJobsLimit(params?.limit, 40)); break
2823
- case 'ensure_agent': result = await ensureScheduler(!!params?.force); break
2824
- case 'run': {
2825
- // Long-running (minutes) like login: ack immediately so the GUI's 60s
2826
- // request timeout can't misread it as a wedged engine. Progress shows
2827
- // via the jobs poll; acquireRunLock inside runPipeline dedupes against
2828
- // the scheduler tick and a double-click.
2829
- void runPipeline(undefined, {}).catch(e => console.error('manual run failed:', e?.message || e))
2830
- result = { started: true }
2831
- break
2832
- }
2833
- case 'login': {
2834
- // Ack immediately: the OAuth round-trip takes minutes (user in browser),
2835
- // and the GUI client times requests out after 60s — a long-lived login
2836
- // response would be misread as a wedged engine. Progress and outcome
2837
- // ride entirely on login-event; the response carries nothing.
2838
- void (async () => {
2839
- let ok = false
2840
- // Attempt latch: once this attempt settles (timeout or completion),
2841
- // late events from a still-dangling OAuth flow must not reach the
2842
- // UI — a stale success/error would clobber a NEWER login attempt's
2843
- // state (the closed for this attempt has already been sent).
2844
- let settled = false
2845
- const sendEvent = (o: Record<string, unknown>) => { if (!settled) send({ type: 'event', event: 'login-event', payload: o }) }
2846
- try {
2847
- // Bound the OAuth wait: if the user closes the browser without
2848
- // authorizing, the callback never arrives and the flow would hang
2849
- // forever — with the GUI's login button locked until app restart.
2850
- // True cancellation is not available (the browser flow of
2851
- // @earendil-works/pi-ai takes no AbortSignal), so on timeout the
2852
- // abandoned flow keeps running muted (settled latch above). Two
2853
- // consequences, both surfaced in the timeout message: a LATE
2854
- // authorization still persists credentials silently (login may
2855
- // actually have succeeded — hence “refresh to confirm”), and the dangling
2856
- // localhost callback server may hold its port until serve exits,
2857
- // so an immediate retry can fail fast with a port-busy error.
2858
- const timeout = new Promise<never>((_, rej) => {
2859
- 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)
2860
- ;(t as any).unref?.()
2861
- })
2862
- await Promise.race([
2863
- loginChatGPT({ json: true, deviceCode: !!params?.deviceCode, emit: (o) => { if (o.event === 'success') ok = true; sendEvent(o) } }),
2864
- timeout,
2865
- ])
2866
- } catch (e: any) {
2867
- // loginChatGPT handles its own errors; this catches the timeout
2868
- // above plus anything it lets escape (fail visibly).
2869
- sendEvent({ event: 'error', message: String(e?.message || e) })
2870
- }
2871
- sendEvent({ event: 'closed', code: ok ? 0 : 1 })
2872
- settled = true
2873
- })()
2874
- result = { started: true }
2875
- break
2876
- }
2877
- default: throw new Error(`unknown method: ${method}`)
2878
- }
2879
- send({ type: 'res', id, result })
2880
- } catch (e: any) {
2881
- send({ type: 'res', id, error: String(e?.message || e) })
2882
- }
2883
- }
2884
-
2885
- async function serve(): Promise<void> {
2886
- loadEnvConfig()
2887
- // The protocol owns stdout; route any stray console.log from reused helpers
2888
- // (e.g. installScheduler) to stderr so it can't corrupt the JSONL stream.
2889
- console.log = (...args: any[]) => { console.error(...args) }
2890
- const send = (o: unknown) => process.stdout.write(JSON.stringify(o) + '\n')
2891
- let buf = ''
2892
- process.stdin.setEncoding('utf8')
2893
- process.stdin.on('data', (chunk: string) => {
2894
- buf += chunk
2895
- let nl: number
2896
- while ((nl = buf.indexOf('\n')) >= 0) {
2897
- const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1)
2898
- if (!line) continue
2899
- let req: any
2900
- try { req = JSON.parse(line) } catch { continue }
2901
- void dispatchServe(req, send)
2902
- }
2903
- })
2904
- await new Promise<void>((resolve) => { process.stdin.on('end', resolve); process.stdin.on('close', resolve) })
2905
- }
2906
-
2907
2601
  // ────────────────────────────────────────────────────────────────────────────
2908
2602
  // CLI commands
2909
2603
  // ────────────────────────────────────────────────────────────────────────────
@@ -2948,7 +2642,6 @@ cli.command('upgrade', 'Upgrade to the latest published version via bun add -g')
2948
2642
  cli.command('doctor', 'Check environment')
2949
2643
  .option('--json', 'Output structured status as JSON (for the GUI)')
2950
2644
  .action((opts: { json?: boolean }) => doctor(opts))
2951
- cli.command('serve', 'Run a persistent JSON-RPC engine over stdio (used by the desktop GUI)').action(serve)
2952
2645
  cli.command('login', 'Sign in to ChatGPT (Codex OAuth) for the pi summary backend')
2953
2646
  .option('--json', 'Emit machine-readable JSON events (for the GUI client)')
2954
2647
  .option('--device-code', 'Use the device-code flow instead of the browser callback (needs the ChatGPT security-settings opt-in)')
@@ -2963,9 +2656,27 @@ cli.command('config <action>', 'Read/write file-based config. action: get (print
2963
2656
  cli.command('install-launch-agent', 'Install background scheduler (mac LaunchAgent / Windows Task Scheduler)')
2964
2657
  .option('--load', 'Also (re)load/start it immediately')
2965
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))
2966
2662
  cli.command('uninstall-launch-agent', 'Remove the background scheduler').action(uninstallScheduler)
2967
2663
  cli.command('status', 'Print background scheduler status').action(printSchedulerStatus)
2968
2664
 
2969
2665
  cli.help()
2970
2666
  cli.version(VERSION)
2971
- 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
+ }