@fastagent-sh/voicenote 0.18.6 → 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,46 +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,openai",
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. Credentials are
125
- resolved by `pi auth check` (covering OAuth, keys stored by `pi` → `/login`, and
126
- each provider's own API-key env var). A provider that deterministically cannot
127
- work — no credentials, or not a provider pi knows — is dropped from the chain,
128
- because its inevitable "No API key found" would replace the real error from the
129
- provider that actually failed. If that empties the chain, `vn run --mode notes`
130
- skips instead of paying for a transcript whose summary cannot happen.
131
- `vn doctor` prints the effective chain and the status of anything not ready.
122
+ ### Which model writes the notes
132
123
 
133
- ### 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`.
134
130
 
135
- 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.
136
133
 
137
- For the CLI, merge these values into `~/.config/voicenote/config.json`:
138
-
139
- ```json
140
- {
141
- "VOICENOTE_PI_PROVIDER": "deepseek",
142
- "VOICENOTE_PI_MODEL_SUMMARY": "deepseek-v4-flash",
143
- "DEEPSEEK_API_KEY": "..."
144
- }
145
- ```
146
-
147
- 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.
148
-
149
- 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.
150
137
 
151
138
  ## Usage
152
139
 
153
140
  ```bash
154
141
  vn doctor # check environment and config
155
- vn run # default: Volcano ASR + pi-codex notes
142
+ vn run # default: Volcano ASR + pi notes
156
143
  vn run --mode transcript # transcript only, skip semantic notes
157
144
  vn run --latest # process only the latest valid recording
158
145
  vn run --latest --force # re-run the latest one
@@ -280,7 +267,7 @@ A self-contained macOS `.app` (Tauri v2) for **non-terminal users**: the target
280
267
 
281
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).
282
269
 
283
- - 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).
284
271
  - After that: the main view shows agent activity + recent notes (open note / open folder)
285
272
 
286
273
  ### What's bundled
@@ -338,7 +325,7 @@ irm https://raw.githubusercontent.com/fastagent-sh/voicenote/main/scripts/instal
338
325
 
339
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).
340
327
 
341
- **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.
342
329
 
343
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.
344
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,26 +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,openai",
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` 是从左到右尝试的回退链。凭证由 `pi auth check` 判定(覆盖
125
- OAuth、`pi` → `/login` 存下的 key、以及各 provider 自己的 API key 环境变量)。
126
- 确定不可能工作的 provider(没凭证, 或 pi 根本不认识这个名字)会被剔除 —— 否则
127
- 它必然抛出的 "No API key found" 会覆盖掉真正失败的那个 provider 的错误。若链因此
128
- 变空, `vn run --mode notes` 会直接跳过, 而不是花钱转写一个注定无法生成纪要的录音。
129
- 实际生效的链和非 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)。额度和鉴权错误不重试。
130
133
 
131
134
  ## 用法
132
135
 
133
136
  ```bash
134
137
  vn doctor # 检查环境与配置
135
- vn run # 默认:Volcano ASR + pi-codex 纪要
138
+ vn run # 默认:Volcano ASR + pi 纪要
136
139
  vn run --mode transcript # 只生成 transcript,跳过语义整理
137
140
  vn run --latest # 只处理最新有效录音
138
141
  vn run --latest --force # 重跑最新条
@@ -184,7 +187,7 @@ vn uninstall-launch-agent
184
187
  3. 复制原始音频到 `${VOICENOTE_WORKSPACE}/_audio/YYYY-MM/`
185
188
  4. 转写:火山豆包【大模型录音文件识别标准版 API】,本地音频先传到 TOS,提交任务后轮询结果,完成后默认删除 TOS 对象
186
189
  5. 转写完成后立刻落盘原始 transcript(不做 lossy 清洗),避免后面步骤失败导致 ASR 费用白付
187
- 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`
188
191
  7. 写出 notes / metadata;系统不做任何归档决定,文件留在配置的 workspace 中
189
192
 
