@fastagent-sh/voicenote 0.18.7 → 0.19.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,33 @@ 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",
118
116
  "VOICENOTE_PI_THINKING": "high",
119
117
  "VOICENOTE_PI_SUMMARY_TOOLS": "read,grep",
120
118
  "VOICENOTE_CONTEXT_DIR": "/Users/you/vault"
121
119
  }
122
120
  ```
123
121
 
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.
122
+ ### Which model writes the notes
136
123
 
137
- ### DeepSeek notes
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`.
138
130
 
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.
131
+ `DEEPSEEK_API_KEY` and `OPENAI_API_KEY` in the config are only forwarded to pi's
132
+ environment for providers that read them.
140
133
 
141
- For the CLI, merge these values into `~/.config/voicenote/config.json`:
142
-
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.
134
+ Transient failures (dropped socket, 5xx, 429) are retried on the same provider up
135
+ to `VOICENOTE_PI_RETRIES` times (default 3). Quota and auth errors are not
136
+ retried.
154
137
 
155
138
  ## Usage
156
139
 
157
140
  ```bash
158
141
  vn doctor # check environment and config
159
- vn run # default: Volcano ASR + pi-codex notes
142
+ vn run # default: Volcano ASR + pi notes
160
143
  vn run --mode transcript # transcript only, skip semantic notes
161
144
  vn run --latest # process only the latest valid recording
162
145
  vn run --latest --force # re-run the latest one
@@ -284,7 +267,7 @@ A self-contained macOS `.app` (Tauri v2) for **non-terminal users**: the target
284
267
 
285
268
  **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
269
 
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).
270
+ - 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
271
  - After that: the main view shows agent activity + recent notes (open note / open folder)
289
272
 
290
273
  ### What's bundled
@@ -342,7 +325,7 @@ irm https://raw.githubusercontent.com/fastagent-sh/voicenote/main/scripts/instal
342
325
 
343
326
  `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
327
 
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.
328
+ **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
329
 
347
330
  > 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
331
 
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,29 @@ 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",
118
116
  "VOICENOTE_PI_THINKING": "high",
119
117
  "VOICENOTE_PI_SUMMARY_TOOLS": "read,grep",
120
118
  "VOICENOTE_CONTEXT_DIR": "/Users/you/vault"
121
119
  }
122
120
  ```
123
121
 
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` 打印。
122
+ ### 纪要用哪个模型
123
+
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` 重试纪要即可。
128
+
129
+ 配置里的 `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` 只是透传给 pi 的环境变量。
130
+
131
+ 瞬时性失败(断连、5xx、429)会在同一个 provider 上重试, 次数由 `VOICENOTE_PI_RETRIES`
132
+ 控制(默认 3)。额度和鉴权错误不重试。
132
133
 
133
134
  ## 用法
134
135
 
135
136
  ```bash
136
137
  vn doctor # 检查环境与配置
137
- vn run # 默认:Volcano ASR + pi-codex 纪要
138
+ vn run # 默认:Volcano ASR + pi 纪要
138
139
  vn run --mode transcript # 只生成 transcript,跳过语义整理
139
140
  vn run --latest # 只处理最新有效录音
140
141
  vn run --latest --force # 重跑最新条
@@ -186,7 +187,7 @@ vn uninstall-launch-agent
186
187
  3. 复制原始音频到 `${VOICENOTE_WORKSPACE}/_audio/YYYY-MM/`
187
188
  4. 转写:火山豆包【大模型录音文件识别标准版 API】,本地音频先传到 TOS,提交任务后轮询结果,完成后默认删除 TOS 对象
188
189
  5. 转写完成后立刻落盘原始 transcript(不做 lossy 清洗),避免后面步骤失败导致 ASR 费用白付
189
- 6. summary 模型(默认 pi codex 走 ChatGPT Plus)直接看原始 transcript,在纪要生成阶段内部完成必要清理、说话人还原、观点/争论/共识形成过程还原;如果 summary 失败,下一次 `vn run` / `vn run --latest` 会复用已保存 transcript,直接重试纪要生成,不需要 `vn forget`
190
+ 6. summary 模型(由 pi 自身配置决定)直接看原始 transcript,在纪要生成阶段内部完成必要清理、说话人还原、观点/争论/共识形成过程还原;如果 summary 失败,下一次 `vn run` / `vn run --latest` 会复用已保存 transcript,直接重试纪要生成,不需要 `vn forget`
190
191
  7. 写出 notes / metadata;系统不做任何归档决定,文件留在配置的 workspace 中
191
192
 
