@fastagent-sh/voicenote 0.21.0 → 0.22.0
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 +16 -18
- package/README.zh-CN.md +16 -16
- package/package.json +4 -5
- package/src/cli.ts +363 -672
- package/src/jobs.ts +10 -10
- package/src/runLock.ts +1 -1
- package/src/tos.ts +18 -0
- package/src/envConfig.ts +0 -129
package/src/jobs.ts
CHANGED
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
// fields have to agree about the same fact — an earlier cut expressed "gave up"
|
|
21
21
|
// as `code` while leaving `state` alone, and the view and the classifier
|
|
22
22
|
// promptly disagreed about what such a record was.
|
|
23
|
-
|
|
23
|
+
type JobState = 'queued' | 'running' | 'done' | 'filtered' | 'error' | 'gave_up'
|
|
24
24
|
|
|
25
25
|
/** Which stage produced a failure; also carries the filter reason. */
|
|
26
|
-
|
|
26
|
+
type JobCode = 'transcribe_failed' | 'summary_failed' | 'interrupted' | 'too_small' | 'too_short' | 'too_old' | null
|
|
27
27
|
|
|
28
28
|
export type JobRecord = {
|
|
29
29
|
name: string
|
|
@@ -60,18 +60,18 @@ export const ownsOutput = (j: JobRecord): boolean => j.paths != null
|
|
|
60
60
|
export const MAX_ATTEMPTS = 3
|
|
61
61
|
|
|
62
62
|
/** The subset of JobCode a refusal may write to disk. */
|
|
63
|
-
|
|
63
|
+
type FilterCode = Extract<JobCode, 'too_small' | 'too_short' | 'too_old'>
|
|
64
64
|
|
|
65
65
|
// Split by whether the refusal is persisted, so the code that reaches disk is
|
|
66
66
|
// typed as such. A single `code: string` needed a cast at the write site, and
|
|
67
67
|
// the cast was the only thing keeping a display-only reason out of the record.
|
|
68
|
-
|
|
68
|
+
type Verdict =
|
|
69
69
|
| { run: true }
|
|
70
70
|
| { run: false; persist: true; code: FilterCode; detail: string | null }
|
|
71
71
|
| { run: false; persist: false; code: 'already_done' | 'gave_up'; detail: string | null }
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
type ScanFacts = { recordedAt: Date; sizeBytes: number; durationSeconds: number | null }
|
|
74
|
+
type Limits = { maxAgeHours: number; minBytes: number; minDurationSeconds: number }
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* The single answer to "should this recording run now, and in what form".
|
|
@@ -114,7 +114,7 @@ export function classify(
|
|
|
114
114
|
const RUNNABLE_STATES = new Set<JobState>(['queued', 'running', 'error'])
|
|
115
115
|
|
|
116
116
|
/** Every way a started attempt can end. */
|
|
117
|
-
|
|
117
|
+
type Outcome =
|
|
118
118
|
| { kind: 'done'; title: string | null; paths: Record<string, string | null> | null }
|
|
119
119
|
| { kind: 'summary_failed'; title: string | null; paths: Record<string, string | null> | null; message: string }
|
|
120
120
|
| { kind: 'failed'; message: string }
|
|
@@ -240,7 +240,7 @@ export function pruneUnseen(jobs: Record<string, JobRecord>, seen: Set<string>,
|
|
|
240
240
|
|
|
241
241
|
export type CurrentJob = { pid: number; source_id: string; step: string; started_at: string }
|
|
242
242
|
|
|
243
|
-
|
|
243
|
+
type JobView = {
|
|
244
244
|
status: 'running' | 'queued' | 'done' | 'notes_failed' | 'error' | 'gave_up' | 'filtered'
|
|
245
245
|
name: string
|
|
246
246
|
title: string | null
|
|
@@ -275,7 +275,7 @@ export function patchJob(entry: JobRecord, patch: Partial<JobRecord>, now: strin
|
|
|
275
275
|
export const emptyState = (): StateFile => ({ version: 2, jobs: {} })
|
|
276
276
|
|
|
277
277
|
/**
|
|
278
|
-
* One meaning of `limit` for both front doors (the CLI flag and the GUI's
|
|
278
|
+
* One meaning of `limit` for both front doors (the CLI flag and the GUI's call):
|
|
279
279
|
* 0 = no limit, absent = `fallback`, anything else must be a non-negative
|
|
280
280
|
* integer. Coercing garbage to a default is how a truncated list gets mistaken
|
|
281
281
|
* for a complete one — the exact bug this module exists to remove.
|
|
@@ -342,7 +342,7 @@ const FILTER_LABELS: Record<string, string> = {
|
|
|
342
342
|
}
|
|
343
343
|
|
|
344
344
|
/** `2026-07-29T12:06:29` → `2026-07-29 12:06`. */
|
|
345
|
-
|
|
345
|
+
function displayTime(recordedAt: string | null): string | null {
|
|
346
346
|
if (!recordedAt) return null
|
|
347
347
|
return recordedAt.slice(0, 16).replace('T', ' ')
|
|
348
348
|
}
|
package/src/runLock.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// to 'reclaimed'. The heartbeat and release paths branch on these three
|
|
5
5
|
// states, and collapsing 'unknown' into 'reclaimed' (or 'mine') is exactly
|
|
6
6
|
// the bug that would either hand a live lock away or delete a reclaimer's lock.
|
|
7
|
-
|
|
7
|
+
type LockOwnership = "mine" | "reclaimed" | "unknown";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* @param raw lock-file contents, or null if the file could not be read
|
package/src/tos.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type TosConfig = {
|
|
2
|
+
endpoint: string
|
|
3
|
+
region: string
|
|
4
|
+
bucket: string
|
|
5
|
+
accessKey: string
|
|
6
|
+
secretKey: string
|
|
7
|
+
keep: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function tosObject(config: TosConfig, key: string): Bun.S3File {
|
|
11
|
+
return new Bun.S3Client({
|
|
12
|
+
accessKeyId: config.accessKey,
|
|
13
|
+
secretAccessKey: config.secretKey,
|
|
14
|
+
region: config.region,
|
|
15
|
+
endpoint: `https://${config.bucket}.${config.endpoint}`,
|
|
16
|
+
virtualHostedStyle: true,
|
|
17
|
+
}).file(key)
|
|
18
|
+
}
|
package/src/envConfig.ts
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
// Pure logic behind cli.ts's env-config provenance. Extracted (no fs, no
|
|
2
|
-
// process.env) so its invariants are testable — see envConfig.test.ts:
|
|
3
|
-
//
|
|
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.
|
|
7
|
-
// 2. Hydration: only keys the real environment does NOT set are filled from
|
|
8
|
-
// files — an explicit empty string in the environment (e.g.
|
|
9
|
-
// VOICENOTE_PI_SUMMARY_TOOLS="") counts as set and is never overridden.
|
|
10
|
-
// 3. Scheduler embedding: a value is embedded only when it is NOT recoverable
|
|
11
|
-
// from the files at run time — i.e. it is a real-environment value that the
|
|
12
|
-
// files either don't provide or provide differently. Hydrated values and
|
|
13
|
-
// real-env values equal to the file value are skipped (vn run re-reads the
|
|
14
|
-
// files each start, and scheduler env outranks config.json, so embedding a
|
|
15
|
-
// recoverable value would freeze it against future config edits).
|
|
16
|
-
// 4. A real-env value that DIFFERS from the file value is embedded as a
|
|
17
|
-
// deliberate override, but reported (frozenOverrides) so the caller can
|
|
18
|
-
// warn: it may equally be a stale shell session shadowing a fresh config
|
|
19
|
-
// edit, and it will keep overriding until the scheduler is reinstalled.
|
|
20
|
-
|
|
21
|
-
/** Omit `environment` when checking which values files can reproduce on their own. */
|
|
22
|
-
export function parseFileEnv(
|
|
23
|
-
keys: readonly string[],
|
|
24
|
-
configData: Record<string, unknown>,
|
|
25
|
-
zshrcContent: string | null,
|
|
26
|
-
home: string,
|
|
27
|
-
environment: Record<string, string | undefined> = {},
|
|
28
|
-
): Record<string, string> {
|
|
29
|
-
const raw = new Map<string, string>()
|
|
30
|
-
const literal = new Set<string>()
|
|
31
|
-
for (const key of keys) {
|
|
32
|
-
const v = configData[key]
|
|
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)
|
|
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
|
|
59
|
-
}
|
|
60
|
-
return Object.fromEntries(Array.from(raw.keys(), key => [key, resolve(key)]))
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Which keys to copy from fileEnv into an environment (invariant 2). */
|
|
64
|
-
export function hydrateFromFileEnv(
|
|
65
|
-
keys: readonly string[],
|
|
66
|
-
processEnv: Record<string, string | undefined>,
|
|
67
|
-
fileEnv: Record<string, string>,
|
|
68
|
-
): Record<string, string> {
|
|
69
|
-
const out: Record<string, string> = {}
|
|
70
|
-
for (const key of keys) {
|
|
71
|
-
if (processEnv[key] !== undefined) continue // real env wins, incl. ""
|
|
72
|
-
const v = fileEnv[key]
|
|
73
|
-
if (v !== undefined) out[key] = v
|
|
74
|
-
}
|
|
75
|
-
return out
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Per-key no_proxy/NO_PROXY derivation, made pure so its provenance invariant
|
|
80
|
-
* is testable (it is the whole reason envKeysToEmbed exists). Given the
|
|
81
|
-
* current env value, any previously-captured pre-merge original, whether the
|
|
82
|
-
* key is already marked hydrated, and whether a proxy is active, returns:
|
|
83
|
-
* - runtime: the value to put in the environment (always volcano-merged)
|
|
84
|
-
* - capture: the pre-merge real-env original to remember (or undefined:
|
|
85
|
-
* either we synthesized the value, or it was already captured) — this is
|
|
86
|
-
* what the scheduler embeds, never the merged value
|
|
87
|
-
* - hydrate: true when we synthesized the value from nothing (fully
|
|
88
|
-
* rebuildable at run time, so it must NOT be embedded)
|
|
89
|
-
* Merge is idempotent, so re-running on an already-merged value (reload) is
|
|
90
|
-
* safe; capture-once is preserved by honoring capturedOriginal.
|
|
91
|
-
*/
|
|
92
|
-
export function deriveNoProxy(
|
|
93
|
-
current: string | undefined,
|
|
94
|
-
capturedOriginal: string | undefined,
|
|
95
|
-
alreadyHydrated: boolean,
|
|
96
|
-
proxyActive: boolean,
|
|
97
|
-
base: string,
|
|
98
|
-
volcanoHosts: readonly string[],
|
|
99
|
-
): { runtime: string; capture: string | undefined; hydrate: boolean } {
|
|
100
|
-
const merge = (v: string): string => {
|
|
101
|
-
const items = v.split(',').map(s => s.trim()).filter(Boolean)
|
|
102
|
-
for (const h of volcanoHosts) if (!items.includes(h)) items.push(h)
|
|
103
|
-
return items.join(',')
|
|
104
|
-
}
|
|
105
|
-
if (current === undefined) {
|
|
106
|
-
return { runtime: merge(proxyActive ? base : ''), capture: undefined, hydrate: true }
|
|
107
|
-
}
|
|
108
|
-
const capture = (!alreadyHydrated && capturedOriginal === undefined) ? current : undefined
|
|
109
|
-
return { runtime: merge(current), capture, hydrate: false }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Which env values the scheduler must snapshot (invariants 3 + 4). */
|
|
113
|
-
export function envKeysToEmbed(
|
|
114
|
-
keys: readonly string[],
|
|
115
|
-
processEnv: Record<string, string | undefined>,
|
|
116
|
-
hydratedKeys: ReadonlySet<string>,
|
|
117
|
-
fileEnv: Record<string, string>,
|
|
118
|
-
): { embed: Record<string, string>; frozenOverrides: string[] } {
|
|
119
|
-
const embed: Record<string, string> = {}
|
|
120
|
-
const frozenOverrides: string[] = []
|
|
121
|
-
for (const k of keys) {
|
|
122
|
-
const v = processEnv[k]
|
|
123
|
-
if (v === undefined) continue
|
|
124
|
-
if (hydratedKeys.has(k) || fileEnv[k] === v) continue // recoverable at run time
|
|
125
|
-
embed[k] = v
|
|
126
|
-
if (fileEnv[k] !== undefined) frozenOverrides.push(k)
|
|
127
|
-
}
|
|
128
|
-
return { embed, frozenOverrides }
|
|
129
|
-
}
|