@fastagent-sh/voicenote 0.22.0 → 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 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,7 +216,11 @@ 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
 
218
- 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 — `vn forget <name>` drops the record and re-queues it. Re-queuing is not the same as re-transcribing: if the transcript is already on disk it is reused, so `vn forget` never re-pays for ASR. (`vn forget` takes the run lock, so it refuses while a run is in progress — wait for that run to finish and repeat.)
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
+
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.
221
226
 
@@ -227,7 +232,8 @@ 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/`
230
- - 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` is the only writer; `vn jobs` and the GUI dashboard are pure reads of it, 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`.
235
+ - Pending manual imports: `${VOICENOTE_WORKSPACE}/_inbox/` (removed after success)
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
 
233
239
  ## Automation
@@ -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. It skips safely when no recorder is plugged in; once the VTR6500 is connected, new recordings are processed automatically.
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 only a "status dashboard + quick access to output" — it does **not** drive processing. The full pipeline 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 + recent notes (open note / open folder)
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,7 +211,11 @@ 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
 
213
- 失败的录音会在后续运行中重试,但**最多 3 次**(转写失败、纪要失败、以及被中途 kill 的运行都算)。超过后标记为 `Gave up` 并不再自动重试,避免一个坏文件每个调度周期都烧一次 ASR/LLM 额度 —— `vn forget <name>` 会删掉该记录并重新入队。重新入队不等于重新转写:磁盘上已有 transcript 时会直接复用,所以 `vn forget` 不会让你再付一次 ASR。(`vn forget` 需要 run lock,因此在某次 run 进行中时会拒绝执行 —— 等该次 run 结束后重试即可。)
214
+ 历史范围默认是 48 小时。GUI 可在**设置 → 处理多长时间范围内的录音**中选择最近 7 天、30 天或全部录音。扩大范围后,之前因 `too_old` 被过滤的录音会按新条件重新入队;dashboard 的过滤汇总也会直接链接到该设置。
215
+
216
+ 需要立即处理本地文件时,把一个支持的音频文件拖进 GUI 即可。应用会先原子复制到 `${VOICENOTE_WORKSPACE}/_inbox`;即使当前正在运行其他任务或录音笔未连接,也会持久排队。手动导入优先于录音笔的自动扫描任务,并跳过自动扫描的时间、大小和时长过滤。成功后删除 inbox 临时副本,失败时保留以供“重试”。导入按内容寻址;当系统已知相同内容的完成记录时,再次拖入会打开已有纪要,不会重复支付 ASR。
217
+
218
+ 失败的录音会在后续运行中重试,但**最多 3 次**(转写失败、纪要失败、以及被中途 kill 的运行都算)。超过后标记为 `Gave up` 并不再自动重试,避免一个坏文件每个调度周期都烧一次 ASR/LLM 额度。点击 GUI 记录上的**重试**会重置次数、保留已有产物并立即再跑;CLI 也可以用 `vn forget <name>` 删除记录后重新入队。两种方式都会复用磁盘上已有的 transcript,不会重复支付 ASR 费用。它们都需要 run lock;如果当前正在处理,请等本次 run 结束后再重试。
214
219
 
215
220
  源文件已不在录音笔上的记录,会在下一次扫描时被遗忘(并记入日志),**已经产出纪要或 transcript 的除外** —— 那部分历史会保留。所以换录音笔、或从设备上删文件,不再会留下永久的 “失败” 条目。
216
221
 
@@ -222,7 +227,8 @@ 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/`
225
- - 状态:`${VOICENOTE_WORKSPACE}/_state/jobs.json` —— 每条录音一条记录,包含 `state`(生命周期位置:`queued`、`running`、`done`、`filtered`、`error`,以及重试耗尽后的 `gave_up`)、`code`(原因:`summary_failed`、`transcribe_failed`、`interrupted`、`too_small` 等)、重试次数和产物路径。`vn run` 是唯一的写入方,`vn jobs` 和 GUI 面板都只是它的纯读取 —— 你看到的队列就是会跑的队列。0.18 之前的 `processed.json` 会在首次运行时自动转换,旧文件保留为 `processed.json.v1.bak`。
230
+ - 待处理的手动导入:`${VOICENOTE_WORKSPACE}/_inbox/`(成功后删除)
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
 
