@fastagent-sh/voicenote 0.18.5 → 0.18.6

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
@@ -158,6 +158,7 @@ vn run --latest # process only the latest valid recording
158
158
  vn run --latest --force # re-run the latest one
159
159
  vn run --pdf # additionally render a PDF after notes
160
160
  vn run --dry-run # print the plan only
161
+ vn run /path/to/audio.m4a # process one file by path (skips the scan, ignores age/size/duration filters)
161
162
  vn list # list this month's notes
162
163
  vn list --month 2026-05 # specific month
163
164
  vn last # print the latest processing summary
package/README.zh-CN.md CHANGED
@@ -138,6 +138,7 @@ vn run --latest # 只处理最新有效录音
138
138
  vn run --latest --force # 重跑最新条
139
139
  vn run --pdf # 生成纪要后额外渲染 PDF
140
140
  vn run --dry-run # 仅列出计划
141
+ vn run /path/to/audio.m4a # 直接处理单个文件(不扫描目录, 不套用时长/大小/时效过滤)
141
142
  vn list # 列出本月笔记
142
143
  vn list --month 2026-05 # 指定月份
143
144
  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.6",
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.6'
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) {
@@ -2061,9 +2066,10 @@ function pidAlive(pid: number): boolean {
2061
2066
  try { process.kill(pid, 0); return true } catch (e: any) { return e?.code === 'EPERM' }
2062
2067
  }
2063
2068
 
2064
- async function runPipeline(opts: any): Promise<void> {
2069
+ async function runPipeline(file: string | undefined, opts: any): Promise<void> {
2065
2070
  wireDailyLog()
2066
2071
  const config = getConfig()
2072
+ opts = { ...opts, file }
2067
2073
  const lock = await acquireRunLock()
2068
2074
  if (!lock) {
2069
2075
  console.log('voicenote pipeline already running; skip')
@@ -2089,21 +2095,30 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2089
2095
  const interrupted = reconcileInterrupted(store.jobs, nowIso())
2090
2096
  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
2097
 
2092
- if (!existsSync(config.recordDir)) {
2098
+ // Explicit file: process exactly that path, wherever it lives. Nothing is
2099
+ // scanned, so the listing is never "complete" (no pruning), and the recorder
2100
+ // filters (age/size/duration) don't apply — the user named the file.
2101
+ const single = opts.file ? resolve(String(opts.file)) : null
2102
+ if (single && !statSync(single, { throwIfNoEntry: false })?.isFile()) throw new Error(`Not a file: ${single}`)
2103
+ if (!single && !existsSync(config.recordDir)) {
2093
2104
  if (shouldLogIdleStatus(`missing:${config.recordDir}`)) {
2094
2105
  console.log(`Idle: recorder not mounted or record dir missing: ${config.recordDir} (repeated idle logs suppressed for 30m)`)
2095
2106
  }
2096
2107
  return
2097
2108
  }
2098
- const { recordings, complete: scanComplete } = await scanRecordings(config)
2109
+ const { recordings, complete: scanComplete } = single
2110
+ ? { recordings: [await toRecording(single)], complete: false }
2111
+ : await scanRecordings(config)
2099
2112
  const mode = normalizeRunMode(opts)
2100
2113
  const force = Boolean(opts.force)
2101
2114
  const eligible: Recording[] = []
2102
2115
  const skipCounts: Record<string, number> = {}
2103
2116
  const skipSamples: Record<string, string[]> = {}
2104
- const verboseSkips = Boolean(opts.verbose || opts.dryRun)
2117
+ // An explicitly named file that gets skipped must say why, not fall into the
2118
+ // idle-suppressed silence meant for the 60s scheduler tick.
2119
+ const verboseSkips = Boolean(opts.verbose || opts.dryRun || single)
2105
2120
  const seen = new Set<string>()
2106
- const limits = limitsOf(config)
2121
+ const limits = single ? { maxAgeHours: 0, minBytes: 0, minDurationSeconds: 0 } : limitsOf(config)
2107
2122
  for (const rec of recordings) {
2108
2123
  seen.add(rec.sourceId)
2109
2124
  const entry = recordFor(store, rec)
@@ -2117,7 +2132,7 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2117
2132
  // Only prune against a listing we believe to be complete: if the recorder went
2118
2133
  // away mid-glob the scan is partial, and pruning would wipe live queue entries
2119
2134
  // (they'd return on the next scan, but their retry counters would not).
2120
- const dropped = pruneUnseen(store.jobs, seen, scanComplete && existsSync(config.recordDir))
2135
+ const dropped = pruneUnseen(store.jobs, seen, !single && scanComplete && existsSync(config.recordDir))
2121
2136
  // The only routine path that deletes state — never do it silently.
2122
2137
  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
2138
  const skipSummary = Object.entries(skipCounts).map(([reason, count]) => `${reason}=${count}`).join(', ') || 'none'
@@ -2202,7 +2217,7 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2202
2217
  }
2203
2218
  // Whole recorder went away — every remaining target would fail the same way
2204
2219
  // and churn ASR-free but noisy retries. Stop and let the next run rescan.
2205
- if (!existsSync(config.recordDir)) {
2220
+ if (!single && !existsSync(config.recordDir)) {
2206
2221
  console.error(`Recorder disappeared mid-run (${config.recordDir}); stopping. Remaining recordings stay queued.`)
2207
2222
  break
2208
2223
  }
@@ -2902,7 +2917,7 @@ async function dispatchServe(req: any, send: (o: unknown) => void): Promise<void
2902
2917
  // request timeout can't misread it as a wedged engine. Progress shows
2903
2918
  // via the jobs poll; acquireRunLock inside runPipeline dedupes against
2904
2919
  // the scheduler tick and a double-click.
2905
- void runPipeline({}).catch(e => console.error('manual run failed:', e?.message || e))
2920
+ void runPipeline(undefined, {}).catch(e => console.error('manual run failed:', e?.message || e))
2906
2921
  result = { started: true }
2907
2922
  break
2908
2923
  }
@@ -2986,7 +3001,7 @@ async function serve(): Promise<void> {
2986
3001
 
2987
3002
  const cli = cac('vn')
2988
3003
 
2989
- cli.command('run', 'Scan recorder and process recordings (Volcano ASR + pi notes)')
3004
+ cli.command('run [file]', 'Scan recorder and process recordings, or process one audio file by path (Volcano ASR + pi notes)')
2990
3005
  .option('--mode <mode>', 'Output mode: notes (default) | transcript', { default: 'notes' })
2991
3006
  .option('--latest', 'Only process newest eligible recording')
2992
3007
  .option('--force', 'Reprocess already processed recordings')