@fastagent-sh/voicenote 0.19.0 → 0.21.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 CHANGED
@@ -113,6 +113,8 @@ Optional settings:
113
113
  "VOICENOTE_RECORD_DIR": "/Volumes/VTR6500/RECORD",
114
114
  "VOICENOTE_MAX_AGE_HOURS": "48",
115
115
  "VOICENOTE_PI_BIN": "pi",
116
+ "VOICENOTE_PI_MODEL": "openai-codex/gpt-5.6-sol",
117
+ "PI_CODING_AGENT_DIR": "$HOME/.config/voicenote/pi-agent",
116
118
  "VOICENOTE_PI_THINKING": "high",
117
119
  "VOICENOTE_PI_SUMMARY_TOOLS": "read,grep",
118
120
  "VOICENOTE_CONTEXT_DIR": "/Users/you/vault"
@@ -121,12 +123,29 @@ Optional settings:
121
123
 
122
124
  ### Which model writes the notes
123
125
 
124
- voicenote does not choose one. It runs `pi -p` with no `--provider`/`--model`, so
125
- the provider, model and credentials are whatever pi itself is configured to use
126
- (`pi` → `/login <provider>`, pi's settings, or a provider API key in the
127
- environment). Change the model in pi, not here. There is no fallback to a second
128
- provider: if pi fails, the transcript is kept and the summary can be retried with
129
- `vn run --latest`.
126
+ `VOICENOTE_PI_MODEL` is passed straight to pi as `--model`. pi accepts
127
+ `provider/id`, so one value pins both (`openai-codex/gpt-5.6-sol`). Leave it unset
128
+ and pi's own configured default model is used.
129
+
130
+ Credentials always belong to pi (`pi` → `/login <provider>`, or a provider API key
131
+ in the environment); voicenote never picks a provider and never falls back to a
132
+ second one. If pi fails, the transcript is kept and the summary can be retried
133
+ with `vn run --latest`.
134
+
135
+ ### Credentials of their own
136
+
137
+ `PI_CODING_AGENT_DIR` relocates pi's config directory, which is where it keeps
138
+ `auth.json`. Point it at a voicenote-owned directory and `vn login` writes there,
139
+ pi refreshes the tokens there, and an interactive pi session cannot clobber
140
+ them — it rewrites its own `auth.json` wholesale on exit, which has silently
141
+ dropped providers before:
142
+
143
+ ```bash
144
+ echo '{"env":{"PI_CODING_AGENT_DIR":"$HOME/.config/voicenote/pi-agent"}}' | vn config set
145
+ vn login # signs in and stores credentials in that directory
146
+ ```
147
+
148
+ `vn doctor` prints the path it will read (`pi.auth=...`).
130
149
 
131
150
  `DEEPSEEK_API_KEY` and `OPENAI_API_KEY` in the config are only forwarded to pi's
132
151
  environment for providers that read them.
package/README.zh-CN.md CHANGED
@@ -113,6 +113,8 @@ brew install ffmpeg
113
113
  "VOICENOTE_RECORD_DIR": "/Volumes/VTR6500/RECORD",
114
114
  "VOICENOTE_MAX_AGE_HOURS": "48",
115
115
  "VOICENOTE_PI_BIN": "pi",
116
+ "VOICENOTE_PI_MODEL": "openai-codex/gpt-5.6-sol",
117
+ "PI_CODING_AGENT_DIR": "$HOME/.config/voicenote/pi-agent",
116
118
  "VOICENOTE_PI_THINKING": "high",
117
119
  "VOICENOTE_PI_SUMMARY_TOOLS": "read,grep",
118
120
  "VOICENOTE_CONTEXT_DIR": "/Users/you/vault"
@@ -121,10 +123,26 @@ brew install ffmpeg
121
123
 
122
124
  ### 纪要用哪个模型
123
125
 