192
193
  失败的录音会在后续运行中重试,但**最多 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.19.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.19.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,6 @@ const ENV_KEYS = [
127
126
  'VOICENOTE_PI_BIN',
128
127
  'VOICENOTE_PI_CLI',
129
128
  'VOICENOTE_FFPROBE_BIN',
130
- 'VOICENOTE_PI_PROVIDER',
131
- 'VOICENOTE_PI_MODEL',
132
- 'VOICENOTE_PI_MODEL_SUMMARY',
133
129
  'VOICENOTE_PI_THINKING',
134
130
  'VOICENOTE_PI_SUMMARY_TOOLS',
135
131
  'VOICENOTE_CONTEXT_DIR',
@@ -1135,7 +1131,7 @@ async function volcanoTranscribeAudio(volc: VolcanoConfig, audioPath: string, re
1135
1131
  if (queryFailures >= 10) throw new Error(`Volcano query failed ${queryFailures}x in a row: ${desc}`)
1136
1132
  if (Date.now() - started > maxWaitMs) throw new Error(`Volcano: timeout after ${formatElapsed(Date.now() - started)} (last error: ${desc})`)
1137
1133
  // console.error (not log) so wireDailyLog tags it [ERROR] and `vn errors`
1138
- // surfaces it — matching chatCompleteViaPiCodex's transient-retry logging.
1134
+ // surfaces it — matching chatCompleteViaPi's transient-retry logging.
1139
1135
  // A repeatedly-near-threshold ASR wobble is exactly what ops wants to see.
1140
1136
  console.error(`… Volcano: transient query failure (attempt ${queryFailures}/10, will retry): ${desc}`)
1141
1137
  }
@@ -1280,7 +1276,9 @@ ${transcript}`
1280
1276
  }
1281
1277
 
1282
1278
  // ───────────────────────────────────────────────────────────────────────
1283
- // Summary via pi (pi-codex — ChatGPT Plus/Pro OAuth, no OpenAI API key)
1279
+ // Summary via pi. Provider, model and credentials are pi's own configuration:
1280
+ // we invoke `pi -p` with no --provider/--model and never fall back elsewhere,
1281
+ // so whatever the user selected in pi is what writes the notes.
1284
1282
  // ───────────────────────────────────────────────────────────────────────
1285
1283
 
1286
1284
  function piCodexBin(): string {
@@ -1300,21 +1298,6 @@ function piInvocation(args: string[]): { bin: string; args: string[] } {
1300
1298
  return cli ? { bin, args: [cli, ...args] } : { bin, args }
1301
1299
  }
1302
1300
 
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
1301
  // ───────────────────────────────────────────────────────────────────────
1319
1302
  // ChatGPT (OpenAI Codex) OAuth login — headless device-code flow.
1320
1303
  // Today the only way to authenticate the pi summary backend is to open pi's
@@ -1484,57 +1467,6 @@ async function configSet(): Promise<void> {
1484
1467
  }
1485
1468
  }
1486
1469
 
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
1470
  function stripJsonFences(text: string): string {
1539
1471
  const trimmed = text.trim()
1540
1472
  const fence = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```\s*$/i)
@@ -1562,21 +1494,18 @@ function extractFirstJsonObject(text: string): string {
1562
1494
  return trimmed
1563
1495
  }
1564
1496
 
