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