@fastagent-sh/voicenote 0.20.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
@@ -114,6 +114,7 @@ Optional settings:
114
114
  "VOICENOTE_MAX_AGE_HOURS": "48",
115
115
  "VOICENOTE_PI_BIN": "pi",
116
116
  "VOICENOTE_PI_MODEL": "openai-codex/gpt-5.6-sol",
117
+ "PI_CODING_AGENT_DIR": "$HOME/.config/voicenote/pi-agent",
117
118
  "VOICENOTE_PI_THINKING": "high",
118
119
  "VOICENOTE_PI_SUMMARY_TOOLS": "read,grep",
119
120
  "VOICENOTE_CONTEXT_DIR": "/Users/you/vault"
@@ -131,6 +132,21 @@ in the environment); voicenote never picks a provider and never falls back to a
131
132
  second one. If pi fails, the transcript is kept and the summary can be retried
132
133
  with `vn run --latest`.
133
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=...`).
149
+
134
150
  `DEEPSEEK_API_KEY` and `OPENAI_API_KEY` in the config are only forwarded to pi's
135
151
  environment for providers that read them.
136
152
 
package/README.zh-CN.md CHANGED
@@ -114,6 +114,7 @@ brew install ffmpeg
114
114
  "VOICENOTE_MAX_AGE_HOURS": "48",
115
115
  "VOICENOTE_PI_BIN": "pi",
116
116
  "VOICENOTE_PI_MODEL": "openai-codex/gpt-5.6-sol",
117
+ "PI_CODING_AGENT_DIR": "$HOME/.config/voicenote/pi-agent",
117
118
  "VOICENOTE_PI_THINKING": "high",
118
119
  "VOICENOTE_PI_SUMMARY_TOOLS": "read,grep",
119
120
  "VOICENOTE_CONTEXT_DIR": "/Users/you/vault"
@@ -130,6 +131,19 @@ brew install ffmpeg
130
131
  不选 provider, 也不会回退到第二个。pi 失败时 transcript 会保留, 用 `vn run --latest`
131
132
  重试纪要即可。
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=` 一行会打印实际读取的路径。
146
+
133
147
  配置里的 `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` 只是透传给 pi 的环境变量。
134
148
 
135
149
  瞬时性失败(断连、5xx、429)会在同一个 provider 上重试, 次数由 `VOICENOTE_PI_RETRIES`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastagent-sh/voicenote",
3
- "version": "0.20.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.20.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,6 +133,7 @@ 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',
129
138
  'VOICENOTE_PI_MODEL',
130
139
  'VOICENOTE_PI_THINKING',
@@ -175,6 +184,14 @@ function systemProxyUrl(): string | null {
175
184
  } catch { return null }
176
185
  }
177
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
+
178
195
  function applyDerivedProxy(): void {
179
196
  const host = process.env.LOCAL_PROXY_HOST
180
197
  const port = process.env.LOCAL_PROXY_PORT
@@ -191,6 +208,7 @@ function applyDerivedProxy(): void {
191
208
  const needs = (k: string) => !process.env[k] || process.env[k]!.includes('${')
192
209
  for (const k of ['http_proxy', 'https_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY']) {
193
210
  if (needs(k)) { process.env[k] = url; hydratedEnvKeys.add(k) } // derived, not real env
211
+ childProxyEnv[k] = process.env[k]!
194
212
  }
195
213
  }
196
214
  // no_proxy/NO_PROXY: seed a base when a proxy is active, then always merge
@@ -201,6 +219,7 @@ function applyDerivedProxy(): void {
201
219
  for (const k of ['no_proxy', 'NO_PROXY'] as const) {
202
220
  const r = deriveNoProxy(process.env[k], premergeRealNoProxy[k], hydratedEnvKeys.has(k), !!url, baseNoProxy, VOLCANO_NO_PROXY_HOSTS)
203
221
  process.env[k] = r.runtime
222
+ childProxyEnv[k] = r.runtime
204
223
  if (r.hydrate) hydratedEnvKeys.add(k)
205
224
  if (r.capture !== undefined) premergeRealNoProxy[k] = r.capture
206
225
  }
@@ -1309,15 +1328,16 @@ function piInvocation(args: string[]): { bin: string; args: string[] } {
1309
1328
  // ───────────────────────────────────────────────────────────────────────
1310
1329
 
1311
1330
  async function persistPiOAuth(providerId: string, creds: Record<string, unknown>): Promise<void> {
1312
- await mkdir(dirname(PI_AUTH_PATH), { recursive: true })
1331
+ const authPath = piAuthPath()
1332
+ await mkdir(dirname(authPath), { recursive: true })
1313
1333
  let existing: Json = {}
1314
- if (existsSync(PI_AUTH_PATH)) {
1315
- 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) }
1316
1336
  }
1317
1337
  existing[providerId] = { type: 'oauth', ...creds }
1318
- const tmp = `${PI_AUTH_PATH}.tmp-${process.pid}`
1338
+ const tmp = `${authPath}.tmp-${process.pid}`
1319
1339
  await writeFile(tmp, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 })
1320
- await rename(tmp, PI_AUTH_PATH)
1340
+ await rename(tmp, authPath)
1321
1341
  }