1565
- async function chatCompleteViaPiProvider(opts: {
1497
+ async function runPi(opts: {
1566
1498
  systemPrompt: string
1567
1499
  userPrompt: string
1568
- model: string
1569
- provider: string
1570
1500
  timeoutMs?: number
1571
1501
  thinking?: string
1572
1502
  tools?: string // e.g. 'read,grep'; empty/undefined = --no-tools
1573
1503
  appendSystemPrompt?: string
1574
1504
  cwd?: string // agent working dir: the knowledge base, so read/grep/find default there
1575
1505
  }): Promise<string> {
1506
+ // No --provider/--model: pi's own configuration picks the model and credentials.
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,
@@ -1595,62 +1524,42 @@ async function chatCompleteViaPiProvider(opts: {
1595
1524
  child.on('error', err => { if (timer) clearTimeout(timer); reject(err) })
1596
1525
  child.on('close', code => {
1597
1526
  if (timer) clearTimeout(timer)
1598
- if (code !== 0) return reject(new Error(`pi ${opts.provider} exited ${code}: ${(stderr || stdout).slice(0, 800)}`))
1527
+ if (code !== 0) return reject(new Error(`pi exited ${code}: ${(stderr || stdout).slice(0, 800)}`))
1599
1528
  const text = stdout.trim()
1600
- if (!text) return reject(new Error(`pi ${opts.provider} returned empty output`))
1529
+ if (!text) return reject(new Error('pi returned empty output'))
1601
1530
  resolve(text)
1602
1531
  })
1532
+ // A pi that dies before draining stdin (bad flags, crash on startup) closes the
1533
+ // pipe mid-write. Without this handler the EPIPE is an unhandled 'error' event
1534
+ // that kills the whole run, hiding pi's actual error; 'close' below reports it.
1535
+ child.stdin.on('error', (e: NodeJS.ErrnoException) => {
1536
+ if (e.code !== 'EPIPE') warnSideEffect('write prompt to pi stdin', e)
1537
+ })
1603
1538
  child.stdin.end(opts.userPrompt)
1604
1539
  })
1605
1540
  }
1606
1541
 
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.
1542
+ // A transient pi failure (proxy reset, dropped socket, upstream 5xx/429) is
1543
+ // retried: a momentary blip must not cost a run its notes. Quota/auth/4xx are NOT
1544
+ // transient — retrying them only wastes time, so they fail the summary at once.
1613
1545
  function isTransientPiError(e: any): boolean {
1614
1546
  const msg = String(e?.message || e).toLowerCase()
1615
1547
  if (/quota|unauthorized|invalid.*(key|token|credential)|forbidden|\b40[0-4]\b/.test(msg)) return false
1616
1548
  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
1549
  }
1618
1550
 
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())
1551
+ async function chatCompleteViaPi(opts: Parameters<typeof runPi>[0]): Promise<string> {
1626
1552
  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
- }
1553
+ for (let attempt = 1; ; attempt++) {
1554
+ try {
1555
+ return await runPi(opts)
1556
+ } catch (e: any) {
1557
+ if (attempt >= maxAttempts || !isTransientPiError(e)) throw e
1558
+ const backoffMs = Math.min(30000, 2000 * 2 ** (attempt - 1))
1559
+ console.error(`pi transient error (attempt ${attempt}/${maxAttempts}); retrying in ${backoffMs}ms: ${e?.message || e}`)
1560
+ await new Promise(res => setTimeout(res, backoffMs))
1644
1561
  }
1645
- failures.push(`${provider}: ${String(lastError?.message || lastError).slice(0, 400)}`)
1646
1562
  }
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')
1654
1563
  }
1655
1564
 
