@fastagent-sh/voicenote 0.18.4 → 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
@@ -194,7 +195,9 @@ The install script writes an editable template:
194
195
  }
195
196
  ```
196
197
 
197
- Changes take effect on the next `vn run`. A legacy `~/.config/voicenote/speakers.json` is still read as a compatibility fallback.
198
+ Changes take effect on the next `vn run`. Config values and unquoted/double-quoted `.zshrc` exports support simple `$VAR` / `${VAR}` references to other settings and `$HOME`. Single-quoted shell values stay literal. Shell commands are never executed. Runtime references honor inherited environment values; scheduler comparisons resolve from files alone.
199
+
200
+ A legacy `~/.config/voicenote/speakers.json` is still read as a compatibility fallback.
198
201
 
199
202
  ## Workflow
200
203
 
@@ -237,7 +240,7 @@ The LaunchAgent invokes `vn run` every 60 seconds. It skips safely when no recor
237
240
 
238
241
  > Config changes (`config.json` or `~/.zshrc`) are picked up automatically by the background agent on its next run — no reinstall needed. The plist only snapshots real environment variables and pi's absolute path: **after changing `VOICENOTE_PI_BIN`, re-run `vn install-launch-agent` and reload** (`vn upgrade` regenerates the plist automatically). If pi is not signed in or ASR is not configured, the agent skips processing instead of burning ASR spend.
239
242
  >
240
- > Exception: if you `export http_proxy=...` directly in your shell (instead of using `LOCAL_PROXY_HOST`) and then run `vn install-launch-agent`, that real env value is snapshotted into the plist and keeps overriding later `LOCAL_PROXY_HOST` changes; re-run `vn install-launch-agent` to clear it. Prefer `LOCAL_PROXY_HOST`/`LOCAL_PROXY_PORT` for proxy configuration.
243
+ > Proxy values that match the file configuration, including expanded variable references, are not embedded and produce no override warning. Values supplied only by the shell, or differing from the files, are embedded as explicit overrides. To clear an unwanted override, update or unset the shell variable, then run `vn install-launch-agent --load`. Prefer `LOCAL_PROXY_HOST`/`LOCAL_PROXY_PORT` in `config.json` for proxy configuration.
241
244
 
242
245
  Logs:
243
246
 
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.4",
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.4'
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)
@@ -209,20 +209,21 @@ function applyDerivedProxy(): void {
209
209
  }
210
210
  }
211
211
 
212
- // What the config files would provide for each ENV_KEY, independent of this
213
- // process's environment. Primary source is ~/.config/voicenote/config.json
212
+ // File values for each ENV_KEY. Hydration passes the current environment for
213
+ // variable references; scheduler comparison uses files alone. Primary source is
214
+ // ~/.config/voicenote/config.json
214
215
  // (ENV-style runtime keys at the top level; identity under `speakers`); the
215
216
  // legacy fallback is `export KEY=...` lines in ~/.zshrc, for CLI installs
216
217
  // that predate config.json. Precedence/expansion logic lives in envConfig.ts
217
218
  // (pure + tested). Two consumers: loadEnvConfig() hydrates these into
218
219
  // process.env for keys the real environment doesn't set, and launchAgentEnv()
219
220
  // uses them to decide which values are recoverable at run time.
220
- function fileProvidedEnv(): Record<string, string> {
221
+ function fileProvidedEnv(environment: Record<string, string | undefined> = {}): Record<string, string> {
221
222
  const data = loadJsonSync<Record<string, unknown>>(CONFIG_ENV_PATH, {})
222
223
  let zshrc: string | null = null
223
224
  const zshrcPath = join(os.homedir(), '.zshrc')
224
225
  if (existsSync(zshrcPath)) { try { zshrc = readFileSync(zshrcPath, 'utf8') } catch { zshrc = null } }
225
- return parseFileEnv(ENV_KEYS, data, zshrc, os.homedir())
226
+ return parseFileEnv(ENV_KEYS, data, zshrc, os.homedir(), environment)
226
227
  }
227
228
 
228
229
  let envConfigLoaded = false
@@ -231,7 +232,7 @@ function loadEnvConfig(): void {
231
232
  envConfigLoaded = true
232
233
  // Precedence: process.env > config.json (GUI) > ~/.zshrc (legacy); an
233
234
  // explicit empty string in the environment is never overridden.
234
- const toApply = hydrateFromFileEnv(ENV_KEYS, process.env, fileProvidedEnv())
235
+ const toApply = hydrateFromFileEnv(ENV_KEYS, process.env, fileProvidedEnv(process.env))
235
236
  for (const [key, v] of Object.entries(toApply)) { process.env[key] = v; hydratedEnvKeys.add(key) }
236
237
  // Derive http_proxy etc. from LOCAL_PROXY_HOST/PORT regardless of source, and
237
238
  // always keep Volcano hosts on NO_PROXY. (Runs even with no config files.)
@@ -799,6 +800,18 @@ function isCandidateFile(path: string): boolean {
799
800
  * treating a half-read device as authoritative would delete live queue entries
800
801
  * along with their retry counters.
801
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
+
802
815
  async function scanRecordings(config: Config): Promise<{ recordings: Recording[]; complete: boolean }> {
803
816
  if (!existsSync(config.recordDir)) return { recordings: [], complete: false }
804
817
  const recordings: Recording[] = []
@@ -812,14 +825,7 @@ async function scanRecordings(config: Config): Promise<{ recordings: Recording[]
812
825
  if (!st) { complete = false; continue }
813
826
  if (!st.isFile()) continue
814
827
  try {
815
- recordings.push({
816
- sourcePath: file,
817
- sizeBytes: st.size,
818
- modifiedAt: st.mtime.toISOString(),
819
- durationSeconds: await ffprobeDuration(file),
820
- sourceId: await sourceIdFor(file),
821
- recordedAt: parseRecordedAt(file),
822
- })
828
+ recordings.push(await toRecording(file))
823
829
  } catch (e) { complete = false; warnSideEffect(`read ${basename(file)} during scan`, e) }
824
830
  }
825
831
  } catch (e) {
@@ -2060,9 +2066,10 @@ function pidAlive(pid: number): boolean {
2060
2066
  try { process.kill(pid, 0); return true } catch (e: any) { return e?.code === 'EPERM' }
2061
2067
  }
2062
2068
 
2063
- async function runPipeline(opts: any): Promise<void> {
2069
+ async function runPipeline(file: string | undefined, opts: any): Promise<void> {
2064
2070
  wireDailyLog()
2065
2071
  const config = getConfig()
2072
+ opts = { ...opts, file }
2066
2073
  const lock = await acquireRunLock()
2067
2074
  if (!lock) {
2068
2075
  console.log('voicenote pipeline already running; skip')
@@ -2088,21 +2095,30 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2088
2095
  const interrupted = reconcileInterrupted(store.jobs, nowIso())
2089
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(', ')}`)
2090
2097
 
