@fastagent-sh/voicenote 0.18.0 → 0.18.2

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
@@ -121,6 +121,15 @@ Optional settings:
121
121
  }
122
122
  ```
123
123
 
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.
132
+
124
133
  ## Usage
125
134
 
126
135
  ```bash
@@ -242,7 +251,7 @@ git push --follow-tags
242
251
 
243
252
  The workflow lives at `.github/workflows/release.yml`: CI explicitly runs typecheck/test/build + an artifact smoke test, then `npm publish --ignore-scripts` (deterministic publishing, no lifecycle dependence). Publishing uses **npm trusted publishing (OIDC)**: no long-lived token (`id-token: write` + a Trusted Publisher configured on npmjs.com), with provenance attached automatically. A bare local `npm publish` is still guarded by `prepublishOnly` (typecheck+test+build).
244
253
 
245
- > **First-release exception**: npm has no pending-publisher, so trusted publishing cannot publish a package's very first version. Publish once manually with `npm login` + `npm publish --ignore-scripts`, then add a Trusted Publisher on the package settings page at npmjs.com (repo `fastagent-sh/voicenote`, workflow `release.yml`); CI takes over afterwards (the npm account needs 2FA).
254
+ > Both are already done for this package (Trusted Publisher configured, CI publishing since 0.18.0 with provenance), so a routine release needs nothing but the tag. Kept for forks: npm has no pending-publisher, so trusted publishing cannot publish a package's *very first* version publish once manually with `npm login` + `npm publish --ignore-scripts`, then add a Trusted Publisher on the package settings page at npmjs.com (repo, workflow `release.yml`); CI takes over afterwards (the npm account needs 2FA).
246
255
 
247
256
  ## Desktop app (GUI, `app/`)
248
257
 
package/README.zh-CN.md CHANGED
@@ -121,6 +121,13 @@ brew install ffmpeg
121
121
  }
122
122
  ```
123
123
 
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` 打印。
130
+
124
131
  ## 用法
125
132
 