1656
1565
  function piThinkingLevel(): string {
@@ -1677,20 +1586,19 @@ function piSummaryToolsHint(contextDir: string): string {
1677
1586
  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
1587
  }
1679
1588
 
1680
- // Summary runs through pi with the configured provider. The agent's working dir IS the knowledge
1589
+ // Summary runs through pi. The agent's working dir IS the knowledge
1681
1590
  // base, so read/grep/find operate there directly. If a configured context dir is
1682
1591
  // missing, say so loudly and run without tools rather than searching the wrong
1683
1592
  // 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 }> {
1593
+ async function chatComplete(opts: { systemPrompt: string; userPrompt: string; config: Config }): Promise<string> {
1685
1594
  const wantTools = !!piSummaryTools()
1686
1595
  const ctx = wantTools ? summaryContextDir(opts.config) : undefined
1687
1596
  const ctxExists = ctx ? existsSync(ctx) : false
1688
1597
  if (ctx && !ctxExists) console.error(`Warning: context dir ${ctx} does not exist; summary agent runs WITHOUT read/grep cross-reference.`)
1689
1598
  const toolsActive = wantTools && ctxExists
1690
- return chatCompleteViaPiCodex({
1599
+ return chatCompleteViaPi({
1691
1600
  systemPrompt: opts.systemPrompt,
1692
1601
  userPrompt: opts.userPrompt,
1693
- model: piCodexModelFor(),
1694
1602
  timeoutMs: 60 * 60 * 1000,
1695
1603
  thinking: piThinkingLevel(),
1696
1604
  tools: toolsActive ? piSummaryTools() : undefined,
@@ -1699,14 +1607,14 @@ async function chatComplete(opts: { systemPrompt: string; userPrompt: string; co
1699
1607
  })
1700
1608
  }
1701
1609
 
1702
- async function summarizeTranscript(config: Config, transcript: string, rec: Recording, localAudioPath: string): Promise<{ meta: Json; provider: string }> {
1610
+ async function summarizeTranscript(config: Config, transcript: string, rec: Recording, localAudioPath: string): Promise<Json> {
1703
1611
  const messages = summaryMessages(config, transcript, rec, localAudioPath)
1704
1612
  const systemPrompt = String(messages[0]!.content)
1705
1613
  const userPrompt = String(messages[1]!.content)
1706
- const { text, provider } = await chatComplete({ systemPrompt, userPrompt, config })
1614
+ const text = await chatComplete({ systemPrompt, userPrompt, config })
1707
1615
  const jsonText = extractFirstJsonObject(text)
1708
1616
  try {
1709
- return { meta: JSON.parse(jsonText || '{}') as Json, provider }
1617
+ return JSON.parse(jsonText || '{}') as Json
1710
1618
  } catch (e: any) {
1711
1619
  throw new Error(`summary returned non-JSON output (${e?.message || e}). First 400 chars: ${text.slice(0, 400)}`)
1712
1620
  }
@@ -1797,9 +1705,7 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1797
1705
  const needsNotes = mode === 'notes'
1798
1706
  const resumeSummary = needsNotes && Boolean(opts.resumeFromTranscriptFiles)
1799
1707
  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
1708
+ const llmBackendLabel = needsNotes && !opts.dryRun ? 'pi' : null
1803
1709
  const plan = resumeSummary
1804
1710
  ? 'reuse saved transcript → integrated semantic notes → write metadata/index (no auto move)'
1805
1711
  : `copy audio → transcribe → write transcript${needsNotes ? ' → integrated semantic notes' : ''} → write metadata/index (no auto move)`
@@ -1851,13 +1757,10 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1851
1757
  }
1852
1758
 
1853
1759
  let summaryError: any = null
1854
- let summaryProvider: string | null = null
1855
1760
  if (needsNotes) {
1856
- progressStep(nextStep(), totalSteps, 'Generate integrated semantic notes', `model=${piCodexModelFor()} via ${llmBackendLabel}`)
1761
+ progressStep(nextStep(), totalSteps, 'Generate integrated semantic notes', 'via pi (pi\'s own provider/model config)')
1857
1762
  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
1763
+ meta = await withHeartbeat('generate integrated semantic notes', () => summarizeTranscript(config, transcript, rec, files.audio), 60)
1861
1764
  } catch (e: any) {
1862
1765
  summaryError = e
1863
1766
  console.error(`Summary step failed; transcript is preserved. Error: ${e?.message || e}`)
@@ -1874,10 +1777,9 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1874
1777
  meta.duration_seconds = rec.durationSeconds
1875
1778
  meta.asr_provider = 'volcano'
1876
1779
  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
1780
+ // pi picks the model, so we cannot name it here. Null when no summary ran —
1781
+ // summary_error says why.
1782
+ meta.llm_backend = needsNotes && !summaryError ? 'pi' : null
1881
1783
  meta.processed_at = nowIso()
1882
1784
  if (summaryError) meta.summary_error = String(summaryError?.message || summaryError)
1883
1785
 
@@ -2161,10 +2063,6 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2161
2063
  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
2064
  return
2163
2065
  }
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
2066
  }
2169
2067
  if (!targets.length) {
2170
2068
  if (verboseSkips || shouldLogIdleStatus(`idle:${config.recordDir}:${recordings.length}:${skipSummary}:${samplesLine}`)) {
@@ -2773,12 +2671,6 @@ async function collectDoctor() {
2773
2671
  const ff = await runCommand(ffprobeBin(), ['-version'], 5000)
2774
2672
  const v = config.volcano
2775
2673
  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
2674
  return {
2783
2675
  version: VERSION,
2784
2676
  bun: process.versions.bun || null,
@@ -2794,7 +2686,9 @@ async function collectDoctor() {
2794
2686
  language: v.language ?? null,
2795
2687
  }
2796
2688
  : { 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 },
2689
+ // Provider/model/credentials are pi's own configuration; `pi.available` is
2690
+ // 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 },
2798
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) },
2799
2693
  // Outbound proxy for HTTPS endpoints (updater/GitHub): honor the standard
2800
2694
  // env chain, not just lowercase http_proxy — an https_proxy-only setup must
@@ -2861,17 +2755,14 @@ async function doctor(opts: { json?: boolean } = {}): Promise<void> {
2861
2755
  } else {
2862
2756
  console.log(`volcano=not configured`)
2863
2757
  }
2864
- 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')
2758
+ console.log(`summaryBackend=${s.summary.backend} (provider/model come from pi's own config)`)
2759
+ console.log(`pi.bin=${s.pi.bin}`)
2869
2760
  console.log(`pi.thinking=${s.summary.thinking}`)
2870
2761
  console.log(`pi.summaryTools=${s.summary.tools || '<disabled>'}`)
2871
2762
  if (s.summary.contextDir) console.log(`pi.contextDir=${s.summary.contextDir} (summary agent cwd + read/grep cross-reference root)`)
2872
2763
  console.log(`pi.version=${s.pi.version || 'missing'}`)
2873
2764
  // 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.
2765
+ // nothing fixed.
2875
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)'}`)
2876
2767
  console.log(`defaultMode=notes`)
2877
2768
  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
- }