@fastagent-sh/voicenote 0.21.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/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
- export type JobState = 'queued' | 'running' | 'done' | 'filtered' | 'error' | 'gave_up'
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
- export type JobCode = 'transcribe_failed' | 'summary_failed' | 'interrupted' | 'too_small' | 'too_short' | 'too_old' | null
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
- export type FilterCode = Extract<JobCode, 'too_small' | 'too_short' | 'too_old'>
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
- export type Verdict =
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
- export type ScanFacts = { recordedAt: Date; sizeBytes: number; durationSeconds: number | null }
74
- export type Limits = { maxAgeHours: number; minBytes: number; minDurationSeconds: number }
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
- export type Outcome =
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 }
@@ -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.
@@ -240,7 +247,8 @@ export function pruneUnseen(jobs: Record<string, JobRecord>, seen: Set<string>,
240
247
 
241
248
  export type CurrentJob = { pid: number; source_id: string; step: string; started_at: string }
242
249
 
243
- export type JobView = {
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
@@ -275,7 +283,7 @@ export function patchJob(entry: JobRecord, patch: Partial<JobRecord>, now: strin
275
283
  export const emptyState = (): StateFile => ({ version: 2, jobs: {} })
276
284
 
277
285
  /**
278
- * One meaning of `limit` for both front doors (the CLI flag and the GUI's RPC):
286
+ * One meaning of `limit` for both front doors (the CLI flag and the GUI's call):
279
287
  * 0 = no limit, absent = `fallback`, anything else must be a non-negative
280
288
  * integer. Coercing garbage to a default is how a truncated list gets mistaken
281
289
  * for a complete one — the exact bug this module exists to remove.
@@ -342,7 +350,7 @@ const FILTER_LABELS: Record<string, string> = {
342
350
  }
343
351
 
344
352
  /** `2026-07-29T12:06:29` → `2026-07-29 12:06`. */
345
- export function displayTime(recordedAt: string | null): string | null {
353
+ function displayTime(recordedAt: string | null): string | null {
346
354
  if (!recordedAt) return null
347
355
  return recordedAt.slice(0, 16).replace('T', ' ')
348
356
  }
@@ -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),
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
- export type LockOwnership = "mine" | "reclaimed" | "unknown";
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
- }