126
133
  ```bash
@@ -242,7 +249,7 @@ git push --follow-tags
242
249
 
243
250
  workflow 位于 `.github/workflows/release.yml`:CI 显式跑 typecheck/test/build + 产物冒烟,再 `npm publish --ignore-scripts`(确定发布,不依赖 lifecycle)。发布走 **npm trusted publishing(OIDC)**:免长期 token(`id-token: write` + npmjs.com 上配好 Trusted Publisher),自动带 provenance。本地裸 `npm publish` 则由 `prepublishOnly`(typecheck+test+build)兼底。
244
251
 
245
- > **首发例外**:npm 无 pending-publisher,trusted publishing 发不了包的第一个版本。先本机 `npm login` 后手动 `npm publish --ignore-scripts` 发一次,再到 npmjs.com 包设置页加 Trusted Publisher(repo `fastagent-sh/voicenote`、workflow `release.yml`),之后 CI 自动接管(需 npm 账号开 2FA)。
252
+ > 本包这两步都已完成(Trusted Publisher 已配置,自 0.18.0 起由 CI 发布并带 provenance),常规发版只需打 tag。以下保留给 fork 者:npm 无 pending-publisher,trusted publishing 发不了包的**第一个**版本 —— 先本机 `npm login` 后手动 `npm publish --ignore-scripts` 发一次,再到 npmjs.com 包设置页加 Trusted Publisher(repoworkflow `release.yml`),之后 CI 自动接管(需 npm 账号开 2FA)。
246
253
 
247
254
  ## 桌面客户端(GUI,`app/`)
248
255
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastagent-sh/voicenote",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
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,6 +31,7 @@
31
31
  "src/envConfig.ts",
32
32
  "src/jobs.ts",
33
33
  "src/runLock.ts",
34
+ "src/piProvider.ts",
34
35
  "README.md",
35
36
  "LICENSE"
36
37
  ],
package/src/cli.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { cac } from 'cac'
3
3
  import { deriveNoProxy, envKeysToEmbed, hydrateFromFileEnv, parseFileEnv } from './envConfig'
4
4
  import { parseLockOwner } from './runLock'
5
+ import { parsePiAuthStatus, parseProviderChain, usableChain, type PiAuthStatus } from './piProvider'
5
6
  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'
6
7
  import { createHash, createHmac, randomUUID } from 'node:crypto'
7
8
  import { appendFile, chmod, mkdir, readFile, writeFile, copyFile, rename, unlink, stat, readdir, rm } from 'node:fs/promises'
@@ -12,7 +13,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
12
13
  import { spawn, spawnSync } from 'node:child_process'
13
14
  import os from 'node:os'
14
15
 
15
- const VERSION = '0.18.0'
16
+ const VERSION = '0.18.2'
16
17
  const LAUNCH_AGENT_LABEL = 'sh.fastagent.voicenote'
17
18
  const LAUNCH_AGENT_LABEL_LEGACY = 'com.kid7st.voicenote' // pre-fastagent installs; cleaned up on install
18
19
  const TASK_NAME = 'VoiceNote' // Windows Task Scheduler name (mac uses LAUNCH_AGENT_LABEL)
@@ -1292,10 +1293,19 @@ function piInvocation(args: string[]): { bin: string; args: string[] } {
1292
1293
  return cli ? { bin, args: [cli, ...args] } : { bin, args }
1293
1294
  }
1294
1295
 
1295
- // Heuristic for "pi is logged in": the OAuth credential file exists. Used to skip
1296
- // the pipeline before spending ASR on notes whose pi-codex summary would fail.
1297
- function piAuthAvailable(): boolean {
1298
- return existsSync(PI_AUTH_PATH)
1296
+ // Gate before ASR is spent. "May work", not "will": only a chain pruned to
1297
+ // nothing (every provider deterministically unusable) stops a run see
1298
+ // usableChain for why an unanswerable probe does not.
1299
+ function summaryMayWork(): boolean {
1300
+ return piProviderCandidates().length > 0
1301
+ }
1302
+
1303
+ // The one diagnostic for an empty chain, shared by the pre-ASR gate and the
1304
+ // summary call itself. Both must name the same cause: a generic "exhausted" here
1305
+ // would overwrite the truth exactly the way "No API key found for openai" did.
1306
+ function noUsableProviderMessage(): string {
1307
+ const statuses = piConfiguredProviders().map(p => `${p}:${piProviderAuthStatus(p)}`).join(' ')
1308
+ 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.`
1299
1309
  }
1300
1310
 
1301
1311
  // ───────────────────────────────────────────────────────────────────────
@@ -1467,15 +1477,50 @@ async function configSet(): Promise<void> {
1467
1477
  }
1468
1478
  }
1469
1479
 
