@fastagent-sh/voicenote 0.18.7 → 0.20.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
@@ -78,7 +78,7 @@ setx VOICENOTE_RECORD_DIR "E:\RECORD"
78
78
  ## Dependencies
79
79
 
80
80
  - **Bun >= 1.3 (required at runtime)** — the code uses `Bun.Glob` / `Bun.file`; plain Node cannot run it
81
- - Node / npm — only used to install the pi CLI (pi-codex backend)
81
+ - Node / npm — only used to install the pi CLI (the notes backend)
82
82
  - ffmpeg / ffprobe (audio duration detection):
83
83
 
84
84
  ```bash
@@ -113,50 +113,36 @@ 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_PROVIDER": "openai-codex",
117
- "VOICENOTE_PI_MODEL": "gpt-5.5",
116
+ "VOICENOTE_PI_MODEL": "openai-codex/gpt-5.6-sol",
118
117
  "VOICENOTE_PI_THINKING": "high",
119
118
  "VOICENOTE_PI_SUMMARY_TOOLS": "read,grep",
120
119
  "VOICENOTE_CONTEXT_DIR": "/Users/you/vault"
121
120
  }
122
121
  ```
123
122
 
124
- `VOICENOTE_PI_PROVIDER` is a fallback chain, tried left to right; it defaults to
125
- `openai-codex` alone. Add the paid API path explicitly (`openai-codex,openai`) if
126
- you keep an OpenAI key around. Credentials are
127
- resolved by `pi auth check` (covering OAuth, keys stored by `pi` → `/login`, and
128
- each provider's own API-key env var). A provider that deterministically cannot
129
- work — no credentials, or not a provider pi knows — is dropped from the chain,
130
- because its inevitable "No API key found" would replace the real error from the
131
- provider that actually failed. When a chain still fails everywhere, the reported
132
- error lists every provider's failure, so a fallback's missing key never hides why
133
- the first provider broke. If pruning empties the chain, `vn run --mode notes`
134
- skips instead of paying for a transcript whose summary cannot happen.
135
- `vn doctor` prints the effective chain and the status of anything not ready.
123
+ ### Which model writes the notes
136
124
 
137
- ### DeepSeek notes
125
+ `VOICENOTE_PI_MODEL` is passed straight to pi as `--model`. pi accepts
126
+ `provider/id`, so one value pins both (`openai-codex/gpt-5.6-sol`). Leave it unset
127
+ and pi's own configured default model is used.
138
128
 
139
- In the desktop app, open **Settings → Notes generation**, select **DeepSeek API**, enter your API key, and save. The model defaults to `deepseek-v4-flash`; enter `deepseek-v4-pro` to use Pro. ChatGPT sign-in is only shown for configurations that use ChatGPT. Audio transcription continues to use Volcano.
129
+ Credentials always belong to pi (`pi` → `/login <provider>`, or a provider API key
130
+ in the environment); voicenote never picks a provider and never falls back to a
131
+ second one. If pi fails, the transcript is kept and the summary can be retried
132
+ with `vn run --latest`.
140
133
 
141
- For the CLI, merge these values into `~/.config/voicenote/config.json`:
134
+ `DEEPSEEK_API_KEY` and `OPENAI_API_KEY` in the config are only forwarded to pi's
135
+ environment for providers that read them.
142
136
 
143
- ```json
144
- {
145
- "VOICENOTE_PI_PROVIDER": "deepseek",
146
- "VOICENOTE_PI_MODEL_SUMMARY": "deepseek-v4-flash",
147
- "DEEPSEEK_API_KEY": "..."
148
- }
149
- ```
150
-
151
- The notes-specific model setting takes precedence over `VOICENOTE_PI_MODEL`. When switching providers in the GUI, the model resets to that provider's default. API keys can also come from pi's authentication file or the environment; pi's authentication file takes priority over API-key environment variables.
152
-
153
- Save changes, then check `vn doctor` or the app's Status panel. Credential checks confirm configuration, not API connectivity or account balance. Each provider in a fallback chain receives the same model ID, so configure DeepSeek on its own rather than mixing it with OpenAI.
137
+ Transient failures (dropped socket, 5xx, 429) are retried on the same provider up
138
+ to `VOICENOTE_PI_RETRIES` times (default 3). Quota and auth errors are not
139
+ retried.
154
140
 
155
141
  ## Usage
156
142
 
157
143
  ```bash
158
144
  vn doctor # check environment and config
159
- vn run # default: Volcano ASR + pi-codex notes
145
+ vn run # default: Volcano ASR + pi notes
160
146
  vn run --mode transcript # transcript only, skip semantic notes
161
147
  vn run --latest # process only the latest valid recording
162
148
  vn run --latest --force # re-run the latest one
@@ -284,7 +270,7 @@ A self-contained macOS `.app` (Tauri v2) for **non-terminal users**: the target
284
270
 
285
271
  **Positioning**: the GUI is only a "status dashboard + quick access to output" — it does **not** drive processing. The full pipeline runs autonomously every 60s via the background LaunchAgent using the bundled engine (it keeps running with the GUI closed).
286
272
 
287
- - First run: settings (identity / Volcano keys / notes provider / proxy). Choose DeepSeek with an API key, or ChatGPT with browser sign-in (`vn login`'s browser-callback flow).
273
+ - First run: settings (identity / Volcano keys / proxy). The notes model comes from pi; ChatGPT users can sign in from the Status panel (`vn login`'s browser-callback flow).
288
274
  - After that: the main view shows agent activity + recent notes (open note / open folder)
289
275
 
290
276
  ### What's bundled
@@ -342,7 +328,7 @@ irm https://raw.githubusercontent.com/fastagent-sh/voicenote/main/scripts/instal
342
328
 
343
329
  `install-app.sh` downloads the packaged `.app` from GitHub Releases → installs to `/Applications` → **removes the quarantine flag for the user** (Gatekeeper bypass for un-notarized builds) → opens it. The target machine needs no bun/pi/ffprobe/global vn (all bundled).
344
330
 
345
- **First launch**: the app lands on Settings. Fill in identity, your Volcano ASR/TOS keys, notes provider/model, and proxy as needed. For DeepSeek or OpenAI API, enter the corresponding API key; for ChatGPT, save and click "Sign in to ChatGPT" in the Status panel. Saving installs and loads the background LaunchAgent using the bundled engine. Once credentials are configured, plug in the recorder for automatic transcription and notes.
331
+ **First launch**: the app lands on Settings. Fill in identity, your Volcano ASR/TOS keys, and proxy as needed. Notes are written by pi with pi's own provider and model; for ChatGPT, click "Sign in to ChatGPT" in the Status panel. Saving installs and loads the background LaunchAgent using the bundled engine. Once credentials are configured, plug in the recorder for automatic transcription and notes.
346
332
 
347
333
  > The background agent label is `sh.fastagent.voicenote` (same as the CLI version; only one exists per machine). If the `.app` is moved, open it once to recalibrate the plist.
348
334
 
package/README.zh-CN.md CHANGED
@@ -78,7 +78,7 @@ setx VOICENOTE_RECORD_DIR "E:\RECORD"
78
78
  ## 依赖
79
79
 
80
80
  - **Bun >= 1.3(运行时必需)** -- 代码用到 `Bun.Glob` / `Bun.file`,纯 Node 无法运行
81
- - Node / npm -- 仅用于安装 pi CLI(pi-codex 后端)
81
+ - Node / npm -- 仅用于安装 pi CLI(纪要后端)
82
82
  - ffmpeg / ffprobe(音频时长检测):
83
83
 
84
84
  ```bash
@@ -113,28 +113,33 @@ 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_PROVIDER": "openai-codex",
117
- "VOICENOTE_PI_MODEL": "gpt-5.5",
116
+ "VOICENOTE_PI_MODEL": "openai-codex/gpt-5.6-sol",
118
117
  "VOICENOTE_PI_THINKING": "high",
119
118
  "VOICENOTE_PI_SUMMARY_TOOLS": "read,grep",
120
119
  "VOICENOTE_CONTEXT_DIR": "/Users/you/vault"
121
120
  }