190
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.6",
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.6'
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,54 +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
- for (const [idx, provider] of providers.entries()) {
1629
- if (idx > 0) console.error(`pi provider fallback: trying ${provider} after ${providers[idx - 1]} failed: ${lastError?.message || lastError}`)
1630
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1631
- try {
1632
- return { text: await chatCompleteViaPiProvider({ ...opts, provider }), provider }
1633
- } catch (e: any) {
1634
- lastError = e
1635
- if (attempt < maxAttempts && isTransientPiError(e)) {
1636
- const backoffMs = Math.min(30000, 2000 * 2 ** (attempt - 1))
1637
- console.error(`pi ${provider} transient error (attempt ${attempt}/${maxAttempts}); retrying in ${backoffMs}ms: ${e?.message || e}`)
1638
- await new Promise(res => setTimeout(res, backoffMs))
1639
- continue
1640
- }
1641
- break // non-transient, or retries exhausted → fall back to next provider
1642
- }
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))
1643
1561
  }
1644
1562
  }
1645
- throw lastError || new Error('pi provider fallback exhausted')
1646
1563
  }
1647
1564
 
1648
1565
  function piThinkingLevel(): string {
@@ -1669,20 +1586,19 @@ function piSummaryToolsHint(contextDir: string): string {
1669
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).`
1670
1587
  }
1671
1588
 
1672
- // 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
1673
1590
  // base, so read/grep/find operate there directly. If a configured context dir is
1674
1591
  // missing, say so loudly and run without tools rather than searching the wrong
1675
1592
  // tree (tools, the cwd hint, and the spawn cwd move together).
1676
- 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> {
1677
1594
  const wantTools = !!piSummaryTools()
1678
1595
  const ctx = wantTools ? summaryContextDir(opts.config) : undefined
1679
1596
  const ctxExists = ctx ? existsSync(ctx) : false
1680
1597
  if (ctx && !ctxExists) console.error(`Warning: context dir ${ctx} does not exist; summary agent runs WITHOUT read/grep cross-reference.`)
1681
1598
  const toolsActive = wantTools && ctxExists
1682
- return chatCompleteViaPiCodex({
1599
+ return chatCompleteViaPi({
1683
1600
  systemPrompt: opts.systemPrompt,
1684
1601
  userPrompt: opts.userPrompt,
1685
- model: piCodexModelFor(),
1686
1602
  timeoutMs: 60 * 60 * 1000,
1687
1603
  thinking: piThinkingLevel(),
1688
1604
  tools: toolsActive ? piSummaryTools() : undefined,
@@ -1691,14 +1607,14 @@ async function chatComplete(opts: { systemPrompt: string; userPrompt: string; co
1691
1607
  })
1692
1608
  }
1693
1609
 
1694
- 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> {
1695
1611
  const messages = summaryMessages(config, transcript, rec, localAudioPath)
1696
1612
  const systemPrompt = String(messages[0]!.content)
1697
1613
  const userPrompt = String(messages[1]!.content)
1698
- const { text, provider } = await chatComplete({ systemPrompt, userPrompt, config })
1614
+ const text = await chatComplete({ systemPrompt, userPrompt, config })
1699
1615
  const jsonText = extractFirstJsonObject(text)
1700
1616
  try {
1701
- return { meta: JSON.parse(jsonText || '{}') as Json, provider }
1617
+ return JSON.parse(jsonText || '{}') as Json
1702
1618
  } catch (e: any) {
1703
1619
  throw new Error(`summary returned non-JSON output (${e?.message || e}). First 400 chars: ${text.slice(0, 400)}`)
1704
1620
  }
@@ -1789,9 +1705,7 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1789
1705
  const needsNotes = mode === 'notes'
1790
1706
  const resumeSummary = needsNotes && Boolean(opts.resumeFromTranscriptFiles)
1791
1707
  const transcribeBackendLabel = `volcano:${config.volcano?.resourceId || 'volc.seedasr.auc'}`
1792
- // Lazy: resolving it probes pi (network, and `auth check` refreshes tokens on
1793
- // disk). Transcript mode never calls pi, and --dry-run promises no side effects.
1794
- const llmBackendLabel = needsNotes && !opts.dryRun ? `pi:${piProviderFor()}` : null
1708
+ const llmBackendLabel = needsNotes && !opts.dryRun ? 'pi' : null
1795
1709
  const plan = resumeSummary
1796
1710
  ? 'reuse saved transcript → integrated semantic notes → write metadata/index (no auto move)'
1797
1711
  : `copy audio → transcribe → write transcript${needsNotes ? ' → integrated semantic notes' : ''} → write metadata/index (no auto move)`
@@ -1843,13 +1757,10 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1843
1757
  }
1844
1758
 
1845
1759
  let summaryError: any = null
1846
- let summaryProvider: string | null = null
1847
1760
  if (needsNotes) {
1848
- 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)')
1849
1762
  try {
1850
- const summary = await withHeartbeat('generate integrated semantic notes', () => summarizeTranscript(config, transcript, rec, files.audio), 60)
1851
- meta = summary.meta
1852
- summaryProvider = summary.provider
1763
+ meta = await withHeartbeat('generate integrated semantic notes', () => summarizeTranscript(config, transcript, rec, files.audio), 60)
1853
1764
  } catch (e: any) {
1854
1765
  summaryError = e
1855
1766
  console.error(`Summary step failed; transcript is preserved. Error: ${e?.message || e}`)
@@ -1866,10 +1777,9 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1866
1777
  meta.duration_seconds = rec.durationSeconds
1867
1778
  meta.asr_provider = 'volcano'
1868
1779
  meta.transcribe_model = config.volcano?.resourceId || 'volc.seedasr.auc'
1869
- meta.summary_model = needsNotes && !summaryError ? piCodexModelFor() : null
1870
- // The provider that actually answered, not the head of the chain it may have
1871
- // fallen back from. Null when no summary ran — summary_error says why.
1872
- 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
1873
1783
  meta.processed_at = nowIso()
1874
1784
  if (summaryError) meta.summary_error = String(summaryError?.message || summaryError)
1875
1785
 
@@ -2153,10 +2063,6 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2153
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.')
2154
2064
  return
2155
2065
  }
2156
- if (mode === 'notes' && !summaryMayWork()) {
2157
- if (shouldLogIdleStatus(`pi-noauth:${config.recordDir}`)) console.error(`Skipping to avoid spending ASR on notes whose summary would fail: ${noUsableProviderMessage()}`)
2158
- return
2159
- }
2160
2066
  }
2161
2067
  if (!targets.length) {
2162
2068
  if (verboseSkips || shouldLogIdleStatus(`idle:${config.recordDir}:${recordings.length}:${skipSummary}:${samplesLine}`)) {
@@ -2765,12 +2671,6 @@ async function collectDoctor() {
2765
2671
  const ff = await runCommand(ffprobeBin(), ['-version'], 5000)
2766
2672
  const v = config.volcano
2767
2673
  const tools = piSummaryTools()
2768
- // An explicit status refresh must see newly saved keys and OAuth logins.
2769
- piAuthStatusCache.clear()
2770
- const configuredProviders = piConfiguredProviders()
2771
- const effectiveProviders = piProviderCandidates()
2772
- const providerStatus = Object.fromEntries(configuredProviders.map(p => [p, piProviderAuthStatus(p)]))
2773
- const summaryReady = effectiveProviders.length > 0
2774
2674
  return {
2775
2675
  version: VERSION,
2776
2676
  bun: process.versions.bun || null,
@@ -2786,7 +2686,9 @@ async function collectDoctor() {
2786
2686
  language: v.language ?? null,
2787
2687
  }
2788
2688
  : { configured: false as const },
2789
- 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 },
2790
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) },
2791
2693
  // Outbound proxy for HTTPS endpoints (updater/GitHub): honor the standard
2792
2694
  // env chain, not just lowercase http_proxy — an https_proxy-only setup must
@@ -2853,17 +2755,14 @@ async function doctor(opts: { json?: boolean } = {}): Promise<void> {
2853
2755
  } else {
2854
2756
  console.log(`volcano=not configured`)
2855
2757
  }
2856
- console.log(`summaryBackend=${s.summary.backend}`)
2857
- console.log(`pi.bin=${s.pi.bin} providers=${s.summary.effectiveProviders.join(',') || '<none usable>'} model.summary=${s.summary.model}`)
2858
- const notReady = Object.entries(s.summary.providerStatus).filter(([, st]) => st !== 'ready')
2859
- 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)`)
2860
- 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}`)
2861
2760
  console.log(`pi.thinking=${s.summary.thinking}`)
2862
2761
  console.log(`pi.summaryTools=${s.summary.tools || '<disabled>'}`)
2863
2762
  if (s.summary.contextDir) console.log(`pi.contextDir=${s.summary.contextDir} (summary agent cwd + read/grep cross-reference root)`)
2864
2763
  console.log(`pi.version=${s.pi.version || 'missing'}`)
2865
2764
  // Neutral fact, not an instruction: an API-key user has no auth.json and needs
2866
- // nothing fixed. summary.ready above is what says whether anything is wrong.
2765
+ // nothing fixed.
2867
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)'}`)
2868
2767
  console.log(`defaultMode=notes`)
