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