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