122
121
  ```
123
122
 
124
- `VOICENOTE_PI_PROVIDER` 是从左到右尝试的回退链, 默认只有 `openai-codex`。如果你确实配了
125
- OpenAI API key, 可以显式写成 `openai-codex,openai`。凭证由 `pi auth check` 判定(覆盖
126
- OAuth、`pi` → `/login` 存下的 key、以及各 provider 自己的 API key 环境变量)。
127
- 确定不可能工作的 provider(没凭证, 或 pi 根本不认识这个名字)会被剔除 —— 否则
128
- 它必然抛出的 "No API key found" 会覆盖掉真正失败的那个 provider 的错误。整条链都失败时,
129
- 报错会列出每个 provider 的失败原因, 回退项缺 key 不会再掩盖第一个 provider 的真正错误。
130
- 若剪枝后链变空, `vn run --mode notes` 会直接跳过, 而不是花钱转写一个注定无法生成纪要的录音。
131
- 实际生效的链和非 ready 项的状态由 `vn doctor` 打印。
123
+ ### 纪要用哪个模型
124
+
125
+ `VOICENOTE_PI_MODEL` 会原样传给 pi 的 `--model`。pi 支持 `provider/id` 写法, 所以
126
+ 一个值就能同时定 provider 和模型(`openai-codex/gpt-5.6-sol`)。不设就用 pi 自己配置
127
+ 的默认模型。
128
+
129
+ 凭证始终属于 pi(`pi` → `/login <provider>`, 或环境里的 provider API key); voicenote
130
+ 不选 provider, 也不会回退到第二个。pi 失败时 transcript 会保留, 用 `vn run --latest`
131
+ 重试纪要即可。
132
+
133
+ 配置里的 `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` 只是透传给 pi 的环境变量。
134
+
135
+ 瞬时性失败(断连、5xx、429)会在同一个 provider 上重试, 次数由 `VOICENOTE_PI_RETRIES`
136
+ 控制(默认 3)。额度和鉴权错误不重试。
132
137
 
133
138
  ## 用法
134
139
 
135
140
  ```bash
136
141
  vn doctor # 检查环境与配置
137
- vn run # 默认:Volcano ASR + pi-codex 纪要
142
+ vn run # 默认:Volcano ASR + pi 纪要
138
143
  vn run --mode transcript # 只生成 transcript,跳过语义整理
139
144
  vn run --latest # 只处理最新有效录音
140
145
  vn run --latest --force # 重跑最新条
@@ -186,7 +191,7 @@ vn uninstall-launch-agent
186
191
  3. 复制原始音频到 `${VOICENOTE_WORKSPACE}/_audio/YYYY-MM/`