2091
- 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)) {
2092
2104
  if (shouldLogIdleStatus(`missing:${config.recordDir}`)) {
2093
2105
  console.log(`Idle: recorder not mounted or record dir missing: ${config.recordDir} (repeated idle logs suppressed for 30m)`)
2094
2106
  }
2095
2107
  return
2096
2108
  }
2097
- const { recordings, complete: scanComplete } = await scanRecordings(config)
2109
+ const { recordings, complete: scanComplete } = single
2110
+ ? { recordings: [await toRecording(single)], complete: false }
2111
+ : await scanRecordings(config)
2098
2112
  const mode = normalizeRunMode(opts)
2099
2113
  const force = Boolean(opts.force)
2100
2114
  const eligible: Recording[] = []
2101
2115
  const skipCounts: Record<string, number> = {}
2102
2116
  const skipSamples: Record<string, string[]> = {}
2103
- 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)
2104
2120
  const seen = new Set<string>()
2105
- const limits = limitsOf(config)
2121
+ const limits = single ? { maxAgeHours: 0, minBytes: 0, minDurationSeconds: 0 } : limitsOf(config)
2106
2122
  for (const rec of recordings) {
2107
2123
  seen.add(rec.sourceId)
2108
2124
  const entry = recordFor(store, rec)
@@ -2116,7 +2132,7 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2116
2132
  // Only prune against a listing we believe to be complete: if the recorder went
2117
2133
  // away mid-glob the scan is partial, and pruning would wipe live queue entries
2118
2134
  // (they'd return on the next scan, but their retry counters would not).
2119
- const dropped = pruneUnseen(store.jobs, seen, scanComplete && existsSync(config.recordDir))
2135
+ const dropped = pruneUnseen(store.jobs, seen, !single && scanComplete && existsSync(config.recordDir))
2120
2136
  // The only routine path that deletes state — never do it silently.
2121
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})` : ''}`)
2122
2138
  const skipSummary = Object.entries(skipCounts).map(([reason, count]) => `${reason}=${count}`).join(', ') || 'none'
