@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 +5 -2
- package/README.zh-CN.md +1 -0
- package/package.json +1 -1
- package/src/cli.ts +41 -31
- package/src/envConfig.ts +33 -14
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`.
|
|
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
|
-
>
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
213
|
-
//
|
|
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
|
-
|
|
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 } =
|
|
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
|
-
|
|
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}
|
|
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
|
-
// $
|
|
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
|
-
/**
|
|
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
|
|
28
|
-
const
|
|
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')
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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). */
|