187
192
  4. 转写:火山豆包【大模型录音文件识别标准版 API】,本地音频先传到 TOS,提交任务后轮询结果,完成后默认删除 TOS 对象
188
193
  5. 转写完成后立刻落盘原始 transcript(不做 lossy 清洗),避免后面步骤失败导致 ASR 费用白付
189
- 6. summary 模型(默认 pi codex 走 ChatGPT Plus)直接看原始 transcript,在纪要生成阶段内部完成必要清理、说话人还原、观点/争论/共识形成过程还原;如果 summary 失败,下一次 `vn run` / `vn run --latest` 会复用已保存 transcript,直接重试纪要生成,不需要 `vn forget`
194
+ 6. summary 模型(由 pi 自身配置决定)直接看原始 transcript,在纪要生成阶段内部完成必要清理、说话人还原、观点/争论/共识形成过程还原;如果 summary 失败,下一次 `vn run` / `vn run --latest` 会复用已保存 transcript,直接重试纪要生成,不需要 `vn forget`
190
195
  7. 写出 notes / metadata;系统不做任何归档决定,文件留在配置的 workspace 中
191
196
 
192
197
  失败的录音会在后续运行中重试,但**最多 3 次**(转写失败、纪要失败、以及被中途 kill 的运行都算)。超过后标记为 `Gave up` 并不再自动重试,避免一个坏文件每个调度周期都烧一次 ASR/LLM 额度 —— `vn forget <name>` 会删掉该记录并重新入队。重新入队不等于重新转写:磁盘上已有 transcript 时会直接复用,所以 `vn forget` 不会让你再付一次 ASR。(`vn forget` 需要 run lock,因此在某次 run 进行中时会拒绝执行 —— 等该次 run 结束后重试即可。)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastagent-sh/voicenote",
3
- "version": "0.18.7",
3
+ "version": "0.20.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",
@@ -31,7 +31,6 @@
31
31
  "src/envConfig.ts",
32
32
  "src/jobs.ts",
33
33
  "src/runLock.ts",
34
- "src/piProvider.ts",
35
34
  "README.md",
36
35
  "LICENSE"
37
36
  ],
package/src/cli.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  import { cac } from 'cac'
3
3
  import { deriveNoProxy, envKeysToEmbed, hydrateFromFileEnv, parseFileEnv } from './envConfig'
4
4
  import { parseLockOwner } from './runLock'
5
- import { defaultPiModel, parsePiAuthStatus, parseProviderChain, usableChain, type PiAuthStatus } from './piProvider'
6
5
  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'
7
6
  import { createHash, createHmac, randomUUID } from 'node:crypto'
8
7
  import { appendFile, chmod, mkdir, readFile, writeFile, copyFile, rename, unlink, stat, readdir, rm } from 'node:fs/promises'
@@ -13,7 +12,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
13
12
  import { spawn, spawnSync } from 'node:child_process'
14
13
  import os from 'node:os'
15
14
 
16
- const VERSION = '0.18.7'
15
+ const VERSION = '0.20.0'
17
16
  const LAUNCH_AGENT_LABEL = 'sh.fastagent.voicenote'
18
17
  const LAUNCH_AGENT_LABEL_LEGACY = 'com.kid7st.voicenote' // pre-fastagent installs; cleaned up on install
19
18
  const TASK_NAME = 'VoiceNote' // Windows Task Scheduler name (mac uses LAUNCH_AGENT_LABEL)
@@ -127,9 +126,7 @@ const ENV_KEYS = [
127
126
  'VOICENOTE_PI_BIN',
128
127
  'VOICENOTE_PI_CLI',
129
128
  'VOICENOTE_FFPROBE_BIN',
130
- 'VOICENOTE_PI_PROVIDER',
131
129
  'VOICENOTE_PI_MODEL',
132
- 'VOICENOTE_PI_MODEL_SUMMARY',
133
130
  'VOICENOTE_PI_THINKING',
134
131
  'VOICENOTE_PI_SUMMARY_TOOLS',
135
132
  'VOICENOTE_CONTEXT_DIR',
@@ -1135,7 +1132,7 @@ async function volcanoTranscribeAudio(volc: VolcanoConfig, audioPath: string, re
1135
1132
  if (queryFailures >= 10) throw new Error(`Volcano query failed ${queryFailures}x in a row: ${desc}`)
1136
1133
  if (Date.now() - started > maxWaitMs) throw new Error(`Volcano: timeout after ${formatElapsed(Date.now() - started)} (last error: ${desc})`)
1137
1134
  // console.error (not log) so wireDailyLog tags it [ERROR] and `vn errors`
1138
- // surfaces it — matching chatCompleteViaPiCodex's transient-retry logging.
1135
+ // surfaces it — matching chatCompleteViaPi's transient-retry logging.
1139
1136
  // A repeatedly-near-threshold ASR wobble is exactly what ops wants to see.
1140
1137
  console.error(`… Volcano: transient query failure (attempt ${queryFailures}/10, will retry): ${desc}`)
1141
1138
  }
@@ -1280,7 +1277,9 @@ ${transcript}`
1280
1277
  }
1281
1278
 
1282
1279
  // ───────────────────────────────────────────────────────────────────────
1283
- // Summary via pi (pi-codex — ChatGPT Plus/Pro OAuth, no OpenAI API key)
1280
+ // Summary via pi. Provider, model and credentials are pi's own configuration:
1281
+ // we invoke `pi -p` with no --provider/--model and never fall back elsewhere,
1282
+ // so whatever the user selected in pi is what writes the notes.
1284
1283
  // ───────────────────────────────────────────────────────────────────────
1285
1284
 
1286
1285
  function piCodexBin(): string {
@@ -1300,21 +1299,6 @@ function piInvocation(args: string[]): { bin: string; args: string[] } {
1300
1299
  return cli ? { bin, args: [cli, ...args] } : { bin, args }
1301
1300
  }
1302
1301
 
1303
- // Gate before ASR is spent. "May work", not "will": only a chain pruned to
1304
- // nothing (every provider deterministically unusable) stops a run — see
1305
- // usableChain for why an unanswerable probe does not.
1306
- function summaryMayWork(): boolean {
1307
- return piProviderCandidates().length > 0
1308
- }
1309
-
1310
- // The one diagnostic for an empty chain, shared by the pre-ASR gate and the
1311
- // summary call itself. Both must name the same cause: a generic "exhausted" here
1312
- // would overwrite the truth exactly the way "No API key found for openai" did.
1313
- function noUsableProviderMessage(): string {
1314
- const statuses = piConfiguredProviders().map(p => `${p}:${piProviderAuthStatus(p)}`).join(' ')
1315
- return `no usable summary provider (${statuses}) — each has no credentials, or is not a provider pi knows. Sign in (\`vn login\` for openai-codex, else \`pi\` → \`/login <provider>\` or its API key), or fix VOICENOTE_PI_PROVIDER.`
1316
- }
1317
-
1318
1302
  // ───────────────────────────────────────────────────────────────────────
1319
1303
  // ChatGPT (OpenAI Codex) OAuth login — headless device-code flow.
1320
1304
  // Today the only way to authenticate the pi summary backend is to open pi's
@@ -1484,57 +1468,6 @@ async function configSet(): Promise<void> {
1484
1468
  }
1485
1469
  }
