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