@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 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,openai",
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. Credentials are
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. If that empties the chain, `vn run --mode notes`
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,openai",
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` 是从左到右尝试的回退链。凭证由 `pi auth check` 判定(覆盖
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
- 变空, `vn run --mode notes` 会直接跳过, 而不是花钱转写一个注定无法生成纪要的录音。
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.5",
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.5'
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
- if (!existsSync(config.recordDir)) {
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 } = await scanRecordings(config)
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
- const verboseSkips = Boolean(opts.verbose || opts.dryRun)
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
- export const DEFAULT_PI_PROVIDERS = ['openai-codex', 'openai']
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'