1486
1470
 
1487
- // A healthy probe takes ~0.5s and the chain is re-read many times per run, so
1488
- // cache it — but briefly: `vn serve` is long-lived, and the answer changes out of
1489
- // band when the user runs `pi` → `/login` elsewhere, or an OAuth token expires.
1490
- const PI_AUTH_TTL_MS = 60_000
1491
- // Refresh is deliberately left on (no --no-refresh) so an expired OAuth that
1492
- // cannot be renewed reads as broken rather than ready — which means the probe
1493
- // touches the network and can hang. Probes are serial, so a hung pi blocks a
1494
- // scheduler tick for this × the chain length. Kept impatient (a healthy probe is
1495
- // ~0.5s) because a timeout yields 'unknown', which is neutral: the run proceeds
1496
- // and pi reports the real error, so cutting it short costs nothing.
1497
- const PI_AUTH_PROBE_MS = 2000
1498
- const piAuthStatusCache = new Map<string, { status: PiAuthStatus; at: number }>()
1499
- function piProviderAuthStatus(provider: string): PiAuthStatus {
1500
- const cached = piAuthStatusCache.get(provider)
1501
- if (cached && Date.now() - cached.at < PI_AUTH_TTL_MS) return cached.status
1502
- const inv = piInvocation(['auth', 'check', '--provider', provider, '--json'])
1503
- // Bun's spawnSync needs an explicit env to inherit keys loaded from config.json.
1504
- const out = spawnSync(inv.bin, inv.args, { encoding: 'utf8', timeout: PI_AUTH_PROBE_MS, windowsHide: true, env: process.env })
1505
- // No pi binary is deterministic evidence in its own right — next run gets the
1506
- // same answer — so it belongs with 'unusable', not with a probe that timed out.
1507
- const status = (out.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT'
1508
- ? 'unusable'
1509
- : parsePiAuthStatus(out.stdout || '')
1510
- // 'unknown' is neutral by design, so nothing downstream reports it. Say it here
1511
- // — throttled, not once-per-process: `vn serve` re-probes every TTL and a probe
1512
- // that never answers would otherwise go silent after its first tick.
1513
- if (status !== 'ready' && shouldLogIdleStatus(`pi-auth-probe:${provider}:${status}`)) {
1514
- console.error(`pi auth check ${provider} → ${status}: ${String(out.error?.message || out.stderr || out.stdout || 'no output').slice(0, 200)}`)
1515
- }
1516
- piAuthStatusCache.set(provider, { status, at: Date.now() })
1517
- return status
1518
- }
1519
-
1520
- function piConfiguredProviders(): string[] {
1521
- return parseProviderChain(process.env.VOICENOTE_PI_PROVIDER)
1522
- }
1523
-
1524
- function piProviderCandidates(): string[] {
1525
- return usableChain(piConfiguredProviders(), piProviderAuthStatus)
1526
- }
1527
-
1528
- function piProviderFor(): string {
1529
- // Label for logs and metadata.llm_backend. Empty chain is a real state now, and
1530
- // naming a provider we already know is unusable would put a lie in the record.
1531
- return piProviderCandidates()[0] || 'none-usable'
1532
- }
1533
-
1534
- function piCodexModelFor(): string {
1535
- return process.env.VOICENOTE_PI_MODEL_SUMMARY || process.env.VOICENOTE_PI_MODEL || defaultPiModel(piConfiguredProviders()[0]!)
1536
- }
1537
-
1538
1471
  function stripJsonFences(text: string): string {
1539
1472
  const trimmed = text.trim()
1540
1473
  const fence = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```\s*$/i)
@@ -1562,11 +1495,9 @@ function extractFirstJsonObject(text: string): string {
1562
1495
  return trimmed
1563
1496
  }
1564
1497
 
1565
- async function chatCompleteViaPiProvider(opts: {
1498
+ async function runPi(opts: {
1566
1499
  systemPrompt: string
1567
1500
  userPrompt: string
1568
- model: string
1569
- provider: string
1570
1501
  timeoutMs?: number
1571
1502
  thinking?: string
1572
1503
  tools?: string // e.g. 'read,grep'; empty/undefined = --no-tools
@@ -1575,12 +1506,14 @@ async function chatCompleteViaPiProvider(opts: {
1575
1506
  }): Promise<string> {
1576
1507
  const args = [
1577
1508
  '-p',
1578
- '--provider', opts.provider,
1579
- '--model', opts.model,
1580
1509
  '--mode', 'text',
1581
1510
  '--no-extensions', '--no-skills', '--no-context-files', '--no-session', '--no-prompt-templates', '--no-themes',
1582
1511
  '--system-prompt', opts.systemPrompt,
1583
1512
  ]
1513
+ // Unset means pi's own default model and provider. There is no second
1514
+ // provider to fall back to either way.
1515
+ const model = piSummaryModel()
1516
+ if (model) args.push('--model', model)
1584
1517
  if (opts.thinking) args.push('--thinking', opts.thinking)
1585
1518
  if (opts.tools && opts.tools.trim()) args.push('--tools', opts.tools.trim())
1586
1519
  else args.push('--no-tools')
@@ -1595,62 +1528,48 @@ async function chatCompleteViaPiProvider(opts: {
1595
1528
  child.on('error', err => { if (timer) clearTimeout(timer); reject(err) })
1596
1529
  child.on('close', code => {
1597
1530
  if (timer) clearTimeout(timer)
1598
- if (code !== 0) return reject(new Error(`pi ${opts.provider} exited ${code}: ${(stderr || stdout).slice(0, 800)}`))
1531
+ if (code !== 0) return reject(new Error(`pi exited ${code}: ${(stderr || stdout).slice(0, 800)}`))
1599
1532
  const text = stdout.trim()
1600
- if (!text) return reject(new Error(`pi ${opts.provider} returned empty output`))
1533
+ if (!text) return reject(new Error('pi returned empty output'))
1601
1534
  resolve(text)
1602
1535
  })
1536
+ // A pi that dies before draining stdin (bad flags, crash on startup) closes the
1537
+ // pipe mid-write. Without this handler the EPIPE is an unhandled 'error' event
1538
+ // that kills the whole run, hiding pi's actual error; 'close' below reports it.
1539
+ child.stdin.on('error', (e: NodeJS.ErrnoException) => {
1540
+ if (e.code !== 'EPIPE') warnSideEffect('write prompt to pi stdin', e)
1541
+ })
1603
1542
  child.stdin.end(opts.userPrompt)
1604
1543
  })
1605
1544
  }
1606
1545
 
1607
- // A transient pi failure (proxy reset, dropped socket, upstream 5xx/429) must be
1608
- // retried on the SAME provider before falling back. Otherwise a momentary blip on
1609
- // the free codex path cascades straight into the paid OpenAI API and, if that is
1610
- // out of quota, a hard failure + stub — exactly what looks like a "quota problem"
1611
- // when the real cause was one closed socket. Quota/auth/4xx are NOT transient:
1612
- // retrying them just wastes time, so they fall through to the next provider.
1546
+ // A transient pi failure (proxy reset, dropped socket, upstream 5xx/429) is
1547
+ // retried: a momentary blip must not cost a run its notes. Quota/auth/4xx are NOT
1548
+ // transient — retrying them only wastes time, so they fail the summary at once.
1613
1549
  function isTransientPiError(e: any): boolean {
1614
1550
  const msg = String(e?.message || e).toLowerCase()
1615
1551
  if (/quota|unauthorized|invalid.*(key|token|credential)|forbidden|\b40[0-4]\b/.test(msg)) return false
1616
1552
  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)
1617
1553
  }
1618
1554
 
1619
- // Returns the provider that actually answered, not the one we started with: the
1620
- // chain falls back, and metadata recording the head would misname the run.
1621
- async function chatCompleteViaPiCodex(opts: Omit<Parameters<typeof chatCompleteViaPiProvider>[0], 'provider'>): Promise<{ text: string; provider: string }> {
1622
- const providers = piProviderCandidates()
1623
- // Reachable past the pre-ASR gate: credentials can lapse mid-run, or the caller
1624
- // may not go through it at all.
1625
- if (!providers.length) throw new Error(noUsableProviderMessage())
1555
+ async function chatCompleteViaPi(opts: Parameters<typeof runPi>[0]): Promise<string> {
1626
1556
  const maxAttempts = Math.max(1, Number(process.env.VOICENOTE_PI_RETRIES || 3))
1627
- let lastError: any = null
1628
- const failures: string[] = []
1629
- for (const [idx, provider] of providers.entries()) {
1630
- if (idx > 0) console.error(`pi provider fallback: trying ${provider} after ${providers[idx - 1]} failed: ${lastError?.message || lastError}`)
1631
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1632
- try {
1633
- return { text: await chatCompleteViaPiProvider({ ...opts, provider }), provider }
1634
- } catch (e: any) {
1635
- lastError = e
1636
- if (attempt < maxAttempts && isTransientPiError(e)) {
1637
- const backoffMs = Math.min(30000, 2000 * 2 ** (attempt - 1))
1638
- console.error(`pi ${provider} transient error (attempt ${attempt}/${maxAttempts}); retrying in ${backoffMs}ms: ${e?.message || e}`)
1639
- await new Promise(res => setTimeout(res, backoffMs))
1640
- continue
1641
- }
1642
- break // non-transient, or retries exhausted → fall back to next provider
1643
- }
1557
+ for (let attempt = 1; ; attempt++) {
1558
+ try {
1559
+ return await runPi(opts)
1560
+ } catch (e: any) {
1561
+ if (attempt >= maxAttempts || !isTransientPiError(e)) throw e
1562
+ const backoffMs = Math.min(30000, 2000 * 2 ** (attempt - 1))
1563
+ console.error(`pi transient error (attempt ${attempt}/${maxAttempts}); retrying in ${backoffMs}ms: ${e?.message || e}`)
1564
+ await new Promise(res => setTimeout(res, backoffMs))
1644
1565
  }
1645
- failures.push(`${provider}: ${String(lastError?.message || lastError).slice(0, 400)}`)
1646
1566
  }
1647
- // Report EVERY provider's error, not just the chain's last one. The last link is
1648
- // usually the fallback nobody signed into, so its "No API key found for openai"
1649
- // buried the actual reason openai-codex failed (usage limit, expired token, 5xx)
1650
- // in the notes stub and in the GUI — which then disagreed with a dashboard that
1651
- // correctly showed ChatGPT as connected.
1652
- if (failures.length > 1) throw new Error(`pi summary failed on every provider — ${failures.join(' | ')}`)
1653
- throw lastError || new Error('pi provider fallback exhausted')
1567
+ }
1568
+
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()
1654
1573
  }
1655
1574
 
1656
1575
  function piThinkingLevel(): string {
@@ -1677,20 +1596,19 @@ function piSummaryToolsHint(contextDir: string): string {
1677
1596
  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).`
1678
1597
  }
1679
1598
 
1680
- // Summary runs through pi with the configured provider. The agent's working dir IS the knowledge
1599
+ // Summary runs through pi. The agent's working dir IS the knowledge
1681
1600
  // base, so read/grep/find operate there directly. If a configured context dir is
1682
1601
  // missing, say so loudly and run without tools rather than searching the wrong
1683
1602
  // tree (tools, the cwd hint, and the spawn cwd move together).
1684
- async function chatComplete(opts: { systemPrompt: string; userPrompt: string; config: Config }): Promise<{ text: string; provider: string }> {
1603
+ async function chatComplete(opts: { systemPrompt: string; userPrompt: string; config: Config }): Promise<string> {
1685
1604
  const wantTools = !!piSummaryTools()
1686
1605
  const ctx = wantTools ? summaryContextDir(opts.config) : undefined
1687
1606
  const ctxExists = ctx ? existsSync(ctx) : false
1688
1607
  if (ctx && !ctxExists) console.error(`Warning: context dir ${ctx} does not exist; summary agent runs WITHOUT read/grep cross-reference.`)
1689
1608
  const toolsActive = wantTools && ctxExists
1690
- return chatCompleteViaPiCodex({
1609
+ return chatCompleteViaPi({
1691
1610
  systemPrompt: opts.systemPrompt,
1692
1611
  userPrompt: opts.userPrompt,
1693
- model: piCodexModelFor(),
1694
1612
  timeoutMs: 60 * 60 * 1000,
1695
1613
  thinking: piThinkingLevel(),
1696
1614
  tools: toolsActive ? piSummaryTools() : undefined,
@@ -1699,14 +1617,14 @@ async function chatComplete(opts: { systemPrompt: string; userPrompt: string; co
1699
1617
  })
1700
1618
  }
1701
1619
 
1702
- async function summarizeTranscript(config: Config, transcript: string, rec: Recording, localAudioPath: string): Promise<{ meta: Json; provider: string }> {
1620
+ async function summarizeTranscript(config: Config, transcript: string, rec: Recording, localAudioPath: string): Promise<Json> {
1703
1621
  const messages = summaryMessages(config, transcript, rec, localAudioPath)
1704
1622
  const systemPrompt = String(messages[0]!.content)
1705
1623
  const userPrompt = String(messages[1]!.content)
1706
- const { text, provider } = await chatComplete({ systemPrompt, userPrompt, config })
1624
+ const text = await chatComplete({ systemPrompt, userPrompt, config })
1707
1625
  const jsonText = extractFirstJsonObject(text)
1708
1626
  try {
1709
- return { meta: JSON.parse(jsonText || '{}') as Json, provider }
1627
+ return JSON.parse(jsonText || '{}') as Json
1710
1628
  } catch (e: any) {
1711
1629
  throw new Error(`summary returned non-JSON output (${e?.message || e}). First 400 chars: ${text.slice(0, 400)}`)
1712
1630
  }
@@ -1797,9 +1715,7 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1797
1715
  const needsNotes = mode === 'notes'
1798
1716
  const resumeSummary = needsNotes && Boolean(opts.resumeFromTranscriptFiles)
1799
1717
  const transcribeBackendLabel = `volcano:${config.volcano?.resourceId || 'volc.seedasr.auc'}`
1800
- // Lazy: resolving it probes pi (network, and `auth check` refreshes tokens on
1801
- // disk). Transcript mode never calls pi, and --dry-run promises no side effects.
1802
- const llmBackendLabel = needsNotes && !opts.dryRun ? `pi:${piProviderFor()}` : null
1718
+ const llmBackendLabel = needsNotes && !opts.dryRun ? 'pi' : null
1803
1719
  const plan = resumeSummary
1804
1720
  ? 'reuse saved transcript → integrated semantic notes → write metadata/index (no auto move)'
1805
1721
  : `copy audio → transcribe → write transcript${needsNotes ? ' → integrated semantic notes' : ''} → write metadata/index (no auto move)`
@@ -1851,13 +1767,10 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1851
1767
  }
1852
1768
 
1853
1769
  let summaryError: any = null
1854
- let summaryProvider: string | null = null
1855
1770
  if (needsNotes) {
1856
- progressStep(nextStep(), totalSteps, 'Generate integrated semantic notes', `model=${piCodexModelFor()} via ${llmBackendLabel}`)
1771
+ progressStep(nextStep(), totalSteps, 'Generate integrated semantic notes', `via pi, model=${piSummaryModel() || "pi's own default"}`)
1857
1772
  try {
1858
- const summary = await withHeartbeat('generate integrated semantic notes', () => summarizeTranscript(config, transcript, rec, files.audio), 60)
1859
- meta = summary.meta
1860
- summaryProvider = summary.provider
1773
+ meta = await withHeartbeat('generate integrated semantic notes', () => summarizeTranscript(config, transcript, rec, files.audio), 60)
1861
1774
  } catch (e: any) {
1862
1775
  summaryError = e
1863
1776
  console.error(`Summary step failed; transcript is preserved. Error: ${e?.message || e}`)
@@ -1874,10 +1787,9 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1874
1787
  meta.duration_seconds = rec.durationSeconds
1875
1788
  meta.asr_provider = 'volcano'
1876
1789
  meta.transcribe_model = config.volcano?.resourceId || 'volc.seedasr.auc'
1877
- meta.summary_model = needsNotes && !summaryError ? piCodexModelFor() : null
1878
- // The provider that actually answered, not the head of the chain it may have
1879
- // fallen back from. Null when no summary ran — summary_error says why.
1880
- meta.llm_backend = summaryProvider ? `pi:${summaryProvider}` : null
1790
+ // pi picks the model, so we cannot name it here. Null when no summary ran —
1791
+ // summary_error says why.
1792
+ meta.llm_backend = needsNotes && !summaryError ? 'pi' : null
1881
1793
  meta.processed_at = nowIso()
1882
1794
  if (summaryError) meta.summary_error = String(summaryError?.message || summaryError)
1883
1795
 
@@ -2161,10 +2073,6 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2161
2073
  if (shouldLogIdleStatus(`asr-misconfig:${config.recordDir}`)) console.error('ASR not configured: Volcano needs VOLCANO_ASR_KEY / VOLCANO_TOS_*. Skipping; run `vn doctor`, fix config, then re-run.')
2162
2074
  return
2163
2075
  }
2164
- if (mode === 'notes' && !summaryMayWork()) {
2165
- if (shouldLogIdleStatus(`pi-noauth:${config.recordDir}`)) console.error(`Skipping to avoid spending ASR on notes whose summary would fail: ${noUsableProviderMessage()}`)
2166
- return
2167
- }
2168
2076
  }
2169
2077
  if (!targets.length) {
2170
2078
  if (verboseSkips || shouldLogIdleStatus(`idle:${config.recordDir}:${recordings.length}:${skipSummary}:${samplesLine}`)) {
@@ -2773,12 +2681,6 @@ async function collectDoctor() {
2773
2681
  const ff = await runCommand(ffprobeBin(), ['-version'], 5000)
2774
2682
  const v = config.volcano
2775
2683
  const tools = piSummaryTools()
2776
- // An explicit status refresh must see newly saved keys and OAuth logins.
2777
- piAuthStatusCache.clear()
2778
- const configuredProviders = piConfiguredProviders()
2779
- const effectiveProviders = piProviderCandidates()
2780
- const providerStatus = Object.fromEntries(configuredProviders.map(p => [p, piProviderAuthStatus(p)]))
2781
- const summaryReady = effectiveProviders.length > 0
2782
2684
  return {
2783
2685
  version: VERSION,
2784
2686
  bun: process.versions.bun || null,
@@ -2794,7 +2696,9 @@ async function collectDoctor() {
2794
2696
  language: v.language ?? null,
2795
2697
  }
2796
2698
  : { configured: false as const },
2797
- summary: { backend: `pi:${effectiveProviders.join('→') || 'none-usable'}`, ready: summaryReady, configuredProviders, effectiveProviders, providerStatus, model: piCodexModelFor(), thinking: piThinkingLevel(), tools: tools || null, contextDir: tools ? summaryContextDir(config) : null },
2699
+ // Provider/model/credentials are pi's own configuration; `pi.available` is
2700
+ // all we can honestly report about whether a summary can run.
2701
+ summary: { backend: 'pi', model: piSummaryModel() || null, thinking: piThinkingLevel(), tools: tools || null, contextDir: tools ? summaryContextDir(config) : null },
2798
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) },
2799
2703
  // Outbound proxy for HTTPS endpoints (updater/GitHub): honor the standard
2800
2704
  // env chain, not just lowercase http_proxy — an https_proxy-only setup must
@@ -2862,16 +2766,13 @@ async function doctor(opts: { json?: boolean } = {}): Promise<void> {
2862
2766
  console.log(`volcano=not configured`)
2863
2767
  }
2864
2768
  console.log(`summaryBackend=${s.summary.backend}`)
2865
- console.log(`pi.bin=${s.pi.bin} providers=${s.summary.effectiveProviders.join(',') || '<none usable>'} model.summary=${s.summary.model}`)
2866
- const notReady = Object.entries(s.summary.providerStatus).filter(([, st]) => st !== 'ready')
2867
- if (notReady.length) console.log(`pi.providerStatus=${notReady.map(([p, st]) => `${p}:${st}`).join(' ')} (unusable=pi not installed, no credentials, or no such provider — dropped from the chain; unknown=pi ran but gave no usable answer, kept anyway)`)
2868
- if (!s.summary.ready) console.log('summary.ready=NO — every configured provider is unusable; `vn run --mode notes` will skip rather than spend ASR')
2769
+ console.log(`pi.bin=${s.pi.bin} model=${s.summary.model || "<pi's own default>"}`)
2869
2770
  console.log(`pi.thinking=${s.summary.thinking}`)
2870
2771
  console.log(`pi.summaryTools=${s.summary.tools || '<disabled>'}`)
2871
2772
  if (s.summary.contextDir) console.log(`pi.contextDir=${s.summary.contextDir} (summary agent cwd + read/grep cross-reference root)`)
2872
2773
  console.log(`pi.version=${s.pi.version || 'missing'}`)
2873
2774
  // Neutral fact, not an instruction: an API-key user has no auth.json and needs
2874
- // nothing fixed. summary.ready above is what says whether anything is wrong.
2775
+ // nothing fixed.
2875
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)'}`)
2876
2777
  console.log(`defaultMode=notes`)
2877
2778
  console.log(`proxy=${s.proxy.url || '<unset>'}`)
package/src/piProvider.ts DELETED
@@ -1,62 +0,0 @@
1
- // Which pi providers the summary chain tries. Credential *detection* lives in
2
- // cli.ts (it shells out to `pi auth check`); only the pure logic is here.
3
-
4
- // Only the free ChatGPT OAuth path by default. The paid OpenAI API fallback is
5
- // opt-in (VOICENOTE_PI_PROVIDER='openai-codex,openai', or the GUI dropdown):
6
- // shipping it in the default chain meant every codex failure was retried against
7
- // a provider almost nobody has a key for, and that second failure was the one the
8
- // user saw.
9
- export const DEFAULT_PI_PROVIDERS = ['openai-codex']
10
-
11
- export function defaultPiModel(provider: string): string {
12
- return provider === 'deepseek' ? 'deepseek-v4-flash' : 'gpt-5.5'
13
- }
14
-
15
- export function parseProviderChain(raw: string | undefined): string[] {
16
- const parsed = Array.from(new Set((raw ?? '').split(',').map(s => s.trim()).filter(Boolean)))
17
- return parsed.length ? parsed : [...DEFAULT_PI_PROVIDERS]
18
- }
19
-
20
- export type PiAuthStatus = 'ready' | 'unusable' | 'unknown'
21
-
22
- // `pi auth check --json` is the one source of truth for credentials — OAuth, keys
23
- // stored by `/login`, and each provider's own env var (OPENAI_API_KEY,
24
- // GEMINI_API_KEY, …), names we must not reimplement guessing.
25
- //
26
- // 'unusable' is a DETERMINISTIC failure: no credentials, or no such provider —
27
- // next run gets the same answer. Note an OAuth token that has expired beyond
28
- // refresh lands here too: with refresh on, pi reports `credentials_not_configured`.
29
- //
30
- // 'unknown' is pi running but giving no usable answer: a timeout, non-JSON
31
- // output, or pi's own `status:"invalid"` — which per pi's auth-check means its
32
- // model runtime is in an error state or checkAuth threw, i.e. a pi-side fault,
33
- // not a verdict about this provider's credentials. (A missing pi binary is not
34
- // in here: the caller maps ENOENT to 'unusable', since it is deterministic.)
35
- export function parsePiAuthStatus(stdout: string): PiAuthStatus {
36
- try {
37
- const r = JSON.parse(stdout) as { status?: unknown; reason?: unknown }
38
- if (r.status === 'ready') return 'ready'
39
- if (r.reason === 'credentials_not_configured' || r.reason === 'provider_not_found') return 'unusable'
40
- } catch { /* not JSON → unknown */ }
41
- return 'unknown'
42
- }
43
-
44
- // One rule: only a deterministic failure justifies a decision.
45
- //
46
- // An 'unusable' provider can only fail — and because the chain throws its LAST
47
- // error, that failure ("No API key found for openai") would overwrite the real
48
- // error from the provider that actually broke, which is the bug this exists to
49
- // kill. So it is pruned, and a chain left with nothing blocks the run before ASR
50
- // is spent.
51
- //
52
- // 'unknown' is deliberately neutral: it neither prunes nor blocks. A probe that
53
- // could not answer is not evidence, and guessing on its behalf is the same
54
- // mistake in the other direction — let the run proceed and pi report the truth.
55
- //
56
- // Hence "may work": surviving this filter only means "not known to be broken",
57
- // and nothing downstream may read it as authenticated.
58
- const mayWork = (status: PiAuthStatus) => status !== 'unusable'
59
-
60
- export function usableChain(configured: string[], statusOf: (provider: string) => PiAuthStatus): string[] {
61
- return configured.filter(p => mayWork(statusOf(p)))
62
- }