@fastagent-sh/voicenote 0.22.1 → 0.22.2
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 +9 -3
- package/README.zh-CN.md +9 -3
- package/package.json +1 -1
- package/src/cli.ts +143 -41
- package/src/jobs.ts +20 -7
package/README.md
CHANGED
|
@@ -177,6 +177,7 @@ vn open <slug> # open a note by filename fragment
|
|
|
177
177
|
vn forget <id|filename> # let a recording be processed again
|
|
178
178
|
vn log # print today's log tail (--lines N / -f follow / --err include launchd.err / --date YYYY-MM-DD)
|
|
179
179
|
vn errors # print recent ERROR logs
|
|
180
|
+
vn import /path/to/audio.mp3 # copy one local recording into the durable manual-import queue
|
|
180
181
|
vn login # sign in to ChatGPT for the notes backend (browser callback; `--device-code` for headless machines). No pi TUI needed
|
|
181
182
|
vn upgrade # reinstall latest npm package
|
|
182
183
|
vn install-launch-agent
|
|
@@ -215,6 +216,10 @@ Changes take effect on the next `vn run`. `~`, `$HOME`, and `${HOME}` are accept
|
|
|
215
216
|
6. The summary model (default: pi codex via ChatGPT Plus) reads the raw transcript directly, performing necessary cleanup, speaker restoration, and reconstruction of views/debates/consensus inside the notes-generation stage; if the summary fails, the next `vn run` / `vn run --latest` reuses the saved transcript and retries only the notes generation — no `vn forget` needed
|
|
216
217
|
7. Write notes / metadata; the system makes no archiving decisions — files stay in the configured workspace
|
|
217
218
|
|
|
219
|
+
The history range defaults to 48 hours. In the GUI, choose 7 days, 30 days, or all recordings under **Settings → Recording history to process**. Expanding it re-evaluates recordings previously filtered as `too_old`; the dashboard's filtered summary links directly to this setting.
|
|
220
|
+
|
|
221
|
+
To process a local file immediately, drop one supported audio file onto the GUI. It is copied atomically to `${VOICENOTE_WORKSPACE}/_inbox`, queued even if another run is active or the recorder is disconnected, and processed before automatic recorder items without the automatic age/size/duration filters. The temporary inbox copy is removed after success and retained after failure for Retry. Imports are content-addressed, so dropping the same audio again opens the existing note instead of paying for ASR twice when a matching completed job is known.
|
|
222
|
+
|
|
218
223
|
A failing recording is retried on later runs, but at most **3 times** (whether it fails in transcription or in summarisation, and a run killed mid-job counts too). After that it is marked `Gave up` and left alone, so one broken file can't burn ASR/LLM budget on every scheduler tick. Use **Retry** on its GUI row to reset the budget, preserve saved outputs, and run it again; `vn forget <name>` is the CLI escape hatch that drops the record and re-queues it. Either path reuses a saved transcript instead of paying for ASR again. Both take the run lock, so retry after the active run finishes if the state file is busy.
|
|
219
224
|
|
|
220
225
|
Records whose source file is no longer on the recorder are forgotten on the next scan (and the removal is logged), *unless* they already produced notes or a transcript — that history is kept. This is why swapping recorders, or deleting files from the device, no longer leaves permanent "failed" rows behind.
|
|
@@ -227,6 +232,7 @@ Records whose source file is no longer on the recorder are forgotten on the next
|
|
|
227
232
|
- Original audio: `${VOICENOTE_WORKSPACE}/_audio/YYYY-MM/`
|
|
228
233
|
- Full transcripts: `${VOICENOTE_WORKSPACE}/_transcripts/YYYY-MM/`
|
|
229
234
|
- Metadata: `${VOICENOTE_WORKSPACE}/_metadata/YYYY-MM/`
|
|
235
|
+
- Pending manual imports: `${VOICENOTE_WORKSPACE}/_inbox/` (removed after success)
|
|
230
236
|
- State: `${VOICENOTE_WORKSPACE}/_state/jobs.json` — one record per recording, holding its `state` — where it is in its lifecycle (`queued`, `running`, `done`, `filtered`, `error`, or `gave_up` once retries are spent) — plus a `code` saying why (`summary_failed`, `transcribe_failed`, `interrupted`, `too_small`, …), its attempt count and its output paths. `vn run` writes lifecycle updates; only explicit retry/forget actions mutate it otherwise. `vn jobs` and passive GUI refreshes are pure reads, so what you see is what will run. A pre-0.18 `processed.json` is converted automatically on the first run and kept as `processed.json.v1.bak`.
|
|
231
237
|
- Index: `${VOICENOTE_WORKSPACE}/_index/notes.jsonl`
|
|
232
238
|
|
|
@@ -242,7 +248,7 @@ launchctl enable gui/$(id -u)/sh.fastagent.voicenote
|
|
|
242
248
|
vn status
|
|
243
249
|
```
|
|
244
250
|
|
|
245
|
-
The LaunchAgent invokes `vn run` every 60 seconds.
|
|
251
|
+
The LaunchAgent invokes `vn run` every 60 seconds. Without a recorder it still processes queued local imports; once the VTR6500 is connected, new recorder items are processed automatically too.
|
|
246
252
|
|
|
247
253
|
> `config.json` changes are picked up by the background agent on its next run. The plist stores only a fixed PATH and executable paths: **after changing `VOICENOTE_PI_BIN`, re-run `vn install-launch-agent --load`** (`vn upgrade` does this automatically). Shell-only settings are deliberately not copied into the scheduler; persist them with `vn config set`. If pi or ASR is not configured, the agent skips before spending ASR.
|
|
248
254
|
|
|
@@ -282,10 +288,10 @@ The workflow lives at `.github/workflows/release.yml`: CI explicitly runs typech
|
|
|
282
288
|
|
|
283
289
|
A self-contained macOS `.app` (Tauri v2) for **non-terminal users**: the target machine needs no pre-installed bun / pi / ffprobe / global `vn`.
|
|
284
290
|
|
|
285
|
-
**Positioning**: the GUI is a status dashboard with quick access to output and manual Sync/Retry controls. The full pipeline still runs autonomously every 60s via the background LaunchAgent using the bundled CLI (it keeps running with the GUI closed).
|
|
291
|
+
**Positioning**: the GUI is a status dashboard with quick access to output, drag-to-import, and manual Sync/Retry controls. The full pipeline still runs autonomously every 60s via the background LaunchAgent using the bundled CLI (it keeps running with the GUI closed).
|
|
286
292
|
|
|
287
293
|
- First run: settings (identity / Volcano keys / proxy). The notes model comes from pi; ChatGPT users can sign in from the Status panel (`vn login`'s browser-callback flow).
|
|
288
|
-
- After that: the main view shows agent activity and recent notes, opens outputs, and
|
|
294
|
+
- After that: the main view shows agent activity and recent notes, opens outputs, retries failed recordings, and accepts one local audio file dropped anywhere on the window.
|
|
289
295
|
|
|
290
296
|
### What's bundled
|
|
291
297
|
|
package/README.zh-CN.md
CHANGED
|
@@ -172,6 +172,7 @@ vn open <slug> # 按文件名片段打开纪要
|
|
|
172
172
|
vn forget <id|filename> # 让某条录音重新被处理
|
|
173
173
|
vn log # 打印今天日志末尾(--lines N / -f 跟随 / --err 含 launchd.err / --date YYYY-MM-DD)
|
|
174
174
|
vn errors # 打印最近 ERROR 日志
|
|
175
|
+
vn import /path/to/audio.mp3 # 把一个本地录音复制进持久化手动导入队列
|
|
175
176
|
vn login # 登录 ChatGPT, 纪要后端用(默认浏览器回调; 无头机器用 `--device-code`)。无需开 pi TUI
|
|
176
177
|
vn upgrade # reinstall latest npm package
|
|
177
178
|
vn install-launch-agent
|
|
@@ -210,6 +211,10 @@ vn uninstall-launch-agent
|
|
|
210
211
|
6. summary 模型(由 pi 自身配置决定)直接看原始 transcript,在纪要生成阶段内部完成必要清理、说话人还原、观点/争论/共识形成过程还原;如果 summary 失败,下一次 `vn run` / `vn run --latest` 会复用已保存 transcript,直接重试纪要生成,不需要 `vn forget`
|
|
211
212
|
7. 写出 notes / metadata;系统不做任何归档决定,文件留在配置的 workspace 中
|
|
212
213
|
|
|
214
|
+
历史范围默认是 48 小时。GUI 可在**设置 → 处理多长时间范围内的录音**中选择最近 7 天、30 天或全部录音。扩大范围后,之前因 `too_old` 被过滤的录音会按新条件重新入队;dashboard 的过滤汇总也会直接链接到该设置。
|
|
215
|
+
|
|
216
|
+
需要立即处理本地文件时,把一个支持的音频文件拖进 GUI 即可。应用会先原子复制到 `${VOICENOTE_WORKSPACE}/_inbox`;即使当前正在运行其他任务或录音笔未连接,也会持久排队。手动导入优先于录音笔的自动扫描任务,并跳过自动扫描的时间、大小和时长过滤。成功后删除 inbox 临时副本,失败时保留以供“重试”。导入按内容寻址;当系统已知相同内容的完成记录时,再次拖入会打开已有纪要,不会重复支付 ASR。
|
|
217
|
+
|
|
213
218
|
失败的录音会在后续运行中重试,但**最多 3 次**(转写失败、纪要失败、以及被中途 kill 的运行都算)。超过后标记为 `Gave up` 并不再自动重试,避免一个坏文件每个调度周期都烧一次 ASR/LLM 额度。点击 GUI 记录上的**重试**会重置次数、保留已有产物并立即再跑;CLI 也可以用 `vn forget <name>` 删除记录后重新入队。两种方式都会复用磁盘上已有的 transcript,不会重复支付 ASR 费用。它们都需要 run lock;如果当前正在处理,请等本次 run 结束后再重试。
|
|
214
219
|
|
|
215
220
|
源文件已不在录音笔上的记录,会在下一次扫描时被遗忘(并记入日志),**已经产出纪要或 transcript 的除外** —— 那部分历史会保留。所以换录音笔、或从设备上删文件,不再会留下永久的 “失败” 条目。
|
|
@@ -222,6 +227,7 @@ vn uninstall-launch-agent
|
|
|
222
227
|
- 原始音频:`${VOICENOTE_WORKSPACE}/_audio/YYYY-MM/`
|
|
223
228
|
- 完整转写:`${VOICENOTE_WORKSPACE}/_transcripts/YYYY-MM/`
|
|
224
229
|
- metadata:`${VOICENOTE_WORKSPACE}/_metadata/YYYY-MM/`
|
|
230
|
+
- 待处理的手动导入:`${VOICENOTE_WORKSPACE}/_inbox/`(成功后删除)
|
|
225
231
|
- 状态:`${VOICENOTE_WORKSPACE}/_state/jobs.json` —— 每条录音一条记录,包含 `state`(生命周期位置:`queued`、`running`、`done`、`filtered`、`error`,以及重试耗尽后的 `gave_up`)、`code`(原因:`summary_failed`、`transcribe_failed`、`interrupted`、`too_small` 等)、重试次数和产物路径。`vn run` 写入正常生命周期变化,除此之外只有显式重试或 forget 操作会修改它;`vn jobs` 和 GUI 的被动刷新都是纯读取,因此看到的队列就是实际会跑的队列。0.18 之前的 `processed.json` 会在首次运行时自动转换,旧文件保留为 `processed.json.v1.bak`。
|
|
226
232
|
- 索引:`${VOICENOTE_WORKSPACE}/_index/notes.jsonl`
|
|
227
233
|
|
|
@@ -237,7 +243,7 @@ launchctl enable gui/$(id -u)/sh.fastagent.voicenote
|
|
|
237
243
|
vn status
|
|
238
244
|
```
|
|
239
245
|
|
|
240
|
-
LaunchAgent 每 60 秒调用 `vn run
|
|
246
|
+
LaunchAgent 每 60 秒调用 `vn run`。没插录音笔时仍会处理本地导入队列;插上 VTR6500 后也会自动处理录音笔里的新录音。
|
|
241
247
|
|
|
242
248
|
> 后台 agent 会在下一次运行时读取 `config.json` 的改动。plist 只保存固定 PATH 和可执行文件路径:**改了 `VOICENOTE_PI_BIN` 后需重跑 `vn install-launch-agent --load`**(`vn upgrade` 会自动处理)。shell 中临时设置的值不会复制进 scheduler,请用 `vn config set` 持久化。未配置 pi / ASR 时,agent 会在支付 ASR 成本前跳过。
|
|
243
249
|
|
|
@@ -277,10 +283,10 @@ workflow 位于 `.github/workflows/release.yml`:CI 显式跑 typecheck、测试
|
|
|
277
283
|
|
|
278
284
|
面向**非终端用户**:一个自包含的 macOS `.app`(Tauri v2),目标机器无需预装 bun / pi / ffprobe / 全局 `vn`。
|
|
279
285
|
|
|
280
|
-
**定位**:GUI 是工作状态 dashboard
|
|
286
|
+
**定位**:GUI 是工作状态 dashboard,提供产出快捷入口、拖拽导入以及手动同步/重试。全流程仍由后台 LaunchAgent 用包内 CLI 每 60s 自主运行(关掉 GUI 也跑)。
|
|
281
287
|
|
|
282
288
|
- 首次:配置向导(身份 / Volcano keys / 代理)→ ChatGPT 登录(设备无终端,走 `vn login` 的浏览器回调流)
|
|
283
|
-
- 之后:主界面显示 agent
|
|
289
|
+
- 之后:主界面显示 agent 活动和最近纪要,可打开产物、重试失败录音,也可把一个本地音频文件拖到窗口任意位置立即排队
|
|
284
290
|
|
|
285
291
|
### 打包内容
|
|
286
292
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fastagent-sh/voicenote",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.2",
|
|
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
|
@@ -5,7 +5,7 @@ import { parseLockOwner } from './runLock'
|
|
|
5
5
|
import { tosObject, type TosConfig as VolcanoTosConfig } from './tos'
|
|
6
6
|
import { applyOutcome, buildJobsView, classify, emptyState, localIso, MAX_ATTEMPTS, migrateLegacyState, ownsOutput, parseJobsLimit, parseStateFile, parseStrictJson, patchJob, pruneUnseen, reconcileInterrupted, requeueFailed, startAttempt, SUMMARY_FAILED_STATUS, type CurrentJob, type JobRecord, type StateFile } from './jobs'
|
|
7
7
|
import { createHash, randomUUID } from 'node:crypto'
|
|
8
|
-
import { appendFile, chmod, mkdir, readFile, writeFile, copyFile, rename, unlink, stat, readdir } from 'node:fs/promises'
|
|
8
|
+
import { appendFile, chmod, mkdir, readFile, writeFile, copyFile, rename, unlink, stat, readdir, rmdir, utimes } 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
11
|
import { basename, dirname, extname, join, resolve } from 'node:path'
|
|
@@ -52,7 +52,9 @@ type Recording = {
|
|
|
52
52
|
modifiedAt: string
|
|
53
53
|
durationSeconds: number | null
|
|
54
54
|
sourceId: string
|
|
55
|
+
contentHash: string
|
|
55
56
|
recordedAt: Date
|
|
57
|
+
imported: boolean
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
type LocalFiles = {
|
|
@@ -354,8 +356,10 @@ function formatSeconds(seconds: number | null | undefined): string {
|
|
|
354
356
|
// File state IO
|
|
355
357
|
// ────────────────────────────────────────────────────────────────────────────
|
|
356
358
|
|
|
359
|
+
const inboxPathFor = (config: Config) => join(config.workspace, '_inbox')
|
|
360
|
+
|
|
357
361
|
async function ensureDirs(config: Config): Promise<void> {
|
|
358
|
-
for (const dir of ['_state', '_index', '_audio', '_transcripts', '_metadata']) {
|
|
362
|
+
for (const dir of ['_state', '_index', '_audio', '_transcripts', '_metadata', '_inbox']) {
|
|
359
363
|
await mkdir(join(config.workspace, dir), { recursive: true })
|
|
360
364
|
}
|
|
361
365
|
}
|
|
@@ -646,10 +650,10 @@ async function sha256File(path: string): Promise<string> {
|
|
|
646
650
|
return h.digest('hex')
|
|
647
651
|
}
|
|
648
652
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
return createHash('sha256').update(`${path}|${
|
|
653
|
+
function sourceIdFor(path: string, size: number, mtimeMs: number, digest: string, imported = false): string {
|
|
654
|
+
// Manual imports are content-addressed: dropping the same audio again must
|
|
655
|
+
// find its existing job even after the temporary inbox copy was removed.
|
|
656
|
+
return imported ? `import:${digest}` : createHash('sha256').update(`${path}|${size}|${Math.floor(mtimeMs / 1000)}|${digest}`).digest('hex')
|
|
653
657
|
}
|
|
654
658
|
|
|
655
659
|
function runCommand(command: string, args: string[], timeoutMs = 20000): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
@@ -744,41 +748,49 @@ function isCandidateFile(path: string): boolean {
|
|
|
744
748
|
* treating a half-read device as authoritative would delete live queue entries
|
|
745
749
|
* along with their retry counters.
|
|
746
750
|
*/
|
|
747
|
-
async function toRecording(config: Config, file: string): Promise<Recording> {
|
|
751
|
+
async function toRecording(config: Config, file: string, imported = false): Promise<Recording> {
|
|
748
752
|
const st = await stat(file)
|
|
753
|
+
const contentHash = await sha256File(file)
|
|
749
754
|
return {
|
|
750
755
|
sourcePath: file,
|
|
751
756
|
sizeBytes: st.size,
|
|
752
757
|
modifiedAt: st.mtime.toISOString(),
|
|
753
758
|
durationSeconds: await ffprobeDuration(config, file),
|
|
754
|
-
sourceId:
|
|
759
|
+
sourceId: sourceIdFor(file, st.size, st.mtimeMs, contentHash, imported),
|
|
760
|
+
contentHash,
|
|
755
761
|
recordedAt: parseRecordedAt(file),
|
|
762
|
+
imported,
|
|
756
763
|
}
|
|
757
764
|
}
|
|
758
765
|
|
|
759
766
|
async function scanRecordings(config: Config): Promise<{ recordings: Recording[]; complete: boolean }> {
|
|
760
|
-
if (!existsSync(config.recordDir)) return { recordings: [], complete: false }
|
|
761
767
|
const recordings: Recording[] = []
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
768
|
+
const recorderPresent = existsSync(config.recordDir)
|
|
769
|
+
let complete = recorderPresent
|
|
770
|
+
const roots = [
|
|
771
|
+
...(recorderPresent ? [{ dir: config.recordDir, imported: false }] : []),
|
|
772
|
+
...(existsSync(inboxPathFor(config)) ? [{ dir: inboxPathFor(config), imported: true }] : []),
|
|
773
|
+
]
|
|
774
|
+
for (const root of roots) {
|
|
775
|
+
try {
|
|
776
|
+
for await (const file of new Bun.Glob('**/*').scan({ cwd: root.dir, absolute: true, dot: true })) {
|
|
777
|
+
if (!isCandidateFile(file)) continue
|
|
778
|
+
const st = await stat(file).catch(() => null)
|
|
779
|
+
// Listed a moment ago but unreadable now: the device is going away, or
|
|
780
|
+
// this file is. Either way the listing is no longer trustworthy.
|
|
781
|
+
if (!st) { complete = false; continue }
|
|
782
|
+
if (!st.isFile()) continue
|
|
783
|
+
try {
|
|
784
|
+
recordings.push(await toRecording(config, file, root.imported))
|
|
785
|
+
} catch (e) { complete = false; warnSideEffect(`read ${basename(file)} during scan`, e) }
|
|
786
|
+
}
|
|
787
|
+
} catch (e) {
|
|
788
|
+
complete = false
|
|
789
|
+
warnSideEffect(`scan ${root.imported ? 'import inbox' : 'recorder'}`, e)
|
|
774
790
|
}
|
|
775
|
-
} catch (e) {
|
|
776
|
-
complete = false
|
|
777
|
-
warnSideEffect('scan recorder', e)
|
|
778
791
|
}
|
|
779
|
-
//
|
|
780
|
-
|
|
781
|
-
recordings.sort((a, b) => a.recordedAt.getTime() - b.recordedAt.getTime())
|
|
792
|
+
// Explicit imports go first; each group remains oldest-first.
|
|
793
|
+
recordings.sort((a, b) => Number(b.imported) - Number(a.imported) || a.recordedAt.getTime() - b.recordedAt.getTime())
|
|
782
794
|
return { recordings, complete }
|
|
783
795
|
}
|
|
784
796
|
|
|
@@ -1735,13 +1747,16 @@ async function saveState(config: Config, store: StateFile): Promise<void> {
|
|
|
1735
1747
|
function recordFor(store: StateFile, rec: Recording): JobRecord {
|
|
1736
1748
|
const existing = store.jobs[rec.sourceId]
|
|
1737
1749
|
const next: JobRecord = existing ?? {
|
|
1738
|
-
name: basename(rec.sourcePath), source_path: rec.sourcePath, recorded_at: localIso(rec.recordedAt),
|
|
1750
|
+
name: basename(rec.sourcePath), source_path: rec.sourcePath, content_hash: rec.contentHash, recorded_at: localIso(rec.recordedAt),
|
|
1739
1751
|
size_bytes: rec.sizeBytes, duration_seconds: rec.durationSeconds,
|
|
1740
1752
|
state: 'queued', code: null, detail: null, attempts: 0, updated_at: nowIso(), title: null, paths: null,
|
|
1753
|
+
...(rec.imported ? { origin: 'import' as const } : {}),
|
|
1741
1754
|
}
|
|
1742
1755
|
next.source_path = rec.sourcePath
|
|
1756
|
+
next.content_hash = rec.contentHash
|
|
1743
1757
|
next.size_bytes = rec.sizeBytes
|
|
1744
1758
|
next.duration_seconds = rec.durationSeconds
|
|
1759
|
+
next.origin = rec.imported ? 'import' : undefined
|
|
1745
1760
|
store.jobs[rec.sourceId] = next
|
|
1746
1761
|
return next
|
|
1747
1762
|
}
|
|
@@ -1831,15 +1846,17 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
|
|
|
1831
1846
|
// filters (age/size/duration) don't apply — the user named the file.
|
|
1832
1847
|
const single = opts.file ? resolve(String(opts.file)) : null
|
|
1833
1848
|
if (single && !statSync(single, { throwIfNoEntry: false })?.isFile()) throw new Error(`Not a file: ${single}`)
|
|
1834
|
-
if (
|
|
1849
|
+
if (single && !isCandidateFile(single)) throw new Error(`Unsupported audio file. Use: ${[...AUDIO_EXTENSIONS].join(', ')}`)
|
|
1850
|
+
const recorderPresent = existsSync(config.recordDir)
|
|
1851
|
+
const { recordings, complete: scanComplete } = single
|
|
1852
|
+
? { recordings: [await toRecording(config, single)], complete: false }
|
|
1853
|
+
: await scanRecordings(config)
|
|
1854
|
+
if (!single && !recorderPresent && !recordings.length) {
|
|
1835
1855
|
if (shouldLogIdleStatus(`missing:${config.recordDir}`)) {
|
|
1836
|
-
console.log(`Idle: recorder not mounted
|
|
1856
|
+
console.log(`Idle: recorder not mounted and no manual imports are queued: ${config.recordDir} (repeated idle logs suppressed for 30m)`)
|
|
1837
1857
|
}
|
|
1838
1858
|
return
|
|
1839
1859
|
}
|
|
1840
|
-
const { recordings, complete: scanComplete } = single
|
|
1841
|
-
? { recordings: [await toRecording(config, single)], complete: false }
|
|
1842
|
-
: await scanRecordings(config)
|
|
1843
1860
|
const mode = normalizeRunMode(opts)
|
|
1844
1861
|
const force = Boolean(opts.force)
|
|
1845
1862
|
const eligible: Recording[] = []
|
|
@@ -1849,17 +1866,28 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
|
|
|
1849
1866
|
// idle-suppressed silence meant for the 60s scheduler tick.
|
|
1850
1867
|
const verboseSkips = Boolean(opts.verbose || opts.dryRun || single)
|
|
1851
1868
|
const seen = new Set<string>()
|
|
1852
|
-
const
|
|
1853
|
-
|
|
1854
|
-
: { maxAgeHours: config.maxAgeHours, minBytes: config.minBytes, minDurationSeconds: config.minDurationSeconds }
|
|
1869
|
+
const automaticLimits = { maxAgeHours: config.maxAgeHours, minBytes: config.minBytes, minDurationSeconds: config.minDurationSeconds }
|
|
1870
|
+
const manualLimits = { maxAgeHours: 0, minBytes: 0, minDurationSeconds: 0 }
|
|
1855
1871
|
for (const rec of recordings) {
|
|
1856
1872
|
seen.add(rec.sourceId)
|
|
1873
|
+
const completedDuplicate = rec.imported && !store.jobs[rec.sourceId]
|
|
1874
|
+
? completedJobByHash(store, rec.contentHash)
|
|
1875
|
+
: undefined
|
|
1876
|
+
if (completedDuplicate) {
|
|
1877
|
+
const name = basename(rec.sourcePath)
|
|
1878
|
+
skipCounts.already_done = (skipCounts.already_done || 0) + 1
|
|
1879
|
+
;(skipSamples.already_done ||= []).push(name)
|
|
1880
|
+
if (!opts.dryRun) await removeImportedSource(rec.sourcePath)
|
|
1881
|
+
if (verboseSkips) console.log(` Skip: ${name} (already_done)`)
|
|
1882
|
+
continue
|
|
1883
|
+
}
|
|
1857
1884
|
const entry = recordFor(store, rec)
|
|
1858
|
-
const verdict = classify(rec, store.jobs[rec.sourceId],
|
|
1885
|
+
const verdict = classify(rec, store.jobs[rec.sourceId], single || rec.imported ? manualLimits : automaticLimits, { force, notesMode: mode === 'notes', now: Date.now() })
|
|
1859
1886
|
if (verdict.run) { eligible.push(rec); continue }
|
|
1860
1887
|
skipCounts[verdict.code] = (skipCounts[verdict.code] || 0) + 1
|
|
1861
1888
|
;(skipSamples[verdict.code] ||= []).push(entry.name)
|
|
1862
1889
|
if (verdict.persist) patchJob(entry, { state: 'filtered', code: verdict.code, detail: verdict.detail }, nowIso())
|
|
1890
|
+
if (rec.imported && verdict.code === 'already_done' && !opts.dryRun) await removeImportedSource(rec.sourcePath)
|
|
1863
1891
|
if (verboseSkips) console.log(` Skip: ${entry.name} (${verdict.code}${verdict.detail ? `: ${verdict.detail}` : ''})`)
|
|
1864
1892
|
}
|
|
1865
1893
|
// Only prune against a listing we believe to be complete: if the recorder went
|
|
@@ -1908,8 +1936,9 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
|
|
|
1908
1936
|
}
|
|
1909
1937
|
await saveState(config, store)
|
|
1910
1938
|
|
|
1911
|
-
for (const rec of targets) {
|
|
1939
|
+
for (const [targetIndex, rec] of targets.entries()) {
|
|
1912
1940
|
const entry = store.jobs[rec.sourceId]!
|
|
1941
|
+
let importedDone = false
|
|
1913
1942
|
// --force means "start over", so it refunds the retry budget too. Without
|
|
1914
1943
|
// this it only skips one refusal: a spent record would be back at `gave_up`
|
|
1915
1944
|
// the moment this attempt failed.
|
|
@@ -1925,6 +1954,7 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
|
|
|
1925
1954
|
applyOutcome(entry, result.status === SUMMARY_FAILED_STATUS
|
|
1926
1955
|
? { kind: 'summary_failed', title: result.title ?? null, paths: result.final_paths ?? null, message: String(result.summary_error ?? 'summary failed; transcript saved') }
|
|
1927
1956
|
: { kind: 'done', title: result.title ?? null, paths: result.final_paths ?? null }, nowIso())
|
|
1957
|
+
importedDone = rec.imported && result.status !== SUMMARY_FAILED_STATUS
|
|
1928
1958
|
} catch (e: any) {
|
|
1929
1959
|
const message = String(e?.message || e)
|
|
1930
1960
|
console.error(`ERROR processing ${rec.sourcePath}: ${message}`)
|
|
@@ -1944,9 +1974,10 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
|
|
|
1944
1974
|
clearCurrent()
|
|
1945
1975
|
await saveState(config, store) // per job, not per batch: a kill -9 costs one job, not the batch
|
|
1946
1976
|
}
|
|
1947
|
-
|
|
1948
|
-
//
|
|
1949
|
-
|
|
1977
|
+
if (importedDone) await removeImportedSource(rec.sourcePath)
|
|
1978
|
+
// Whole recorder went away — every remaining recorder target would fail the
|
|
1979
|
+
// same way. Local imports do not depend on the recorder and keep running.
|
|
1980
|
+
if (!single && !existsSync(config.recordDir) && targets.slice(targetIndex + 1).some(target => !target.imported)) {
|
|
1950
1981
|
console.error(`Recorder disappeared mid-run (${config.recordDir}); stopping. Remaining recordings stay queued.`)
|
|
1951
1982
|
break
|
|
1952
1983
|
}
|
|
@@ -2288,6 +2319,74 @@ async function openTarget(arg?: string): Promise<void> {
|
|
|
2288
2319
|
console.log(`open ${target}`)
|
|
2289
2320
|
}
|
|
2290
2321
|
|
|
2322
|
+
function completedJobByHash(store: StateFile, digest: string): [string, JobRecord] | undefined {
|
|
2323
|
+
return Object.entries(store.jobs).find(([, job]) => job.state === 'done' && job.content_hash === digest)
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
type ImportResult = {
|
|
2327
|
+
status: 'queued' | 'running' | 'gave_up' | 'already_done'
|
|
2328
|
+
id: string
|
|
2329
|
+
name: string
|
|
2330
|
+
title: string | null
|
|
2331
|
+
notes: string | null
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
async function importRecording(file: string, opts: { json?: boolean }): Promise<void> {
|
|
2335
|
+
const source = resolve(file)
|
|
2336
|
+
const sourceStat = await stat(source).catch(() => null)
|
|
2337
|
+
if (!sourceStat?.isFile()) throw new Error(`Not a file: ${source}`)
|
|
2338
|
+
if (!isCandidateFile(source)) throw new Error(`Unsupported audio file. Use: ${[...AUDIO_EXTENSIONS].join(', ')}`)
|
|
2339
|
+
|
|
2340
|
+
const config = getConfig()
|
|
2341
|
+
await ensureDirs(config)
|
|
2342
|
+
const digest = await sha256File(source)
|
|
2343
|
+
const id = `import:${digest}`
|
|
2344
|
+
const store = await loadState(config)
|
|
2345
|
+
const entry = store.jobs[id]
|
|
2346
|
+
const doneMatch = entry?.state === 'done'
|
|
2347
|
+
? [id, entry] as const
|
|
2348
|
+
: entry ? undefined : completedJobByHash(store, digest)
|
|
2349
|
+
let result: ImportResult
|
|
2350
|
+
|
|
2351
|
+
if (doneMatch) {
|
|
2352
|
+
const [doneId, done] = doneMatch
|
|
2353
|
+
result = { status: 'already_done', id: doneId, name: done.name, title: done.title, notes: done.paths?.notes ?? null }
|
|
2354
|
+
} else {
|
|
2355
|
+
const digestDir = join(inboxPathFor(config), digest)
|
|
2356
|
+
const queuedName = (await readdir(digestDir).catch(() => []))
|
|
2357
|
+
.find(name => isCandidateFile(name) && statSync(join(digestDir, name), { throwIfNoEntry: false })?.isFile())
|
|
2358
|
+
const existingSource = entry?.source_path && existsSync(entry.source_path) ? entry.source_path : null
|
|
2359
|
+
const inboxFile = existingSource ?? (queuedName ? join(digestDir, queuedName) : join(digestDir, basename(source)))
|
|
2360
|
+
if (!existsSync(inboxFile)) {
|
|
2361
|
+
await mkdir(dirname(inboxFile), { recursive: true })
|
|
2362
|
+
const tmp = join(dirname(inboxFile), `.${basename(inboxFile)}.tmp-${process.pid}`)
|
|
2363
|
+
try {
|
|
2364
|
+
await copyFile(source, tmp)
|
|
2365
|
+
await utimes(tmp, sourceStat.atime, sourceStat.mtime)
|
|
2366
|
+
await rename(tmp, inboxFile)
|
|
2367
|
+
} finally {
|
|
2368
|
+
await unlink(tmp).catch(() => {})
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
result = {
|
|
2372
|
+
status: entry?.state === 'running' ? 'running' : entry?.state === 'gave_up' ? 'gave_up' : 'queued',
|
|
2373
|
+
id, name: entry?.name ?? basename(source), title: entry?.title ?? null, notes: entry?.paths?.notes ?? null,
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
if (opts.json) console.log(JSON.stringify(result))
|
|
2378
|
+
else if (result.status === 'already_done') console.log(`already processed: ${result.title || result.name}`)
|
|
2379
|
+
else console.log(`${result.status}: ${result.name}`)
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
async function removeImportedSource(path: string): Promise<void> {
|
|
2383
|
+
try { await unlink(path) }
|
|
2384
|
+
catch (e: any) { if (e?.code !== 'ENOENT') { warnSideEffect(`remove imported source ${path}`, e); return } }
|
|
2385
|
+
await rmdir(dirname(path)).catch((e: any) => {
|
|
2386
|
+
if (e?.code !== 'ENOENT' && e?.code !== 'ENOTEMPTY') warnSideEffect(`remove empty import dir ${dirname(path)}`, e)
|
|
2387
|
+
})
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2291
2390
|
async function forgetRecording(needle: string): Promise<void> {
|
|
2292
2391
|
const config = getConfig()
|
|
2293
2392
|
// Under the run lock: `vn run` holds the state file in memory for the length
|
|
@@ -2641,6 +2740,9 @@ cli.command('jobs', 'Show every recording\'s processing status (running, queued,
|
|
|
2641
2740
|
|
|
2642
2741
|
cli.command('open [target]', 'Open notes dir, config dir (`config`), logs dir (`logs`), or a note matching the slug').action((target?: string) => openTarget(target))
|
|
2643
2742
|
|
|
2743
|
+
cli.command('import <file>', 'Copy one audio file into the durable manual-import queue')
|
|
2744
|
+
.option('--json', 'Output structured status (for the GUI)')
|
|
2745
|
+
.action((file: string, opts: { json?: boolean }) => importRecording(file, opts))
|
|
2644
2746
|
cli.command('forget <key>', 'Drop a recording\'s job record so it is queued again (a saved transcript on disk is still reused)').action((key: string) => forgetRecording(key))
|
|
2645
2747
|
cli.command('retry <id>', 'Requeue one failed recording while retaining saved outputs').action((id: string) => retryRecording(id))
|
|
2646
2748
|
|
package/src/jobs.ts
CHANGED
|
@@ -28,6 +28,7 @@ type JobCode = 'transcribe_failed' | 'summary_failed' | 'interrupted' | 'too_sma
|
|
|
28
28
|
export type JobRecord = {
|
|
29
29
|
name: string
|
|
30
30
|
source_path: string
|
|
31
|
+
content_hash?: string
|
|
31
32
|
/** Local wall-clock `YYYY-MM-DDTHH:mm:ss` — sorts lexicographically, no TZ drift. */
|
|
32
33
|
recorded_at: string
|
|
33
34
|
size_bytes: number
|
|
@@ -39,6 +40,7 @@ export type JobRecord = {
|
|
|
39
40
|
updated_at: string
|
|
40
41
|
title: string | null
|
|
41
42
|
paths: Record<string, string | null> | null
|
|
43
|
+
origin?: 'import'
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export type StateFile = {
|
|
@@ -101,17 +103,17 @@ export function classify(
|
|
|
101
103
|
if (rec.sizeBytes < limits.minBytes) return { run: false, persist: true, code: 'too_small', detail: `${rec.sizeBytes} < ${limits.minBytes} bytes` }
|
|
102
104
|
if (rec.durationSeconds !== null && rec.durationSeconds < limits.minDurationSeconds) return { run: false, persist: true, code: 'too_short', detail: `${rec.durationSeconds.toFixed(0)}s < ${limits.minDurationSeconds}s` }
|
|
103
105
|
|
|
104
|
-
// `error`, `queued` and `running` are retryable
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
106
|
+
// `error`, `queued` and `running` are retryable. A previously `filtered`
|
|
107
|
+
// record is runnable too once it passes the CURRENT filters, so widening the
|
|
108
|
+
// history range in Settings actually re-queues recordings marked `too_old`.
|
|
109
|
+
// Anything else came off disk hand-edited or from a newer build: refuse it.
|
|
108
110
|
if (entry && !RUNNABLE_STATES.has(entry.state)) {
|
|
109
111
|
return { run: false, persist: false, code: 'gave_up', detail: `Unrecognised state '${entry.state}'; \`vn forget ${entry.name}\` to start over` }
|
|
110
112
|
}
|
|
111
113
|
return { run: true }
|
|
112
114
|
}
|
|
113
115
|
|
|
114
|
-
const RUNNABLE_STATES = new Set<JobState>(['queued', 'running', 'error'])
|
|
116
|
+
const RUNNABLE_STATES = new Set<JobState>(['queued', 'running', 'error', 'filtered'])
|
|
115
117
|
|
|
116
118
|
/** Every way a started attempt can end. */
|
|
117
119
|
type Outcome =
|
|
@@ -256,6 +258,8 @@ type JobView = {
|
|
|
256
258
|
step: string | null
|
|
257
259
|
detail: string | null
|
|
258
260
|
notes: string | null
|
|
261
|
+
history_filtered: boolean
|
|
262
|
+
imported: boolean
|
|
259
263
|
}
|
|
260
264
|
|
|
261
265
|
export const SUMMARY_FAILED_STATUS = 'summary_failed_transcript_saved'
|
|
@@ -368,6 +372,7 @@ function foldFiltered(records: JobRecord[]): JobView | null {
|
|
|
368
372
|
status: 'filtered',
|
|
369
373
|
name: `${records.length} recording${records.length > 1 ? 's' : ''} filtered out`,
|
|
370
374
|
title: null, time: null, step: null, detail, notes: null,
|
|
375
|
+
history_filtered: records.some(r => r.code === 'too_old'), imported: false,
|
|
371
376
|
}
|
|
372
377
|
}
|
|
373
378
|
|
|
@@ -375,7 +380,7 @@ export function buildJobsView(
|
|
|
375
380
|
state: StateFile,
|
|
376
381
|
current: CurrentJob | null,
|
|
377
382
|
opts: { limit: number; alive: (pid: number) => boolean; recorderPresent: boolean },
|
|
378
|
-
): { items: JobView[]; total: number; queued_total: number; recorder_present: boolean } {
|
|
383
|
+
): { items: JobView[]; total: number; queued_total: number; recorder_queued_total: number; recorder_present: boolean } {
|
|
379
384
|
// Two independent conditions must agree before a row is shown as running:
|
|
380
385
|
// the declaring process is alive, AND the record itself says `running`.
|
|
381
386
|
// current.json survives a kill -9, so the pid alone could be recycled by an
|
|
@@ -399,6 +404,8 @@ export function buildJobsView(
|
|
|
399
404
|
step: null,
|
|
400
405
|
detail: null as string | null,
|
|
401
406
|
notes: j.paths?.notes ?? null,
|
|
407
|
+
history_filtered: false,
|
|
408
|
+
imported: j.origin === 'import',
|
|
402
409
|
_t: j.recorded_at ?? '',
|
|
403
410
|
}
|
|
404
411
|
if (live && live.source_id === id) { running.push({ ...base, status: 'running', step: live.step }); continue }
|
|
@@ -443,5 +450,11 @@ export function buildJobsView(
|
|
|
443
450
|
// from the visible page would contradict the "… X more" line right above it.
|
|
444
451
|
// Read `recorder_present` live from the caller, never stored — a persisted
|
|
445
452
|
// flag would keep claiming the recorder is connected after the agent stops.
|
|
446
|
-
return {
|
|
453
|
+
return {
|
|
454
|
+
items,
|
|
455
|
+
total,
|
|
456
|
+
queued_total: running.length + queued.length,
|
|
457
|
+
recorder_queued_total: [...running, ...queued].filter(job => !job.imported).length,
|
|
458
|
+
recorder_present: opts.recorderPresent,
|
|
459
|
+
}
|
|
447
460
|
}
|