@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/LICENSE +21 -0
- package/README.md +351 -0
- package/README.zh-CN.md +351 -0
- package/package.json +58 -0
- package/src/cli.ts +2843 -0
- package/src/envConfig.ts +110 -0
- package/src/runLock.ts +20 -0
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
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()
|