124
- voicenote 不选。它执行 `pi -p`, 不传 `--provider`/`--model`, 所以 provider、模型和凭证
125
- 全部来自 pi 自己的配置(`pi` → `/login <provider>`、pi 的设置, 或环境里的 provider
126
- API key)。要换模型就去 pi 里改。也不会回退到第二个 provider: pi 失败时 transcript 会
127
- 保留, 用 `vn run --latest` 重试纪要即可。
126
+ `VOICENOTE_PI_MODEL` 会原样传给 pi 的 `--model`。pi 支持 `provider/id` 写法, 所以
127
+ 一个值就能同时定 provider 和模型(`openai-codex/gpt-5.6-sol`)。不设就用 pi 自己配置
128
+ 的默认模型。
129
+
130
+ 凭证始终属于 pi(`pi` → `/login <provider>`, 或环境里的 provider API key); voicenote
131
+ 不选 provider, 也不会回退到第二个。pi 失败时 transcript 会保留, 用 `vn run --latest`
132
+ 重试纪要即可。
133
+
134
+ ### 给 voicenote 一份独立凭证
135
+
136
+ `PI_CODING_AGENT_DIR` 可以改掉 pi 的配置目录, `auth.json` 就存在那里。指向一个
137
+ voicenote 自己的目录后, `vn login` 写入这里, pi 也在这里刷新 token, 交互式 pi 会话
138
+ 碰不到它 —— 那边退出时会整份覆写自己的 `auth.json`, 之前就把 provider 条目悄悄弄丢过:
139
+
140
+ ```bash
141
+ echo '{"env":{"PI_CODING_AGENT_DIR":"$HOME/.config/voicenote/pi-agent"}}' | vn config set
142
+ vn login # 登录后凭证就存在该目录
143
+ ```
144
+
145
+ `vn doctor` 的 `pi.auth=` 一行会打印实际读取的路径。
128
146
 
129
147
  配置里的 `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` 只是透传给 pi 的环境变量。
130
148
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastagent-sh/voicenote",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Voice recordings → diarized transcripts → integrated semantic Markdown notes. Currently optimized for the PHILIPS VTR6500 recorder, but the workflow is generic.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -12,7 +12,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
12
12
  import { spawn, spawnSync } from 'node:child_process'
13
13
  import os from 'node:os'
14
14
 
15
- const VERSION = '0.19.0'
15
+ const VERSION = '0.21.0'
16
16
  const LAUNCH_AGENT_LABEL = 'sh.fastagent.voicenote'
17
17
  const LAUNCH_AGENT_LABEL_LEGACY = 'com.kid7st.voicenote' // pre-fastagent installs; cleaned up on install
18
18
  const TASK_NAME = 'VoiceNote' // Windows Task Scheduler name (mac uses LAUNCH_AGENT_LABEL)
@@ -31,7 +31,15 @@ const LOG_DIR = join(STATE_DIR, 'logs')
31
31
  const LOCK_PATH = join(STATE_DIR, 'run.lock')
32
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')
34
+ // pi keeps credentials in its config dir, which PI_CODING_AGENT_DIR relocates.
35
+ // Point it at a voicenote-owned directory to get an auth.json that only the
36
+ // pipeline reads and refreshes: an interactive pi session rewrites its own
37
+ // auth.json wholesale on exit and has already dropped entries that way.
38
+ // Resolved per call — the env is hydrated from config.json after module load.
39
+ function piAuthPath(): string {
40
+ const dir = process.env.PI_CODING_AGENT_DIR || join(os.homedir(), '.pi', 'agent')
41
+ return join(expandHome(dir), 'auth.json')
42
+ }
35
43
 
36
44
  const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.wma', '.aac', '.flac'])
37
45
 
@@ -125,7 +133,9 @@ const ENV_KEYS = [
125
133
  'VOLCANO_TOS_KEEP',
126
134
  'VOICENOTE_PI_BIN',
127
135
  'VOICENOTE_PI_CLI',
136
+ 'PI_CODING_AGENT_DIR',
128
137
  'VOICENOTE_FFPROBE_BIN',
138
+ 'VOICENOTE_PI_MODEL',
129
139
  'VOICENOTE_PI_THINKING',
130
140
  'VOICENOTE_PI_SUMMARY_TOOLS',
131
141
  'VOICENOTE_CONTEXT_DIR',
@@ -174,6 +184,14 @@ function systemProxyUrl(): string | null {
174
184
  } catch { return null }
175
185
  }
176
186
 
