@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 +10 -1
- package/README.zh-CN.md +8 -1
- package/package.json +2 -1
- package/src/cli.ts +100 -26
- package/src/piProvider.ts +53 -0
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
|
-
>
|
|
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
|
-
>
|
|
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(repo、workflow `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.
|
|
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.
|
|
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
|
-
//
|
|
1296
|
-
//
|
|
1297
|
-
|
|
1298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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' && !
|
|
2082
|
-
if (shouldLogIdleStatus(`pi-noauth:${config.recordDir}`)) console.error(
|
|
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:${
|
|
2715
|
-
pi: { bin: piCodexBin(), version: piCheck.code === 0 ? (piCheck.stdout.trim() || piCheck.stderr.trim() || null) : null, available: piCheck.code === 0, auth:
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|