@fastagent-sh/voicenote 0.18.5 → 0.18.7
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 +8 -3
- package/README.zh-CN.md +7 -4
- package/package.json +1 -1
- package/src/cli.ts +43 -20
- package/src/piProvider.ts +6 -1
package/README.md
CHANGED
|
@@ -113,7 +113,7 @@ 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
|
|
116
|
+
"VOICENOTE_PI_PROVIDER": "openai-codex",
|
|
117
117
|
"VOICENOTE_PI_MODEL": "gpt-5.5",
|
|
118
118
|
"VOICENOTE_PI_THINKING": "high",
|
|
119
119
|
"VOICENOTE_PI_SUMMARY_TOOLS": "read,grep",
|
|
@@ -121,12 +121,16 @@ Optional settings:
|
|
|
121
121
|
}
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
-
`VOICENOTE_PI_PROVIDER` is a fallback chain, tried left to right
|
|
124
|
+
`VOICENOTE_PI_PROVIDER` is a fallback chain, tried left to right; it defaults to
|
|
125
|
+
`openai-codex` alone. Add the paid API path explicitly (`openai-codex,openai`) if
|
|
126
|
+
you keep an OpenAI key around. Credentials are
|
|
125
127
|
resolved by `pi auth check` (covering OAuth, keys stored by `pi` → `/login`, and
|
|
126
128
|
each provider's own API-key env var). A provider that deterministically cannot
|
|
127
129
|
work — no credentials, or not a provider pi knows — is dropped from the chain,
|
|
128
130
|
because its inevitable "No API key found" would replace the real error from the
|
|
129
|
-
provider that actually failed.
|
|
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`
|
|
130
134
|
skips instead of paying for a transcript whose summary cannot happen.
|
|
131
135
|
`vn doctor` prints the effective chain and the status of anything not ready.
|
|
132
136
|
|
|
@@ -158,6 +162,7 @@ vn run --latest # process only the latest valid recording
|
|
|
158
162
|
vn run --latest --force # re-run the latest one
|
|
159
163
|
vn run --pdf # additionally render a PDF after notes
|
|
160
164
|
vn run --dry-run # print the plan only
|
|
165
|
+
vn run /path/to/audio.m4a # process one file by path (skips the scan, ignores age/size/duration filters)
|
|
161
166
|
vn list # list this month's notes
|
|
162
167
|
vn list --month 2026-05 # specific month
|
|
163
168
|
vn last # print the latest processing summary
|
package/README.zh-CN.md
CHANGED
|
@@ -113,7 +113,7 @@ 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
|
|
116
|
+
"VOICENOTE_PI_PROVIDER": "openai-codex",
|
|
117
117
|
"VOICENOTE_PI_MODEL": "gpt-5.5",
|
|
118
118
|
"VOICENOTE_PI_THINKING": "high",
|
|
119
119
|
"VOICENOTE_PI_SUMMARY_TOOLS": "read,grep",
|
|
@@ -121,11 +121,13 @@ brew install ffmpeg
|
|
|
121
121
|
}
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
-
`VOICENOTE_PI_PROVIDER`
|
|
124
|
+
`VOICENOTE_PI_PROVIDER` 是从左到右尝试的回退链, 默认只有 `openai-codex`。如果你确实配了
|
|
125
|
+
OpenAI API key, 可以显式写成 `openai-codex,openai`。凭证由 `pi auth check` 判定(覆盖
|
|
125
126
|
OAuth、`pi` → `/login` 存下的 key、以及各 provider 自己的 API key 环境变量)。
|
|
126
127
|
确定不可能工作的 provider(没凭证, 或 pi 根本不认识这个名字)会被剔除 —— 否则
|
|
127
|
-
它必然抛出的 "No API key found" 会覆盖掉真正失败的那个 provider
|
|
128
|
-
|
|
128
|
+
它必然抛出的 "No API key found" 会覆盖掉真正失败的那个 provider 的错误。整条链都失败时,
|
|
129
|
+
报错会列出每个 provider 的失败原因, 回退项缺 key 不会再掩盖第一个 provider 的真正错误。
|
|
130
|
+
若剪枝后链变空, `vn run --mode notes` 会直接跳过, 而不是花钱转写一个注定无法生成纪要的录音。
|
|
129
131
|
实际生效的链和非 ready 项的状态由 `vn doctor` 打印。
|
|
130
132
|
|
|
131
133
|
## 用法
|
|
@@ -138,6 +140,7 @@ vn run --latest # 只处理最新有效录音
|
|
|
138
140
|
vn run --latest --force # 重跑最新条
|
|
139
141
|
vn run --pdf # 生成纪要后额外渲染 PDF
|
|
140
142
|
vn run --dry-run # 仅列出计划
|
|
143
|
+
vn run /path/to/audio.m4a # 直接处理单个文件(不扫描目录, 不套用时长/大小/时效过滤)
|
|
141
144
|
vn list # 列出本月笔记
|
|
142
145
|
vn list --month 2026-05 # 指定月份
|
|
143
146
|
vn last # 打印最新处理摘要
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fastagent-sh/voicenote",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.7",
|
|
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",
|
package/src/cli.ts
CHANGED
|
@@ -8,12 +8,12 @@ import { createHash, createHmac, randomUUID } from 'node:crypto'
|
|
|
8
8
|
import { appendFile, chmod, mkdir, readFile, writeFile, copyFile, rename, unlink, stat, readdir, rm } from 'node:fs/promises'
|
|
9
9
|
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, openSync, closeSync, statSync, readSync, unlinkSync, renameSync } from 'node:fs'
|
|
10
10
|
import { dlopen, FFIType, suffix } from 'bun:ffi'
|
|
11
|
-
import { basename, dirname, extname, join } from 'node:path'
|
|
11
|
+
import { basename, dirname, extname, join, resolve } from 'node:path'
|
|
12
12
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
13
13
|
import { spawn, spawnSync } from 'node:child_process'
|
|
14
14
|
import os from 'node:os'
|
|
15
15
|
|
|
16
|
-
const VERSION = '0.18.
|
|
16
|
+
const VERSION = '0.18.7'
|
|
17
17
|
const LAUNCH_AGENT_LABEL = 'sh.fastagent.voicenote'
|
|
18
18
|
const LAUNCH_AGENT_LABEL_LEGACY = 'com.kid7st.voicenote' // pre-fastagent installs; cleaned up on install
|
|
19
19
|
const TASK_NAME = 'VoiceNote' // Windows Task Scheduler name (mac uses LAUNCH_AGENT_LABEL)
|
|
@@ -800,6 +800,18 @@ function isCandidateFile(path: string): boolean {
|
|
|
800
800
|
* treating a half-read device as authoritative would delete live queue entries
|
|
801
801
|
* along with their retry counters.
|
|
802
802
|
*/
|
|
803
|
+
async function toRecording(file: string): Promise<Recording> {
|
|
804
|
+
const st = await stat(file)
|
|
805
|
+
return {
|
|
806
|
+
sourcePath: file,
|
|
807
|
+
sizeBytes: st.size,
|
|
808
|
+
modifiedAt: st.mtime.toISOString(),
|
|
809
|
+
durationSeconds: await ffprobeDuration(file),
|
|
810
|
+
sourceId: await sourceIdFor(file),
|
|
811
|
+
recordedAt: parseRecordedAt(file),
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
803
815
|
async function scanRecordings(config: Config): Promise<{ recordings: Recording[]; complete: boolean }> {
|
|
804
816
|
if (!existsSync(config.recordDir)) return { recordings: [], complete: false }
|
|
805
817
|
const recordings: Recording[] = []
|
|
@@ -813,14 +825,7 @@ async function scanRecordings(config: Config): Promise<{ recordings: Recording[]
|
|
|
813
825
|
if (!st) { complete = false; continue }
|
|
814
826
|
if (!st.isFile()) continue
|
|
815
827
|
try {
|
|
816
|
-
recordings.push(
|
|
817
|
-
sourcePath: file,
|
|
818
|
-
sizeBytes: st.size,
|
|
819
|
-
modifiedAt: st.mtime.toISOString(),
|
|
820
|
-
durationSeconds: await ffprobeDuration(file),
|
|
821
|
-
sourceId: await sourceIdFor(file),
|
|
822
|
-
recordedAt: parseRecordedAt(file),
|
|
823
|
-
})
|
|
828
|
+
recordings.push(await toRecording(file))
|
|
824
829
|
} catch (e) { complete = false; warnSideEffect(`read ${basename(file)} during scan`, e) }
|
|
825
830
|
}
|
|
826
831
|
} catch (e) {
|
|
@@ -1620,6 +1625,7 @@ async function chatCompleteViaPiCodex(opts: Omit<Parameters<typeof chatCompleteV
|
|
|
1620
1625
|
if (!providers.length) throw new Error(noUsableProviderMessage())
|
|
1621
1626
|
const maxAttempts = Math.max(1, Number(process.env.VOICENOTE_PI_RETRIES || 3))
|
|
1622
1627
|
let lastError: any = null
|
|
1628
|
+
const failures: string[] = []
|
|
1623
1629
|
for (const [idx, provider] of providers.entries()) {
|
|
1624
1630
|
if (idx > 0) console.error(`pi provider fallback: trying ${provider} after ${providers[idx - 1]} failed: ${lastError?.message || lastError}`)
|
|
1625
1631
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -1636,7 +1642,14 @@ async function chatCompleteViaPiCodex(opts: Omit<Parameters<typeof chatCompleteV
|
|
|
1636
1642
|
break // non-transient, or retries exhausted → fall back to next provider
|
|
1637
1643
|
}
|
|
1638
1644
|
}
|
|
1639
|
-
|
|
1645
|
+
failures.push(`${provider}: ${String(lastError?.message || lastError).slice(0, 400)}`)
|
|
1646
|
+
}
|
|
1647
|
+
// Report EVERY provider's error, not just the chain's last one. The last link is
|
|
1648
|
+
// usually the fallback nobody signed into, so its "No API key found for openai"
|
|
1649
|
+
// buried the actual reason openai-codex failed (usage limit, expired token, 5xx)
|
|
1650
|
+
// in the notes stub and in the GUI — which then disagreed with a dashboard that
|
|
1651
|
+
// correctly showed ChatGPT as connected.
|
|
1652
|
+
if (failures.length > 1) throw new Error(`pi summary failed on every provider — ${failures.join(' | ')}`)
|
|
1640
1653
|
throw lastError || new Error('pi provider fallback exhausted')
|
|
1641
1654
|
}
|
|
1642
1655
|
|
|
@@ -2061,9 +2074,10 @@ function pidAlive(pid: number): boolean {
|
|
|
2061
2074
|
try { process.kill(pid, 0); return true } catch (e: any) { return e?.code === 'EPERM' }
|
|
2062
2075
|
}
|
|
2063
2076
|
|
|
2064
|
-
async function runPipeline(opts: any): Promise<void> {
|
|
2077
|
+
async function runPipeline(file: string | undefined, opts: any): Promise<void> {
|
|
2065
2078
|
wireDailyLog()
|
|
2066
2079
|
const config = getConfig()
|
|
2080
|
+
opts = { ...opts, file }
|
|
2067
2081
|
const lock = await acquireRunLock()
|
|
2068
2082
|
if (!lock) {
|
|
2069
2083
|
console.log('voicenote pipeline already running; skip')
|
|
@@ -2089,21 +2103,30 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
|
|
|
2089
2103
|
const interrupted = reconcileInterrupted(store.jobs, nowIso())
|
|
2090
2104
|
if (interrupted.length) console.log(`Reclaimed ${interrupted.length} job(s) left running by an interrupted run: ${interrupted.slice(0, 3).map(j => j.name).join(', ')}`)
|
|
2091
2105
|
|
|
2092
|
-
|
|
2106
|
+
// Explicit file: process exactly that path, wherever it lives. Nothing is
|
|
2107
|
+
// scanned, so the listing is never "complete" (no pruning), and the recorder
|
|
2108
|
+
// filters (age/size/duration) don't apply — the user named the file.
|
|
2109
|
+
const single = opts.file ? resolve(String(opts.file)) : null
|
|
2110
|
+
if (single && !statSync(single, { throwIfNoEntry: false })?.isFile()) throw new Error(`Not a file: ${single}`)
|
|
2111
|
+
if (!single && !existsSync(config.recordDir)) {
|
|
2093
2112
|
if (shouldLogIdleStatus(`missing:${config.recordDir}`)) {
|
|
2094
2113
|
console.log(`Idle: recorder not mounted or record dir missing: ${config.recordDir} (repeated idle logs suppressed for 30m)`)
|
|
2095
2114
|
}
|
|
2096
2115
|
return
|
|
2097
2116
|
}
|
|
2098
|
-
const { recordings, complete: scanComplete } =
|
|
2117
|
+
const { recordings, complete: scanComplete } = single
|
|
2118
|
+
? { recordings: [await toRecording(single)], complete: false }
|
|
2119
|
+
: await scanRecordings(config)
|
|
2099
2120
|
const mode = normalizeRunMode(opts)
|
|
2100
2121
|
const force = Boolean(opts.force)
|
|
2101
2122
|
const eligible: Recording[] = []
|
|
2102
2123
|
const skipCounts: Record<string, number> = {}
|
|
2103
2124
|
const skipSamples: Record<string, string[]> = {}
|
|
2104
|
-
|
|
2125
|
+
// An explicitly named file that gets skipped must say why, not fall into the
|
|
2126
|
+
// idle-suppressed silence meant for the 60s scheduler tick.
|
|
2127
|
+
const verboseSkips = Boolean(opts.verbose || opts.dryRun || single)
|
|
2105
2128
|
const seen = new Set<string>()
|
|
2106
|
-
const limits = limitsOf(config)
|
|
2129
|
+
const limits = single ? { maxAgeHours: 0, minBytes: 0, minDurationSeconds: 0 } : limitsOf(config)
|
|
2107
2130
|
for (const rec of recordings) {
|
|
2108
2131
|
seen.add(rec.sourceId)
|
|
2109
2132
|
const entry = recordFor(store, rec)
|
|
@@ -2117,7 +2140,7 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
|
|
|
2117
2140
|
// Only prune against a listing we believe to be complete: if the recorder went
|
|
2118
2141
|
// away mid-glob the scan is partial, and pruning would wipe live queue entries
|
|
2119
2142
|
// (they'd return on the next scan, but their retry counters would not).
|
|
2120
|
-
const dropped = pruneUnseen(store.jobs, seen, scanComplete && existsSync(config.recordDir))
|
|
2143
|
+
const dropped = pruneUnseen(store.jobs, seen, !single && scanComplete && existsSync(config.recordDir))
|
|
2121
2144
|
// The only routine path that deletes state — never do it silently.
|
|
2122
2145
|
if (dropped.length) console.log(`Forgot ${dropped.length} record(s) whose source is no longer on the recorder: ${dropped.slice(0, 3).map(j => j.name).join(', ')}${dropped.length > 3 ? `…(+${dropped.length - 3})` : ''}`)
|
|
2123
2146
|
const skipSummary = Object.entries(skipCounts).map(([reason, count]) => `${reason}=${count}`).join(', ') || 'none'
|
|
@@ -2202,7 +2225,7 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
|
|
|
2202
2225
|
}
|
|
2203
2226
|
// Whole recorder went away — every remaining target would fail the same way
|
|
2204
2227
|
// and churn ASR-free but noisy retries. Stop and let the next run rescan.
|
|
2205
|
-
if (!existsSync(config.recordDir)) {
|
|
2228
|
+
if (!single && !existsSync(config.recordDir)) {
|
|
2206
2229
|
console.error(`Recorder disappeared mid-run (${config.recordDir}); stopping. Remaining recordings stay queued.`)
|
|
2207
2230
|
break
|
|
2208
2231
|
}
|
|
@@ -2902,7 +2925,7 @@ async function dispatchServe(req: any, send: (o: unknown) => void): Promise<void
|
|
|
2902
2925
|
// request timeout can't misread it as a wedged engine. Progress shows
|
|
2903
2926
|
// via the jobs poll; acquireRunLock inside runPipeline dedupes against
|
|
2904
2927
|
// the scheduler tick and a double-click.
|
|
2905
|
-
void runPipeline({}).catch(e => console.error('manual run failed:', e?.message || e))
|
|
2928
|
+
void runPipeline(undefined, {}).catch(e => console.error('manual run failed:', e?.message || e))
|
|
2906
2929
|
result = { started: true }
|
|
2907
2930
|
break
|
|
2908
2931
|
}
|
|
@@ -2986,7 +3009,7 @@ async function serve(): Promise<void> {
|
|
|
2986
3009
|
|
|
2987
3010
|
const cli = cac('vn')
|
|
2988
3011
|
|
|
2989
|
-
cli.command('run', 'Scan recorder and process recordings (Volcano ASR + pi notes)')
|
|
3012
|
+
cli.command('run [file]', 'Scan recorder and process recordings, or process one audio file by path (Volcano ASR + pi notes)')
|
|
2990
3013
|
.option('--mode <mode>', 'Output mode: notes (default) | transcript', { default: 'notes' })
|
|
2991
3014
|
.option('--latest', 'Only process newest eligible recording')
|
|
2992
3015
|
.option('--force', 'Reprocess already processed recordings')
|
package/src/piProvider.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// Which pi providers the summary chain tries. Credential *detection* lives in
|
|
2
2
|
// cli.ts (it shells out to `pi auth check`); only the pure logic is here.
|
|
3
3
|
|
|
4
|
-
|
|
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']
|
|
5
10
|
|
|
6
11
|
export function defaultPiModel(provider: string): string {
|
|
7
12
|
return provider === 'deepseek' ? 'deepseek-v4-flash' : 'gpt-5.5'
|