1480
+ // A healthy probe takes ~0.5s and the chain is re-read many times per run, so
1481
+ // cache it — but briefly: `vn serve` is long-lived, and the answer changes out of
1482
+ // band when the user runs `pi` → `/login` elsewhere, or an OAuth token expires.
1483
+ const PI_AUTH_TTL_MS = 60_000
1484
+ // Refresh is deliberately left on (no --no-refresh) so an expired OAuth that
1485
+ // cannot be renewed reads as broken rather than ready — which means the probe
1486
+ // touches the network and can hang. Probes are serial, so a hung pi blocks a
1487
+ // scheduler tick for this × the chain length. Kept impatient (a healthy probe is
1488
+ // ~0.5s) because a timeout yields 'unknown', which is neutral: the run proceeds
1489
+ // and pi reports the real error, so cutting it short costs nothing.
1490
+ const PI_AUTH_PROBE_MS = 2000
1491
+ const piAuthStatusCache = new Map<string, { status: PiAuthStatus; at: number }>()
1492
+ function piProviderAuthStatus(provider: string): PiAuthStatus {
1493
+ const cached = piAuthStatusCache.get(provider)
1494
+ if (cached && Date.now() - cached.at < PI_AUTH_TTL_MS) return cached.status
1495
+ const inv = piInvocation(['auth', 'check', '--provider', provider, '--json'])
1496
+ const out = spawnSync(inv.bin, inv.args, { encoding: 'utf8', timeout: PI_AUTH_PROBE_MS, windowsHide: true })
1497
+ // No pi binary is deterministic evidence in its own right — next run gets the
1498
+ // same answer — so it belongs with 'unusable', not with a probe that timed out.
1499
+ const status = (out.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT'
1500
+ ? 'unusable'
1501
+ : parsePiAuthStatus(out.stdout || '')
1502
+ // 'unknown' is neutral by design, so nothing downstream reports it. Say it here
1503
+ // — throttled, not once-per-process: `vn serve` re-probes every TTL and a probe
1504
+ // that never answers would otherwise go silent after its first tick.
1505
+ if (status !== 'ready' && shouldLogIdleStatus(`pi-auth-probe:${provider}:${status}`)) {
1506
+ console.error(`pi auth check ${provider} → ${status}: ${String(out.error?.message || out.stderr || out.stdout || 'no output').slice(0, 200)}`)
1507
+ }
1508
+ piAuthStatusCache.set(provider, { status, at: Date.now() })
1509
+ return status
1510
+ }
1511
+
1512
+ function piConfiguredProviders(): string[] {
1513
+ return parseProviderChain(process.env.VOICENOTE_PI_PROVIDER)
1514
+ }
1515
+
1470
1516
  function piProviderCandidates(): string[] {
1471
- const configured = process.env.VOICENOTE_PI_PROVIDER?.trim()
1472
- const raw = configured || 'openai-codex,openai'
1473
- const providers = raw.split(',').map(s => s.trim()).filter(Boolean)
1474
- return providers.length ? Array.from(new Set(providers)) : ['openai-codex', 'openai']
1517
+ return usableChain(piConfiguredProviders(), piProviderAuthStatus)
1475
1518
  }
1476
1519
 
1477
1520
  function piProviderFor(): string {
1478
- return piProviderCandidates()[0] || 'openai-codex'
1521
+ // Label for logs and metadata.llm_backend. Empty chain is a real state now, and
1522
+ // naming a provider we already know is unusable would put a lie in the record.
1523
+ return piProviderCandidates()[0] || 'none-usable'
1479
1524
  }
1480
1525
 
1481
1526
  function piCodexModelFor(): string {
@@ -1563,15 +1608,20 @@ function isTransientPiError(e: any): boolean {
1563
1608
  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)
1564
1609
  }
1565
1610
 
1566
- async function chatCompleteViaPiCodex(opts: Omit<Parameters<typeof chatCompleteViaPiProvider>[0], 'provider'>): Promise<string> {
1611
+ // Returns the provider that actually answered, not the one we started with: the
1612
+ // chain falls back, and metadata recording the head would misname the run.
1613
+ async function chatCompleteViaPiCodex(opts: Omit<Parameters<typeof chatCompleteViaPiProvider>[0], 'provider'>): Promise<{ text: string; provider: string }> {
1567
1614
  const providers = piProviderCandidates()
1615
+ // Reachable past the pre-ASR gate: credentials can lapse mid-run, or the caller
1616
+ // may not go through it at all.
1617
+ if (!providers.length) throw new Error(noUsableProviderMessage())
1568
1618
  const maxAttempts = Math.max(1, Number(process.env.VOICENOTE_PI_RETRIES || 3))
1569
1619
  let lastError: any = null
1570
1620
  for (const [idx, provider] of providers.entries()) {
1571
1621
  if (idx > 0) console.error(`pi provider fallback: trying ${provider} after ${providers[idx - 1]} failed: ${lastError?.message || lastError}`)
1572
1622
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1573
1623
  try {
1574
- return await chatCompleteViaPiProvider({ ...opts, provider })
1624
+ return { text: await chatCompleteViaPiProvider({ ...opts, provider }), provider }
1575
1625
  } catch (e: any) {
1576
1626
  lastError = e
1577
1627
  if (attempt < maxAttempts && isTransientPiError(e)) {
@@ -1615,7 +1665,7 @@ function piSummaryToolsHint(contextDir: string): string {
1615
1665
  // base, so read/grep/find operate there directly. If a configured context dir is
1616
1666
  // missing, say so loudly and run without tools rather than searching the wrong
1617
1667
  // tree (tools, the cwd hint, and the spawn cwd move together).
1618
- async function chatComplete(opts: { systemPrompt: string; userPrompt: string; config: Config }): Promise<string> {
1668
+ async function chatComplete(opts: { systemPrompt: string; userPrompt: string; config: Config }): Promise<{ text: string; provider: string }> {
1619
1669
  const wantTools = !!piSummaryTools()
1620
1670
  const ctx = wantTools ? summaryContextDir(opts.config) : undefined
1621
1671
  const ctxExists = ctx ? existsSync(ctx) : false
@@ -1633,14 +1683,14 @@ async function chatComplete(opts: { systemPrompt: string; userPrompt: string; co
1633
1683
  })
1634
1684
  }
1635
1685
 
1636
- async function summarizeTranscript(config: Config, transcript: string, rec: Recording, localAudioPath: string): Promise<Json> {
1686
+ async function summarizeTranscript(config: Config, transcript: string, rec: Recording, localAudioPath: string): Promise<{ meta: Json; provider: string }> {
1637
1687
  const messages = summaryMessages(config, transcript, rec, localAudioPath)
1638
1688
  const systemPrompt = String(messages[0]!.content)
1639
1689
  const userPrompt = String(messages[1]!.content)
1640
- const text = await chatComplete({ systemPrompt, userPrompt, config })
1690
+ const { text, provider } = await chatComplete({ systemPrompt, userPrompt, config })
1641
1691
  const jsonText = extractFirstJsonObject(text)
1642
1692
  try {
1643
- return JSON.parse(jsonText || '{}')
1693
+ return { meta: JSON.parse(jsonText || '{}') as Json, provider }
1644
1694
  } catch (e: any) {
1645
1695
  throw new Error(`summary returned non-JSON output (${e?.message || e}). First 400 chars: ${text.slice(0, 400)}`)
1646
1696
  }
@@ -1731,14 +1781,16 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1731
1781
  const needsNotes = mode === 'notes'
1732
1782
  const resumeSummary = needsNotes && Boolean(opts.resumeFromTranscriptFiles)
1733
1783
  const transcribeBackendLabel = `volcano:${config.volcano?.resourceId || 'volc.seedasr.auc'}`
1734
- const llmBackendLabel = `pi:${piProviderFor()}`
1784
+ // Lazy: resolving it probes pi (network, and `auth check` refreshes tokens on
1785
+ // disk). Transcript mode never calls pi, and --dry-run promises no side effects.
1786
+ const llmBackendLabel = needsNotes && !opts.dryRun ? `pi:${piProviderFor()}` : null
1735
1787
  const plan = resumeSummary
1736
1788
  ? 'reuse saved transcript → integrated semantic notes → write metadata/index (no auto move)'
1737
1789
  : `copy audio → transcribe → write transcript${needsNotes ? ' → integrated semantic notes' : ''} → write metadata/index (no auto move)`
1738
1790
 
1739
1791
  console.log(`\n=== voicenote job: ${basename(rec.sourcePath)} ===`)
1740
1792
  console.log(`Source: ${rec.sourcePath}`)
1741
- console.log(`Audio: duration=${rec.durationSeconds == null ? 'unknown' : formatSeconds(rec.durationSeconds)}, size=${formatBytes(rec.sizeBytes)}, mode=${mode}, asr=${transcribeBackendLabel}, llm=${llmBackendLabel}`)
1793
+ console.log(`Audio: duration=${rec.durationSeconds == null ? 'unknown' : formatSeconds(rec.durationSeconds)}, size=${formatBytes(rec.sizeBytes)}, mode=${mode}, asr=${transcribeBackendLabel}${llmBackendLabel ? `, llm=${llmBackendLabel}` : ''}`)
1742
1794
  console.log(`Plan: ${plan}`)
1743
1795
  if (opts.dryRun) return { source_path: rec.sourcePath, source_id: rec.sourceId, would_copy_to: files.audio, resume_from_transcript: resumeSummary ? files.transcript : null, size_bytes: rec.sizeBytes, duration_seconds: rec.durationSeconds, mode }
1744
1796
 
@@ -1783,10 +1835,13 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1783
1835
  }
1784
1836
 
1785
1837
  let summaryError: any = null
1838
+ let summaryProvider: string | null = null
1786
1839
  if (needsNotes) {
1787
1840
  progressStep(nextStep(), totalSteps, 'Generate integrated semantic notes', `model=${piCodexModelFor()} via ${llmBackendLabel}`)
1788
1841
  try {
1789
- meta = await withHeartbeat('generate integrated semantic notes', () => summarizeTranscript(config, transcript, rec, files.audio), 60)
1842
+ const summary = await withHeartbeat('generate integrated semantic notes', () => summarizeTranscript(config, transcript, rec, files.audio), 60)
1843
+ meta = summary.meta
1844
+ summaryProvider = summary.provider
1790
1845
  } catch (e: any) {
1791
1846
  summaryError = e
1792
1847
  console.error(`Summary step failed; transcript is preserved. Error: ${e?.message || e}`)
@@ -1804,7 +1859,9 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
1804
1859
  meta.asr_provider = 'volcano'
1805
1860
  meta.transcribe_model = config.volcano?.resourceId || 'volc.seedasr.auc'
1806
1861
  meta.summary_model = needsNotes && !summaryError ? piCodexModelFor() : null
1807
- meta.llm_backend = needsNotes ? llmBackendLabel : null
1862
+ // The provider that actually answered, not the head of the chain it may have
1863
+ // fallen back from. Null when no summary ran — summary_error says why.
1864
+ meta.llm_backend = summaryProvider ? `pi:${summaryProvider}` : null
1808
1865
  meta.processed_at = nowIso()
1809
1866
  if (summaryError) meta.summary_error = String(summaryError?.message || summaryError)
1810
1867
 
@@ -2078,8 +2135,8 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2078
2135
  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.')
2079
2136
  return
2080
2137
  }
2081
- if (mode === 'notes' && !piAuthAvailable()) {
2082
- if (shouldLogIdleStatus(`pi-noauth:${config.recordDir}`)) console.error('pi is not logged in (~/.pi/agent/auth.json missing). Skipping to avoid spending ASR on notes whose summary would fail. Run `pi` to log in, then re-run.')
2138
+ if (mode === 'notes' && !summaryMayWork()) {
2139
+ if (shouldLogIdleStatus(`pi-noauth:${config.recordDir}`)) console.error(`Skipping to avoid spending ASR on notes whose summary would fail: ${noUsableProviderMessage()}`)
2083
2140
  return
2084
2141
  }
2085
2142
  }
@@ -2696,6 +2753,12 @@ async function collectDoctor() {
2696
2753
  const ff = await runCommand(ffprobeBin(), ['-version'], 5000)
2697
2754
  const v = config.volcano
2698
2755
  const tools = piSummaryTools()
2756
+ // Configured vs effective, plus per-provider status: "why did my chain shrink"
2757
+ // and "why is my misspelled provider still listed" are the same question.
2758
+ const configuredProviders = piConfiguredProviders()
2759
+ const effectiveProviders = piProviderCandidates()
2760
+ const providerStatus = Object.fromEntries(configuredProviders.map(p => [p, piProviderAuthStatus(p)]))
2761
+ const summaryReady = effectiveProviders.length > 0
2699
2762
  return {
2700
2763
  version: VERSION,
2701
2764
  bun: process.versions.bun || null,
@@ -2711,13 +2774,18 @@ async function collectDoctor() {
2711
2774
  language: v.language ?? null,
2712
2775
  }
2713
2776
  : { configured: false as const },
2714
- summary: { backend: `pi:${piProviderCandidates().join('→')}`, providers: piProviderCandidates(), model: piCodexModelFor(), thinking: piThinkingLevel(), tools: tools || null, contextDir: tools ? summaryContextDir(config) : null },
2715
- pi: { bin: piCodexBin(), version: piCheck.code === 0 ? (piCheck.stdout.trim() || piCheck.stderr.trim() || null) : null, available: piCheck.code === 0, auth: piAuthAvailable() },
2777
+ summary: { backend: `pi:${effectiveProviders.join('→') || 'none-usable'}`, ready: summaryReady, configuredProviders, effectiveProviders, providerStatus, model: piCodexModelFor(), thinking: piThinkingLevel(), tools: tools || null, contextDir: tools ? summaryContextDir(config) : null },
2778
+ pi: { bin: piCodexBin(), version: piCheck.code === 0 ? (piCheck.stdout.trim() || piCheck.stderr.trim() || null) : null, available: piCheck.code === 0, auth: existsSync(PI_AUTH_PATH) },
2716
2779
  // Outbound proxy for HTTPS endpoints (updater/GitHub): honor the standard
2717
2780
  // env chain, not just lowercase http_proxy — an https_proxy-only setup must
2718
2781
  // still route the updater.
2719
2782
  proxy: { url: process.env.https_proxy || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.HTTP_PROXY || null },
2720
2783
  identity: { self: config.speakers.self.name || null, aliases: config.speakers.self.aliases, knownCount: config.speakers.known.length },
2784
+ // The thresholds that silently decide what never gets processed. Without
2785
+ // them here, confirming a change to VOICENOTE_MAX_AGE_HOURS meant planting
2786
+ // a test recording and watching the scan — not a reasonable way to check
2787
+ // a setting.
2788
+ filters: { maxAgeHours: config.maxAgeHours, minBytes: config.minBytes, minDurationSeconds: config.minDurationSeconds },
2721
2789
  deps: { ffprobe: ff.code === 0 },
2722
2790
  agent: await agentStatus(),
2723
2791
  }
@@ -2763,6 +2831,7 @@ async function doctor(opts: { json?: boolean } = {}): Promise<void> {
2763
2831
  console.log(`node=${s.node}`)
2764
2832
  console.log(`recordDir=${s.recorder.dir} exists=${s.recorder.exists}`)
2765
2833
  console.log(`workspace=${s.workspace}`)
2834
+ console.log(`filters=maxAge:${s.filters.maxAgeHours > 0 ? `${s.filters.maxAgeHours}h` : 'none'} minSize:${(s.filters.minBytes / 1000).toFixed(0)}KB minDuration:${s.filters.minDurationSeconds}s`)
2766
2835
  if (s.volcano.configured) {
2767
2836
  console.log(`volcano.auth=${s.volcano.auth}`)
2768
2837
  console.log(`volcano.resourceId=${s.volcano.resourceId}`)
@@ -2773,12 +2842,17 @@ async function doctor(opts: { json?: boolean } = {}): Promise<void> {
2773
2842
  console.log(`volcano=not configured`)
2774
2843
  }
2775
2844
  console.log(`summaryBackend=${s.summary.backend}`)
2776
- console.log(`pi.bin=${s.pi.bin} providers=${s.summary.providers.join(',')} model.summary=${s.summary.model}`)
2845
+ console.log(`pi.bin=${s.pi.bin} providers=${s.summary.effectiveProviders.join(',') || '<none usable>'} model.summary=${s.summary.model}`)
2846
+ const notReady = Object.entries(s.summary.providerStatus).filter(([, st]) => st !== 'ready')
2847
+ 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)`)
2848
+ if (!s.summary.ready) console.log('summary.ready=NO — every configured provider is unusable; `vn run --mode notes` will skip rather than spend ASR')
2777
2849
  console.log(`pi.thinking=${s.summary.thinking}`)
2778
2850
  console.log(`pi.summaryTools=${s.summary.tools || '<disabled>'}`)
2779
2851
  if (s.summary.contextDir) console.log(`pi.contextDir=${s.summary.contextDir} (summary agent cwd + read/grep cross-reference root)`)
2780
2852
  console.log(`pi.version=${s.pi.version || 'missing'}`)
2781
- console.log(`pi.auth=${s.pi.auth ? 'logged-in' : 'NOT logged-in run `vn login` to sign in, else the summary step will fail'}`)
2853
+ // Neutral fact, not an instruction: an API-key user has no auth.json and needs
2854
+ // nothing fixed. summary.ready above is what says whether anything is wrong.
2855
+ 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)'}`)
2782
2856
  console.log(`defaultMode=notes`)
2783
2857
  console.log(`proxy=${s.proxy.url || '<unset>'}`)
2784
2858
  console.log(`speakers.self=${s.identity.self || '<unset>'}`)
@@ -0,0 +1,53 @@
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 parseProviderChain(raw: string | undefined): string[] {
7
+ const parsed = Array.from(new Set((raw ?? '').split(',').map(s => s.trim()).filter(Boolean)))
8
+ return parsed.length ? parsed : [...DEFAULT_PI_PROVIDERS]
9
+ }
10
+
11
+ export type PiAuthStatus = 'ready' | 'unusable' | 'unknown'
12
+
13
+ // `pi auth check --json` is the one source of truth for credentials — OAuth, keys
14
+ // stored by `/login`, and each provider's own env var (OPENAI_API_KEY,
15
+ // GEMINI_API_KEY, …), names we must not reimplement guessing.
16
+ //
17
+ // 'unusable' is a DETERMINISTIC failure: no credentials, or no such provider —
18
+ // next run gets the same answer. Note an OAuth token that has expired beyond
19
+ // refresh lands here too: with refresh on, pi reports `credentials_not_configured`.
20
+ //
21
+ // 'unknown' is pi running but giving no usable answer: a timeout, non-JSON
22
+ // output, or pi's own `status:"invalid"` — which per pi's auth-check means its
23
+ // model runtime is in an error state or checkAuth threw, i.e. a pi-side fault,
24
+ // not a verdict about this provider's credentials. (A missing pi binary is not
25
+ // in here: the caller maps ENOENT to 'unusable', since it is deterministic.)
26
+ export function parsePiAuthStatus(stdout: string): PiAuthStatus {
27
+ try {
28
+ const r = JSON.parse(stdout) as { status?: unknown; reason?: unknown }
29
+ if (r.status === 'ready') return 'ready'
30
+ if (r.reason === 'credentials_not_configured' || r.reason === 'provider_not_found') return 'unusable'
31
+ } catch { /* not JSON → unknown */ }
32
+ return 'unknown'
33
+ }
34
+
35
+ // One rule: only a deterministic failure justifies a decision.
36
+ //
37
+ // An 'unusable' provider can only fail — and because the chain throws its LAST
38
+ // error, that failure ("No API key found for openai") would overwrite the real
39
+ // error from the provider that actually broke, which is the bug this exists to
40
+ // kill. So it is pruned, and a chain left with nothing blocks the run before ASR
41
+ // is spent.
42
+ //
43
+ // 'unknown' is deliberately neutral: it neither prunes nor blocks. A probe that
44
+ // could not answer is not evidence, and guessing on its behalf is the same
45
+ // mistake in the other direction — let the run proceed and pi report the truth.
46
+ //
47
+ // Hence "may work": surviving this filter only means "not known to be broken",
48
+ // and nothing downstream may read it as authenticated.
49
+ const mayWork = (status: PiAuthStatus) => status !== 'unusable'
50
+
51
+ export function usableChain(configured: string[], statusOf: (provider: string) => PiAuthStatus): string[] {
52
+ return configured.filter(p => mayWork(statusOf(p)))
53
+ }