228
234
  ## 自动化
@@ -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`。没插录音笔时安全跳过;插上 VTR6500 后自动处理新录音。
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 + 产出快捷入口」,**不驱动处理**。真正的全流程由后台 LaunchAgent 用包内 CLI 每 60s 自主运行(关掉 GUI 也跑)。
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.0",
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
@@ -3,9 +3,9 @@ import { cac } from 'cac'
3
3
  import packageJson from '../package.json' with { type: 'json' }
4
4
  import { parseLockOwner } from './runLock'
5
5
  import { tosObject, type TosConfig as VolcanoTosConfig } from './tos'
6
- import { applyOutcome, buildJobsView, classify, emptyState, localIso, MAX_ATTEMPTS, migrateLegacyState, ownsOutput, parseJobsLimit, parseStateFile, parseStrictJson, patchJob, pruneUnseen, reconcileInterrupted, startAttempt, SUMMARY_FAILED_STATUS, type CurrentJob, type JobRecord, type StateFile } from './jobs'
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
- async function sourceIdFor(path: string): Promise<string> {
650
- const st = await stat(path)
651
- const digest = await sha256File(path)
652
- return createHash('sha256').update(`${path}|${st.size}|${Math.floor(st.mtimeMs / 1000)}|${digest}`).digest('hex')
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: await sourceIdFor(file),
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
- let complete = true
763
- try {
764
- for await (const file of new Bun.Glob('**/*').scan({ cwd: config.recordDir, absolute: true, dot: true })) {
765
- if (!isCandidateFile(file)) continue
766
- const st = await stat(file).catch(() => null)
767
- // Listed a moment ago but unreadable now: the device is going away, or
768
- // this file is. Either way the listing is no longer trustworthy.
769
- if (!st) { complete = false; continue }
770
- if (!st.isFile()) continue
771
- try {
772
- recordings.push(await toRecording(config, file))
773
- } catch (e) { complete = false; warnSideEffect(`read ${basename(file)} during scan`, e) }
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
- // Oldest first: backlog is drained in chronological order, so every file is
780
- // guaranteed a turn before newer arrivals jump the queue.
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 (!single && !existsSync(config.recordDir)) {
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 or record dir missing: ${config.recordDir} (repeated idle logs suppressed for 30m)`)
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 limits = single
1853
- ? { maxAgeHours: 0, minBytes: 0, minDurationSeconds: 0 }
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], limits, { force, notesMode: mode === 'notes', now: Date.now() })
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
- // Whole recorder went away — every remaining target would fail the same way
1948
- // and churn ASR-free but noisy retries. Stop and let the next run rescan.
1949
- if (!single && !existsSync(config.recordDir)) {
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
@@ -2310,6 +2409,21 @@ async function forgetRecording(needle: string): Promise<void> {
2310
2409
  } finally { await lock.release() }
2311
2410
  }
2312
2411
 
2412
+ async function retryRecording(id: string): Promise<void> {
2413
+ const config = getConfig()
2414
+ const lock = await acquireRunLock()
2415
+ if (!lock) throw new Error('A voicenote run is in progress. Retry once it finishes.')
2416
+ try {
2417
+ await migrateStateOnDisk(config)
2418
+ const store = await loadState(config)
2419
+ const entry = store.jobs[id]
2420
+ if (!entry) throw new Error('Recording no longer exists in the processing list.')
2421
+ if (!requeueFailed(entry, nowIso())) throw new Error(`Cannot retry a recording in state '${entry.state}'.`)
2422
+ await saveState(config, store)
2423
+ console.log(`queued ${entry.name} for retry`)
2424
+ } finally { await lock.release() }
2425
+ }
2426
+
2313
2427
  async function showLog(opts: { lines?: number; follow?: boolean; err?: boolean; date?: string }): Promise<void> {
2314
2428
  const lines = Number(opts.lines || 30)
2315
2429
  const wanted = [opts.date ? join(LOG_DIR, `${opts.date}.log`) : dailyLogPath()]
@@ -2626,7 +2740,11 @@ cli.command('jobs', 'Show every recording\'s processing status (running, queued,
2626
2740
 
2627
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))
2628
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))
2629
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))
2747
+ cli.command('retry <id>', 'Requeue one failed recording while retaining saved outputs').action((id: string) => retryRecording(id))
2630
2748
 