@@ -2201,7 +2217,7 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
2201
2217
  }
2202
2218
  // Whole recorder went away — every remaining target would fail the same way
2203
2219
  // and churn ASR-free but noisy retries. Stop and let the next run rescan.
2204
- if (!existsSync(config.recordDir)) {
2220
+ if (!single && !existsSync(config.recordDir)) {
2205
2221
  console.error(`Recorder disappeared mid-run (${config.recordDir}); stopping. Remaining recordings stay queued.`)
2206
2222
  break
2207
2223
  }
@@ -2250,12 +2266,6 @@ async function launchAgentEnv(): Promise<Record<string, string>> {
2250
2266
  // about: it may equally be a stale shell session, and it will keep
2251
2267
  // overriding config edits until the scheduler is reinstalled.
2252
2268
  //
2253
- // Known blind spot: the matrix compares each key against its OWN file value,
2254
- // so it can't see cross-key derivations. A real-env http_proxy is embedded
2255
- // as-is and will shadow a GUI edit to LOCAL_PROXY_HOST (different key name)
2256
- // until reinstall. Only no_proxy is special-cased (originals below) because
2257
- // WE synthesize it; http_proxy from a user's shell is left as a real value.
2258
- //
2259
2269
  // no_proxy/NO_PROXY carry a volcano-hosts merge we added; substitute the
2260
2270
  // pre-merge real-env original (or drop it entirely if we synthesized the
2261
2271
  // whole value) so the scheduler never freezes our merge over config edits.
@@ -2265,7 +2275,7 @@ async function launchAgentEnv(): Promise<Record<string, string>> {
2265
2275
  const { embed, frozenOverrides } = envKeysToEmbed(ENV_KEYS, embedEnv, hydratedEnvKeys, fileEnv)
2266
2276
  Object.assign(env, embed)
2267
2277
  for (const k of frozenOverrides) {
2268
- console.error(`Warning: environment ${k} differs from the config file value; the environment value is snapshotted into the scheduler and will override config edits until you re-run \`vn install-launch-agent\`.`)
2278
+ console.error(`Warning: environment ${k} overrides the config file. Update or unset it, then re-run \`vn install-launch-agent --load\` to apply the intended value.`)
2269
2279
  }
2270
2280
  // Embed pi's ABSOLUTE path so launchd resolves it regardless of the fixed plist
2271
2281
  // PATH (npm global bin can live outside it under nvm / custom prefixes). Resolve
@@ -2907,7 +2917,7 @@ async function dispatchServe(req: any, send: (o: unknown) => void): Promise<void
2907
2917
  // request timeout can't misread it as a wedged engine. Progress shows
2908
2918
  // via the jobs poll; acquireRunLock inside runPipeline dedupes against
2909
2919
  // the scheduler tick and a double-click.
2910
- 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))
2911
2921
  result = { started: true }
2912
2922
  break
2913
2923
  }
@@ -2991,7 +3001,7 @@ async function serve(): Promise<void> {
2991
3001
 
2992
3002
  const cli = cac('vn')
2993
3003
 
2994
- 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)')
2995
3005
  .option('--mode <mode>', 'Output mode: notes (default) | transcript', { default: 'notes' })
2996
3006
  .option('--latest', 'Only process newest eligible recording')
2997
3007
  .option('--force', 'Reprocess already processed recordings')
