@fastagent-sh/voicenote 0.17.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts ADDED
@@ -0,0 +1,2843 @@
1
+ #!/usr/bin/env bun
2
+ import { cac } from 'cac'
3
+ import { deriveNoProxy, envKeysToEmbed, hydrateFromFileEnv, parseFileEnv } from './envConfig'
4
+ import { parseLockOwner } from './runLock'
5
+ import { createHash, createHmac, randomUUID } from 'node:crypto'
6
+ import { appendFile, chmod, mkdir, readFile, writeFile, copyFile, rename, unlink, stat, readdir, rm } from 'node:fs/promises'
7
+ import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, openSync, closeSync, statSync, readSync, unlinkSync, renameSync } from 'node:fs'
8
+ import { dlopen, FFIType, suffix } from 'bun:ffi'
9
+ import { basename, dirname, extname, join } from 'node:path'
10
+ import { fileURLToPath, pathToFileURL } from 'node:url'
11
+ import { spawn, spawnSync } from 'node:child_process'
12
+ import os from 'node:os'
13
+
14
+ const VERSION = '0.17.9'
15
+ const LAUNCH_AGENT_LABEL = 'sh.fastagent.voicenote'
16
+ const LAUNCH_AGENT_LABEL_LEGACY = 'com.kid7st.voicenote' // pre-fastagent installs; cleaned up on install
17
+ const TASK_NAME = 'VoiceNote' // Windows Task Scheduler name (mac uses LAUNCH_AGENT_LABEL)
18
+
19
+ // Single switch every platform branch routes through. Declared before the path
20
+ // consts so they can read it.
21
+ const IS_WINDOWS = process.platform === 'win32'
22
+ const IS_MAC = process.platform === 'darwin'
23
+
24
+ // Per-OS base dirs. Windows -> native AppData (Roaming for config, Local for
25
+ // logs/lock/state); mac/Linux -> ~/.config and ~/.local/state. appConfigDir /
26
+ // appStateDir are hoisted function decls (defined just below).
27
+ const CONFIG_DIR = appConfigDir()
28
+ const STATE_DIR = appStateDir()
29
+ const LOG_DIR = join(STATE_DIR, 'logs')
30
+ const LOCK_PATH = join(STATE_DIR, 'run.lock')
31
+ const SPEAKERS_PATH = join(CONFIG_DIR, 'speakers.json')
32
+ const CONFIG_ENV_PATH = join(CONFIG_DIR, 'config.json')
33
+ const PI_AUTH_PATH = join(os.homedir(), '.pi', 'agent', 'auth.json')
34
+
35
+ const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.wma', '.aac', '.flac'])
36
+
37
+ // Per-OS base directory resolution (see CONFIG_DIR / STATE_DIR above).
38
+ function appConfigDir(): string {
39
+ if (IS_WINDOWS) return join(process.env.APPDATA || join(os.homedir(), 'AppData', 'Roaming'), 'voicenote')
40
+ return join(os.homedir(), '.config', 'voicenote')
41
+ }
42
+ function appStateDir(): string {
43
+ if (IS_WINDOWS) return join(process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local'), 'voicenote')
44
+ return join(os.homedir(), '.local', 'state', 'voicenote')
45
+ }
46
+
47
+ type Json = Record<string, any>
48
+
49
+ type Recording = {
50
+ sourcePath: string
51
+ sizeBytes: number
52
+ modifiedAt: string
53
+ durationSeconds: number | null
54
+ sourceId: string
55
+ recordedAt: Date
56
+ }
57
+
58
+ type LocalFiles = {
59
+ audio: string
60
+ transcript: string
61
+ notes: string
62
+ metadata: string
63
+ }
64
+
65
+ type SpeakerSelf = { name: string | null; aliases: string[] }
66
+ type SpeakerKnown = { name: string; aliases: string[]; relationship?: string | null }
67
+ type SpeakersConfig = { self: SpeakerSelf; known: SpeakerKnown[] }
68
+
69
+
70
+ type VolcanoTosConfig = {
71
+ endpoint: string
72
+ region: string
73
+ bucket: string
74
+ accessKey: string
75
+ secretKey: string
76
+ keep: boolean
77
+ }
78
+
79
+ type VolcanoConfig = {
80
+ apiKey: string // X-Api-Key (new Volcano console)
81
+ resourceId: string
82
+ language?: string
83
+ tos: VolcanoTosConfig
84
+ }
85
+
86
+ type Config = {
87
+ deviceVolume: string
88
+ recordDir: string
89
+ workspace: string
90
+ minBytes: number
91
+ minDurationSeconds: number
92
+ maxAgeHours: number
93
+ speakers: SpeakersConfig
94
+ volcano: VolcanoConfig | null
95
+ }
96
+
97
+ // ────────────────────────────────────────────────────────────────────────────
98
+ // Env loading
99
+ // ────────────────────────────────────────────────────────────────────────────
100
+
101
+ // Single source of truth for every env var the pipeline reads. Both consumers
102
+ // derive from this list so they can never drift:
103
+ // - loadEnvConfig() hydrates these from config.json / ~/.zshrc for non-interactive runs
104
+ // - launchAgentEnv() embeds the REAL-environment subset into the LaunchAgent
105
+ // plist (file-sourced values are skipped — vn run re-reads the files at
106
+ // startup, and plist env overrides config.json, so embedding a file value
107
+ // would freeze it: later GUI edits would silently never reach the agent)
108
+ // Anything documented in the README as a configurable knob MUST live here.
109
+ const ENV_KEYS = [
110
+ 'VOICENOTE_DEVICE_VOLUME',
111
+ 'VOICENOTE_RECORD_DIR',
112
+ 'VOICENOTE_WORKSPACE',
113
+ 'VOICENOTE_MIN_BYTES',
114
+ 'VOICENOTE_MIN_DURATION_SECONDS',
115
+ 'VOICENOTE_MAX_AGE_HOURS',
116
+ 'VOLCANO_ASR_KEY',
117
+ 'VOLCANO_ASR_RESOURCE_ID',
118
+ 'VOLCANO_ASR_LANGUAGE',
119
+ 'VOLCANO_TOS_REGION',
120
+ 'VOLCANO_TOS_ENDPOINT',
121
+ 'VOLCANO_TOS_BUCKET',
122
+ 'VOLCANO_TOS_ACCESS_KEY',
123
+ 'VOLCANO_TOS_SECRET_KEY',
124
+ 'VOLCANO_TOS_KEEP',
125
+ 'VOICENOTE_PI_BIN',
126
+ 'VOICENOTE_PI_CLI',
127
+ 'VOICENOTE_FFPROBE_BIN',
128
+ 'VOICENOTE_PI_PROVIDER',
129
+ 'VOICENOTE_PI_MODEL',
130
+ 'VOICENOTE_PI_MODEL_SUMMARY',
131
+ 'VOICENOTE_PI_THINKING',
132
+ 'VOICENOTE_PI_SUMMARY_TOOLS',
133
+ 'VOICENOTE_CONTEXT_DIR',
134
+ 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
135
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
136
+ 'LOCAL_PROXY_HOST', 'LOCAL_PROXY_PORT', 'LOCAL_NO_PROXY',
137
+ 'OPENAI_API_KEY',
138
+ ]
139
+
140
+ // Volcano endpoints (TOS object storage + openspeech ASR) should NEVER go through
141
+ // the SOCKS/HTTP proxy that pi (ChatGPT Codex OAuth) may need:
142
+ // 1) the proxy bandwidth often chokes on multi-megabyte PUTs to TOS
143
+ // 2) routing China-mainland Volcano APIs through an overseas proxy is slower / unreliable
144
+ const VOLCANO_NO_PROXY_HOSTS = ['.volces.com', '.volcengineapi.com', 'openspeech.bytedance.com']
145
+
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
+ function systemProxyUrl(): string | null {
167
+ if (process.platform !== 'darwin') return null
168
+ try {
169
+ const out = spawnSync('scutil', ['--proxy'], { encoding: 'utf8', timeout: 3000 })
170
+ if (out.status !== 0 || !out.stdout) return null
171
+ const get = (k: string) => out.stdout.match(new RegExp(`\\b${k}\\s*:\\s*(\\S+)`))?.[1]
172
+ if (get('HTTPSEnable') === '1' && get('HTTPSProxy') && get('HTTPSPort')) return `http://${get('HTTPSProxy')}:${get('HTTPSPort')}`
173
+ if (get('HTTPEnable') === '1' && get('HTTPProxy') && get('HTTPPort')) return `http://${get('HTTPProxy')}:${get('HTTPPort')}`
174
+ return null
175
+ } catch { return null }
176
+ }
177
+
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
+ }
195
+ }
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
+ // What the config files would provide for each ENV_KEY, independent of this
210
+ // process's environment. Primary source is ~/.config/voicenote/config.json
211
+ // (ENV-style runtime keys at the top level; identity under `speakers`); the
212
+ // legacy fallback is `export KEY=...` lines in ~/.zshrc, for CLI installs
213
+ // that predate config.json. Precedence/expansion logic lives in envConfig.ts
214
+ // (pure + tested). Two consumers: loadEnvConfig() hydrates these into
215
+ // process.env for keys the real environment doesn't set, and launchAgentEnv()
216
+ // uses them to decide which values are recoverable at run time.
217
+ function fileProvidedEnv(): Record<string, string> {
218
+ const data = loadJsonSync<Record<string, unknown>>(CONFIG_ENV_PATH, {})
219
+ let zshrc: string | null = null
220
+ const zshrcPath = join(os.homedir(), '.zshrc')
221
+ if (existsSync(zshrcPath)) { try { zshrc = readFileSync(zshrcPath, 'utf8') } catch { zshrc = null } }
222
+ return parseFileEnv(ENV_KEYS, data, zshrc, os.homedir())
223
+ }
224
+
225
+ let envConfigLoaded = false
226
+ function loadEnvConfig(): void {
227
+ if (envConfigLoaded) return
228
+ envConfigLoaded = true
229
+ // Precedence: process.env > config.json (GUI) > ~/.zshrc (legacy); an
230
+ // explicit empty string in the environment is never overridden.
231
+ const toApply = hydrateFromFileEnv(ENV_KEYS, process.env, fileProvidedEnv())
232
+ for (const [key, v] of Object.entries(toApply)) { process.env[key] = v; hydratedEnvKeys.add(key) }
233
+ // Derive http_proxy etc. from LOCAL_PROXY_HOST/PORT regardless of source, and
234
+ // always keep Volcano hosts on NO_PROXY. (Runs even with no config files.)
235
+ applyDerivedProxy()
236
+ }
237
+
238
+ // Re-hydrate after config.json changes. Needed by the long-lived `vn serve`:
239
+ // without this, a GUI config edit only reaches OTHER processes (vn run reads
240
+ // the file fresh each start), while serve's own doctor kept reporting stale
241
+ // values and ensure_agent re-embedded them into the scheduler env on
242
+ // reinstall. Only hydrated/derived keys are dropped — real environment
243
+ // variables keep their precedence (a real-env no_proxy stays merged in place;
244
+ // its pre-merge original survives in premergeRealNoProxy, and the volcano
245
+ // merge is idempotent on the next pass).
246
+ function reloadEnvConfig(): void {
247
+ for (const k of hydratedEnvKeys) delete process.env[k]
248
+ hydratedEnvKeys.clear()
249
+ envConfigLoaded = false
250
+ loadEnvConfig()
251
+ }
252
+
253
+ function getVolcanoConfigFromEnv(): VolcanoConfig | null {
254
+ const apiKey = process.env.VOLCANO_ASR_KEY || ''
255
+ const tosAccess = process.env.VOLCANO_TOS_ACCESS_KEY
256
+ const tosSecret = process.env.VOLCANO_TOS_SECRET_KEY
257
+ const bucket = process.env.VOLCANO_TOS_BUCKET
258
+ if (!apiKey || !tosAccess || !tosSecret || !bucket) return null
259
+ const region = process.env.VOLCANO_TOS_REGION || 'cn-hongkong'
260
+ const endpoint = process.env.VOLCANO_TOS_ENDPOINT || `tos-s3-${region}.volces.com`
261
+ const keep = ['1', 'true', 'yes'].includes((process.env.VOLCANO_TOS_KEEP || '0').toLowerCase())
262
+ return {
263
+ apiKey,
264
+ resourceId: process.env.VOLCANO_ASR_RESOURCE_ID || 'volc.seedasr.auc',
265
+ language: process.env.VOLCANO_ASR_LANGUAGE || undefined,
266
+ tos: { endpoint, region, bucket, accessKey: tosAccess, secretKey: tosSecret, keep },
267
+ }
268
+ }
269
+
270
+ function volcanoAuthHeaders(volc: VolcanoConfig, taskId: string, includeSequence: boolean): Record<string, string> {
271
+ const base: Record<string, string> = {
272
+ 'X-Api-Resource-Id': volc.resourceId,
273
+ 'X-Api-Request-Id': taskId,
274
+ 'Content-Type': 'application/json',
275
+ }
276
+ if (includeSequence) base['X-Api-Sequence'] = '-1'
277
+ base['X-Api-Key'] = volc.apiKey
278
+ return base
279
+ }
280
+
281
+ function getConfig(): Config {
282
+ loadEnvConfig()
283
+ const deviceVolume = process.env.VOICENOTE_DEVICE_VOLUME || 'VTR6500'
284
+ const recordDir = process.env.VOICENOTE_RECORD_DIR || `/Volumes/${deviceVolume}/RECORD`
285
+ return {
286
+ deviceVolume,
287
+ recordDir,
288
+ workspace: expandHome(process.env.VOICENOTE_WORKSPACE || '~/Documents/meetings'),
289
+ minBytes: Number(process.env.VOICENOTE_MIN_BYTES || 100000),
290
+ minDurationSeconds: Number(process.env.VOICENOTE_MIN_DURATION_SECONDS || 60),
291
+ // Only recordings from the last N hours are picked up (0 = no limit), so a
292
+ // fresh install doesn't drain the recorder's entire history.
293
+ maxAgeHours: Number(process.env.VOICENOTE_MAX_AGE_HOURS || 48),
294
+ volcano: getVolcanoConfigFromEnv(),
295
+ speakers: loadSpeakers(),
296
+ }
297
+ }
298
+
299
+ // ────────────────────────────────────────────────────────────────────────────
300
+ // Config files (~/.config/voicenote)
301
+ // ────────────────────────────────────────────────────────────────────────────
302
+
303
+ const DEFAULT_SPEAKERS: SpeakersConfig = { self: { name: null, aliases: [] }, known: [] }
304
+
305
+
306
+ function loadJsonSync<T>(path: string, fallback: T): T {
307
+ if (!existsSync(path)) return fallback
308
+ try { return JSON.parse(readFileSync(path, 'utf8')) as T } catch (e) { warnSideEffect(`parse ${path}`, e); return fallback }
309
+ }
310
+
311
+ function normalizeSpeakers(data: unknown): SpeakersConfig {
312
+ const raw = (data && typeof data === 'object') ? data as Partial<SpeakersConfig> : {}
313
+ return {
314
+ self: {
315
+ name: typeof raw.self?.name === 'string' ? raw.self.name : null,
316
+ aliases: Array.isArray(raw.self?.aliases) ? raw.self!.aliases.filter((a): a is string => typeof a === 'string') : [],
317
+ },
318
+ known: Array.isArray(raw.known)
319
+ ? raw.known
320
+ .filter((k): k is SpeakerKnown => !!k && typeof k === 'object' && typeof (k as SpeakerKnown).name === 'string')
321
+ .map(k => ({ name: k.name, aliases: Array.isArray(k.aliases) ? k.aliases.filter((a): a is string => typeof a === 'string') : [], relationship: k.relationship ?? null }))
322
+ : [],
323
+ }
324
+ }
325
+
326
+ function loadConfigJson(): Record<string, unknown> {
327
+ return loadJsonSync<Record<string, unknown>>(CONFIG_ENV_PATH, {})
328
+ }
329
+
330
+ function loadSpeakers(): SpeakersConfig {
331
+ ensureConfigSeed()
332
+ const config = loadConfigJson()
333
+ if (config.speakers) return normalizeSpeakers(config.speakers)
334
+ // Backward compatibility for installs created before speakers moved into config.json.
335
+ return normalizeSpeakers(loadJsonSync<unknown>(SPEAKERS_PATH, DEFAULT_SPEAKERS))
336
+ }
337
+
338
+
339
+ let configSeeded = false
340
+ function ensureConfigSeed(): void {
341
+ if (configSeeded) return
342
+ configSeeded = true
343
+ try {
344
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true })
345
+
346
+ const current = loadConfigJson()
347
+ if (!current.speakers) {
348
+ const legacy = existsSync(SPEAKERS_PATH) ? loadJsonSync<unknown>(SPEAKERS_PATH, DEFAULT_SPEAKERS) : DEFAULT_SPEAKERS
349
+ current.speakers = normalizeSpeakers(legacy)
350
+ writeFileSync(CONFIG_ENV_PATH, JSON.stringify(current, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
351
+ }
352
+ } catch {
353
+ // Don't crash if we can't seed; commands still work with defaults in memory.
354
+ }
355
+ }
356
+
357
+ // ────────────────────────────────────────────────────────────────────────────
358
+ // Misc helpers
359
+ // ────────────────────────────────────────────────────────────────────────────
360
+
361
+ function expandHome(path: string): string {
362
+ if (path === '~') return os.homedir()
363
+ if (path.startsWith('~/')) return join(os.homedir(), path.slice(2))
364
+ return path
365
+ }
366
+
367
+ function nowIso(): string { return new Date().toISOString() }
368
+ function pad(n: number): string { return String(n).padStart(2, '0') }
369
+
370
+ function dateParts(d: Date): { month: string; prefix: string } {
371
+ const month = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`
372
+ // Local time in filenames uses HH-MM only (the recorder cannot produce two recordings within the same minute)
373
+ const prefix = `${month}-${pad(d.getDate())}-${pad(d.getHours())}-${pad(d.getMinutes())}`
374
+ return { month, prefix }
375
+ }
376
+
377
+ function safeSlug(text: string, maxLen = 48): string {
378
+ const cleaned = (text || '').trim().replace(/[\\/:*?"<>|\n\r\t]+/g, '-').replace(/\s+/g, '-').replace(/^-+|-+$/g, '')
379
+ return cleaned.slice(0, maxLen).replace(/-+$/g, '') || 'note'
380
+ }
381
+
382
+ function formatSeconds(seconds: number | null | undefined): string {
383
+ const total = Math.max(0, Math.round(seconds || 0))
384
+ const h = Math.floor(total / 3600)
385
+ const m = Math.floor((total % 3600) / 60)
386
+ const s = total % 60
387
+ return h ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
388
+ }
389
+
390
+ // ────────────────────────────────────────────────────────────────────────────
391
+ // File state IO
392
+ // ────────────────────────────────────────────────────────────────────────────
393
+
394
+ async function ensureDirs(config: Config): Promise<void> {
395
+ for (const dir of ['_state', '_index', '_audio', '_transcripts', '_metadata']) {
396
+ await mkdir(join(config.workspace, dir), { recursive: true })
397
+ }
398
+ }
399
+
400
+ async function readJson<T>(path: string, fallback: T): Promise<T> {
401
+ if (!existsSync(path)) return fallback
402
+ try { return JSON.parse(await readFile(path, 'utf8')) as T } catch (e) { warnSideEffect(`parse ${path}`, e); return fallback }
403
+ }
404
+
405
+ async function writeJson(path: string, data: any): Promise<void> {
406
+ await mkdir(dirname(path), { recursive: true })
407
+ const tmp = `${path}.tmp`
408
+ await writeFile(tmp, JSON.stringify(data, null, 2), 'utf8')
409
+ await rename(tmp, path)
410
+ }
411
+
412
+ async function appendJsonl(path: string, data: any): Promise<void> {
413
+ await mkdir(dirname(path), { recursive: true })
414
+ await appendFile(path, JSON.stringify(data) + '\n', 'utf8')
415
+ }
416
+
417
+ const SUMMARY_FAILED_STATUS = 'summary_failed_transcript_saved'
418
+ const RAW_TRANSCRIPT_MARKER = '## Raw transcript (no lossy cleanup)\n\n'
419
+ const RAW_TRANSCRIPT_MARKER_LEGACY = '## 原始 transcript(不做 lossy 清洗)\n\n' // pre-0.18 files on disk
420
+
421
+ function isSummaryFailedEntry(entry: any): boolean {
422
+ return entry?.status === SUMMARY_FAILED_STATUS
423
+ }
424
+
425
+
426
+ // ────────────────────────────────────────────────────────────────────────────
427
+ // Logging (rolling daily log)
428
+ // ────────────────────────────────────────────────────────────────────────────
429
+
430
+ function dailyLogPath(): string {
431
+ const d = new Date()
432
+ return join(LOG_DIR, `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}.log`)
433
+ }
434
+
435
+ // Best-effort side effects (logging, idle-state) must not crash the pipeline, but
436
+ // failures should still be observable. Write directly to stderr (not console.error,
437
+ // which wireDailyLog wraps and would recurse into the same failing file) once per tag.
438
+ const sideEffectWarned = new Set<string>()
439
+ function warnSideEffect(where: string, e: unknown): void {
440
+ if (sideEffectWarned.has(where)) return
441
+ sideEffectWarned.add(where)
442
+ process.stderr.write(`[voicenote] non-fatal: ${where} failed: ${e instanceof Error ? e.message : String(e)}\n`)
443
+ }
444
+
445
+ let logWired = false
446
+ function wireDailyLog(): void {
447
+ if (logWired) return
448
+ logWired = true
449
+ try { mkdirSync(LOG_DIR, { recursive: true }) } catch (e) { warnSideEffect('log dir mkdir', e) }
450
+ const path = dailyLogPath()
451
+ const append = (level: 'INFO' | 'ERROR', args: any[]) => {
452
+ const line = args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')
453
+ const stamped = `${nowIso()} [${level}] ${line}\n`
454
+ try { appendFileSync(path, stamped, 'utf8') } catch (e) { warnSideEffect('daily log append', e) }
455
+ }
456
+ const origLog = console.log.bind(console)
457
+ const origErr = console.error.bind(console)
458
+ console.log = (...a: any[]) => { append('INFO', a); origLog(...a) }
459
+ console.error = (...a: any[]) => { append('ERROR', a); origErr(...a) }
460
+ }
461
+
462
+ function formatBytes(bytes: number): string {
463
+ if (!Number.isFinite(bytes)) return 'unknown size'
464
+ const units = ['B', 'KB', 'MB', 'GB']
465
+ let value = bytes
466
+ let unit = 0
467
+ while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++ }
468
+ return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`
469
+ }
470
+
471
+ function formatElapsed(ms: number): string {
472
+ const total = Math.max(0, Math.round(ms / 1000))
473
+ const h = Math.floor(total / 3600)
474
+ const m = Math.floor((total % 3600) / 60)
475
+ const s = total % 60
476
+ if (h) return `${h}h ${m}m ${s}s`
477
+ if (m) return `${m}m ${s}s`
478
+ return `${s}s`
479
+ }
480
+
481
+ function progressStep(step: number, total: number, title: string, detail?: string): void {
482
+ console.log(`▶ Step ${step}/${total}: ${title}${detail ? ` — ${detail}` : ''}`)
483
+ }
484
+
485
+ async function withHeartbeat<T>(label: string, work: () => Promise<T>, heartbeatSeconds = 60): Promise<T> {
486
+ const started = Date.now()
487
+ const timer = setInterval(() => {
488
+ console.log(`… Still working: ${label} (${formatElapsed(Date.now() - started)} elapsed)`)
489
+ }, Math.max(10, heartbeatSeconds) * 1000)
490
+ ;(timer as any).unref?.()
491
+ try {
492
+ const result = await work()
493
+ console.log(`✓ Done: ${label} (${formatElapsed(Date.now() - started)})`)
494
+ return result
495
+ } catch (e) {
496
+ console.error(`✗ Failed: ${label} after ${formatElapsed(Date.now() - started)}`)
497
+ throw e
498
+ } finally {
499
+ clearInterval(timer)
500
+ }
501
+ }
502
+
503
+ function shouldLogIdleStatus(key: string, intervalMs = 30 * 60 * 1000): boolean {
504
+ const path = join(LOG_DIR, 'idle-status.json')
505
+ const now = Date.now()
506
+ let prev: any = null
507
+ try { prev = JSON.parse(readFileSync(path, 'utf8')) } catch {}
508
+ const should = prev?.key !== key || now - Number(prev?.at || 0) >= intervalMs
509
+ if (should) {
510
+ try {
511
+ mkdirSync(LOG_DIR, { recursive: true })
512
+ writeFileSync(path, JSON.stringify({ key, at: now, iso: nowIso() }, null, 2) + '\n', 'utf8')
513
+ } catch (e) { warnSideEffect('idle-status write', e) }
514
+ }
515
+ return should
516
+ }
517
+
518
+ type RunMode = 'notes' | 'transcript'
519
+ function normalizeRunMode(opts: any): RunMode {
520
+ const raw = String(opts.mode || 'notes').toLowerCase()
521
+ if (raw === 'note') return 'notes'
522
+ if (raw === 'notes' || raw === 'transcript') return raw
523
+ throw new Error(`Invalid --mode "${raw}". Use: notes|transcript`)
524
+ }
525
+
526
+ // ────────────────────────────────────────────────────────────────────────────
527
+ // Cross-process lock
528
+ // ────────────────────────────────────────────────────────────────────────────
529
+
530
+ // Single-instance mutual exclusion via an OS advisory lock (flock) held on an open
531
+ // fd. The kernel releases it automatically when the process exits — including
532
+ // SIGKILL/crash — so there is NO pid / mtime / heartbeat / stale-steal logic to
533
+ // race on. flock is loaded from libSystem (macOS). On Windows we instead use a
534
+ // pid+timestamp lockfile (acquireRunLockWindows); on Linux flock is unavailable
535
+ // via this path and we degrade to no cross-process lock with a warning.
536
+ const flockFn = (() => {
537
+ try {
538
+ const lib = dlopen(`libSystem.${suffix}`, { flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 } })
539
+ return lib.symbols.flock as (fd: number, op: number) => number
540
+ } catch { return null }
541
+ })()
542
+ const FLOCK_EX_NB = 2 | 4 // LOCK_EX | LOCK_NB
543
+ const FLOCK_UN = 8
544
+
545
+ // Windows lock: no flock here. A pid+timestamp lockfile, created atomically with
546
+ // 'wx'. We only reclaim an existing lock when its owner pid is dead OR the lock is
547
+ // stale (older than STALE_MS). The holder refreshes its timestamp every 5 minutes
548
+ // (heartbeat below), so a legitimately long RUNNING job — ASR on a multi-hour
549
+ // recording — never looks stale. The staleness escape exists for the pid-reuse
550
+ // false positive (owner died, an unrelated process now has its pid, the aliveness
551
+ // probe lies); its known cost: a machine asleep >30min can lose the lock on wake
552
+ // (timers don't fire while asleep), so the heartbeat verifies ownership before
553
+ // each refresh and, if the lock was reclaimed, stops touching it and warns — the
554
+ // old run finishes unprotected rather than corrupting the new holder's record.
555
+ // Task Scheduler's IgnoreNew already blocks the common 60s overlap; this only has
556
+ // to cover a manual `vn run` racing the scheduled one. The tiny create/reclaim
557
+ // window is acceptable: its failure mode is conservatively skipping one run (same
558
+ // as mac when flock is already held).
559
+ async function acquireRunLockWindows(): Promise<{ release: () => Promise<void> } | null> {
560
+ await mkdir(dirname(LOCK_PATH), { recursive: true })
561
+ const STALE_MS = 30 * 60 * 1000
562
+ const tryCreate = (): number | null => {
563
+ try { return openSync(LOCK_PATH, 'wx') }
564
+ catch (e: any) { if (e?.code === 'EEXIST') return null; throw e }
565
+ }
566
+ let fd = tryCreate()
567
+ if (fd === null) {
568
+ let reclaim = false
569
+ try {
570
+ const data = JSON.parse(readFileSync(LOCK_PATH, 'utf8'))
571
+ const pid = Number(data.pid), ts = Number(data.ts)
572
+ const alive = pid > 0 && (() => { try { process.kill(pid, 0); return true } catch (e: any) { return e?.code === 'EPERM' } })()
573
+ const fresh = Number.isFinite(ts) && (Date.now() - ts) < STALE_MS
574
+ reclaim = !alive || !fresh
575
+ } catch { reclaim = true } // unreadable/corrupt lock -> reclaim
576
+ if (!reclaim) return null // another live run holds it
577
+ try { unlinkSync(LOCK_PATH) } catch {}
578
+ fd = tryCreate()
579
+ if (fd === null) return null // someone grabbed it in the gap
580
+ }
581
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }))
582
+ closeSync(fd)
583
+ // Tri-state ownership (parse logic + its 'unknown'-on-read-failure invariant
584
+ // are pure + tested in runLock.ts). A transient read failure must NOT be
585
+ // treated as loss of ownership: that would kill the heartbeat and hand the
586
+ // lock away over a momentary glitch — the overlap the heartbeat prevents.
587
+ const lockOwnership = () => {
588
+ let raw: string | null
589
+ try { raw = readFileSync(LOCK_PATH, 'utf8') } catch { raw = null }
590
+ return parseLockOwner(raw, process.pid)
591
+ }
592
+ // Heartbeat: keep ts fresh while we hold the lock; verify ownership first
593
+ // (see header comment — the lock can be reclaimed after a long sleep).
594
+ // The refresh writes a temp file and renames it into place: a plain
595
+ // truncate+write would open a window where a concurrent acquire reads
596
+ // empty/partial JSON, treats the lock as corrupt, and reclaims a LIVE lock.
597
+ const heartbeat = setInterval(() => {
598
+ const owner = lockOwnership()
599
+ if (owner === 'reclaimed') {
600
+ clearInterval(heartbeat)
601
+ console.error('Run lock was reclaimed by another process (machine slept >30min?); this run continues but is no longer protected against overlap.')
602
+ return
603
+ }
604
+ if (owner === 'unknown') { warnSideEffect('windows lock heartbeat read', new Error('lock unreadable this tick; will retry')); return }
605
+ try {
606
+ const tmp = `${LOCK_PATH}.hb-${process.pid}`
607
+ writeFileSync(tmp, JSON.stringify({ pid: process.pid, ts: Date.now() }))
608
+ renameSync(tmp, LOCK_PATH) // atomic replace, also on Windows
609
+ } catch (e) { warnSideEffect('windows lock heartbeat', e) }
610
+ }, 5 * 60 * 1000)
611
+ ;(heartbeat as any).unref?.()
612
+ let released = false
613
+ const release = async () => {
614
+ if (released) return
615
+ released = true
616
+ clearInterval(heartbeat)
617
+ // Only remove the lock when it is provably still OURS. Not 'reclaimed'
618
+ // (that's the new holder's lock) and not 'unknown' either: a transient
619
+ // read failure could be a reclaimer mid-swap, and the heartbeat does NOT
620
+ // rewrite on 'unknown', so unlinking here would leave a real vacuum until
621
+ // STALE_MS. Leaking our own lock on a rare transient failure is the lesser
622
+ // evil — it self-heals after STALE_MS via the staleness check.
623
+ try { if (lockOwnership() === 'mine') unlinkSync(LOCK_PATH) } catch {}
624
+ }
625
+ process.once('exit', () => { void release() })
626
+ process.once('SIGINT', () => { void release(); process.exit(130) })
627
+ process.once('SIGTERM', () => { void release(); process.exit(143) })
628
+ return { release }
629
+ }
630
+
631
+ async function acquireRunLock(): Promise<{ release: () => Promise<void> } | null> {
632
+ if (IS_WINDOWS) return acquireRunLockWindows()
633
+ await mkdir(dirname(LOCK_PATH), { recursive: true })
634
+ if (!flockFn) {
635
+ console.error('Warning: flock unavailable on this runtime; proceeding without cross-process locking.')
636
+ return { release: async () => {} }
637
+ }
638
+ // The lock is a regular file we keep open. Builds ≤ 0.15.2 used a *directory*
639
+ // here, held purely by its existence, with no pid or refreshed mtime inside — so
640
+ // a leftover legacy dir carries NO reliable signal about whether an old `vn run`
641
+ // still holds it. Rather than guess (and risk deleting a live lock → concurrent
642
+ // double-processing), refuse to auto-reclaim it: warn and skip. Normal upgrades
643
+ // don't hit this (≤ 0.15.2 removes its own dir lock on SIGTERM/exit); it only
644
+ // appears after a hard crash of an old build, where a one-time manual cleanup is
645
+ // the safe move.
646
+ let fd: number
647
+ try { fd = openSync(LOCK_PATH, 'w') }
648
+ catch (e: any) {
649
+ if (e?.code !== 'EISDIR') throw e
650
+ console.error(`Found a legacy (≤ 0.15.2) lock directory at ${LOCK_PATH}; it carries no liveness info and can't be auto-reclaimed safely. If no 'vn run' is active, remove it once: rm -rf "${LOCK_PATH}" — skipping this run.`)
651
+ return null
652
+ }
653
+ if (flockFn(fd, FLOCK_EX_NB) !== 0) { closeSync(fd); return null } // another run holds it
654
+ let released = false
655
+ const release = async () => {
656
+ if (released) return
657
+ released = true
658
+ try { flockFn(fd, FLOCK_UN) } catch {}
659
+ try { closeSync(fd) } catch {}
660
+ }
661
+ process.once('exit', () => { void release() })
662
+ process.once('SIGINT', () => { void release(); process.exit(130) })
663
+ process.once('SIGTERM', () => { void release(); process.exit(143) })
664
+ return { release }
665
+ }
666
+
667
+ // ────────────────────────────────────────────────────────────────────────────
668
+ // Recording scan
669
+ // ────────────────────────────────────────────────────────────────────────────
670
+
671
+ function parseRecordedAt(path: string): Date {
672
+ const stem = basename(path, extname(path))
673
+ const m = stem.match(/(20\d{12})/)
674
+ if (m?.[1]) {
675
+ const s = m[1]
676
+ return new Date(Number(s.slice(0, 4)), Number(s.slice(4, 6)) - 1, Number(s.slice(6, 8)), Number(s.slice(8, 10)), Number(s.slice(10, 12)), Number(s.slice(12, 14)))
677
+ }
678
+ return new Date()
679
+ }
680
+
681
+ async function sha256File(path: string): Promise<string> {
682
+ const h = createHash('sha256')
683
+ const reader = Bun.file(path).stream().getReader()
684
+ while (true) {
685
+ const { done, value } = await reader.read()
686
+ if (done) break
687
+ h.update(value)
688
+ }
689
+ return h.digest('hex')
690
+ }
691
+
692
+ async function sourceIdFor(path: string): Promise<string> {
693
+ const st = await stat(path)
694
+ const digest = await sha256File(path)
695
+ return createHash('sha256').update(`${path}|${st.size}|${Math.floor(st.mtimeMs / 1000)}|${digest}`).digest('hex')
696
+ }
697
+
698
+ function runCommand(command: string, args: string[], timeoutMs = 20000): Promise<{ stdout: string; stderr: string; code: number }> {
699
+ return new Promise((res) => {
700
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
701
+ let stdout = '', stderr = ''
702
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
703
+ child.stdout.on('data', d => stdout += String(d))
704
+ child.stderr.on('data', d => stderr += String(d))
705
+ child.on('close', code => { clearTimeout(timer); res({ stdout, stderr, code: code ?? 1 }) })
706
+ child.on('error', err => { clearTimeout(timer); res({ stdout, stderr: String(err), code: 1 }) })
707
+ })
708
+ }
709
+
710
+ // Cross-platform "reveal in file manager / open URL in default app".
711
+ // macOS `open`, Linux `xdg-open`, Windows `start` (a cmd builtin, so via `cmd /c`;
712
+ // the empty "" is start's title arg so a quoted path/URL isn't swallowed as title).
713
+ function openPath(target: string, timeoutMs = 5000): Promise<{ stdout: string; stderr: string; code: number }> {
714
+ if (IS_WINDOWS) return runCommand('cmd', ['/c', 'start', '', target], timeoutMs)
715
+ if (IS_MAC) return runCommand('open', [target], timeoutMs)
716
+ return runCommand('xdg-open', [target], timeoutMs)
717
+ }
718
+
719
+ // Cross-platform replacement for `tail -n N [-F] files`. Windows ships no `tail`,
720
+ // so even a non-follow `vn log` would break; a pure-JS implementation also drops a
721
+ // process dependency on mac/Linux. Follow mode polls appended bytes every second.
722
+ async function tailFiles(files: string[], lines: number, follow: boolean): Promise<void> {
723
+ const header = files.length > 1
724
+ const lastLines = (text: string, n: number) => {
725
+ const arr = text.split('\n')
726
+ if (arr.length && arr[arr.length - 1] === '') arr.pop()
727
+ return arr.slice(-n).join('\n')
728
+ }
729
+ const sizes = new Map<string, number>()
730
+ for (const f of files) {
731
+ const text = await readFile(f, 'utf8').catch(() => '')
732
+ if (header) process.stdout.write(`==> ${f} <==\n`)
733
+ const tail = lastLines(text, lines)
734
+ if (tail) process.stdout.write(tail + '\n')
735
+ sizes.set(f, Buffer.byteLength(text))
736
+ }
737
+ if (!follow) return
738
+ await new Promise<void>((resolve) => {
739
+ let stop = false
740
+ process.once('SIGINT', () => { stop = true; resolve() })
741
+ const poll = () => {
742
+ if (stop) return
743
+ for (const f of files) {
744
+ try {
745
+ const size = statSync(f).size
746
+ const prev = sizes.get(f) ?? 0
747
+ if (size > prev) {
748
+ const fd = openSync(f, 'r')
749
+ try {
750
+ const buf = Buffer.alloc(size - prev)
751
+ readSync(fd, buf, 0, buf.length, prev)
752
+ if (header) process.stdout.write(`==> ${f} <==\n`)
753
+ process.stdout.write(buf.toString('utf8'))
754
+ } finally { closeSync(fd) }
755
+ sizes.set(f, size)
756
+ } else if (size < prev) {
757
+ sizes.set(f, size) // rotated/truncated
758
+ }
759
+ } catch {}
760
+ }
761
+ if (!stop) setTimeout(poll, 1000)
762
+ }
763
+ setTimeout(poll, 1000)
764
+ })
765
+ }
766
+
767
+ // ffprobe is the only ffmpeg-suite binary the pipeline actually uses (duration
768
+ // detection). Resolve a configurable path so a bundled binary (GUI .app sidecar)
769
+ // can be used without relying on PATH — mirrors the VOICENOTE_PI_BIN convention.
770
+ function ffprobeBin(): string { return process.env.VOICENOTE_FFPROBE_BIN || 'ffprobe' }
771
+
772
+ async function ffprobeDuration(path: string): Promise<number | null> {
773
+ const result = await runCommand(ffprobeBin(), ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', path])
774
+ if (result.code !== 0) return null
775
+ const v = Number(result.stdout.trim())
776
+ return Number.isFinite(v) ? v : null
777
+ }
778
+
779
+ function isCandidateFile(path: string): boolean {
780
+ const name = basename(path)
781
+ if (name.startsWith('._') || name.startsWith('.')) return false
782
+ if (!AUDIO_EXTENSIONS.has(extname(path).toLowerCase())) return false
783
+ const parts = path.split(/[/\\]/)
784
+ if (parts.includes('.Spotlight-V100') || parts.includes('.fseventsd') || parts.includes('System Volume Information')) return false
785
+ return true
786
+ }
787
+
788
+ async function scanRecordings(config: Config): Promise<Recording[]> {
789
+ if (!existsSync(config.recordDir)) return []
790
+ const recordings: Recording[] = []
791
+ for await (const file of new Bun.Glob('**/*').scan({ cwd: config.recordDir, absolute: true, dot: true })) {
792
+ if (!isCandidateFile(file)) continue
793
+ const st = await stat(file).catch(() => null)
794
+ if (!st?.isFile()) continue
795
+ recordings.push({
796
+ sourcePath: file,
797
+ sizeBytes: st.size,
798
+ modifiedAt: st.mtime.toISOString(),
799
+ durationSeconds: await ffprobeDuration(file),
800
+ sourceId: await sourceIdFor(file),
801
+ recordedAt: parseRecordedAt(file),
802
+ })
803
+ }
804
+ // Oldest first: backlog is drained in chronological order, so every file is
805
+ // guaranteed a turn before newer arrivals jump the queue.
806
+ return recordings.sort((a, b) => a.recordedAt.getTime() - b.recordedAt.getTime())
807
+ }
808
+
809
+ function shouldSkip(rec: Recording, state: Json, config: Config, force: boolean, mode: RunMode): [boolean, string] {
810
+ const processed = state.processed_source_ids?.[rec.sourceId]
811
+ if (processed && !force) {
812
+ // A summary failure is not a completed job: the expensive transcript was
813
+ // saved, so the next normal notes run should resume at summary instead of
814
+ // requiring `vn forget` and paying ASR again.
815
+ if (mode === 'notes' && isSummaryFailedEntry(processed)) return [false, '']
816
+ return [true, 'already_processed']
817
+ }
818
+ const ageHours = (Date.now() - rec.recordedAt.getTime()) / 3600_000
819
+ if (config.maxAgeHours > 0 && ageHours > config.maxAgeHours) return [true, `too_old:${ageHours.toFixed(0)}h>${config.maxAgeHours}h`]
820
+ if (rec.sizeBytes < config.minBytes) return [true, `too_small:${rec.sizeBytes}<${config.minBytes}`]
821
+ if (rec.durationSeconds !== null && rec.durationSeconds < config.minDurationSeconds) return [true, `too_short:${rec.durationSeconds.toFixed(1)}<${config.minDurationSeconds}`]
822
+ return [false, '']
823
+ }
824
+
825
+ // ────────────────────────────────────────────────────────────────────────────
826
+ // File path planning
827
+ // ────────────────────────────────────────────────────────────────────────────
828
+
829
+ function initialLocalFiles(config: Config, rec: Recording): LocalFiles {
830
+ const { month, prefix } = dateParts(rec.recordedAt)
831
+ return {
832
+ audio: join(config.workspace, '_audio', month, `${prefix}-original${extname(rec.sourcePath).toLowerCase()}`),
833
+ transcript: join(config.workspace, '_transcripts', month, `${prefix}-transcript.md`),
834
+ notes: join(config.workspace, month, `${prefix}-note.md`),
835
+ metadata: join(config.workspace, '_metadata', month, `${prefix}-metadata.json`),
836
+ }
837
+ }
838
+
839
+ function localFilesFromState(config: Config, rec: Recording, entry: any): LocalFiles {
840
+ const fallback = initialLocalFiles(config, rec)
841
+ const paths = entry?.final_paths || entry?.local_paths || {}
842
+ return {
843
+ audio: typeof paths.audio === 'string' ? paths.audio : fallback.audio,
844
+ transcript: typeof paths.transcript === 'string' ? paths.transcript : fallback.transcript,
845
+ notes: typeof paths.notes === 'string' ? paths.notes : fallback.notes,
846
+ metadata: typeof paths.metadata === 'string' ? paths.metadata : fallback.metadata,
847
+ }
848
+ }
849
+
850
+ function resumableTranscriptFiles(config: Config, rec: Recording, state: Json, mode: RunMode, force: boolean): LocalFiles | null {
851
+ if (force || mode !== 'notes') return null
852
+ const entry = state.processed_source_ids?.[rec.sourceId]
853
+ if (!isSummaryFailedEntry(entry)) return null
854
+ const files = localFilesFromState(config, rec, entry)
855
+ return existsSync(files.transcript) ? files : null
856
+ }
857
+
858
+ async function readSavedTranscript(path: string): Promise<string> {
859
+ const markdown = await readFile(path, 'utf8')
860
+ const marker = [RAW_TRANSCRIPT_MARKER, RAW_TRANSCRIPT_MARKER_LEGACY].find(m => markdown.includes(m))
861
+ if (!marker) throw new Error(`Cannot resume summary: saved transcript is missing raw transcript marker: ${path}`)
862
+ const transcript = markdown.slice(markdown.indexOf(marker) + marker.length).trim()
863
+ if (!transcript) throw new Error(`Cannot resume summary: saved transcript is empty: ${path}`)
864
+ return transcript
865
+ }
866
+
867
+ async function removeFailedSummaryStub(path: string): Promise<void> {
868
+ if (!existsSync(path)) return
869
+ try {
870
+ const body = await readFile(path, 'utf8')
871
+ if (body.startsWith('# Pending summary: ') || body.startsWith('# 待补纪要:')) await unlink(path)
872
+ } catch (e) { warnSideEffect(`remove failed-summary stub ${path}`, e) }
873
+ }
874
+
875
+ async function titledLocalFiles(config: Config, rec: Recording, meta: Json, files: LocalFiles): Promise<LocalFiles> {
876
+ const { month, prefix } = dateParts(rec.recordedAt)
877
+ const base = `${prefix}-${safeSlug(meta.title || 'note')}`
878
+ const targets: LocalFiles = {
879
+ audio: join(config.workspace, '_audio', month, `${base}-original${extname(rec.sourcePath).toLowerCase()}`),
880
+ transcript: join(config.workspace, '_transcripts', month, `${base}-transcript.md`),
881
+ notes: join(config.workspace, month, `${base}.md`),
882
+ metadata: join(config.workspace, '_metadata', month, `${base}-metadata.json`),
883
+ }
884
+ for (const p of Object.values(targets)) await mkdir(dirname(p), { recursive: true })
885
+ if (existsSync(files.audio) && files.audio !== targets.audio) {
886
+ if (existsSync(targets.audio)) await unlink(targets.audio)
887
+ await rename(files.audio, targets.audio)
888
+ }
889
+ return targets
890
+ }
891
+
892
+ // ───────────────────────────────────────────────────────────────────────
893
+ // Volcano (Doubao ASR + TOS upload)
894
+ // ───────────────────────────────────────────────────────────────────────
895
+
896
+ function sha256Hex(data: Buffer | string): string {
897
+ return createHash('sha256').update(data).digest('hex')
898
+ }
899
+
900
+ function hmacSha256(key: Buffer | string, data: string): Buffer {
901
+ return createHmac('sha256', key).update(data).digest()
902
+ }
903
+
904
+ function tosCanonicalUri(key: string): string {
905
+ // S3 SigV4: encode each path segment, keep '/' as separator.
906
+ return '/' + key.split('/').map(s => encodeURIComponent(s)).join('/')
907
+ }
908
+
909
+ function tosAmzDate(now: Date = new Date()): { amzDate: string; dateStamp: string } {
910
+ const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
911
+ return { amzDate, dateStamp: amzDate.slice(0, 8) }
912
+ }
913
+
914
+ function tosSigningKey(secretKey: string, dateStamp: string, region: string): Buffer {
915
+ const kDate = hmacSha256('AWS4' + secretKey, dateStamp)
916
+ const kRegion = hmacSha256(kDate, region)
917
+ const kService = hmacSha256(kRegion, 's3')
918
+ return hmacSha256(kService, 'aws4_request')
919
+ }
920
+
921
+ function tosSignRequest(tos: VolcanoTosConfig, method: 'PUT' | 'DELETE', key: string, payloadHash: string, contentType?: string): { url: string; headers: Record<string, string> } {
922
+ const host = `${tos.bucket}.${tos.endpoint}`
923
+ const { amzDate, dateStamp } = tosAmzDate()
924
+ const canonicalUri = tosCanonicalUri(key)
925
+ const headers: Record<string, string> = {
926
+ host,
927
+ 'x-amz-content-sha256': payloadHash,
928
+ 'x-amz-date': amzDate,
929
+ }
930
+ if (contentType) headers['content-type'] = contentType
931
+ const sortedNames = Object.keys(headers).sort()
932
+ const canonicalHeaders = sortedNames.map(h => `${h}:${headers[h]}\n`).join('')
933
+ const signedHeaders = sortedNames.join(';')
934
+ const canonicalRequest = [method, canonicalUri, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
935
+ const credentialScope = `${dateStamp}/${tos.region}/s3/aws4_request`
936
+ const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n')
937
+ const signingKey = tosSigningKey(tos.secretKey, dateStamp, tos.region)
938
+ const signature = hmacSha256(signingKey, stringToSign).toString('hex')
939
+ const authorization = `AWS4-HMAC-SHA256 Credential=${tos.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
940
+ return { url: `https://${host}${canonicalUri}`, headers: { ...headers, Authorization: authorization } }
941
+ }
942
+
943
+ function tosPresignedGet(tos: VolcanoTosConfig, key: string, expiresSeconds = 3600): string {
944
+ const host = `${tos.bucket}.${tos.endpoint}`
945
+ const { amzDate, dateStamp } = tosAmzDate()
946
+ const canonicalUri = tosCanonicalUri(key)
947
+ const credentialScope = `${dateStamp}/${tos.region}/s3/aws4_request`
948
+ const params: Record<string, string> = {
949
+ 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
950
+ 'X-Amz-Credential': `${tos.accessKey}/${credentialScope}`,
951
+ 'X-Amz-Date': amzDate,
952
+ 'X-Amz-Expires': String(expiresSeconds),
953
+ 'X-Amz-SignedHeaders': 'host',
954
+ }
955
+ const canonicalQuery = Object.keys(params).sort().map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k]!)}`).join('&')
956
+ const canonicalHeaders = `host:${host}\n`
957
+ const canonicalRequest = ['GET', canonicalUri, canonicalQuery, canonicalHeaders, 'host', 'UNSIGNED-PAYLOAD'].join('\n')
958
+ const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n')
959
+ const signature = hmacSha256(tosSigningKey(tos.secretKey, dateStamp, tos.region), stringToSign).toString('hex')
960
+ return `https://${host}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`
961
+ }
962
+
963
+ async function tosUploadObject(tos: VolcanoTosConfig, localPath: string, key: string, contentType: string): Promise<void> {
964
+ const body = await readFile(localPath)
965
+ const payloadHash = sha256Hex(body)
966
+ const { url, headers } = tosSignRequest(tos, 'PUT', key, payloadHash, contentType)
967
+ const res = await fetch(url, { method: 'PUT', body, headers })
968
+ if (!res.ok) {
969
+ const text = await res.text().catch(() => '')
970
+ throw new Error(`TOS upload failed: ${res.status} ${text.slice(0, 500)}`)
971
+ }
972
+ }
973
+
974
+ async function tosDeleteObject(tos: VolcanoTosConfig, key: string): Promise<void> {
975
+ const { url, headers } = tosSignRequest(tos, 'DELETE', key, sha256Hex(''))
976
+ const res = await fetch(url, { method: 'DELETE', headers })
977
+ if (!res.ok && res.status !== 204 && res.status !== 404) {
978
+ const text = await res.text().catch(() => '')
979
+ console.log(`Warn: TOS delete returned ${res.status}: ${text.slice(0, 200)}`)
980
+ }
981
+ }
982
+
983
+ function volcanoFormatFromExt(ext: string): string {
984
+ const e = ext.replace(/^\./, '').toLowerCase()
985
+ if (e === 'mp3') return 'mp3'
986
+ if (e === 'wav') return 'wav'
987
+ return e || 'mp3'
988
+ }
989
+
990
+ function volcanoContentTypeFromExt(ext: string): string {
991
+ const e = ext.replace(/^\./, '').toLowerCase()
992
+ switch (e) {
993
+ case 'mp3': return 'audio/mpeg'
994
+ case 'wav': return 'audio/wav'
995
+ case 'm4a': return 'audio/mp4'
996
+ case 'aac': return 'audio/aac'
997
+ case 'ogg': return 'audio/ogg'
998
+ case 'flac': return 'audio/flac'
999
+ default: return 'application/octet-stream'
1000
+ }
1001
+ }
1002
+
1003
+ async function volcanoSubmitTask(volc: VolcanoConfig, taskId: string, audioUrl: string, format: string): Promise<void> {
1004
+ const body = {
1005
+ user: { uid: 'voicenote' },
1006
+ audio: { url: audioUrl, format },
1007
+ request: {
1008
+ model_name: 'bigmodel',
1009
+ enable_itn: true,
1010
+ enable_punc: true,
1011
+ enable_ddc: true,
1012
+ enable_speaker_info: true,
1013
+ show_utterances: true,
1014
+ ...(volc.language ? { language: volc.language } : {}),
1015
+ },
1016
+ }
1017
+ const res = await fetch('https://openspeech.bytedance.com/api/v3/auc/bigmodel/submit', {
1018
+ method: 'POST',
1019
+ headers: volcanoAuthHeaders(volc, taskId, true),
1020
+ body: JSON.stringify(body),
1021
+ })
1022
+ const status = res.headers.get('X-Api-Status-Code') || ''
1023
+ const message = res.headers.get('X-Api-Message') || ''
1024
+ if (status !== '20000000') {
1025
+ const text = await res.text().catch(() => '')
1026
+ throw new Error(`Volcano submit failed: status=${status} message=${message} body=${text.slice(0, 500)}`)
1027
+ }
1028
+ }
1029
+
1030
+ type VolcanoUtterance = {
1031
+ text?: string
1032
+ start_time?: number
1033
+ end_time?: number
1034
+ speaker_id?: number | string
1035
+ additions?: { speaker_id?: number | string; speaker?: string | number }
1036
+ }
1037
+
1038
+ type VolcanoQueryResult = {
1039
+ status: string
1040
+ message: string
1041
+ result?: { text?: string; utterances?: VolcanoUtterance[] }
1042
+ audio_info?: { duration?: number }
1043
+ }
1044
+
1045
+ async function volcanoQueryResult(volc: VolcanoConfig, taskId: string): Promise<VolcanoQueryResult> {
1046
+ const res = await fetch('https://openspeech.bytedance.com/api/v3/auc/bigmodel/query', {
1047
+ method: 'POST',
1048
+ headers: volcanoAuthHeaders(volc, taskId, false),
1049
+ body: '{}',
1050
+ })
1051
+ const status = res.headers.get('X-Api-Status-Code') || ''
1052
+ const message = res.headers.get('X-Api-Message') || ''
1053
+ const text = await res.text().catch(() => '')
1054
+ let parsed: any = null
1055
+ if (text) { try { parsed = JSON.parse(text) } catch { parsed = null } }
1056
+ return { status, message, result: parsed?.result, audio_info: parsed?.audio_info }
1057
+ }
1058
+
1059
+ function volcanoSpeakerLabel(u: VolcanoUtterance): string {
1060
+ const id = u.speaker_id ?? u.additions?.speaker_id ?? u.additions?.speaker
1061
+ if (id == null || id === '') return 'Speaker A'
1062
+ const n = Number(id)
1063
+ if (Number.isFinite(n) && n >= 0 && n < 26) return `Speaker ${String.fromCharCode(65 + n)}`
1064
+ return `Speaker ${String(id)}`
1065
+ }
1066
+
1067
+ function volcanoFormatTranscript(result: { text?: string; utterances?: VolcanoUtterance[] }): string {
1068
+ const utterances = result.utterances || []
1069
+ if (!utterances.length) return (result.text || '').trim()
1070
+ const lines = utterances
1071
+ .map(u => {
1072
+ const text = String(u.text || '').trim()
1073
+ if (!text) return ''
1074
+ const start = formatSeconds(Math.round((u.start_time || 0) / 1000))
1075
+ const end = formatSeconds(Math.round((u.end_time || 0) / 1000))
1076
+ return `[${start}-${end}] ${volcanoSpeakerLabel(u)}: ${text}`
1077
+ })
1078
+ .filter(Boolean)
1079
+ return lines.join('\n')
1080
+ }
1081
+
1082
+ async function volcanoTranscribeAudio(volc: VolcanoConfig, audioPath: string, rec: Recording): Promise<string> {
1083
+ const ext = extname(audioPath).toLowerCase() || '.mp3'
1084
+ const format = volcanoFormatFromExt(ext)
1085
+ const contentType = volcanoContentTypeFromExt(ext)
1086
+ const { month } = dateParts(rec.recordedAt)
1087
+ const key = `voicenote/${month}/${rec.sourceId}-${Date.now()}${ext}`
1088
+ console.log(`Volcano: upload audio to TOS as ${key}`)
1089
+ await withHeartbeat('upload audio to TOS', () => tosUploadObject(volc.tos, audioPath, key, contentType), 30)
1090
+ let cleanedUp = false
1091
+ const cleanup = async () => {
1092
+ if (cleanedUp || volc.tos.keep) return
1093
+ cleanedUp = true
1094
+ await tosDeleteObject(volc.tos, key).catch(() => {})
1095
+ }
1096
+ try {
1097
+ const audioUrl = tosPresignedGet(volc.tos, key, 6 * 3600)
1098
+ const taskId = randomUUID()
1099
+ console.log(`Volcano: submit ASR task ${taskId} (resource=${volc.resourceId}, format=${format})`)
1100
+ await volcanoSubmitTask(volc, taskId, audioUrl, format)
1101
+ const started = Date.now()
1102
+ const expectedSeconds = rec.durationSeconds || 0
1103
+ const maxWaitMs = Math.max(20 * 60 * 1000, Math.ceil(expectedSeconds * 1000 * 1.5))
1104
+ let lastStatusLog = 0
1105
+ let lastStatus = ''
1106
+ // Tolerate transient failures while polling: by this point the audio is
1107
+ // uploaded and the ASR task is submitted (money spent) — one dropped
1108
+ // socket or an HTTP-level error (gateway 5xx returns no X-Api-Status-Code
1109
+ // header, so q.status comes back empty) must not fail the whole job and
1110
+ // trigger a full re-upload + re-submit on the next tick. Only give up
1111
+ // after many failures in a row; throws when the budget or deadline is hit.
1112
+ let queryFailures = 0
1113
+ const transientQueryFailure = (desc: string): void => {
1114
+ queryFailures++
1115
+ if (queryFailures >= 10) throw new Error(`Volcano query failed ${queryFailures}x in a row: ${desc}`)
1116
+ if (Date.now() - started > maxWaitMs) throw new Error(`Volcano: timeout after ${formatElapsed(Date.now() - started)} (last error: ${desc})`)
1117
+ // console.error (not log) so wireDailyLog tags it [ERROR] and `vn errors`
1118
+ // surfaces it — matching chatCompleteViaPiCodex's transient-retry logging.
1119
+ // A repeatedly-near-threshold ASR wobble is exactly what ops wants to see.
1120
+ console.error(`… Volcano: transient query failure (attempt ${queryFailures}/10, will retry): ${desc}`)
1121
+ }
1122
+ for (;;) {
1123
+ await new Promise(res => setTimeout(res, 8000))
1124
+ let q: VolcanoQueryResult
1125
+ try {
1126
+ q = await volcanoQueryResult(volc, taskId)
1127
+ } catch (e: any) {
1128
+ transientQueryFailure(String(e?.message || e))
1129
+ continue
1130
+ }
1131
+ if (!q.status) {
1132
+ transientQueryFailure(`empty status header (HTTP-level error, body: ${q.message || 'none'})`)
1133
+ continue
1134
+ }
1135
+ queryFailures = 0
1136
+ if (q.status === '20000000' && q.result) {
1137
+ console.log(`✓ Volcano: ASR done in ${formatElapsed(Date.now() - started)}; audio_duration=${q.audio_info?.duration ?? 'unknown'}ms`)
1138
+ return volcanoFormatTranscript(q.result)
1139
+ }
1140
+ if (q.status === '20000001' || q.status === '20000002') {
1141
+ if (q.status !== lastStatus || Date.now() - lastStatusLog > 60_000) {
1142
+ const label = q.status === '20000002' ? 'queued' : 'processing'
1143
+ console.log(`… Volcano: ${label} (status=${q.status}, ${formatElapsed(Date.now() - started)} elapsed)`)
1144
+ lastStatusLog = Date.now()
1145
+ lastStatus = q.status
1146
+ }
1147
+ if (Date.now() - started > maxWaitMs) throw new Error(`Volcano: timeout after ${formatElapsed(Date.now() - started)} (last status=${q.status})`)
1148
+ continue
1149
+ }
1150
+ if (q.status === '20000003') throw new Error('Volcano: 20000003 silent audio (no speech detected)')
1151
+ throw new Error(`Volcano query failed: status=${q.status} message=${q.message}`)
1152
+ }
1153
+ } finally {
1154
+ await cleanup()
1155
+ }
1156
+ }
1157
+
1158
+ async function transcribeAudio(config: Config, audioPath: string, rec: Recording): Promise<string> {
1159
+ if (!config.volcano) throw new Error('Volcano ASR not configured. Set VOLCANO_ASR_KEY / VOLCANO_TOS_* in ~/.zshrc.')
1160
+ return volcanoTranscribeAudio(config.volcano, audioPath, rec)
1161
+ }
1162
+
1163
+
1164
+ function speakerContextBlock(speakers: SpeakersConfig): string {
1165
+ const selfPart = speakers.self.name
1166
+ ? `The user: ${speakers.self.name}${speakers.self.aliases.length ? ` (aliases: ${speakers.self.aliases.join(', ')})` : ''}`
1167
+ : "The user's name is not configured."
1168
+ const knownPart = speakers.known.length
1169
+ ? speakers.known.map(k => `- ${k.name}${k.aliases?.length ? ` (aliases: ${k.aliases.join(', ')})` : ''}${k.relationship ? `, ${k.relationship}` : ''}`).join('\n')
1170
+ : '(no other known speakers)'
1171
+ return `Speaker context (use it to map Speaker A/B/C back to real names, but only when the evidence is solid):\n- ${selfPart}\n- Other known speakers:\n${knownPart}\n\nRules:\n- If the recording has a single speaker and the user's name is configured, treat Speaker A as the user.\n- In multi-speaker conversations, if a speaker is addressed by the user's name/alias, that speaker is the user.\n- In multi-speaker conversations, if a speaker is addressed by a known speaker's name/alias, that speaker is that known person.\n- Otherwise keep Speaker A/B/C as-is; never guess.`
1172
+ }
1173
+
1174
+
1175
+ function summaryMessages(config: Config, transcript: string, rec: Recording, localAudioPath: string): { role: 'system' | 'user'; content: string }[] {
1176
+ const readerName = config.speakers.self.name?.trim() || 'the user'
1177
+ const system = `You are ${readerName}'s personal semantic note-taking assistant, not a generic meeting-minutes template generator.
1178
+
1179
+ Your goal is not to reproduce a "meeting minutes" format, but to turn a recording into the most efficient understanding material: let ${readerName} quickly grasp what the discussion was really about, why it matters, what ideas/judgments/items it contains, what deserves attention, and what to do next.
1180
+
1181
+ Important: do not output only compressed "conclusions". Much of a recording's value lies in how views were raised, challenged, argued, and revised, and how consensus or disagreement formed. Without mechanically copying the transcript, reconstruct the key speakers' views, reasoning, debates, decision evolution, and how consensus emerged.
1182
+
1183
+ Core principles:
1184
+ 1. Structure is entirely determined by content. Do not apply any fixed template or emit fixed sections for form's sake.
1185
+ 2. Prioritize semantic value over paragraph-by-paragraph retelling; but do not flatten the process into conclusions. Important thinking, debate, validation, concession, rebuttal, and consensus-building are themselves semantic value.
1186
+ 3. Multi-person conversations must be reconstructed as much as possible: each side's initial concerns/positions, their reasons and examples, who raised challenges or rebuttals, how the discussion pivoted, which views were revised, what consensus formed, and which disagreements remain open.
1187
+ 4. Solo thinking must also have its reasoning path reconstructed: how the question was raised, how hypotheses were tested, why some options were ruled out, which experience/analogies supported the judgment, and why the current conclusion formed.
1188
+ 5. Freely choose the form: short memo, strategy memo, question tree, decision record, action list, mind-map-style hierarchy, phase review, debate review, study notes, product/technical analysis, etc.; pick whichever fits the content best.
1189
+ 6. If the discussion is conceptual/exploratory, focus on helping the reader understand the train of thought, key concepts, reasoning chains, shifts in views, and passages worth revisiting; do not force-extract to-dos.
1190
+ 7. If the discussion is execution/project-oriented, then besides conclusions, items, owners, risks, and next steps, also explain how those conclusions were reached: what constraints applied, which options were compared, and why the current path was chosen.
1191
+ 8. If the discussion is short, output only the minimal useful content; if long, you may start with a reading guide and then expand. For long content, err on the side of length rather than dropping key reasoning and debates.
1192
+ 9. Avoid filler, boilerplate, and formalistic headings. Every heading should carry information.
1193
+ 10. If real names appear in the transcript (see Speaker context below), use them directly; keep Speaker A/B/C only when unsure.
1194
+ 11. Explicitly flag uncertain or likely mis-transcribed words; do not treat them as facts.
1195
+ 12. Default is Integrated notes mode: the input transcript may not have been separately cleaned. Before generating content, internally perform necessary cleanup: fix obvious typos, unify terminology, restore speakers, merge verbal repetition, fix punctuation and sentence breaks; but never invent information not in the source, and never scrub away the genuine thinking process.
1196
+
1197
+ Write all output content (title, markdown, structured fields) in the dominant language of the transcript.
1198
+
1199
+ Output must be valid JSON, no markdown fences.
1200
+
1201
+ ${speakerContextBlock(config.speakers)}`
1202
+
1203
+ const user = `Generate a "semantic notes" document from the transcript below.
1204
+
1205
+ Processing mode: Integrated notes mode (no separate transcript cleanup pass; perform necessary cleanup, error correction, organization, and speaker restoration while generating the notes)
1206
+
1207
+ The reading scenario you serve:
1208
+ - When ${readerName} opens these notes later, they should immediately know: what is worth reading in this recording, what the core ideas/items are, how those views were discussed/argued, what needs understanding, which questions remain open, and what to do next.
1209
+ - Do not assume this is a "meeting"; it may be thinking aloud, product ideation, a technical discussion, a business judgment, study notes, an idea capture, a phone call, or task execution.
1210
+ - Do not follow Feishu/generic meeting-minutes structures. The markdown structure is determined by the content's semantics.
1211
+ - For multi-person discussions, the notes should help ${readerName} review the process: who raised what question, who held what view, who challenged what, how it was answered, where the turning points were, and how consensus formed or disagreements remained.
1212
+ - If the transcript clearly contains discussion, debate, joint reasoning, option comparison, or evolving views, the markdown body must include a section that carries this "process reconstruction" (title up to you, e.g. "How the discussion unfolded", "How the views evolved", "Debate and consensus"); a bare conclusion list is not acceptable.
1213
+
1214
+ Recording info:
1215
+ - Source file: ${rec.sourcePath}
1216
+ - Local audio: ${localAudioPath}
1217
+ - Time inferred from filename: ${rec.recordedAt.toISOString()}
1218
+ - File size: ${rec.sizeBytes} bytes
1219
+ - Duration: ${rec.durationSeconds} seconds
1220
+
1221
+ Output JSON with these fields:
1222
+ {
1223
+ "title": "A title in the transcript's language that captures the real topic and value; avoid generic 'meeting minutes' phrasing",
1224
+ "date": "YYYY-MM-DD",
1225
+ "start_time": "HH:mm|null",
1226
+ "end_time": "HH:mm|null",
1227
+ "participants": ["Only actually identified real names (including the user's); never Speaker A/B"],
1228
+ "organizations": ["string"],
1229
+ "projects": ["string"],
1230
+ "markdown": "Full markdown body. Must start with an # H1 title. Structure is entirely yours based on the semantics; do not include the trailing source details block, the system appends it.",
1231
+ "discussion_flow": [{"stage": "discussion stage/topic", "what_happened": "what happened in this stage", "speaker_positions": [{"speaker": "real name or Speaker label", "position": "view/concern/reasoning"}], "turning_point": "key pivot or change of view|null", "outcome": "stage consensus/disagreement/open|null"}],
1232
+ "consensus_points": [{"point": "consensus reached", "how_reached": "how this consensus formed through discussion/argument|null"}],
1233
+ "disagreements": [{"issue": "point of disagreement", "positions": [{"speaker": "real name or Speaker label", "position": "stance and reasoning"}], "status": "resolved|unresolved|partially_resolved|null"}],
1234
+ "action_items": [{"task": "string", "owner": "string|null", "due_date": "YYYY-MM-DD|null", "priority": "high|medium|low|null", "note": "string|null"}],
1235
+ "decisions": [{"decision": "string", "reason": "string|null", "owner": "string|null", "date": "YYYY-MM-DD|null", "how_reached": "how this decision was reached|null"}],
1236
+ "open_questions": [{"question": "string", "next_step": "string|null"}],
1237
+ "key_quotes_or_details": ["string"],
1238
+ "transcription_uncertainties": ["string"]
1239
+ }
1240
+
1241
+ Markdown quality requirements:
1242
+ - The first screen must have a high signal-to-noise ratio: the reader should know why this content is worth keeping without reading the full transcript.
1243
+ - No empty sections; no placeholder content like "no clear record / unknown / unidentified".
1244
+ - Do not force headings like "Summary, To-dos, Smart sections, Key decisions, Quotes"; use them only when semantically warranted.
1245
+ - If there are action items, use concrete actionable language; if there are none, do not fabricate any.
1246
+ - If there are ideas/judgments, write out the reasoning chain, not just conclusions.
1247
+ - If there was discussion, debate, or joint reasoning, preserve the key process: view raised → challenge/addition → response/rebuttal → revision/pivot → consensus/disagreement. Do not compress it into a single "in the end they concluded…".
1248
+ - The markdown body should primarily reconstruct the process in natural language; do not just fill discussion_flow/consensus_points/disagreements as metadata and stop — those structured fields only aid your thinking and indexing.
1249
+ - For important consensus, explain how it was reached; for important disagreements, state who held what view, why, and whether it was resolved.
1250
+ - If a conclusion went through option comparison or trade-offs, write out the compared options, the criteria, and why one was dropped or chosen.
1251
+ - For long meetings, review by topic/stage rather than as a running log, but keep each stage's key turning points and representative speakers' views.
1252
+ - Clearly flag controversies, risks, and unverified assumptions.
1253
+ - Timestamps may be used sparingly when they help revisit key passages; do not build a full timeline for form's sake.
1254
+ - If the transcript has uncertain words, surface them in context as reminders; do not treat them as facts.
1255
+ - In Integrated notes mode, especially avoid carrying stutters, repetitions, and typos from the raw transcript into the notes; the body should present cleaned, organized content while preserving the genuine reasoning, debates, and evolution of views.
1256
+
1257
+ Transcript:
1258
+ ${transcript}`
1259
+ return [{ role: 'system', content: system }, { role: 'user', content: user }]
1260
+ }
1261
+
1262
+ // ───────────────────────────────────────────────────────────────────────
1263
+ // Summary via pi (pi-codex — ChatGPT Plus/Pro OAuth, no OpenAI API key)
1264
+ // ───────────────────────────────────────────────────────────────────────
1265
+
1266
+ function piCodexBin(): string {
1267
+ return process.env.VOICENOTE_PI_BIN || 'pi'
1268
+ }
1269
+
1270
+ // pi can't be `bun build --compile`'d (it reads data files from disk), so the
1271
+ // bundled GUI ships pi as plain JS and runs it under a bundled bun. When
1272
+ // VOICENOTE_PI_CLI is set, piCodexBin() is the runtime (bun) and the cli.js is
1273
+ // prepended to pi's args — `<bun> <cli.js> <args>`, no wrapper script and no
1274
+ // shell (critical on Windows, where pi args include a huge --system-prompt that
1275
+ // a .cmd/%* wrapper would mangle). CLI users with a real `pi` on PATH leave
1276
+ // PI_CLI unset and pi is invoked directly.
1277
+ function piInvocation(args: string[]): { bin: string; args: string[] } {
1278
+ const cli = process.env.VOICENOTE_PI_CLI
1279
+ const bin = piCodexBin()
1280
+ return cli ? { bin, args: [cli, ...args] } : { bin, args }
1281
+ }
1282
+
1283
+ // Heuristic for "pi is logged in": the OAuth credential file exists. Used to skip
1284
+ // the pipeline before spending ASR on notes whose pi-codex summary would fail.
1285
+ function piAuthAvailable(): boolean {
1286
+ return existsSync(PI_AUTH_PATH)
1287
+ }
1288
+
1289
+ // ───────────────────────────────────────────────────────────────────────
1290
+ // ChatGPT (OpenAI Codex) OAuth login — headless device-code flow.
1291
+ // Today the only way to authenticate the pi summary backend is to open pi's
1292
+ // interactive TUI and run `/login`. This exposes the same flow as a plain
1293
+ // command so non-TUI users (and the GUI client, via --json) can sign in.
1294
+ // We reuse pi's own OAuth implementation (@earendil-works/pi-ai) and persist
1295
+ // to pi's auth.json in the exact shape it reads: { type: 'oauth', ...creds }.
1296
+ // ───────────────────────────────────────────────────────────────────────
1297
+
1298
+ async function persistPiOAuth(providerId: string, creds: Record<string, unknown>): Promise<void> {
1299
+ await mkdir(dirname(PI_AUTH_PATH), { recursive: true })
1300
+ let existing: Json = {}
1301
+ if (existsSync(PI_AUTH_PATH)) {
1302
+ try { existing = JSON.parse(await readFile(PI_AUTH_PATH, 'utf8')) as Json } catch (e) { warnSideEffect(`parse ${PI_AUTH_PATH}`, e) }
1303
+ }
1304
+ existing[providerId] = { type: 'oauth', ...creds }
1305
+ const tmp = `${PI_AUTH_PATH}.tmp-${process.pid}`
1306
+ await writeFile(tmp, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 })
1307
+ await rename(tmp, PI_AUTH_PATH)
1308
+ }
1309
+
1310
+ async function loginChatGPT(opts: { json?: boolean; deviceCode?: boolean; emit?: (o: Record<string, unknown>) => void }): Promise<void> {
1311
+ // OpenAI's OAuth endpoint is geo-blocked in some regions; hydrate the proxy
1312
+ // env (LOCAL_PROXY_HOST/PORT -> http_proxy) before any request goes out.
1313
+ loadEnvConfig()
1314
+ const json = !!opts.json
1315
+ const emit = opts.emit ?? ((o: Record<string, unknown>) => { if (json) console.log(JSON.stringify(o)) })
1316
+ try {
1317
+ const oauth = await import('@earendil-works/pi-ai/oauth')
1318
+ let creds: Record<string, unknown>
1319
+ if (opts.deviceCode) {
1320
+ // Device-code flow: no localhost server, but the account must first enable
1321
+ // "device code authorization for Codex" in ChatGPT > Settings > Security.
1322
+ creds = await oauth.loginOpenAICodexDeviceCode({
1323
+ onDeviceCode: (info) => {
1324
+ if (json) emit({ event: 'device_code', userCode: info.userCode, verificationUri: info.verificationUri, intervalSeconds: info.intervalSeconds, expiresInSeconds: info.expiresInSeconds })
1325
+ else {
1326
+ console.log('\nTo sign in to ChatGPT (device code):')
1327
+ console.log(` 1. Open ${info.verificationUri}`)
1328
+ console.log(` 2. Enter code: ${info.userCode}`)
1329
+ console.log('\nIf you see "Enable device code authorization", turn it on in')
1330
+ console.log('ChatGPT > Settings > Security — or just rerun `vn login` (browser flow).')
1331
+ console.log('\nWaiting for authorization…')
1332
+ }
1333
+ },
1334
+ }) as Record<string, unknown>
1335
+ } else {
1336
+ // Default: browser-callback flow (same as pi `/login` and the official Codex
1337
+ // CLI). Spins up localhost:1455/auth/callback; no account setting required.
1338
+ creds = await oauth.loginOpenAICodex({
1339
+ onAuth: ({ url }) => {
1340
+ if (json) emit({ event: 'auth_url', url })
1341
+ else {
1342
+ console.log('\nOpening your browser to sign in to ChatGPT…')
1343
+ console.log(`If it doesn't open, paste this into a browser on THIS machine:\n ${url}`)
1344
+ }
1345
+ // Best-effort auto-open; the URL is printed/emitted above as fallback.
1346
+ void openPath(url)
1347
+ },
1348
+ onPrompt: async ({ message }) => {
1349
+ // Only reached if the localhost:1455 callback can't complete (port busy,
1350
+ // or browser on another machine). Fail loudly rather than hang.
1351
+ throw new Error(`${message} — automatic callback failed (is localhost:1455 free, and is your browser on this machine?). Retry, or use --device-code.`)
1352
+ },
1353
+ }) as Record<string, unknown>
1354
+ }
1355
+ await persistPiOAuth(oauth.openaiCodexOAuthProvider.id, creds)
1356
+ if (json) emit({ event: 'success', provider: oauth.openaiCodexOAuthProvider.id })
1357
+ else console.log(`\n✓ Signed in. Credentials saved to ${PI_AUTH_PATH}. Verify with: vn doctor`)
1358
+ } catch (e: any) {
1359
+ let message = String(e?.message || e)
1360
+ if (/unsupported_country_region_territory|\b403\b/.test(message)) {
1361
+ message += ' — OpenAI blocks this region without a proxy. Set LOCAL_PROXY_HOST/LOCAL_PROXY_PORT (or http_proxy) and retry; Volcano stays direct.'
1362
+ }
1363
+ if (json) emit({ event: 'error', message })
1364
+ else console.error(`\nLogin failed: ${message}`)
1365
+ process.exitCode = 1
1366
+ }
1367
+ }
1368
+
1369
+ // ───────────────────────────────────────────────────────────────────────
1370
+ // File-based config (~/.config/voicenote/config.json) — written by the GUI
1371
+ // via `vn config set`, read by loadEnvConfig(). ENV config uses ENV_KEYS;
1372
+ // identity lives under the same file's `speakers` object.
1373
+ // ───────────────────────────────────────────────────────────────────────
1374
+
1375
+ function readStdin(): Promise<string> {
1376
+ return new Promise((resolve) => {
1377
+ let data = ''
1378
+ process.stdin.setEncoding('utf8')
1379
+ process.stdin.on('data', d => { data += d })
1380
+ process.stdin.on('end', () => resolve(data))
1381
+ process.stdin.on('error', () => resolve(data))
1382
+ })
1383
+ }
1384
+
1385
+ function configFileEnv(): Record<string, string> {
1386
+ const raw = loadConfigJson()
1387
+ const env: Record<string, string> = {}
1388
+ for (const k of ENV_KEYS) if (typeof raw[k] === 'string') env[k] = raw[k] as string
1389
+ return env
1390
+ }
1391
+
1392
+ function configGetData(): { path: string; env: Record<string, string>; self: { name: string | null; aliases: string[] } } {
1393
+ const speakers = loadSpeakers()
1394
+ return {
1395
+ path: CONFIG_ENV_PATH,
1396
+ env: configFileEnv(),
1397
+ self: { name: speakers.self.name, aliases: speakers.self.aliases },
1398
+ }
1399
+ }
1400
+
1401
+ function configGet(): void { console.log(JSON.stringify(configGetData(), null, 2)) }
1402
+
1403
+ type ConfigSetPayload = { env?: Record<string, unknown>; self?: { name?: string | null; aliases?: string[] } }
1404
+
1405
+ async function configSetData(payload: ConfigSetPayload): Promise<{ ok: true; path: string; ignoredKeys?: string[] }> {
1406
+ await mkdir(CONFIG_DIR, { recursive: true })
1407
+
1408
+ // Merge env into config.json (only known ENV_KEYS; null deletes a key).
1409
+ const current = loadConfigJson()
1410
+ const known = ENV_KEYS as readonly string[]
1411
+ const ignored: string[] = []
1412
+ if (payload.env) {
1413
+ for (const [k, v] of Object.entries(payload.env)) {
1414
+ if (!known.includes(k)) { ignored.push(k); continue }
1415
+ if (v === null) delete current[k]
1416
+ else if (typeof v === 'string') current[k] = v
1417
+ }
1418
+ }
1419
+ const tmp = `${CONFIG_ENV_PATH}.tmp-${process.pid}`
1420
+ await writeFile(tmp, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 })
1421
+ await rename(tmp, CONFIG_ENV_PATH)
1422
+
1423
+ // Identity lives in config.json too; speakers.json is read only as a legacy fallback.
1424
+ if (payload.self) {
1425
+ const speakers = normalizeSpeakers(current.speakers ?? loadSpeakers())
1426
+ if (payload.self.name !== undefined) speakers.self.name = payload.self.name
1427
+ if (Array.isArray(payload.self.aliases)) speakers.self.aliases = payload.self.aliases
1428
+ current.speakers = speakers
1429
+ await writeFile(tmp, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 })
1430
+ await rename(tmp, CONFIG_ENV_PATH)
1431
+ }
1432
+
1433
+ // Make the new values visible to THIS process immediately (see reloadEnvConfig).
1434
+ reloadEnvConfig()
1435
+
1436
+ return { ok: true, path: CONFIG_ENV_PATH, ...(ignored.length ? { ignoredKeys: ignored } : {}) }
1437
+ }
1438
+
1439
+ async function configSet(): Promise<void> {
1440
+ let payload: ConfigSetPayload
1441
+ try { payload = JSON.parse(await readStdin()) }
1442
+ catch (e: any) { console.error(`Invalid JSON on stdin: ${e?.message || e}`); process.exitCode = 1; return }
1443
+ // Every other key is re-read by the agent on each run, but VOICENOTE_PI_BIN
1444
+ // is snapshotted into the scheduler as a resolved absolute path at install
1445
+ // time (launchd's fixed PATH can't find it otherwise). The GUI reinstalls on
1446
+ // save; the CLI path must be told — but only when the value actually CHANGES.
1447
+ // A GUI-style client resubmits every field on every save, so `in payload`
1448
+ // alone would nag on every unrelated edit.
1449
+ const PI_BIN = 'VOICENOTE_PI_BIN'
1450
+ const before = String(loadConfigJson()[PI_BIN] ?? '')
1451
+ console.log(JSON.stringify(await configSetData(payload)))
1452
+ const piBinChanged = payload.env && PI_BIN in payload.env && String(payload.env[PI_BIN] ?? '') !== before
1453
+ if (piBinChanged) {
1454
+ console.error(`Note: ${PI_BIN} changed — re-run \`vn install-launch-agent\` to apply it to the background scheduler.`)
1455
+ }
1456
+ }
1457
+
1458
+ function piProviderCandidates(): string[] {
1459
+ const configured = process.env.VOICENOTE_PI_PROVIDER?.trim()
1460
+ const raw = configured || 'openai-codex,openai'
1461
+ const providers = raw.split(',').map(s => s.trim()).filter(Boolean)
1462
+ return providers.length ? Array.from(new Set(providers)) : ['openai-codex', 'openai']
1463
+ }
1464
+
1465
+ function piProviderFor(): string {
1466
+ return piProviderCandidates()[0] || 'openai-codex'
1467
+ }
1468
+
1469
+ function piCodexModelFor(): string {
1470
+ return process.env.VOICENOTE_PI_MODEL_SUMMARY || process.env.VOICENOTE_PI_MODEL || 'gpt-5.5'
1471
+ }
1472
+
1473
+ function stripJsonFences(text: string): string {
1474
+ const trimmed = text.trim()
1475
+ const fence = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```\s*$/i)
1476
+ if (fence) return fence[1]!.trim()
1477
+ return trimmed
1478
+ }
1479
+
1480
+ function extractFirstJsonObject(text: string): string {
1481
+ const trimmed = stripJsonFences(text)
1482
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) return trimmed
1483
+ // Find the first balanced {...}
1484
+ let depth = 0, start = -1, inString = false, escape = false
1485
+ for (let i = 0; i < trimmed.length; i++) {
1486
+ const ch = trimmed[i]!
1487
+ if (escape) { escape = false; continue }
1488
+ if (inString) {
1489
+ if (ch === '\\') { escape = true; continue }
1490
+ if (ch === '"') inString = false
1491
+ continue
1492
+ }
1493
+ if (ch === '"') { inString = true; continue }
1494
+ if (ch === '{') { if (depth === 0) start = i; depth++ }
1495
+ else if (ch === '}') { depth--; if (depth === 0 && start !== -1) return trimmed.slice(start, i + 1) }
1496
+ }
1497
+ return trimmed
1498
+ }
1499
+
1500
+ async function chatCompleteViaPiProvider(opts: {
1501
+ systemPrompt: string
1502
+ userPrompt: string
1503
+ model: string
1504
+ provider: string
1505
+ timeoutMs?: number
1506
+ thinking?: string
1507
+ tools?: string // e.g. 'read,grep'; empty/undefined = --no-tools
1508
+ appendSystemPrompt?: string
1509
+ cwd?: string // agent working dir: the knowledge base, so read/grep/find default there
1510
+ }): Promise<string> {
1511
+ const args = [
1512
+ '-p',
1513
+ '--provider', opts.provider,
1514
+ '--model', opts.model,
1515
+ '--mode', 'text',
1516
+ '--no-extensions', '--no-skills', '--no-context-files', '--no-session', '--no-prompt-templates', '--no-themes',
1517
+ '--system-prompt', opts.systemPrompt,
1518
+ ]
1519
+ if (opts.thinking) args.push('--thinking', opts.thinking)
1520
+ if (opts.tools && opts.tools.trim()) args.push('--tools', opts.tools.trim())
1521
+ else args.push('--no-tools')
1522
+ if (opts.appendSystemPrompt) args.push('--append-system-prompt', opts.appendSystemPrompt)
1523
+ return new Promise<string>((resolve, reject) => {
1524
+ const inv = piInvocation(args)
1525
+ const child = spawn(inv.bin, inv.args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: opts.cwd, windowsHide: true })
1526
+ let stdout = '', stderr = ''
1527
+ const timer = opts.timeoutMs ? setTimeout(() => child.kill('SIGKILL'), opts.timeoutMs) : null
1528
+ child.stdout.on('data', d => stdout += String(d))
1529
+ child.stderr.on('data', d => stderr += String(d))
1530
+ child.on('error', err => { if (timer) clearTimeout(timer); reject(err) })
1531
+ child.on('close', code => {
1532
+ if (timer) clearTimeout(timer)
1533
+ if (code !== 0) return reject(new Error(`pi ${opts.provider} exited ${code}: ${(stderr || stdout).slice(0, 800)}`))
1534
+ const text = stdout.trim()
1535
+ if (!text) return reject(new Error(`pi ${opts.provider} returned empty output`))
1536
+ resolve(text)
1537
+ })
1538
+ child.stdin.end(opts.userPrompt)
1539
+ })
1540
+ }
1541
+
1542
+ // A transient pi failure (proxy reset, dropped socket, upstream 5xx/429) must be
1543
+ // retried on the SAME provider before falling back. Otherwise a momentary blip on
1544
+ // the free codex path cascades straight into the paid OpenAI API and, if that is
1545
+ // out of quota, a hard failure + stub — exactly what looks like a "quota problem"
1546
+ // when the real cause was one closed socket. Quota/auth/4xx are NOT transient:
1547
+ // retrying them just wastes time, so they fall through to the next provider.
1548
+ function isTransientPiError(e: any): boolean {
1549
+ const msg = String(e?.message || e).toLowerCase()
1550
+ if (/quota|unauthorized|invalid.*(key|token|credential)|forbidden|\b40[0-4]\b/.test(msg)) return false
1551
+ 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)
1552
+ }
1553
+
1554
+ async function chatCompleteViaPiCodex(opts: Omit<Parameters<typeof chatCompleteViaPiProvider>[0], 'provider'>): Promise<string> {
1555
+ const providers = piProviderCandidates()
1556
+ const maxAttempts = Math.max(1, Number(process.env.VOICENOTE_PI_RETRIES || 3))
1557
+ let lastError: any = null
1558
+ for (const [idx, provider] of providers.entries()) {
1559
+ if (idx > 0) console.error(`pi provider fallback: trying ${provider} after ${providers[idx - 1]} failed: ${lastError?.message || lastError}`)
1560
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1561
+ try {
1562
+ return await chatCompleteViaPiProvider({ ...opts, provider })
1563
+ } catch (e: any) {
1564
+ lastError = e
1565
+ if (attempt < maxAttempts && isTransientPiError(e)) {
1566
+ const backoffMs = Math.min(30000, 2000 * 2 ** (attempt - 1))
1567
+ console.error(`pi ${provider} transient error (attempt ${attempt}/${maxAttempts}); retrying in ${backoffMs}ms: ${e?.message || e}`)
1568
+ await new Promise(res => setTimeout(res, backoffMs))
1569
+ continue
1570
+ }
1571
+ break // non-transient, or retries exhausted → fall back to next provider
1572
+ }
1573
+ }
1574
+ }
1575
+ throw lastError || new Error('pi provider fallback exhausted')
1576
+ }
1577
+
1578
+ function piThinkingLevel(): string {
1579
+ return process.env.VOICENOTE_PI_THINKING || 'high'
1580
+ }
1581
+
1582
+ function piSummaryTools(): string {
1583
+ // Default ON: let the summary model read/grep prior notes for cross-reference consistency.
1584
+ // Set VOICENOTE_PI_SUMMARY_TOOLS='' (empty) to disable.
1585
+ const v = process.env.VOICENOTE_PI_SUMMARY_TOOLS
1586
+ if (v === undefined) return 'read,grep'
1587
+ return v.trim()
1588
+ }
1589
+
1590
+ function summaryContextDir(config: Config): string {
1591
+ // Directory the summary model may read/grep for cross-reference consistency.
1592
+ // Defaults to the workspace itself. Users can opt into a wider notes/vault
1593
+ // directory with VOICENOTE_CONTEXT_DIR, but the published default must not
1594
+ // read outside the configured workspace.
1595
+ return expandHome(process.env.VOICENOTE_CONTEXT_DIR || config.workspace)
1596
+ }
1597
+
1598
+ function piSummaryToolsHint(contextDir: string): string {
1599
+ 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).`
1600
+ }
1601
+
1602
+ // Summary runs on the pi-codex backend. The agent's working dir IS the knowledge
1603
+ // base, so read/grep/find operate there directly. If a configured context dir is
1604
+ // missing, say so loudly and run without tools rather than searching the wrong
1605
+ // tree (tools, the cwd hint, and the spawn cwd move together).
1606
+ async function chatComplete(opts: { systemPrompt: string; userPrompt: string; config: Config }): Promise<string> {
1607
+ const wantTools = !!piSummaryTools()
1608
+ const ctx = wantTools ? summaryContextDir(opts.config) : undefined
1609
+ const ctxExists = ctx ? existsSync(ctx) : false
1610
+ if (ctx && !ctxExists) console.error(`Warning: context dir ${ctx} does not exist; summary agent runs WITHOUT read/grep cross-reference.`)
1611
+ const toolsActive = wantTools && ctxExists
1612
+ return chatCompleteViaPiCodex({
1613
+ systemPrompt: opts.systemPrompt,
1614
+ userPrompt: opts.userPrompt,
1615
+ model: piCodexModelFor(),
1616
+ timeoutMs: 60 * 60 * 1000,
1617
+ thinking: piThinkingLevel(),
1618
+ tools: toolsActive ? piSummaryTools() : undefined,
1619
+ appendSystemPrompt: toolsActive ? piSummaryToolsHint(ctx!) : undefined,
1620
+ cwd: toolsActive ? ctx : undefined,
1621
+ })
1622
+ }
1623
+
1624
+ async function summarizeTranscript(config: Config, transcript: string, rec: Recording, localAudioPath: string): Promise<Json> {
1625
+ const messages = summaryMessages(config, transcript, rec, localAudioPath)
1626
+ const systemPrompt = String(messages[0]!.content)
1627
+ const userPrompt = String(messages[1]!.content)
1628
+ const text = await chatComplete({ systemPrompt, userPrompt, config })
1629
+ const jsonText = extractFirstJsonObject(text)
1630
+ try {
1631
+ return JSON.parse(jsonText || '{}')
1632
+ } catch (e: any) {
1633
+ throw new Error(`summary returned non-JSON output (${e?.message || e}). First 400 chars: ${text.slice(0, 400)}`)
1634
+ }
1635
+ }
1636
+
1637
+ // ────────────────────────────────────────────────────────────────────────────
1638
+ // Metadata + markdown
1639
+ // ────────────────────────────────────────────────────────────────────────────
1640
+
1641
+ function isSpeakerLabel(text: string): boolean {
1642
+ return /^\s*speaker\s+[a-z]\s*$/i.test(text) || /^\s*说话人\s*[A-ZA-Za-za-z一二三四五六七八九十0-9]+\s*$/.test(text)
1643
+ }
1644
+
1645
+ function normalizeMetadata(meta: Json, rec: Recording): Json {
1646
+ const d = rec.recordedAt
1647
+ meta.date ||= `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
1648
+ meta.start_time ||= `${pad(d.getHours())}:${pad(d.getMinutes())}`
1649
+ meta.end_time ??= null
1650
+ for (const key of ['participants', 'organizations', 'projects', 'discussion_flow', 'consensus_points', 'disagreements', 'action_items', 'decisions', 'open_questions', 'key_quotes_or_details', 'transcription_uncertainties']) {
1651
+ if (!Array.isArray(meta[key])) meta[key] = []
1652
+ }
1653
+ meta.participants = meta.participants.filter((p: any) => typeof p === 'string' && p.trim() && !isSpeakerLabel(p))
1654
+ return meta
1655
+ }
1656
+
1657
+ const SOURCE_MARKER = '<!-- voicenote:source -->'
1658
+ function sourceDetails(meta: Json, audioPath: string, transcriptPath: string): string {
1659
+ 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>`
1660
+ }
1661
+
1662
+ function markdownNotes(meta: Json, audioPath: string, transcriptPath: string): string {
1663
+ let body = typeof meta.markdown === 'string' && meta.markdown.trim() ? meta.markdown.trim() : `# ${meta.title || 'Untitled recording notes'}\n`
1664
+ if (!body.startsWith('#')) body = `# ${meta.title || 'Untitled recording notes'}\n\n${body}`
1665
+ if (!body.includes(SOURCE_MARKER)) body = `${body.trim()}\n\n${sourceDetails(meta, audioPath, transcriptPath)}`
1666
+ return `${body.trim()}\n`
1667
+ }
1668
+
1669
+ async function markdownToPdf(markdownPath: string): Promise<string> {
1670
+ const pdfPath = markdownPath.replace(/\.md$/i, '.pdf')
1671
+ const tempBase = join(os.tmpdir(), `voicenote-pdf-${Date.now()}-${Math.random().toString(36).slice(2)}`)
1672
+ const htmlPath = `${tempBase}.html`
1673
+ const cssPath = `${tempBase}.css`
1674
+ const css = `
1675
+ :root { color-scheme: light; }
1676
+ body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; line-height: 1.68; color: #1f2328; max-width: 860px; margin: 40px auto; padding: 0 32px; font-size: 15px; }
1677
+ h1, h2, h3 { line-height: 1.32; margin-top: 1.8em; color: #111827; }
1678
+ h1 { font-size: 28px; border-bottom: 1px solid #e5e7eb; padding-bottom: 12px; }
1679
+ h2 { font-size: 22px; border-bottom: 1px solid #eef2f7; padding-bottom: 6px; }
1680
+ h3 { font-size: 18px; }
1681
+ p, ul, ol, blockquote, table { margin: 0.9em 0; }
1682
+ blockquote { border-left: 4px solid #d0d7de; padding-left: 16px; color: #57606a; }
1683
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; background: #f6f8fa; padding: 0.15em 0.35em; border-radius: 4px; }
1684
+ table { border-collapse: collapse; width: 100%; }
1685
+ th, td { border: 1px solid #d0d7de; padding: 8px 10px; vertical-align: top; }
1686
+ th { background: #f6f8fa; }
1687
+ details { margin-top: 2em; color: #57606a; font-size: 13px; }
1688
+ @page { size: A4; margin: 18mm 16mm; }
1689
+ @media print { body { margin: 0; padding: 0; max-width: none; } h1, h2, h3 { break-after: avoid; } table, blockquote { break-inside: avoid; } }
1690
+ `
1691
+ await writeFile(cssPath, css, 'utf8')
1692
+ try {
1693
+ const title = basename(markdownPath, extname(markdownPath))
1694
+ const pandoc = await runCommand('pandoc', [markdownPath, '--from', 'markdown+smart', '--to', 'html5', '--standalone', '--metadata', `title=${title}`, '--css', cssPath, '-o', htmlPath], 120000)
1695
+ if (pandoc.code !== 0) throw new Error(`pandoc failed: ${pandoc.stderr || pandoc.stdout}`)
1696
+ const chromePath = existsSync('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome') ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : 'google-chrome'
1697
+ const chrome = await runCommand(chromePath, ['--headless', '--disable-gpu', '--no-pdf-header-footer', `--print-to-pdf=${pdfPath}`, pathToFileURL(htmlPath).href], 120000)
1698
+ if (chrome.code !== 0 || !existsSync(pdfPath)) throw new Error(`chrome pdf failed: ${chrome.stderr || chrome.stdout}`)
1699
+ return pdfPath
1700
+ } finally {
1701
+ await unlink(htmlPath).catch(() => {})
1702
+ await unlink(cssPath).catch(() => {})
1703
+ }
1704
+ }
1705
+
1706
+ function transcriptMarkdown(config: Config, rec: Recording, transcript: string, opts: { mode?: RunMode } = {}): string {
1707
+ const transcribeBackend = `Volcano Doubao (resource ${config.volcano?.resourceId || 'volc.seedasr.auc'})`
1708
+ return `# Transcript: ${basename(rec.sourcePath)}\n\n- Source file: \`${rec.sourcePath}\`\n- Transcription backend: ${transcribeBackend}\n- Mode: ${opts.mode || 'notes'}\n- Recorded at: ${rec.recordedAt.toISOString()}\n- File size: ${rec.sizeBytes} bytes\n- Duration: ${rec.durationSeconds ?? 'unknown'} seconds\n- Transcribed at: ${nowIso()}\n\n---\n\n${RAW_TRANSCRIPT_MARKER}${transcript.trim()}`
1709
+ }
1710
+
1711
+ // ────────────────────────────────────────────────────────────────────────────
1712
+ // Pipeline
1713
+ // ────────────────────────────────────────────────────────────────────────────
1714
+
1715
+ async function processRecording(config: Config, rec: Recording, opts: any): Promise<Json> {
1716
+ const jobStarted = Date.now()
1717
+ let files = (opts.resumeFromTranscriptFiles as LocalFiles | null) || initialLocalFiles(config, rec)
1718
+ const mode = normalizeRunMode(opts)
1719
+ const needsNotes = mode === 'notes'
1720
+ const resumeSummary = needsNotes && Boolean(opts.resumeFromTranscriptFiles)
1721
+ const transcribeBackendLabel = `volcano:${config.volcano?.resourceId || 'volc.seedasr.auc'}`
1722
+ const llmBackendLabel = `pi:${piProviderFor()}`
1723
+ const plan = resumeSummary
1724
+ ? 'reuse saved transcript → integrated semantic notes → write metadata/index (no auto move)'
1725
+ : `copy audio → transcribe → write transcript${needsNotes ? ' → integrated semantic notes' : ''} → write metadata/index (no auto move)`
1726
+
1727
+ console.log(`\n=== voicenote job: ${basename(rec.sourcePath)} ===`)
1728
+ console.log(`Source: ${rec.sourcePath}`)
1729
+ console.log(`Audio: duration=${rec.durationSeconds == null ? 'unknown' : formatSeconds(rec.durationSeconds)}, size=${formatBytes(rec.sizeBytes)}, mode=${mode}, asr=${transcribeBackendLabel}, llm=${llmBackendLabel}`)
1730
+ console.log(`Plan: ${plan}`)
1731
+ if (opts.dryRun) return { source_path: rec.sourcePath, source_id: rec.sourceId, would_copy_to: files.audio, resume_from_transcript: resumeSummary ? files.transcript : null, size_bytes: rec.sizeBytes, duration_seconds: rec.durationSeconds, mode }
1732
+
1733
+ const totalSteps = resumeSummary ? 3 : needsNotes ? 4 : 3
1734
+ let stepNo = 0
1735
+ const nextStep = () => ++stepNo
1736
+
1737
+ let transcript = ''
1738
+ let meta: Json = {
1739
+ title: basename(rec.sourcePath, extname(rec.sourcePath)),
1740
+ markdown: '',
1741
+ }
1742
+
1743
+ if (resumeSummary) {
1744
+ progressStep(nextStep(), totalSteps, 'Reuse saved transcript', files.transcript)
1745
+ transcript = await readSavedTranscript(files.transcript)
1746
+ console.log(`✓ Reusing transcript: ${files.transcript}`)
1747
+ if (!existsSync(files.audio)) {
1748
+ await mkdir(dirname(files.audio), { recursive: true })
1749
+ await copyFile(rec.sourcePath, files.audio)
1750
+ console.log(`✓ Local audio restored: ${files.audio}`)
1751
+ }
1752
+ } else {
1753
+ progressStep(nextStep(), totalSteps, 'Copy audio to workspace', files.audio)
1754
+ await mkdir(dirname(files.audio), { recursive: true })
1755
+ await copyFile(rec.sourcePath, files.audio)
1756
+ console.log(`✓ Local audio ready: ${files.audio}`)
1757
+
1758
+ progressStep(nextStep(), totalSteps, 'Transcribe audio', transcribeBackendLabel)
1759
+ transcript = await withHeartbeat('transcribe audio', () => transcribeAudio(config, files.audio, rec), 90)
1760
+
1761
+ // Persist transcript IMMEDIATELY so an expensive ASR result is never lost
1762
+ // if a later step (summary) blows up. We use the initial (untitled) path;
1763
+ // if summary succeeds we'll move it to the titled path below.
1764
+ await mkdir(dirname(files.transcript), { recursive: true })
1765
+ await writeFile(files.transcript, transcriptMarkdown(config, rec, transcript, { mode }), 'utf8')
1766
+ console.log(`✓ Transcript saved: ${files.transcript}`)
1767
+ }
1768
+
1769
+ let summaryError: any = null
1770
+ if (needsNotes) {
1771
+ progressStep(nextStep(), totalSteps, 'Generate integrated semantic notes', `model=${piCodexModelFor()} via ${llmBackendLabel}`)
1772
+ try {
1773
+ meta = await withHeartbeat('generate integrated semantic notes', () => summarizeTranscript(config, transcript, rec, files.audio), 60)
1774
+ } catch (e: any) {
1775
+ summaryError = e
1776
+ console.error(`Summary step failed; transcript is preserved. Error: ${e?.message || e}`)
1777
+ console.error(`Hint: fix LLM auth/credits, then re-run with: vn run --latest`)
1778
+ }
1779
+ }
1780
+
1781
+ meta = normalizeMetadata(meta, rec)
1782
+ meta.processing_mode = mode
1783
+ meta.source_audio_path = rec.sourcePath
1784
+ meta.source_id = rec.sourceId
1785
+ meta.source_size_bytes = rec.sizeBytes
1786
+ meta.source_modified_at = rec.modifiedAt
1787
+ meta.duration_seconds = rec.durationSeconds
1788
+ meta.asr_provider = 'volcano'
1789
+ meta.transcribe_model = config.volcano?.resourceId || 'volc.seedasr.auc'
1790
+ meta.summary_model = needsNotes && !summaryError ? piCodexModelFor() : null
1791
+ meta.llm_backend = needsNotes ? llmBackendLabel : null
1792
+ meta.processed_at = nowIso()
1793
+ if (summaryError) meta.summary_error = String(summaryError?.message || summaryError)
1794
+
1795
+ progressStep(nextStep(), totalSteps, 'Write outputs and index')
1796
+ let failedStubPathToRemove: string | null = null
1797
+ if (needsNotes && !summaryError) {
1798
+ const previousNotes = files.notes
1799
+ const previousMetadata = files.metadata
1800
+ const titled = await titledLocalFiles(config, rec, meta, files)
1801
+ // titledLocalFiles renames the audio; manually move our already-written transcript too.
1802
+ if (titled.transcript !== files.transcript && existsSync(files.transcript)) {
1803
+ await mkdir(dirname(titled.transcript), { recursive: true })
1804
+ if (existsSync(titled.transcript)) await unlink(titled.transcript)
1805
+ await rename(files.transcript, titled.transcript)
1806
+ }
1807
+ files = titled
1808
+ if (previousNotes !== files.notes) failedStubPathToRemove = previousNotes
1809
+ // Resuming a failed run whose title changed leaves the old untitled metadata
1810
+ // from the failed attempt orphaned (note stub is handled above; metadata was not).
1811
+ if (previousMetadata !== files.metadata && existsSync(previousMetadata)) {
1812
+ await unlink(previousMetadata).catch(e => warnSideEffect(`remove orphaned metadata ${previousMetadata}`, e))
1813
+ }
1814
+ }
1815
+ await mkdir(dirname(files.notes), { recursive: true })
1816
+ await mkdir(dirname(files.metadata), { recursive: true })
1817
+
1818
+ if (needsNotes && !summaryError) {
1819
+ await writeFile(files.notes, markdownNotes(meta, files.audio, files.transcript), 'utf8')
1820
+ console.log(`✓ Notes: ${files.notes}`)
1821
+ if (failedStubPathToRemove) await removeFailedSummaryStub(failedStubPathToRemove)
1822
+ if (opts.pdf) {
1823
+ const pdf = await withHeartbeat('render notes PDF', () => markdownToPdf(files.notes), 30)
1824
+ meta.local_paths = { ...files, pdf }
1825
+ console.log(`✓ PDF: ${pdf}`)
1826
+ }
1827
+ } else if (needsNotes && summaryError) {
1828
+ const stubBody = `# Pending summary: ${basename(rec.sourcePath)}\n\n> ⚠ Transcription completed and saved, but the summary stage failed; retry needed.\n\n- Transcript file: \`${files.transcript}\`\n- Original audio: \`${rec.sourcePath}\`\n- Failure reason: ${meta.summary_error}\n- Retry command: \`vn run --latest\`\n`
1829
+ await writeFile(files.notes, stubBody, 'utf8')
1830
+ console.log(`⚠ Stub notes (summary failed): ${files.notes}`)
1831
+ } else if (opts.pdf) {
1832
+ console.log('PDF skipped: --pdf only applies to --mode notes.')
1833
+ }
1834
+
1835
+ meta.local_paths = { ...files, ...(meta.local_paths?.pdf ? { pdf: meta.local_paths.pdf } : {}) }
1836
+ meta.final_paths = {
1837
+ audio: files.audio,
1838
+ transcript: files.transcript,
1839
+ notes: needsNotes ? files.notes : null,
1840
+ metadata: files.metadata,
1841
+ ...(meta.local_paths?.pdf ? { pdf: meta.local_paths.pdf } : {}),
1842
+ }
1843
+
1844
+ if (summaryError) {
1845
+ meta.status = SUMMARY_FAILED_STATUS
1846
+ } else if (needsNotes) {
1847
+ meta.status = 'completed'
1848
+ } else {
1849
+ meta.status = 'transcript_only'
1850
+ }
1851
+
1852
+ await writeJson(files.metadata, meta)
1853
+ await appendJsonl(await notesIndexPath(config), meta)
1854
+ console.log(`✓ Completed: ${meta.title || basename(rec.sourcePath)} (${formatElapsed(Date.now() - jobStarted)} total)`)
1855
+ if (needsNotes) console.log(`Final notes: ${files.notes}`)
1856
+ else console.log(`Final transcript: ${files.transcript}`)
1857
+ return meta
1858
+ }
1859
+
1860
+ async function runPipeline(opts: any): Promise<void> {
1861
+ wireDailyLog()
1862
+ const config = getConfig()
1863
+ const lock = await acquireRunLock()
1864
+ if (!lock) {
1865
+ console.log('voicenote pipeline already running; skip')
1866
+ return
1867
+ }
1868
+ try {
1869
+ await runPipelineLocked(config, opts)
1870
+ } finally {
1871
+ await lock.release()
1872
+ }
1873
+ }
1874
+
1875
+ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
1876
+ await ensureDirs(config)
1877
+ const statePath = join(config.workspace, '_state', 'processed.json')
1878
+ const state = await readJson<Json>(statePath, { processed_source_ids: {}, skipped_source_ids: {} })
1879
+ state.processed_source_ids ||= {}
1880
+ state.skipped_source_ids ||= {}
1881
+
1882
+ if (!existsSync(config.recordDir)) {
1883
+ if (shouldLogIdleStatus(`missing:${config.recordDir}`)) {
1884
+ console.log(`Idle: recorder not mounted or record dir missing: ${config.recordDir} (repeated idle logs suppressed for 30m)`)
1885
+ }
1886
+ return
1887
+ }
1888
+ const recordings = await scanRecordings(config)
1889
+ const mode = normalizeRunMode(opts)
1890
+ const force = Boolean(opts.force)
1891
+ const eligible: Recording[] = []
1892
+ const skipCounts: Record<string, number> = {}
1893
+ const skipSamples: Record<string, string[]> = {}
1894
+ const verboseSkips = Boolean(opts.verbose || opts.dryRun)
1895
+ for (const rec of recordings) {
1896
+ const [skip, reason] = shouldSkip(rec, state, config, force, mode)
1897
+ if (skip) {
1898
+ const reasonKey = reason.split(':')[0] || reason
1899
+ skipCounts[reasonKey] = (skipCounts[reasonKey] || 0) + 1
1900
+ ;(skipSamples[reasonKey] ||= []).push(basename(rec.sourcePath))
1901
+ if (!state.skipped_source_ids[rec.sourceId] && reason !== 'already_processed') {
1902
+ state.skipped_source_ids[rec.sourceId] = { source_path: rec.sourcePath, reason, size_bytes: rec.sizeBytes, duration_seconds: rec.durationSeconds, seen_at: nowIso() }
1903
+ }
1904
+ if (verboseSkips) console.log(` Skip: ${basename(rec.sourcePath)} (${reason})`)
1905
+ } else eligible.push(rec)
1906
+ }
1907
+ const skipSummary = Object.entries(skipCounts).map(([reason, count]) => `${reason}=${count}`).join(', ') || 'none'
1908
+ const scanLine = `Scan summary: found=${recordings.length}; eligible=${eligible.length}; skipped=${recordings.length - eligible.length} (${skipSummary})`
1909
+ const samplesLine = !verboseSkips && Object.keys(skipSamples).length
1910
+ ? `Skipped samples: ${Object.entries(skipSamples).map(([reason, names]) => `${reason}: ${names.slice(0, 3).join(', ')}${names.length > 3 ? `…(+${names.length - 3})` : ''}`).join(' | ')}`
1911
+ : ''
1912
+ const latestOnly = Boolean(opts.latest)
1913
+ const targets = latestOnly ? eligible.slice(-1) : eligible
1914
+ // Preflight: if there is work but the run cannot complete, skip BEFORE spending
1915
+ // ASR money, rather than failing per-recording on every 60s StartInterval tick.
1916
+ // Idle-suppressed so a misconfigured daemon doesn't spam logs. Skipped for
1917
+ // --dry-run, which is a zero-side-effect diagnostic and should still print the
1918
+ // plan even on an unconfigured machine.
1919
+ if (targets.length && !opts.dryRun) {
1920
+ const needsAsr = targets.some(rec => !resumableTranscriptFiles(config, rec, state, mode, force))
1921
+ if (needsAsr && !config.volcano) {
1922
+ if (shouldLogIdleStatus(`asr-misconfig:${config.recordDir}`)) console.error('ASR not configured: Volcano needs VOLCANO_ASR_KEY / VOLCANO_TOS_*. Skipping; run `vn doctor`, fix config, then re-run.')
1923
+ return
1924
+ }
1925
+ if (mode === 'notes' && !piAuthAvailable()) {
1926
+ if (shouldLogIdleStatus(`pi-noauth:${config.recordDir}`)) console.error('pi is not logged in (~/.pi/agent/auth.json missing). Skipping to avoid spending ASR on notes whose summary would fail. Run `pi` to log in, then re-run.')
1927
+ return
1928
+ }
1929
+ }
1930
+ if (!targets.length) {
1931
+ if (verboseSkips || shouldLogIdleStatus(`idle:${config.recordDir}:${recordings.length}:${skipSummary}:${samplesLine}`)) {
1932
+ console.log(scanLine)
1933
+ if (samplesLine) console.log(samplesLine)
1934
+ console.log('Idle: no new recordings to process. (repeated idle logs suppressed for 30m)')
1935
+ }
1936
+ } else {
1937
+ console.log(scanLine)
1938
+ if (samplesLine) console.log(samplesLine)
1939
+ console.log(`Queue: processing ${targets.length} recording(s)${latestOnly ? ' (--latest)' : ''}. Remaining after this run: ${Math.max(0, eligible.length - targets.length)}`)
1940
+ }
1941
+ for (const rec of targets) {
1942
+ try {
1943
+ const resumeFromTranscriptFiles = resumableTranscriptFiles(config, rec, state, mode, force)
1944
+ const result = await processRecording(config, rec, { ...opts, resumeFromTranscriptFiles })
1945
+ if (!opts.dryRun) {
1946
+ state.processed_source_ids[rec.sourceId] = { source_path: rec.sourcePath, processed_at: nowIso(), status: result.status, title: result.title, final_paths: result.final_paths }
1947
+ delete state.skipped_source_ids[rec.sourceId]
1948
+ }
1949
+ } catch (e: any) {
1950
+ console.error(`ERROR processing ${rec.sourcePath}: ${e?.message || e}`)
1951
+ state.skipped_source_ids[rec.sourceId] = { source_path: rec.sourcePath, reason: `error:${e?.message || e}`, seen_at: nowIso() }
1952
+ }
1953
+ }
1954
+ if (!opts.dryRun) await writeJson(statePath, state)
1955
+ }
1956
+
1957
+ // ────────────────────────────────────────────────────────────────────────────
1958
+ // LaunchAgent
1959
+ // ────────────────────────────────────────────────────────────────────────────
1960
+
1961
+ // Bun standalone executables embed source in a virtual FS, so import.meta.url is
1962
+ // NOT a real on-disk path: "/$bunfs/..." on mac/Linux, "B:\~BUN\root\..." on
1963
+ // Windows. Either marker means we're the compiled exe (run it directly via
1964
+ // process.execPath); otherwise we're bun + cli.mjs on disk. NOTE: matching only
1965
+ // $bunfs (the old check) misfired on Windows and leaked the virtual path into the
1966
+ // scheduled task's arguments.
1967
+ function resolveCli(): { cliPath: string; compiled: boolean } {
1968
+ const cliPath = fileURLToPath(import.meta.url)
1969
+ return { cliPath, compiled: /\$bunfs|~BUN/i.test(cliPath) }
1970
+ }
1971
+
1972
+ function plistPath(): string {
1973
+ return join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCH_AGENT_LABEL}.plist`)
1974
+ }
1975
+
1976
+ function xmlEscape(s: string): string {
1977
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;')
1978
+ }
1979
+
1980
+ // Pulled in for `vn install-launch-agent`. launchd does NOT inherit your zsh
1981
+ // environment, so anything the pipeline needs that lives ONLY in the real
1982
+ // environment (e.g. exported from a non-zsh shell, or injected by the GUI)
1983
+ // has to be written into the plist's EnvironmentVariables. Values that came
1984
+ // from config.json/.zshrc are deliberately NOT embedded: vn run re-reads
1985
+ // those files at startup, and since plist env outranks config.json, embedding
1986
+ // them would freeze the values — later GUI edits would silently never reach
1987
+ // the background agent.
1988
+ async function launchAgentEnv(): Promise<Record<string, string>> {
1989
+ loadEnvConfig()
1990
+ const env: Record<string, string> = {
1991
+ PATH: `${os.homedir()}/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`,
1992
+ }
1993
+ // Embed only what is NOT recoverable from the config files at run time —
1994
+ // see envConfig.ts (invariants 3+4) for the full matrix. A real-env value
1995
+ // that differs from the file value is embedded as an override but warned
1996
+ // about: it may equally be a stale shell session, and it will keep
1997
+ // overriding config edits until the scheduler is reinstalled.
1998
+ //
1999
+ // Known blind spot: the matrix compares each key against its OWN file value,
2000
+ // so it can't see cross-key derivations. A real-env http_proxy is embedded
2001
+ // as-is and will shadow a GUI edit to LOCAL_PROXY_HOST (different key name)
2002
+ // until reinstall. Only no_proxy is special-cased (originals below) because
2003
+ // WE synthesize it; http_proxy from a user's shell is left as a real value.
2004
+ //
2005
+ // no_proxy/NO_PROXY carry a volcano-hosts merge we added; substitute the
2006
+ // pre-merge real-env original (or drop it entirely if we synthesized the
2007
+ // whole value) so the scheduler never freezes our merge over config edits.
2008
+ const embedEnv: Record<string, string | undefined> = { ...process.env }
2009
+ for (const k of ['no_proxy', 'NO_PROXY'] as const) embedEnv[k] = premergeRealNoProxy[k]
2010
+ const fileEnv = fileProvidedEnv()
2011
+ const { embed, frozenOverrides } = envKeysToEmbed(ENV_KEYS, embedEnv, hydratedEnvKeys, fileEnv)
2012
+ Object.assign(env, embed)
2013
+ for (const k of frozenOverrides) {
2014
+ console.error(`Warning: environment ${k} differs from the config file value; the environment value is snapshotted into the scheduler and will override config edits until you re-run \`vn install-launch-agent\`.`)
2015
+ }
2016
+ // Embed pi's ABSOLUTE path so launchd resolves it regardless of the fixed plist
2017
+ // PATH (npm global bin can live outside it under nvm / custom prefixes). Resolve
2018
+ // the configured name (including the documented relative `VOICENOTE_PI_BIN="pi"`
2019
+ // and a file-sourced relative name); only an absolute override is left as-is.
2020
+ // The resolved path is regenerated on every (re)install, so config edits that
2021
+ // change VOICENOTE_PI_BIN take effect via ensure_agent's forced reinstall.
2022
+ const configuredPi = env.VOICENOTE_PI_BIN ?? process.env.VOICENOTE_PI_BIN
2023
+ if (!configuredPi?.startsWith('/')) {
2024
+ const w = await runCommand(IS_WINDOWS ? 'where' : 'which', [configuredPi || 'pi'], 5000)
2025
+ const p = w.code === 0 ? (w.stdout.trim().split(/\r?\n/)[0] || '') : ''
2026
+ if (p && existsSync(p)) env.VOICENOTE_PI_BIN = p
2027
+ }
2028
+ return env
2029
+ }
2030
+
2031
+ async function installLaunchAgent(opts: { load?: boolean } = {}): Promise<void> {
2032
+ const { cliPath, compiled } = resolveCli()
2033
+ const programArgs = compiled
2034
+ ? [process.execPath, 'run']
2035
+ : [existsSync('/opt/homebrew/bin/bun') ? '/opt/homebrew/bin/bun' : process.execPath, cliPath, 'run']
2036
+ const programArgsXml = programArgs.map(a => ` <string>${xmlEscape(a)}</string>`).join('\n')
2037
+ const plist = plistPath()
2038
+ await mkdir(dirname(plist), { recursive: true })
2039
+ await mkdir(LOG_DIR, { recursive: true })
2040
+ const env = await launchAgentEnv()
2041
+ const envEntries = Object.entries(env)
2042
+ .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`).join('\n')
2043
+ const content = `<?xml version="1.0" encoding="UTF-8"?>
2044
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2045
+ <plist version="1.0">
2046
+ <dict>
2047
+ <key>Label</key>
2048
+ <string>${LAUNCH_AGENT_LABEL}</string>
2049
+ <key>ProgramArguments</key>
2050
+ <array>
2051
+ ${programArgsXml}
2052
+ </array>
2053
+ <key>RunAtLoad</key>
2054
+ <true/>
2055
+ <key>StartInterval</key>
2056
+ <integer>60</integer>
2057
+ <key>StandardOutPath</key>
2058
+ <string>${LOG_DIR}/launchd.out.log</string>
2059
+ <key>StandardErrorPath</key>
2060
+ <string>${LOG_DIR}/launchd.err.log</string>
2061
+ <key>WorkingDirectory</key>
2062
+ <string>${os.homedir()}</string>
2063
+ <key>EnvironmentVariables</key>
2064
+ <dict>
2065
+ ${envEntries}
2066
+ </dict>
2067
+ </dict>
2068
+ </plist>
2069
+ `
2070
+ await writeFile(plist, content, 'utf8')
2071
+ // The plist may embed real-env secrets (proxy credentials, exported keys);
2072
+ // chmod explicitly — writeFile's mode only applies on creation, and existing
2073
+ // plists from older installs are 0644.
2074
+ await chmod(plist, 0o600)
2075
+ const summary = Object.keys(env).join(', ')
2076
+ console.log(`LaunchAgent written: ${plist}`)
2077
+ console.log(`Embedded env keys: ${summary}`)
2078
+ if (opts.load) {
2079
+ const uid = process.getuid?.()
2080
+ // Remove the legacy-label agent so old installs don't double-run vn.
2081
+ const legacyPlist = join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCH_AGENT_LABEL_LEGACY}.plist`)
2082
+ if (existsSync(legacyPlist)) {
2083
+ await runCommand('launchctl', ['bootout', `gui/${uid}/${LAUNCH_AGENT_LABEL_LEGACY}`], 10000)
2084
+ await unlink(legacyPlist).catch(e => warnSideEffect(`remove legacy LaunchAgent ${legacyPlist}`, e))
2085
+ }
2086
+ await runCommand('launchctl', ['bootout', `gui/${uid}`, plist], 10000) // ignore if not loaded
2087
+ const r = await runCommand('launchctl', ['bootstrap', `gui/${uid}`, plist], 10000)
2088
+ await runCommand('launchctl', ['enable', `gui/${uid}/${LAUNCH_AGENT_LABEL}`], 10000)
2089
+ if (r.code === 0) console.log('LaunchAgent loaded (launchctl bootstrap).')
2090
+ else console.error(`bootstrap exit ${r.code}: ${(r.stderr || r.stdout).trim().slice(0, 200)}`)
2091
+ } else {
2092
+ console.log(`Enable with: launchctl bootstrap gui/$(id -u) ${plist}`)
2093
+ }
2094
+ }
2095
+
2096
+ async function uninstallLaunchAgent(): Promise<void> {
2097
+ await runCommand('launchctl', ['bootout', `gui/${process.getuid?.()}`, plistPath()], 10000)
2098
+ console.log(`Bootout attempted: ${plistPath()}`)
2099
+ }
2100
+
2101
+ // ────────────────────────────────────────────────────────────────────────────
2102
+ // Windows Task Scheduler (parallel to the mac LaunchAgent above)
2103
+ // ────────────────────────────────────────────────────────────────────────────
2104
+
2105
+ function taskXmlPath(): string { return join(STATE_DIR, 'task.xml') }
2106
+ function taskVbsPath(): string { return join(STATE_DIR, 'run-hidden.vbs') }
2107
+
2108
+ // Run via the interpreter currently executing us: process.execPath is the
2109
+ // absolute bun.exe (or the compiled vn.exe). Mirrors installLaunchAgent's
2110
+ // compiled-vs-script detection.
2111
+ function schedulerProgramArgs(): { command: string; argLine: string } {
2112
+ const { cliPath, compiled } = resolveCli()
2113
+ const args = compiled ? ['run'] : [cliPath, 'run']
2114
+ const argLine = args.map(a => (/\s/.test(a) ? `"${a}"` : a)).join(' ')
2115
+ return { command: process.execPath, argLine }
2116
+ }
2117
+
2118
+ async function installScheduledTask(opts: { load?: boolean } = {}): Promise<void> {
2119
+ await mkdir(STATE_DIR, { recursive: true })
2120
+ await mkdir(LOG_DIR, { recursive: true })
2121
+ // The task carries no env (Task Scheduler has no per-task env block), so the
2122
+ // bundled-engine paths the GUI injected via process env (pi runtime + cli.js +
2123
+ // ffprobe) must be persisted to config.json, which `vn run` reads on startup.
2124
+ // (On mac these ride in the LaunchAgent plist instead.)
2125
+ const persist: Record<string, string> = {}
2126
+ for (const k of ['VOICENOTE_PI_BIN', 'VOICENOTE_PI_CLI', 'VOICENOTE_FFPROBE_BIN'] as const) {
2127
+ if (process.env[k]) persist[k] = process.env[k]!
2128
+ }
2129
+ if (Object.keys(persist).length) {
2130
+ await mkdir(CONFIG_DIR, { recursive: true })
2131
+ const current = loadJsonSync<Record<string, unknown>>(CONFIG_ENV_PATH, {})
2132
+ Object.assign(current, persist)
2133
+ await writeFile(CONFIG_ENV_PATH, JSON.stringify(current, null, 2) + '\n')
2134
+ }
2135
+ const { command, argLine } = schedulerProgramArgs()
2136
+ // bun.exe / vn.exe are console-subsystem: an InteractiveToken task flashes a
2137
+ // console window on every tick. Launch through wscript with window style 0
2138
+ // (hidden). wait=True keeps wscript alive for the duration of `vn run` so
2139
+ // IgnoreNew still prevents overlap, and WScript.Quit propagates vn's exit
2140
+ // code so the task's Last Run Result stays meaningful. UTF-16 BOM so
2141
+ // non-ASCII paths survive (wscript reads BOM-less files as ANSI).
2142
+ const fullCmd = `"${command}" ${argLine}`
2143
+ const vbs = `WScript.Quit CreateObject("WScript.Shell").Run("${fullCmd.replace(/"/g, '""')}", 0, True)\r\n`
2144
+ await writeFile(taskVbsPath(), '\ufeff' + vbs, 'utf16le')
2145
+ const wscript = join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'wscript.exe')
2146
+ // Register the task as the current user (DOMAIN\user; DOMAIN == machine name for
2147
+ // local accounts). Without an explicit <UserId>, `schtasks /create /xml` can't tell
2148
+ // who to register as and a standard (non-admin) user gets "Access is denied".
2149
+ const taskUser = process.env.USERDOMAIN && process.env.USERNAME
2150
+ ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}`
2151
+ : (process.env.USERNAME || os.userInfo().username)
2152
+ // Local-time StartBoundary for the TimeTrigger (Task Scheduler wants no zone).
2153
+ const n = new Date()
2154
+ const startBoundary = `${n.getFullYear()}-${pad(n.getMonth() + 1)}-${pad(n.getDate())}T${pad(n.getHours())}:${pad(n.getMinutes())}:${pad(n.getSeconds())}`
2155
+ // The task just runs `vn run`; config comes from config.json (vn config set /
2156
+ // the GUI), so unlike the mac plist there's no env to embed. A TimeTrigger that
2157
+ // repeats every PT1M (mirrors the working `schtasks /sc minute /mo 1` form; a
2158
+ // LogonTrigger gave "Access is denied" for standard users) + IgnoreNew is the
2159
+ // StartInterval(60)+flock equivalent.
2160
+ const xml = `<?xml version="1.0" encoding="UTF-16"?>
2161
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
2162
+ <RegistrationInfo>
2163
+ <Description>VoiceNote: watch the recorder and process new recordings.</Description>
2164
+ </RegistrationInfo>
2165
+ <Triggers>
2166
+ <TimeTrigger>
2167
+ <StartBoundary>${startBoundary}</StartBoundary>
2168
+ <Enabled>true</Enabled>
2169
+ <Repetition>
2170
+ <Interval>PT1M</Interval>
2171
+ <StopAtDurationEnd>false</StopAtDurationEnd>
2172
+ </Repetition>
2173
+ </TimeTrigger>
2174
+ </Triggers>
2175
+ <Principals>
2176
+ <Principal id="Author">
2177
+ <UserId>${xmlEscape(taskUser)}</UserId>
2178
+ <LogonType>InteractiveToken</LogonType>
2179
+ <RunLevel>LeastPrivilege</RunLevel>
2180
+ </Principal>
2181
+ </Principals>
2182
+ <Settings>
2183
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
2184
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
2185
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
2186
+ <StartWhenAvailable>true</StartWhenAvailable>
2187
+ <ExecutionTimeLimit>PT2H</ExecutionTimeLimit>
2188
+ <AllowHardTerminate>true</AllowHardTerminate>
2189
+ <Enabled>true</Enabled>
2190
+ <Hidden>false</Hidden>
2191
+ </Settings>
2192
+ <Actions Context="Author">
2193
+ <Exec>
2194
+ <Command>${xmlEscape(wscript)}</Command>
2195
+ <Arguments>${xmlEscape(`//B //Nologo "${taskVbsPath()}"`)}</Arguments>
2196
+ </Exec>
2197
+ </Actions>
2198
+ </Task>
2199
+ `
2200
+ const xmlPath = taskXmlPath()
2201
+ // schtasks /xml wants UTF-16; prepend a BOM so non-ASCII paths survive.
2202
+ await writeFile(xmlPath, '\ufeff' + xml, 'utf16le')
2203
+ const r = await runCommand('schtasks', ['/create', '/tn', TASK_NAME, '/xml', xmlPath, '/f'], 15000)
2204
+ if (r.code !== 0) {
2205
+ console.error(`schtasks /create failed (exit ${r.code}): ${(r.stderr || r.stdout).trim()}`)
2206
+ process.exitCode = 1
2207
+ return
2208
+ }
2209
+ console.log(`Scheduled task '${TASK_NAME}' installed — runs \`vn run\` every 60s at/after logon.`)
2210
+ console.log(`Command: ${command} ${argLine} (launched hidden via wscript)`)
2211
+ console.log('Note: the task reads config from config.json — set it with `vn config set` (or the GUI) so the background run is configured.')
2212
+ if (opts.load) await runCommand('schtasks', ['/run', '/tn', TASK_NAME], 10000)
2213
+ }
2214
+
2215
+ async function uninstallScheduledTask(): Promise<void> {
2216
+ const r = await runCommand('schtasks', ['/delete', '/tn', TASK_NAME, '/f'], 10000)
2217
+ // Remove our artifacts too: the VBS is the task's actual entry point, and a
2218
+ // leftover copy could make schedulerIsCurrent misjudge a future install.
2219
+ // Only when the task is actually gone — deleting the VBS while the task is
2220
+ // still registered would turn every tick into a silent wscript failure.
2221
+ if (r.code === 0) for (const p of [taskVbsPath(), taskXmlPath()]) { try { unlinkSync(p) } catch {} }
2222
+ console.log(r.code === 0 ? `Scheduled task '${TASK_NAME}' removed.` : `schtasks /delete: ${(r.stderr || r.stdout).trim()}`)
2223
+ }
2224
+
2225
+ // ── Cross-platform scheduler dispatch ──
2226
+ function installScheduler(opts: { load?: boolean } = {}): Promise<void> {
2227
+ return IS_WINDOWS ? installScheduledTask(opts) : installLaunchAgent(opts)
2228
+ }
2229
+ function uninstallScheduler(): Promise<void> {
2230
+ return IS_WINDOWS ? uninstallScheduledTask() : uninstallLaunchAgent()
2231
+ }
2232
+ async function printSchedulerStatus(): Promise<void> {
2233
+ if (IS_WINDOWS) {
2234
+ const r = await runCommand('schtasks', ['/query', '/tn', TASK_NAME, '/v', '/fo', 'LIST'], 10000)
2235
+ process.stdout.write(r.stdout || r.stderr || `Task '${TASK_NAME}' not found.\n`)
2236
+ return
2237
+ }
2238
+ const r = await runCommand('launchctl', ['print', `gui/${process.getuid?.()}/${LAUNCH_AGENT_LABEL}`], 10000)
2239
+ process.stdout.write(r.stdout || r.stderr)
2240
+ }
2241
+
2242
+ // ────────────────────────────────────────────────────────────────────────────
2243
+ // Browse / debug commands
2244
+ // ────────────────────────────────────────────────────────────────────────────
2245
+
2246
+ async function listMeetings(opts: { month?: string }): Promise<void> {
2247
+ const config = getConfig()
2248
+ const month = opts.month || `${new Date().getFullYear()}-${pad(new Date().getMonth() + 1)}`
2249
+ const dir = join(config.workspace, month)
2250
+ if (!existsSync(dir)) {
2251
+ console.log(`No notes in ${dir}`)
2252
+ return
2253
+ }
2254
+ const entries = (await readdir(dir)).filter(f => f.endsWith('.md')).sort()
2255
+ if (!entries.length) {
2256
+ console.log(`No notes in ${dir}`)
2257
+ return
2258
+ }
2259
+ for (const name of entries) {
2260
+ console.log(join(dir, name))
2261
+ }
2262
+ }
2263
+
2264
+ // One-time migration: pre-0.15.4 wrote _index/meetings.jsonl. Rename it to the new
2265
+ // canonical notes.jsonl on first access so all history stays in a single file.
2266
+ async function notesIndexPath(config: Config): Promise<string> {
2267
+ const p = join(config.workspace, '_index', 'notes.jsonl')
2268
+ const legacy = join(config.workspace, '_index', 'meetings.jsonl')
2269
+ if (!existsSync(p) && existsSync(legacy)) await rename(legacy, p).catch(() => {})
2270
+ return p
2271
+ }
2272
+
2273
+ async function lastMeeting(): Promise<void> {
2274
+ const config = getConfig()
2275
+ const indexPath = await notesIndexPath(config)
2276
+ if (!existsSync(indexPath)) {
2277
+ console.log('No notes indexed yet.')
2278
+ return
2279
+ }
2280
+ const lines = (await readFile(indexPath, 'utf8')).trim().split('\n').filter(Boolean)
2281
+ const last = lines[lines.length - 1]
2282
+ if (!last) {
2283
+ console.log('No notes indexed yet.')
2284
+ return
2285
+ }
2286
+ let obj: Json
2287
+ try { obj = JSON.parse(last) } catch { console.log(last); return }
2288
+ console.log(`Title: ${obj.title}`)
2289
+ console.log(`Date: ${obj.date} ${obj.start_time || ''}-${obj.end_time || ''}`)
2290
+ console.log(`Status: ${obj.status || 'unknown'}`)
2291
+ console.log(`Notes: ${obj.final_paths?.notes || obj.local_paths?.notes}`)
2292
+ console.log(`Transcript: ${obj.final_paths?.transcript || obj.local_paths?.transcript}`)
2293
+ console.log(`Audio: ${obj.final_paths?.audio || obj.local_paths?.audio}`)
2294
+ }
2295
+
2296
+
2297
+ async function openTarget(arg?: string): Promise<void> {
2298
+ const config = getConfig()
2299
+ let target = config.workspace
2300
+ if (arg === 'config') {
2301
+ target = CONFIG_DIR
2302
+ } else if (arg === 'logs') {
2303
+ target = LOG_DIR
2304
+ } else if (arg) {
2305
+ // Try matching most recent file in current month containing arg.
2306
+ const month = `${new Date().getFullYear()}-${pad(new Date().getMonth() + 1)}`
2307
+ const dir = join(config.workspace, month)
2308
+ if (existsSync(dir)) {
2309
+ const matches = (await readdir(dir)).filter(f => f.includes(arg) && f.endsWith('.md'))
2310
+ if (matches.length) target = join(dir, matches[matches.length - 1]!)
2311
+ }
2312
+ }
2313
+ await openPath(target)
2314
+ console.log(`open ${target}`)
2315
+ }
2316
+
2317
+ async function forgetRecording(needle: string): Promise<void> {
2318
+ const config = getConfig()
2319
+ const statePath = join(config.workspace, '_state', 'processed.json')
2320
+ const state = await readJson<Json>(statePath, { processed_source_ids: {}, skipped_source_ids: {} })
2321
+ let removed = 0
2322
+ for (const bucket of ['processed_source_ids', 'skipped_source_ids']) {
2323
+ for (const id of Object.keys(state[bucket] || {})) {
2324
+ const entry = state[bucket][id]
2325
+ if (id === needle || entry?.source_path?.includes(needle)) {
2326
+ delete state[bucket][id]
2327
+ removed++
2328
+ }
2329
+ }
2330
+ }
2331
+ await writeJson(statePath, state)
2332
+ console.log(`forgot ${removed} record(s)`)
2333
+ }
2334
+
2335
+ async function showLog(opts: { lines?: number; follow?: boolean; err?: boolean; date?: string }): Promise<void> {
2336
+ const lines = Number(opts.lines || 30)
2337
+ const wanted = [opts.date ? join(LOG_DIR, `${opts.date}.log`) : dailyLogPath()]
2338
+ if (opts.err) wanted.push(join(LOG_DIR, 'launchd.err.log'))
2339
+ const files = wanted.filter(f => existsSync(f))
2340
+ if (!files.length) {
2341
+ console.log(`No log file: ${wanted.join(', ')}`)
2342
+ return
2343
+ }
2344
+ await tailFiles(files, lines, !!opts.follow)
2345
+ }
2346
+
2347
+ async function showErrors(opts: { lines?: number }): Promise<void> {
2348
+ if (!existsSync(LOG_DIR)) {
2349
+ console.log('No logs.')
2350
+ return
2351
+ }
2352
+ // Only the daily rolling logs (YYYY-MM-DD.log) carry timestamped [ERROR] lines;
2353
+ // launchd.out.log/launchd.err.log are raw, never-truncated stdout/stderr mirrors
2354
+ // that sort after dated files alphabetically ('l' > digit) and would otherwise
2355
+ // crowd out the real recent logs in the slice(-3) below.
2356
+ const files = (await readdir(LOG_DIR)).filter(f => /^\d{4}-\d{2}-\d{2}\.log$/.test(f)).sort().slice(-3)
2357
+ if (!files.length) {
2358
+ // Distinguish "no dated logs yet" (fresh install) from "scanned, no errors".
2359
+ console.log('No logs.')
2360
+ return
2361
+ }
2362
+ const lineCount = Number(opts.lines || 20)
2363
+ const errors: string[] = []
2364
+ for (const f of files) {
2365
+ const content = await readFile(join(LOG_DIR, f), 'utf8').catch(() => '')
2366
+ for (const line of content.split('\n')) {
2367
+ if (line.includes('[ERROR]') || line.includes('ERROR processing')) errors.push(line)
2368
+ }
2369
+ }
2370
+ for (const line of errors.slice(-lineCount)) console.log(line)
2371
+ }
2372
+
2373
+ async function upgradeSelf(): Promise<void> {
2374
+ const cmd = IS_WINDOWS ? 'bun' : (existsSync('/opt/homebrew/bin/bun') ? '/opt/homebrew/bin/bun' : 'bun')
2375
+ // `bun add -g` upgrades in place: verified no dependency loop on npm→npm re-add
2376
+ // (the steady-state upgrade path) nor on replacing an old git-ref install. No
2377
+ // remove-first, so a failed add leaves the running vn intact.
2378
+ console.log(`$ ${cmd} add -g @fastagent-sh/voicenote`)
2379
+ const addCode = await new Promise<number>(res =>
2380
+ spawn(cmd, ['add', '-g', '@fastagent-sh/voicenote'], { stdio: 'inherit', shell: IS_WINDOWS })
2381
+ .on('close', c => res(c ?? 1)).on('error', () => res(1)))
2382
+ if (addCode !== 0) {
2383
+ console.error(`Upgrade failed: \`${cmd} add -g @fastagent-sh/voicenote\` exited ${addCode}. Your current install is unchanged; retry later.`)
2384
+ process.exitCode = 1
2385
+ return
2386
+ }
2387
+ // Refresh the background scheduler so it points at the upgraded version. This
2388
+ // process is still the OLD code in memory, so invoke the freshly installed binary
2389
+ // to regenerate.
2390
+ if (IS_WINDOWS) {
2391
+ const installed = (await runCommand('schtasks', ['/query', '/tn', TASK_NAME], 10000)).code === 0
2392
+ if (installed) {
2393
+ const code = await new Promise<number>(res =>
2394
+ spawn('vn', ['install-launch-agent'], { stdio: 'inherit', shell: true })
2395
+ .on('close', c => res(c ?? 1)).on('error', () => res(1)))
2396
+ console.log(code === 0 ? 'Scheduled task refreshed.' : 'Warning: `vn install-launch-agent` failed; re-register manually.')
2397
+ }
2398
+ return
2399
+ }
2400
+ if (existsSync(plistPath())) {
2401
+ console.log('Refreshing LaunchAgent plist for the upgraded version…')
2402
+ const code = await new Promise<number>(res =>
2403
+ spawn('vn', ['install-launch-agent'], { stdio: 'inherit' })
2404
+ .on('close', c => res(c ?? 1)).on('error', () => res(1)))
2405
+ if (code !== 0) {
2406
+ console.error(`Warning: \`vn install-launch-agent\` failed (exit ${code}); the LaunchAgent still points at the previous version. Ensure vn is on PATH and re-run \`vn install-launch-agent\`.`)
2407
+ return
2408
+ }
2409
+ const uid = process.getuid?.()
2410
+ await runCommand('launchctl', ['bootout', `gui/${uid}`, plistPath()], 10000) // ok if not currently loaded
2411
+ const bs = await runCommand('launchctl', ['bootstrap', `gui/${uid}`, plistPath()], 10000)
2412
+ if (bs.code !== 0) {
2413
+ console.error(`Warning: launchctl bootstrap failed: ${(bs.stderr || bs.stdout).trim()}. Reload manually: launchctl bootstrap gui/$(id -u) ${plistPath()}`)
2414
+ return
2415
+ }
2416
+ console.log('LaunchAgent reloaded.')
2417
+ }
2418
+ }
2419
+
2420
+ // ────────────────────────────────────────────────────────────────────────────
2421
+ // Doctor
2422
+ // ────────────────────────────────────────────────────────────────────────────
2423
+
2424
+ // Read up to the last `maxBytes` of a (possibly large, ever-appending) log file
2425
+ // without slurping the whole thing — used to surface the agent's latest activity.
2426
+ function readLogTail(path: string, maxBytes: number): string {
2427
+ try {
2428
+ const size = statSync(path).size
2429
+ const start = Math.max(0, size - maxBytes)
2430
+ const len = size - start
2431
+ const fd = openSync(path, 'r')
2432
+ try {
2433
+ const buf = Buffer.alloc(len)
2434
+ readSync(fd, buf, 0, len, start)
2435
+ return buf.toString('utf8')
2436
+ } finally { closeSync(fd) }
2437
+ } catch { return '' }
2438
+ }
2439
+
2440
+ // Where the background agent's latest activity lands. mac: launchd redirects
2441
+ // the agent's stdout to launchd.out.log. Windows: Task Scheduler redirects
2442
+ // nothing — the agent's own daily rolling log is the only mirror of its
2443
+ // output. wireDailyLog captures the log path once at process start, so a run
2444
+ // spanning midnight keeps writing to its START day's file; pick the
2445
+ // most-recently-modified dated log rather than today's by name, or a
2446
+ // still-running cross-midnight job would look idle on the dashboard.
2447
+ function agentLogPath(): string {
2448
+ if (!IS_WINDOWS) return join(LOG_DIR, 'launchd.out.log')
2449
+ try {
2450
+ const dated = readdirSync(LOG_DIR)
2451
+ .filter(f => /^\d{4}-\d{2}-\d{2}\.log$/.test(f))
2452
+ .map(f => join(LOG_DIR, f))
2453
+ let newest: string | null = null
2454
+ let newestMs = -Infinity
2455
+ for (const p of dated) {
2456
+ const ms = statSync(p).mtimeMs
2457
+ if (ms > newestMs) { newestMs = ms; newest = p }
2458
+ }
2459
+ return newest ?? dailyLogPath()
2460
+ } catch { return dailyLogPath() }
2461
+ }
2462
+
2463
+ // Is the background scheduler installed at all (any version)? Cheaper cousin
2464
+ // of schedulerIsCurrent(), used for the dashboard's installed/not-installed
2465
+ // pill — mac checks the plist file, Windows must ask schtasks (there is no
2466
+ // file whose existence tracks task registration).
2467
+ async function schedulerInstalledAtAll(): Promise<boolean> {
2468
+ if (IS_WINDOWS) return (await runCommand('schtasks', ['/query', '/tn', TASK_NAME], 10000)).code === 0
2469
+ return existsSync(plistPath())
2470
+ }
2471
+
2472
+ // Background agent snapshot for the dashboard (LaunchAgent / Scheduled Task).
2473
+ async function agentStatus() {
2474
+ const logFile = agentLogPath()
2475
+ let logTail: string[] = []
2476
+ let logAt: string | null = null
2477
+ if (existsSync(logFile)) {
2478
+ try { logAt = statSync(logFile).mtime.toISOString() } catch {}
2479
+ logTail = readLogTail(logFile, 16384).split('\n').map(s => s.trim()).filter(Boolean).slice(-8)
2480
+ }
2481
+ // `scheduler` points at the on-disk scheduler entry for `vn doctor` to show.
2482
+ // mac: the plist IS the registration (its existence == installed). Windows:
2483
+ // the task XML is only the staging file we wrote; registration lives in Task
2484
+ // Scheduler (queried by `installed`), so the XML may lag reality — it's an
2485
+ // inspection aid, not proof of registration.
2486
+ return { installed: await schedulerInstalledAtAll(), scheduler: IS_WINDOWS ? taskXmlPath() : plistPath(), logAt, logTail }
2487
+ }
2488
+
2489
+ // Structured health/config snapshot. Single source for both `vn doctor` (text)
2490
+ // and `vn doctor --json` (consumed by the GUI status dashboard).
2491
+ async function collectDoctor() {
2492
+ const config = getConfig()
2493
+ // pi is a bun-based CLI; cold start (esp. behind a proxy) can take >5s, so
2494
+ // give --version a generous timeout to avoid a false 'missing' on a healthy pi.
2495
+ const piInv = piInvocation(['--version'])
2496
+ const piCheck = await runCommand(piInv.bin, piInv.args, 15000)
2497
+ const ff = await runCommand(ffprobeBin(), ['-version'], 5000)
2498
+ const v = config.volcano
2499
+ const tools = piSummaryTools()
2500
+ return {
2501
+ version: VERSION,
2502
+ bun: process.versions.bun || null,
2503
+ node: process.version,
2504
+ recorder: { dir: config.recordDir, exists: existsSync(config.recordDir) },
2505
+ workspace: config.workspace,
2506
+ volcano: v
2507
+ ? {
2508
+ configured: true as const,
2509
+ auth: 'new-console',
2510
+ resourceId: v.resourceId,
2511
+ tos: { bucket: v.tos.bucket, region: v.tos.region, endpoint: v.tos.endpoint, keep: v.tos.keep, accessKey: !!v.tos.accessKey, secretKey: !!v.tos.secretKey },
2512
+ language: v.language ?? null,
2513
+ }
2514
+ : { configured: false as const },
2515
+ summary: { backend: `pi:${piProviderCandidates().join('→')}`, providers: piProviderCandidates(), model: piCodexModelFor(), thinking: piThinkingLevel(), tools: tools || null, contextDir: tools ? summaryContextDir(config) : null },
2516
+ pi: { bin: piCodexBin(), version: piCheck.code === 0 ? (piCheck.stdout.trim() || piCheck.stderr.trim() || null) : null, available: piCheck.code === 0, auth: piAuthAvailable() },
2517
+ // Outbound proxy for HTTPS endpoints (updater/GitHub): honor the standard
2518
+ // env chain, not just lowercase http_proxy — an https_proxy-only setup must
2519
+ // still route the updater.
2520
+ proxy: { url: process.env.https_proxy || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.HTTP_PROXY || null },
2521
+ identity: { self: config.speakers.self.name || null, aliases: config.speakers.self.aliases, knownCount: config.speakers.known.length },
2522
+ deps: { ffprobe: ff.code === 0 },
2523
+ agent: await agentStatus(),
2524
+ }
2525
+ }
2526
+
2527
+ // Recent processed notes (for the GUI dashboard). Reads the canonical jsonl index.
2528
+ // Parse the agent log tail for the recording being processed right now (if any),
2529
+ // and which pipeline step it's on. Heuristic but cheap.
2530
+ function currentJobFromLog(): { status: 'processing'; name: string; step: string } | null {
2531
+ const tail = readLogTail(agentLogPath(), 8192).split('\n')
2532
+ let name: string | null = null
2533
+ let processing = false
2534
+ let step = 'Preparing'
2535
+ for (const line of tail) {
2536
+ const m = line.match(/voicenote job:\s*(.+?)\s*===/)
2537
+ if (m) { name = m[1]!; processing = true; step = 'Preparing'; continue }
2538
+ if (/✓ Completed|Idle:|ERROR processing/i.test(line)) processing = false
2539
+ if (processing) {
2540
+ if (/Step 3|integrated semantic notes|generate/i.test(line)) step = 'Generating notes'
2541
+ else if (/Step 2|Transcribe|Volcano|transcrib/i.test(line)) step = 'Transcribing'
2542
+ else if (/Step 1|Copy audio/i.test(line)) step = 'Preparing'
2543
+ }
2544
+ }
2545
+ return processing && name ? { status: 'processing', name, step } : null
2546
+ }
2547
+
2548
+ // Unified processing status of recent recordings (for the GUI status board):
2549
+ // the live job (if any) + queued on the recorder (pending) + completed (done) +
2550
+ // transcript-saved-but-notes-failed (summary_failed) + errored (failed, will
2551
+ // auto-retry) + filtered (skipped: too_small/too_short).
2552
+ async function jobsListData(limit: number): Promise<{ items: Json[] }> {
2553
+ const config = getConfig()
2554
+ const statePath = join(config.workspace, '_state', 'processed.json')
2555
+ const state = await readJson<Json>(statePath, { processed_source_ids: {}, skipped_source_ids: {} })
2556
+ // VTR6500 names recordings YYYYMMDDHHMMSS — show that as the recording time;
2557
+ // fall back to the processed/seen timestamp.
2558
+ const recTime = (name: string, at: string | null): string | null => {
2559
+ const m = name.match(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})/)
2560
+ if (m) return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}`
2561
+ return at ? at.slice(0, 16).replace('T', ' ') : null
2562
+ }
2563
+ const items: Json[] = []
2564
+ const live = currentJobFromLog()
2565
+ if (live) items.push({ ...live, title: null, at: null, time: recTime(live.name, null), notes: null })
2566
+ const done: Json[] = []
2567
+ const knownPaths = new Set<string>()
2568
+ for (const [id, e] of Object.entries<any>(state.processed_source_ids || {})) {
2569
+ if (e.source_path) knownPaths.add(e.source_path)
2570
+ const name = basename(e.source_path || id)
2571
+ done.push({ status: isSummaryFailedEntry(e) ? 'summary_failed' : 'done', name, title: e.title ?? null, at: e.processed_at ?? null, time: recTime(name, e.processed_at ?? null), notes: e.final_paths?.notes ?? e.local_paths?.notes ?? null })
2572
+ }
2573
+ for (const [id, e] of Object.entries<any>(state.skipped_source_ids || {})) {
2574
+ if (e.source_path) knownPaths.add(e.source_path)
2575
+ const name = basename(e.source_path || id)
2576
+ const rawReason = String(e.reason || '')
2577
+ const isError = rawReason.startsWith('error')
2578
+ // Filter reasons are machine diagnostics (`too_small:1234<100000`); show a
2579
+ // human label instead. Error strings stay raw — that's the diagnostic.
2580
+ const reason = rawReason.startsWith('too_small') ? 'Recording too small, skipped' : rawReason.startsWith('too_short') ? 'Recording too short, skipped' : rawReason.startsWith('too_old') ? 'Recording too old, skipped' : (e.reason ?? null)
2581
+ done.push({ status: isError ? 'failed' : 'skipped', name, title: null, at: e.seen_at ?? null, time: recTime(name, e.seen_at ?? null), reason, notes: null })
2582
+ }
2583
+ done.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')))
2584
+ // Pending: candidate files on the recorder with no state entry yet. Matched by
2585
+ // source_path (not sourceId) so a status poll doesn't hash every file on the
2586
+ // recorder. ponytail: path match misses a re-recorded same-path file, and the
2587
+ // minDurationSeconds filter is absent (ffprobe per poll is too dear) — a
2588
+ // too-short file shows "pending" until a run flips it to "skipped". Fine for a
2589
+ // status view — the pipeline itself still dedupes by content hash and filters
2590
+ // by duration.
2591
+ const pending: Json[] = []
2592
+ if (existsSync(config.recordDir)) {
2593
+ for await (const file of new Bun.Glob('**/*').scan({ cwd: config.recordDir, absolute: true, dot: true })) {
2594
+ if (!isCandidateFile(file) || knownPaths.has(file)) continue
2595
+ if (config.maxAgeHours > 0 && Date.now() - parseRecordedAt(file).getTime() > config.maxAgeHours * 3600_000) continue
2596
+ const st = await stat(file).catch(() => null)
2597
+ if (!st?.isFile() || st.size < config.minBytes) continue
2598
+ const name = basename(file)
2599
+ pending.push({ status: 'pending', name, title: null, at: null, time: recTime(name, null), notes: null })
2600
+ }
2601
+ // Queue order: oldest first, same sort key as the pipeline (parseRecordedAt).
2602
+ pending.sort((a, b) => parseRecordedAt(String(a.name)).getTime() - parseRecordedAt(String(b.name)).getTime())
2603
+ // Cap the queue so a large backlog can't crowd done/failed out of the limit
2604
+ // window; the overflow collapses into one aggregate row.
2605
+ const PENDING_SHOWN = 10
2606
+ if (pending.length > PENDING_SHOWN) {
2607
+ const extra = pending.length - PENDING_SHOWN
2608
+ pending.length = PENDING_SHOWN
2609
+ pending.push({ status: 'pending', name: `…${extra} more queued`, title: null, at: null, time: null, notes: null })
2610
+ }
2611
+ }
2612
+ // Don't double-list the live job if it's also in pending/done.
2613
+ const liveName = live?.name
2614
+ for (const j of [...pending, ...done]) { if (liveName && j.name === liveName) continue; items.push(j) }
2615
+ return { items: items.slice(0, limit) }
2616
+ }
2617
+
2618
+ async function jobsList(opts: { limit?: number; json?: boolean }): Promise<void> {
2619
+ const data = await jobsListData(Number(opts.limit) || 30)
2620
+ if (opts.json) { console.log(JSON.stringify(data, null, 2)); return }
2621
+ if (!data.items.length) { console.log('No jobs yet.'); return }
2622
+ for (const j of data.items) console.log(`[${j.status}] ${j.title || j.name}${j.step ? ' · ' + j.step : ''}${j.reason ? ' · ' + String(j.reason).slice(0, 120) : ''}`)
2623
+ }
2624
+
2625
+ async function doctor(opts: { json?: boolean } = {}): Promise<void> {
2626
+ const s = await collectDoctor()
2627
+ if (opts.json) { console.log(JSON.stringify(s, null, 2)); return }
2628
+ console.log(`version=${s.version}`)
2629
+ console.log(`bun=${s.bun || 'not-bun'}`)
2630
+ console.log(`node=${s.node}`)
2631
+ console.log(`recordDir=${s.recorder.dir} exists=${s.recorder.exists}`)
2632
+ console.log(`workspace=${s.workspace}`)
2633
+ if (s.volcano.configured) {
2634
+ console.log(`volcano.auth=${s.volcano.auth}`)
2635
+ console.log(`volcano.resourceId=${s.volcano.resourceId}`)
2636
+ console.log(`volcano.tos=bucket:${s.volcano.tos.bucket} region:${s.volcano.tos.region} endpoint:${s.volcano.tos.endpoint} keep:${s.volcano.tos.keep}`)
2637
+ console.log(`volcano.tos.accessKey=${s.volcano.tos.accessKey ? 'loaded' : 'missing'} secretKey=${s.volcano.tos.secretKey ? 'loaded' : 'missing'}`)
2638
+ if (s.volcano.language) console.log(`volcano.language=${s.volcano.language}`)
2639
+ } else {
2640
+ console.log(`volcano=not configured`)
2641
+ }
2642
+ console.log(`summaryBackend=${s.summary.backend}`)
2643
+ console.log(`pi.bin=${s.pi.bin} providers=${s.summary.providers.join(',')} model.summary=${s.summary.model}`)
2644
+ console.log(`pi.thinking=${s.summary.thinking}`)
2645
+ console.log(`pi.summaryTools=${s.summary.tools || '<disabled>'}`)
2646
+ if (s.summary.contextDir) console.log(`pi.contextDir=${s.summary.contextDir} (summary agent cwd + read/grep cross-reference root)`)
2647
+ console.log(`pi.version=${s.pi.version || 'missing'}`)
2648
+ console.log(`pi.auth=${s.pi.auth ? 'logged-in' : 'NOT logged-in — run `vn login` to sign in, else the summary step will fail'}`)
2649
+ console.log(`defaultMode=notes`)
2650
+ console.log(`proxy=${s.proxy.url || '<unset>'}`)
2651
+ console.log(`speakers.self=${s.identity.self || '<unset>'}`)
2652
+ console.log(`speakers.known=${s.identity.knownCount}`)
2653
+ console.log(`scheduler=${s.agent.scheduler}`)
2654
+ console.log(`ffprobe=${s.deps.ffprobe ? 'ok' : 'missing'}`)
2655
+ }
2656
+
2657
+ // ────────────────────────────────────────────────────────────────────────────
2658
+ // serve — persistent JSON-RPC engine over stdio (the desktop GUI's client)
2659
+ // ────────────────────────────────────────────────────────────────────────────
2660
+ // One long-lived process the GUI talks to instead of spawning `vn` per call, so
2661
+ // Bun cold start (and on Windows the AV scan + console flash) is paid ONCE.
2662
+ // Protocol (newline-delimited JSON on stdio):
2663
+ // client→: {"type":"req","id":N,"method":M,"params":P}
2664
+ // →client: {"type":"res","id":N,"result":R} | {"type":"res","id":N,"error":E}
2665
+ // →client: {"type":"event","event":"login-event","payload":{...}} (login stream)
2666
+
2667
+ // Is the background scheduler already installed AND pointing at THIS binary?
2668
+ // (Mirrors what the GUI's ensure_agent used to check in Rust; kept here so the
2669
+ // staleness logic lives in one place.)
2670
+ async function schedulerIsCurrent(): Promise<boolean> {
2671
+ const exe = process.execPath
2672
+ if (IS_WINDOWS) {
2673
+ if ((await runCommand('schtasks', ['/query', '/tn', TASK_NAME], 10000)).code !== 0) return false
2674
+ // The task XML points at wscript; the actual engine path lives in the VBS.
2675
+ try { return readFileSync(taskVbsPath(), 'utf16le').includes(exe) } catch { return false }
2676
+ }
2677
+ try { return readFileSync(plistPath(), 'utf8').includes(exe) } catch { return false }
2678
+ }
2679
+
2680
+ async function ensureScheduler(force: boolean): Promise<{ ok: true; skipped?: boolean }> {
2681
+ if (!force && await schedulerIsCurrent()) return { ok: true, skipped: true }
2682
+ await installScheduler({ load: true })
2683
+ return { ok: true }
2684
+ }
2685
+
2686
+ async function dispatchServe(req: any, send: (o: unknown) => void): Promise<void> {
2687
+ const { id, method, params } = req || {}
2688
+ try {
2689
+ let result: unknown
2690
+ switch (method) {
2691
+ case 'config.get': result = configGetData(); break
2692
+ case 'config.set': result = await configSetData(params || {}); break
2693
+ case 'doctor': result = await collectDoctor(); break
2694
+ case 'jobs': result = await jobsListData(Number(params?.limit) || 40); break
2695
+ case 'ensure_agent': result = await ensureScheduler(!!params?.force); break
2696
+ case 'run': {
2697
+ // Long-running (minutes) like login: ack immediately so the GUI's 60s
2698
+ // request timeout can't misread it as a wedged engine. Progress shows
2699
+ // via the jobs poll; acquireRunLock inside runPipeline dedupes against
2700
+ // the scheduler tick and a double-click.
2701
+ void runPipeline({}).catch(e => console.error('manual run failed:', e?.message || e))
2702
+ result = { started: true }
2703
+ break
2704
+ }
2705
+ case 'login': {
2706
+ // Ack immediately: the OAuth round-trip takes minutes (user in browser),
2707
+ // and the GUI client times requests out after 60s — a long-lived login
2708
+ // response would be misread as a wedged engine. Progress and outcome
2709
+ // ride entirely on login-event; the response carries nothing.
2710
+ void (async () => {
2711
+ let ok = false
2712
+ // Attempt latch: once this attempt settles (timeout or completion),
2713
+ // late events from a still-dangling OAuth flow must not reach the
2714
+ // UI — a stale success/error would clobber a NEWER login attempt's
2715
+ // state (the closed for this attempt has already been sent).
2716
+ let settled = false
2717
+ const sendEvent = (o: Record<string, unknown>) => { if (!settled) send({ type: 'event', event: 'login-event', payload: o }) }
2718
+ try {
2719
+ // Bound the OAuth wait: if the user closes the browser without
2720
+ // authorizing, the callback never arrives and the flow would hang
2721
+ // forever — with the GUI's login button locked until app restart.
2722
+ // True cancellation is not available (the browser flow of
2723
+ // @earendil-works/pi-ai takes no AbortSignal), so on timeout the
2724
+ // abandoned flow keeps running muted (settled latch above). Two
2725
+ // consequences, both surfaced in the timeout message: a LATE
2726
+ // authorization still persists credentials silently (login may
2727
+ // actually have succeeded — hence “refresh to confirm”), and the dangling
2728
+ // localhost callback server may hold its port until serve exits,
2729
+ // so an immediate retry can fail fast with a port-busy error.
2730
+ const timeout = new Promise<never>((_, rej) => {
2731
+ 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)
2732
+ ;(t as any).unref?.()
2733
+ })
2734
+ await Promise.race([
2735
+ loginChatGPT({ json: true, deviceCode: !!params?.deviceCode, emit: (o) => { if (o.event === 'success') ok = true; sendEvent(o) } }),
2736
+ timeout,
2737
+ ])
2738
+ } catch (e: any) {
2739
+ // loginChatGPT handles its own errors; this catches the timeout
2740
+ // above plus anything it lets escape (fail visibly).
2741
+ sendEvent({ event: 'error', message: String(e?.message || e) })
2742
+ }
2743
+ sendEvent({ event: 'closed', code: ok ? 0 : 1 })
2744
+ settled = true
2745
+ })()
2746
+ result = { started: true }
2747
+ break
2748
+ }
2749
+ default: throw new Error(`unknown method: ${method}`)
2750
+ }
2751
+ send({ type: 'res', id, result })
2752
+ } catch (e: any) {
2753
+ send({ type: 'res', id, error: String(e?.message || e) })
2754
+ }
2755
+ }
2756
+
2757
+ async function serve(): Promise<void> {
2758
+ loadEnvConfig()
2759
+ // The protocol owns stdout; route any stray console.log from reused helpers
2760
+ // (e.g. installScheduler) to stderr so it can't corrupt the JSONL stream.
2761
+ console.log = (...args: any[]) => { console.error(...args) }
2762
+ const send = (o: unknown) => process.stdout.write(JSON.stringify(o) + '\n')
2763
+ let buf = ''
2764
+ process.stdin.setEncoding('utf8')
2765
+ process.stdin.on('data', (chunk: string) => {
2766
+ buf += chunk
2767
+ let nl: number
2768
+ while ((nl = buf.indexOf('\n')) >= 0) {
2769
+ const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1)
2770
+ if (!line) continue
2771
+ let req: any
2772
+ try { req = JSON.parse(line) } catch { continue }
2773
+ void dispatchServe(req, send)
2774
+ }
2775
+ })
2776
+ await new Promise<void>((resolve) => { process.stdin.on('end', resolve); process.stdin.on('close', resolve) })
2777
+ }
2778
+
2779
+ // ────────────────────────────────────────────────────────────────────────────
2780
+ // CLI commands
2781
+ // ────────────────────────────────────────────────────────────────────────────
2782
+
2783
+ const cli = cac('vn')
2784
+
2785
+ cli.command('run', 'Scan recorder and process recordings (Volcano ASR + pi-codex notes)')
2786
+ .option('--mode <mode>', 'Output mode: notes (default) | transcript', { default: 'notes' })
2787
+ .option('--latest', 'Only process newest eligible recording')
2788
+ .option('--force', 'Reprocess already processed recordings')
2789
+ .option('--dry-run', 'Do not copy / transcribe / write files')
2790
+ .option('--pdf', 'Also render notes to PDF (only meaningful for --mode notes)')
2791
+ .option('--verbose', 'Print per-file skip details during scan')
2792
+ .action(runPipeline)
2793
+
2794
+ cli.command('list', 'List notes in a month')
2795
+ .option('--month <YYYY-MM>', 'Month to list (default: current month)')
2796
+ .action(listMeetings)
2797
+
2798
+ cli.command('last', 'Print summary of most recent processed recording').action(lastMeeting)
2799
+ cli.command('jobs', 'Show processing status of recordings (live + pending + done + summary_failed + failed + skipped)')
2800
+ .option('--limit <n>', 'How many to list', { default: 30 })
2801
+ .option('--json', 'Output as JSON (for the GUI)')
2802
+ .action((opts: { limit?: number; json?: boolean }) => jobsList(opts))
2803
+
2804
+
2805
+ cli.command('open [target]', 'Open notes dir, config dir (`config`), logs dir (`logs`), or a note matching the slug').action((target?: string) => openTarget(target))
2806
+
2807
+ cli.command('forget <key>', 'Remove a recording from processed/skipped state so it can be reprocessed').action((key: string) => forgetRecording(key))
2808
+
2809
+ cli.command('log', 'Print the daily log (today by default)')
2810
+ .option('--lines <n>', 'How many trailing lines to print', { default: 30 })
2811
+ .option('-f, --follow', 'Follow the log live (tail -F)')
2812
+ .option('--err', 'Also include launchd.err.log')
2813
+ .option('--date <YYYY-MM-DD>', 'Show a specific day instead of today')
2814
+ .action(showLog)
2815
+
2816
+ cli.command('errors', 'Show recent ERROR lines from daily logs').option('--lines <n>', 'How many lines to print', { default: 20 }).action(showErrors)
2817
+
2818
+ cli.command('upgrade', 'Upgrade to the latest published version via bun add -g').action(upgradeSelf)
2819
+
2820
+ cli.command('doctor', 'Check environment')
2821
+ .option('--json', 'Output structured status as JSON (for the GUI)')
2822
+ .action((opts: { json?: boolean }) => doctor(opts))
2823
+ cli.command('serve', 'Run a persistent JSON-RPC engine over stdio (used by the desktop GUI)').action(serve)
2824
+ cli.command('login', 'Sign in to ChatGPT (Codex OAuth) for the pi summary backend')
2825
+ .option('--json', 'Emit machine-readable JSON events (for the GUI client)')
2826
+ .option('--device-code', 'Use the device-code flow instead of the browser callback (needs the ChatGPT security-settings opt-in)')
2827
+ .action((opts: { json?: boolean; deviceCode?: boolean }) => loginChatGPT(opts))
2828
+ cli.command('config <action>', 'Read/write file-based config. action: get (print JSON) | set (write from stdin JSON)')
2829
+ .action((action: string) => {
2830
+ if (action === 'set') return configSet()
2831
+ if (action === 'get') return configGet()
2832
+ console.error(`Unknown config action '${action}'. Use: vn config get | vn config set`)
2833
+ process.exitCode = 1
2834
+ })
2835
+ cli.command('install-launch-agent', 'Install background scheduler (mac LaunchAgent / Windows Task Scheduler)')
2836
+ .option('--load', 'Also (re)load/start it immediately')
2837
+ .action((opts: { load?: boolean }) => installScheduler(opts))
2838
+ cli.command('uninstall-launch-agent', 'Remove the background scheduler').action(uninstallScheduler)
2839
+ cli.command('status', 'Print background scheduler status').action(printSchedulerStatus)
2840
+
2841
+ cli.help()
2842
+ cli.version(VERSION)
2843
+ cli.parse()