2631
2749
  cli.command('log', 'Print the daily log (today by default)')
2632
2750
  .option('--lines <n>', 'How many trailing lines to print', { default: 30 })
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 — `error` used to be terminal,
105
- // which is how 127 dead entries piled up without a single retry. Anything else
106
- // came off disk hand-edited or from a newer build: refuse it rather than run
107
- // it, so the scheduler and the view (which shows it as unrecognised) agree.
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 =
@@ -163,6 +165,13 @@ export function startAttempt(entry: JobRecord, now: string): void {
163
165
  patchJob(entry, { state: 'running', code: null, detail: null, attempts: entry.attempts + 1 }, now)
164
166
  }
165
167
 
168
+ /** Manually requeue a failed job while retaining any saved transcript/audio. */
169
+ export function requeueFailed(entry: JobRecord, now: string): boolean {
170
+ if (entry.state !== 'error' && entry.state !== 'gave_up') return false
171
+ patchJob(entry, { state: 'queued', detail: null, attempts: 0 }, now)
172
+ return true
173
+ }
174
+
166
175
  /**
167
176
  * Reclaim records left `running` by a dead run. Safe to do wholesale because the
168
177
  * caller holds the run lock: no other run can own a `running` record right now.
@@ -241,6 +250,7 @@ export function pruneUnseen(jobs: Record<string, JobRecord>, seen: Set<string>,
241
250
  export type CurrentJob = { pid: number; source_id: string; step: string; started_at: string }
242
251
 
243
252
  type JobView = {
253
+ id: string | null
244
254
  status: 'running' | 'queued' | 'done' | 'notes_failed' | 'error' | 'gave_up' | 'filtered'
245
255
  name: string
246
256
  title: string | null
@@ -248,6 +258,8 @@ type JobView = {
248
258
  step: string | null
249
259
  detail: string | null
250
260
  notes: string | null
261
+ history_filtered: boolean
262
+ imported: boolean
251
263
  }
252
264
 
253
265
  export const SUMMARY_FAILED_STATUS = 'summary_failed_transcript_saved'
@@ -356,9 +368,11 @@ function foldFiltered(records: JobRecord[]): JobView | null {
356
368
  }
357
369
  const detail = [...counts].map(([label, n]) => `${label} ×${n}`).join(', ')
358
370
  return {
371
+ id: null,
359
372
  status: 'filtered',
360
373
  name: `${records.length} recording${records.length > 1 ? 's' : ''} filtered out`,
361
374
  title: null, time: null, step: null, detail, notes: null,
375
+ history_filtered: records.some(r => r.code === 'too_old'), imported: false,
362
376
  }
363
377
  }
364
378
 
@@ -366,7 +380,7 @@ export function buildJobsView(
366
380
  state: StateFile,
367
381
  current: CurrentJob | null,
368
382
  opts: { limit: number; alive: (pid: number) => boolean; recorderPresent: boolean },
369
- ): { 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 } {
370
384
  // Two independent conditions must agree before a row is shown as running:
371
385
  // the declaring process is alive, AND the record itself says `running`.
372
386
  // current.json survives a kill -9, so the pid alone could be recycled by an
@@ -383,12 +397,15 @@ export function buildJobsView(
383
397
 
384
398
  for (const [id, j] of Object.entries(state.jobs ?? {})) {
385
399
  const base = {
400
+ id,
386
401
  name: j.name,
387
402
  title: j.title ?? null,
388
403
  time: displayTime(j.recorded_at),
389
404
  step: null,
390
405
  detail: null as string | null,
391
406
  notes: j.paths?.notes ?? null,
407
+ history_filtered: false,
408
+ imported: j.origin === 'import',
392
409
  _t: j.recorded_at ?? '',
393
410
  }
394
411
  if (live && live.source_id === id) { running.push({ ...base, status: 'running', step: live.step }); continue }
@@ -433,5 +450,11 @@ export function buildJobsView(
433
450
  // from the visible page would contradict the "… X more" line right above it.
434
451
  // Read `recorder_present` live from the caller, never stored — a persisted
435
452
  // flag would keep claiming the recorder is connected after the agent stops.
436
- return { items, total, queued_total: running.length + queued.length, recorder_present: opts.recorderPresent }
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
+ }
437
460
  }