187
+ // Bun's child_process does NOT hand a child the proxy variables this process set
188
+ // on process.env (http_proxy/https_proxy/no_proxy and their uppercase forms are
189
+ // special-cased internally; only all_proxy survives). Every spawn that must reach
190
+ // the network through the proxy has to pass them explicitly, so record them here.
191
+ // Without this, a scheduler run whose proxy comes from config.json rather than a
192
+ // real shell env leaves pi with no proxy at all — it fails with `fetch failed`.
193
+ const childProxyEnv: Record<string, string> = {}
194
+
177
195
  function applyDerivedProxy(): void {
178
196
  const host = process.env.LOCAL_PROXY_HOST
179
197
  const port = process.env.LOCAL_PROXY_PORT
@@ -190,6 +208,7 @@ function applyDerivedProxy(): void {
190
208
  const needs = (k: string) => !process.env[k] || process.env[k]!.includes('${')
191
209
  for (const k of ['http_proxy', 'https_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY']) {
192
210
  if (needs(k)) { process.env[k] = url; hydratedEnvKeys.add(k) } // derived, not real env
211
+ childProxyEnv[k] = process.env[k]!
193
212
  }
194
213
  }
195
214
  // no_proxy/NO_PROXY: seed a base when a proxy is active, then always merge
@@ -200,6 +219,7 @@ function applyDerivedProxy(): void {
200
219
  for (const k of ['no_proxy', 'NO_PROXY'] as const) {
201
220
  const r = deriveNoProxy(process.env[k], premergeRealNoProxy[k], hydratedEnvKeys.has(k), !!url, baseNoProxy, VOLCANO_NO_PROXY_HOSTS)
202
221
  process.env[k] = r.runtime
222
+ childProxyEnv[k] = r.runtime
203
223
  if (r.hydrate) hydratedEnvKeys.add(k)
204
224
  if (r.capture !== undefined) premergeRealNoProxy[k] = r.capture
205
225
  }
@@ -1308,15 +1328,16 @@ function piInvocation(args: string[]): { bin: string; args: string[] } {
1308
1328
  // ───────────────────────────────────────────────────────────────────────
1309
1329
 
1310
1330
  async function persistPiOAuth(providerId: string, creds: Record<string, unknown>): Promise<void> {
1311
- await mkdir(dirname(PI_AUTH_PATH), { recursive: true })
1331
+ const authPath = piAuthPath()
1332
+ await mkdir(dirname(authPath), { recursive: true })
1312
1333
  let existing: Json = {}
1313
- if (existsSync(PI_AUTH_PATH)) {
1314
- try { existing = JSON.parse(await readFile(PI_AUTH_PATH, 'utf8')) as Json } catch (e) { warnSideEffect(`parse ${PI_AUTH_PATH}`, e) }
1334
+ if (existsSync(authPath)) {
1335
+ try { existing = JSON.parse(await readFile(authPath, 'utf8')) as Json } catch (e) { warnSideEffect(`parse ${authPath}`, e) }
1315
1336
  }
1316
1337
  existing[providerId] = { type: 'oauth', ...creds }
1317
- const tmp = `${PI_AUTH_PATH}.tmp-${process.pid}`
1338
+ const tmp = `${authPath}.tmp-${process.pid}`
1318
1339
  await writeFile(tmp, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 })
1319
- await rename(tmp, PI_AUTH_PATH)
1340
+ await rename(tmp, authPath)
1320
1341
  }
1321
1342
 
1322
1343
  async function loginChatGPT(opts: { json?: boolean; deviceCode?: boolean; emit?: (o: Record<string, unknown>) => void }): Promise<void> {
@@ -1366,7 +1387,7 @@ async function loginChatGPT(opts: { json?: boolean; deviceCode?: boolean; emit?:
1366
1387
  }
1367
1388
  await persistPiOAuth(oauth.openaiCodexOAuthProvider.id, creds)
1368
1389
  if (json) emit({ event: 'success', provider: oauth.openaiCodexOAuthProvider.id })
1369
- else console.log(`\n✓ Signed in. Credentials saved to ${PI_AUTH_PATH}. Verify with: vn doctor`)
1390
+ else console.log(`\n✓ Signed in. Credentials saved to ${piAuthPath()}. Verify with: vn doctor`)
1370
1391
  } catch (e: any) {
1371
1392
  let message = String(e?.message || e)
1372
1393
  if (/unsupported_country_region_territory|\b403\b/.test(message)) {
@@ -1503,20 +1524,23 @@ async function runPi(opts: {
1503
1524
  appendSystemPrompt?: string
1504
1525
  cwd?: string // agent working dir: the knowledge base, so read/grep/find default there
1505
1526
  }): Promise<string> {
1506
- // No --provider/--model: pi's own configuration picks the model and credentials.
1507
1527
  const args = [
1508
1528
  '-p',
1509
1529
  '--mode', 'text',
1510
1530
  '--no-extensions', '--no-skills', '--no-context-files', '--no-session', '--no-prompt-templates', '--no-themes',
1511
1531
  '--system-prompt', opts.systemPrompt,
1512
1532
  ]
1533
+ // Unset means pi's own default model and provider. There is no second
1534
+ // provider to fall back to either way.
1535
+ const model = piSummaryModel()
1536
+ if (model) args.push('--model', model)
1513
1537
  if (opts.thinking) args.push('--thinking', opts.thinking)
1514
1538
  if (opts.tools && opts.tools.trim()) args.push('--tools', opts.tools.trim())
1515
1539
  else args.push('--no-tools')
1516
1540
  if (opts.appendSystemPrompt) args.push('--append-system-prompt', opts.appendSystemPrompt)
1517
1541
  return new Promise<string>((resolve, reject) => {
1518
1542
  const inv = piInvocation(args)
1519
- const child = spawn(inv.bin, inv.args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: opts.cwd, windowsHide: true })
1543
+ const child = spawn(inv.bin, inv.args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: opts.cwd, windowsHide: true, env: { ...process.env, ...childProxyEnv } })
1520
1544
  let stdout = '', stderr = ''
1521
1545
  const timer = opts.timeoutMs ? setTimeout(() => child.kill('SIGKILL'), opts.timeoutMs) : null
1522
1546
  child.stdout.on('data', d => stdout += String(d))
@@ -1562,6 +1586,12 @@ async function chatCompleteViaPi(opts: Parameters<typeof runPi>[0]): Promise<str
1562
1586
  }
1563
1587
  }
1564
1588
 
1589
+ // pi's --model accepts "provider/id" (e.g. openai-codex/gpt-5.6-sol), so this one
1590
+ // setting pins both. Empty/unset = whatever pi is configured to use.
1591
+ function piSummaryModel(): string {
1592
+ return (process.env.VOICENOTE_PI_MODEL || '').trim()
1593
+ }
1594
+
1565
1595
  function piThinkingLevel(): string {
1566
1596
  return process.env.VOICENOTE_PI_THINKING || 'high'
1567
1597
  }
@@ -1758,7 +1788,7 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1758
1788
 
1759
1789
  let summaryError: any = null
1760
1790
  if (needsNotes) {
1761
- progressStep(nextStep(), totalSteps, 'Generate integrated semantic notes', 'via pi (pi\'s own provider/model config)')
1791
+ progressStep(nextStep(), totalSteps, 'Generate integrated semantic notes', `via pi, model=${piSummaryModel() || "pi's own default"}`)
1762
1792
  try {
1763
1793
  meta = await withHeartbeat('generate integrated semantic notes', () => summarizeTranscript(config, transcript, rec, files.audio), 60)
1764
1794
  } catch (e: any) {
@@ -2688,8 +2718,8 @@ async function collectDoctor() {
2688
2718
  : { configured: false as const },
2689
2719
  // Provider/model/credentials are pi's own configuration; `pi.available` is
2690
2720
  // all we can honestly report about whether a summary can run.
2691
- summary: { backend: 'pi', thinking: piThinkingLevel(), tools: tools || null, contextDir: tools ? summaryContextDir(config) : null },
2692
- pi: { bin: piCodexBin(), version: piCheck.code === 0 ? (piCheck.stdout.trim() || piCheck.stderr.trim() || null) : null, available: piCheck.code === 0, auth: existsSync(PI_AUTH_PATH) },
2721
+ summary: { backend: 'pi', model: piSummaryModel() || null, thinking: piThinkingLevel(), tools: tools || null, contextDir: tools ? summaryContextDir(config) : null },
2722
+ pi: { bin: piCodexBin(), version: piCheck.code === 0 ? (piCheck.stdout.trim() || piCheck.stderr.trim() || null) : null, available: piCheck.code === 0, auth: existsSync(piAuthPath()), authPath: piAuthPath() },
2693
2723
  // Outbound proxy for HTTPS endpoints (updater/GitHub): honor the standard
2694
2724
  // env chain, not just lowercase http_proxy — an https_proxy-only setup must
2695
2725
  // still route the updater.
@@ -2755,15 +2785,15 @@ async function doctor(opts: { json?: boolean } = {}): Promise<void> {
2755
2785
  } else {
2756
2786
  console.log(`volcano=not configured`)
2757
2787
  }
2758
- console.log(`summaryBackend=${s.summary.backend} (provider/model come from pi's own config)`)
2759
- console.log(`pi.bin=${s.pi.bin}`)
2788
+ console.log(`summaryBackend=${s.summary.backend}`)
2789
+ console.log(`pi.bin=${s.pi.bin} model=${s.summary.model || "<pi's own default>"}`)
2760
2790
  console.log(`pi.thinking=${s.summary.thinking}`)
2761
2791
  console.log(`pi.summaryTools=${s.summary.tools || '<disabled>'}`)
2762
2792
  if (s.summary.contextDir) console.log(`pi.contextDir=${s.summary.contextDir} (summary agent cwd + read/grep cross-reference root)`)
2763
2793
  console.log(`pi.version=${s.pi.version || 'missing'}`)
2764
2794
  // Neutral fact, not an instruction: an API-key user has no auth.json and needs
2765
2795
  // nothing fixed.
2766
- console.log(`pi.auth=${s.pi.auth ? 'logged-in (auth.json present)' : 'no ~/.pi/agent/auth.json (fine if a provider API key is set)'}`)
2796
+ console.log(`pi.auth=${s.pi.authPath} ${s.pi.auth ? '(present)' : '(missing — fine if a provider API key is set)'}`)
2767
2797
  console.log(`defaultMode=notes`)
2768
2798
  console.log(`proxy=${s.proxy.url || '<unset>'}`)
2769
2799
  console.log(`speakers.self=${s.identity.self || '<unset>'}`)