package/src/envConfig.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  // Pure logic behind cli.ts's env-config provenance. Extracted (no fs, no
2
2
  // process.env) so its invariants are testable — see envConfig.test.ts:
3
3
  //
4
- // 1. File precedence: config.json (GUI) wins over ~/.zshrc (legacy CLI);
5
- // $HOME tokens are expanded in both.
4
+ // 1. File precedence: config.json (GUI) wins over ~/.zshrc (legacy CLI).
5
+ // Simple $VAR/${VAR} references resolve from these values without running
6
+ // shell code; runtime hydration may also use inherited environment values.
6
7
  // 2. Hydration: only keys the real environment does NOT set are filled from
7
8
  // files — an explicit empty string in the environment (e.g.
8
9
  // VOICENOTE_PI_SUMMARY_TOOLS="") counts as set and is never overridden.
@@ -17,28 +18,46 @@
17
18
  // warn: it may equally be a stale shell session shadowing a fresh config
18
19
  // edit, and it will keep overriding until the scheduler is reinstalled.
19
20
 
20
- /** File-provided values for `keys`: config.json over zshrc, $HOME expanded. */
21
+ /** Omit `environment` when checking which values files can reproduce on their own. */
21
22
  export function parseFileEnv(
22
23
  keys: readonly string[],
23
24
  configData: Record<string, unknown>,
24
25
  zshrcContent: string | null,
25
26
  home: string,
27
+ environment: Record<string, string | undefined> = {},
26
28
  ): Record<string, string> {
27
- const out: Record<string, string> = {}
28
- const expand = (v: string) => v.replace(/\$\{?HOME\}?/g, home)
29
+ const raw = new Map<string, string>()
30
+ const literal = new Set<string>()
29
31
  for (const key of keys) {
30
32
  const v = configData[key]
31
- if (typeof v === 'string') out[key] = expand(v)
33
+ if (typeof v === 'string') { raw.set(key, v); continue }
34
+ const pattern = new RegExp(`(?:^|\\n)\\s*export\\s+${key}=(?:"([^"]*)"|'([^']*)'|([^\\s"'#]+))`)
35
+ const match = zshrcContent?.match(pattern)
36
+ const value = match?.slice(1).find(v => v !== undefined)
37
+ if (value !== undefined) raw.set(key, value)
38
+ if (match?.[2] !== undefined) literal.add(key)
32
39
  }
33
- if (zshrcContent !== null) {
34
- for (const key of keys) {
35
- if (out[key] !== undefined) continue // config.json wins
36
- const pattern = new RegExp(`(?:^|\\n)\\s*export\\s+${key}=(?:"([^"]*)"|'([^']*)'|([^\\s"'#]+))`)
37
- const value = zshrcContent.match(pattern)?.slice(1).find(v => v !== undefined)
38
- if (value !== undefined) out[key] = expand(value)
39
- }
40
+ const resolved = new Map<string, string>()
41
+ const visiting = new Set<string>()
42
+ const resolve = (key: string): string => {
43
+ const cached = resolved.get(key)
44
+ if (cached !== undefined) return cached
45
+ if (visiting.has(key)) throw new Error(`Circular config variable reference: ${key}`)
46
+ visiting.add(key)
47
+ const value = raw.get(key)!
48
+ const expanded = literal.has(key) ? value : value.replace(/\\(\$)|\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, (token, escaped, braced, bare) => {
49
+ if (escaped) return escaped
50
+ const ref = braced || bare
51
+ if (ref === 'HOME') return home
52
+ // Unknown references stay literal, so they cannot look recoverable from files.
53
+ if (Object.hasOwn(environment, ref) && environment[ref] !== undefined) return environment[ref]!
54
+ return raw.has(ref) ? resolve(ref) : token
55
+ })
56
+ visiting.delete(key)
57
+ resolved.set(key, expanded)
58
+ return expanded
40
59
  }
41
- return out
60
+ return Object.fromEntries(Array.from(raw.keys(), key => [key, resolve(key)]))
42
61
  }
43
62
 
44
63
  /** Which keys to copy from fileEnv into an environment (invariant 2). */