2869
2768
  console.log(`proxy=${s.proxy.url || '<unset>'}`)
package/src/piProvider.ts DELETED
@@ -1,57 +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
- export const DEFAULT_PI_PROVIDERS = ['openai-codex', 'openai']
5
-
6
- export function defaultPiModel(provider: string): string {
7
- return provider === 'deepseek' ? 'deepseek-v4-flash' : 'gpt-5.5'
8
- }
9
-
10
- export function parseProviderChain(raw: string | undefined): string[] {
11
- const parsed = Array.from(new Set((raw ?? '').split(',').map(s => s.trim()).filter(Boolean)))
12
- return parsed.length ? parsed : [...DEFAULT_PI_PROVIDERS]
13
- }
14
-
15
- export type PiAuthStatus = 'ready' | 'unusable' | 'unknown'
16
-
17
- // `pi auth check --json` is the one source of truth for credentials — OAuth, keys
18
- // stored by `/login`, and each provider's own env var (OPENAI_API_KEY,
19
- // GEMINI_API_KEY, …), names we must not reimplement guessing.
20
- //
21
- // 'unusable' is a DETERMINISTIC failure: no credentials, or no such provider —
22
- // next run gets the same answer. Note an OAuth token that has expired beyond
23
- // refresh lands here too: with refresh on, pi reports `credentials_not_configured`.
24
- //
25
- // 'unknown' is pi running but giving no usable answer: a timeout, non-JSON
26
- // output, or pi's own `status:"invalid"` — which per pi's auth-check means its
27
- // model runtime is in an error state or checkAuth threw, i.e. a pi-side fault,
28
- // not a verdict about this provider's credentials. (A missing pi binary is not
29
- // in here: the caller maps ENOENT to 'unusable', since it is deterministic.)
30
- export function parsePiAuthStatus(stdout: string): PiAuthStatus {
31
- try {
32
- const r = JSON.parse(stdout) as { status?: unknown; reason?: unknown }
33
- if (r.status === 'ready') return 'ready'
34
- if (r.reason === 'credentials_not_configured' || r.reason === 'provider_not_found') return 'unusable'
35
- } catch { /* not JSON → unknown */ }
36
- return 'unknown'
37
- }
38
-
39
- // One rule: only a deterministic failure justifies a decision.
40
- //
41
- // An 'unusable' provider can only fail — and because the chain throws its LAST
42
- // error, that failure ("No API key found for openai") would overwrite the real
43
- // error from the provider that actually broke, which is the bug this exists to
44
- // kill. So it is pruned, and a chain left with nothing blocks the run before ASR
45
- // is spent.
46
- //
47
- // 'unknown' is deliberately neutral: it neither prunes nor blocks. A probe that
48
- // could not answer is not evidence, and guessing on its behalf is the same
49
- // mistake in the other direction — let the run proceed and pi report the truth.
50
- //
51
- // Hence "may work": surviving this filter only means "not known to be broken",
52
- // and nothing downstream may read it as authenticated.
53
- const mayWork = (status: PiAuthStatus) => status !== 'unusable'
54
-
55
- export function usableChain(configured: string[], statusOf: (provider: string) => PiAuthStatus): string[] {
56
- return configured.filter(p => mayWork(statusOf(p)))
57
- }