@fastagent-sh/voicenote 0.22.0 → 0.22.1
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 +4 -4
- package/README.zh-CN.md +4 -4
- package/package.json +1 -1
- package/src/cli.ts +17 -1
- package/src/jobs.ts +10 -0
package/README.md
CHANGED
|
@@ -215,7 +215,7 @@ Changes take effect on the next `vn run`. `~`, `$HOME`, and `${HOME}` are accept
|
|
|
215
215
|
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
216
|
7. Write notes / metadata; the system makes no archiving decisions — files stay in the configured workspace
|
|
217
217
|
|
|
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
|
|
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. 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
219
|
|
|
220
220
|
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
221
|
|
|
@@ -227,7 +227,7 @@ Records whose source file is no longer on the recorder are forgotten on the next
|
|
|
227
227
|
- Original audio: `${VOICENOTE_WORKSPACE}/_audio/YYYY-MM/`
|
|
228
228
|
- Full transcripts: `${VOICENOTE_WORKSPACE}/_transcripts/YYYY-MM/`
|
|
229
229
|
- 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`
|
|
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` 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
231
|
- Index: `${VOICENOTE_WORKSPACE}/_index/notes.jsonl`
|
|
232
232
|
|
|
233
233
|
## Automation
|
|
@@ -282,10 +282,10 @@ The workflow lives at `.github/workflows/release.yml`: CI explicitly runs typech
|
|
|
282
282
|
|
|
283
283
|
A self-contained macOS `.app` (Tauri v2) for **non-terminal users**: the target machine needs no pre-installed bun / pi / ffprobe / global `vn`.
|
|
284
284
|
|
|
285
|
-
**Positioning**: the GUI is
|
|
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).
|
|
286
286
|
|
|
287
287
|
- 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
|
|
288
|
+
- After that: the main view shows agent activity and recent notes, opens outputs, and can retry failed recordings.
|
|
289
289
|
|
|
290
290
|
### What's bundled
|
|
291
291
|
|
package/README.zh-CN.md
CHANGED
|
@@ -210,7 +210,7 @@ vn uninstall-launch-agent
|
|
|
210
210
|
6. summary 模型(由 pi 自身配置决定)直接看原始 transcript,在纪要生成阶段内部完成必要清理、说话人还原、观点/争论/共识形成过程还原;如果 summary 失败,下一次 `vn run` / `vn run --latest` 会复用已保存 transcript,直接重试纪要生成,不需要 `vn forget`
|
|
211
211
|
7. 写出 notes / metadata;系统不做任何归档决定,文件留在配置的 workspace 中
|
|
212
212
|
|
|
213
|
-
失败的录音会在后续运行中重试,但**最多 3 次**(转写失败、纪要失败、以及被中途 kill 的运行都算)。超过后标记为 `Gave up` 并不再自动重试,避免一个坏文件每个调度周期都烧一次 ASR/LLM
|
|
213
|
+
失败的录音会在后续运行中重试,但**最多 3 次**(转写失败、纪要失败、以及被中途 kill 的运行都算)。超过后标记为 `Gave up` 并不再自动重试,避免一个坏文件每个调度周期都烧一次 ASR/LLM 额度。点击 GUI 记录上的**重试**会重置次数、保留已有产物并立即再跑;CLI 也可以用 `vn forget <name>` 删除记录后重新入队。两种方式都会复用磁盘上已有的 transcript,不会重复支付 ASR 费用。它们都需要 run lock;如果当前正在处理,请等本次 run 结束后再重试。
|
|
214
214
|
|
|
215
215
|
源文件已不在录音笔上的记录,会在下一次扫描时被遗忘(并记入日志),**已经产出纪要或 transcript 的除外** —— 那部分历史会保留。所以换录音笔、或从设备上删文件,不再会留下永久的 “失败” 条目。
|
|
216
216
|
|
|
@@ -222,7 +222,7 @@ vn uninstall-launch-agent
|
|
|
222
222
|
- 原始音频:`${VOICENOTE_WORKSPACE}/_audio/YYYY-MM/`
|
|
223
223
|
- 完整转写:`${VOICENOTE_WORKSPACE}/_transcripts/YYYY-MM/`
|
|
224
224
|
- 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`
|
|
225
|
+
- 状态:`${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
226
|
- 索引:`${VOICENOTE_WORKSPACE}/_index/notes.jsonl`
|
|
227
227
|
|
|
228
228
|
## 自动化
|
|
@@ -277,10 +277,10 @@ workflow 位于 `.github/workflows/release.yml`:CI 显式跑 typecheck、测试
|
|
|
277
277
|
|
|
278
278
|
面向**非终端用户**:一个自包含的 macOS `.app`(Tauri v2),目标机器无需预装 bun / pi / ffprobe / 全局 `vn`。
|
|
279
279
|
|
|
280
|
-
**定位**:GUI
|
|
280
|
+
**定位**:GUI 是工作状态 dashboard,提供产出快捷入口以及手动同步/重试。全流程仍由后台 LaunchAgent 用包内 CLI 每 60s 自主运行(关掉 GUI 也跑)。
|
|
281
281
|
|
|
282
282
|
- 首次:配置向导(身份 / Volcano keys / 代理)→ ChatGPT 登录(设备无终端,走 `vn login` 的浏览器回调流)
|
|
283
|
-
- 之后:主界面显示 agent
|
|
283
|
+
- 之后:主界面显示 agent 活动和最近纪要,可打开产物或重试失败录音
|
|
284
284
|
|
|
285
285
|
### 打包内容
|
|
286
286
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fastagent-sh/voicenote",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.1",
|
|
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,7 +3,7 @@ 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
8
|
import { appendFile, chmod, mkdir, readFile, writeFile, copyFile, rename, unlink, stat, readdir } from 'node:fs/promises'
|
|
9
9
|
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, openSync, closeSync, statSync, readSync, unlinkSync, renameSync } from 'node:fs'
|
|
@@ -2310,6 +2310,21 @@ async function forgetRecording(needle: string): Promise<void> {
|
|
|
2310
2310
|
} finally { await lock.release() }
|
|
2311
2311
|
}
|
|
2312
2312
|
|
|
2313
|
+
async function retryRecording(id: string): Promise<void> {
|
|
2314
|
+
const config = getConfig()
|
|
2315
|
+
const lock = await acquireRunLock()
|
|
2316
|
+
if (!lock) throw new Error('A voicenote run is in progress. Retry once it finishes.')
|
|
2317
|
+
try {
|
|
2318
|
+
await migrateStateOnDisk(config)
|
|
2319
|
+
const store = await loadState(config)
|
|
2320
|
+
const entry = store.jobs[id]
|
|
2321
|
+
if (!entry) throw new Error('Recording no longer exists in the processing list.')
|
|
2322
|
+
if (!requeueFailed(entry, nowIso())) throw new Error(`Cannot retry a recording in state '${entry.state}'.`)
|
|
2323
|
+
await saveState(config, store)
|
|
2324
|
+
console.log(`queued ${entry.name} for retry`)
|
|
2325
|
+
} finally { await lock.release() }
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2313
2328
|
async function showLog(opts: { lines?: number; follow?: boolean; err?: boolean; date?: string }): Promise<void> {
|
|
2314
2329
|
const lines = Number(opts.lines || 30)
|
|
2315
2330
|
const wanted = [opts.date ? join(LOG_DIR, `${opts.date}.log`) : dailyLogPath()]
|
|
@@ -2627,6 +2642,7 @@ cli.command('jobs', 'Show every recording\'s processing status (running, queued,
|
|
|
2627
2642
|
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
2643
|
|
|
2629
2644
|
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
|
+
cli.command('retry <id>', 'Requeue one failed recording while retaining saved outputs').action((id: string) => retryRecording(id))
|
|
2630
2646
|
|
|
2631
2647
|
cli.command('log', 'Print the daily log (today by default)')
|
|
2632
2648
|
.option('--lines <n>', 'How many trailing lines to print', { default: 30 })
|
package/src/jobs.ts
CHANGED
|
@@ -163,6 +163,13 @@ export function startAttempt(entry: JobRecord, now: string): void {
|
|
|
163
163
|
patchJob(entry, { state: 'running', code: null, detail: null, attempts: entry.attempts + 1 }, now)
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/** Manually requeue a failed job while retaining any saved transcript/audio. */
|
|
167
|
+
export function requeueFailed(entry: JobRecord, now: string): boolean {
|
|
168
|
+
if (entry.state !== 'error' && entry.state !== 'gave_up') return false
|
|
169
|
+
patchJob(entry, { state: 'queued', detail: null, attempts: 0 }, now)
|
|
170
|
+
return true
|
|
171
|
+
}
|
|
172
|
+
|
|
166
173
|
/**
|
|
167
174
|
* Reclaim records left `running` by a dead run. Safe to do wholesale because the
|
|
168
175
|
* caller holds the run lock: no other run can own a `running` record right now.
|
|
@@ -241,6 +248,7 @@ export function pruneUnseen(jobs: Record<string, JobRecord>, seen: Set<string>,
|
|
|
241
248
|
export type CurrentJob = { pid: number; source_id: string; step: string; started_at: string }
|
|
242
249
|
|
|
243
250
|
type JobView = {
|
|
251
|
+
id: string | null
|
|
244
252
|
status: 'running' | 'queued' | 'done' | 'notes_failed' | 'error' | 'gave_up' | 'filtered'
|
|
245
253
|
name: string
|
|
246
254
|
title: string | null
|
|
@@ -356,6 +364,7 @@ function foldFiltered(records: JobRecord[]): JobView | null {
|
|
|
356
364
|
}
|
|
357
365
|
const detail = [...counts].map(([label, n]) => `${label} ×${n}`).join(', ')
|
|
358
366
|
return {
|
|
367
|
+
id: null,
|
|
359
368
|
status: 'filtered',
|
|
360
369
|
name: `${records.length} recording${records.length > 1 ? 's' : ''} filtered out`,
|
|
361
370
|
title: null, time: null, step: null, detail, notes: null,
|
|
@@ -383,6 +392,7 @@ export function buildJobsView(
|
|
|
383
392
|
|
|
384
393
|
for (const [id, j] of Object.entries(state.jobs ?? {})) {
|
|
385
394
|
const base = {
|
|
395
|
+
id,
|
|
386
396
|
name: j.name,
|
|
387
397
|
title: j.title ?? null,
|
|
388
398
|
time: displayTime(j.recorded_at),
|