1322
1342
 
1323
1343
  async function loginChatGPT(opts: { json?: boolean; deviceCode?: boolean; emit?: (o: Record<string, unknown>) => void }): Promise<void> {
@@ -1367,7 +1387,7 @@ async function loginChatGPT(opts: { json?: boolean; deviceCode?: boolean; emit?:
1367
1387
  }
1368
1388
  await persistPiOAuth(oauth.openaiCodexOAuthProvider.id, creds)
1369
1389
  if (json) emit({ event: 'success', provider: oauth.openaiCodexOAuthProvider.id })
1370
- 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`)
1371
1391
  } catch (e: any) {
1372
1392
  let message = String(e?.message || e)
1373
1393
  if (/unsupported_country_region_territory|\b403\b/.test(message)) {
@@ -1520,7 +1540,7 @@ async function runPi(opts: {
1520
1540
  if (opts.appendSystemPrompt) args.push('--append-system-prompt', opts.appendSystemPrompt)
1521
1541
  return new Promise<string>((resolve, reject) => {
1522
1542
  const inv = piInvocation(args)
1523
- 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 } })
1524
1544
  let stdout = '', stderr = ''
1525
1545
  const timer = opts.timeoutMs ? setTimeout(() => child.kill('SIGKILL'), opts.timeoutMs) : null
1526
1546
  child.stdout.on('data', d => stdout += String(d))
@@ -2699,7 +2719,7 @@ async function collectDoctor() {
2699
2719
  // Provider/model/credentials are pi's own configuration; `pi.available` is
2700
2720
  // all we can honestly report about whether a summary can run.
2701
2721
  summary: { backend: 'pi', model: piSummaryModel() || null, thinking: piThinkingLevel(), tools: tools || null, contextDir: tools ? summaryContextDir(config) : null },
2702
- pi: { bin: piCodexBin(), version: piCheck.code === 0 ? (piCheck.stdout.trim() || piCheck.stderr.trim() || null) : null, available: piCheck.code === 0, auth: existsSync(PI_AUTH_PATH) },
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() },
2703
2723
  // Outbound proxy for HTTPS endpoints (updater/GitHub): honor the standard
2704
2724
  // env chain, not just lowercase http_proxy — an https_proxy-only setup must
2705
2725
  // still route the updater.
@@ -2773,7 +2793,7 @@ async function doctor(opts: { json?: boolean } = {}): Promise<void> {
2773
2793
  console.log(`pi.version=${s.pi.version || 'missing'}`)
2774
2794
  // Neutral fact, not an instruction: an API-key user has no auth.json and needs
2775
2795
  // nothing fixed.
2776
- 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)'}`)
2777
2797
  console.log(`defaultMode=notes`)
2778
2798
  console.log(`proxy=${s.proxy.url || '<unset>'}`)
2779
2799
  console.log(`speakers.self=${s.identity.self || '<unset>'}`)