@openparachute/vault 0.6.5-rc.2 → 0.6.5

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.
Files changed (70) hide show
  1. package/.parachute/module.json +1 -0
  2. package/core/src/core.test.ts +7 -7
  3. package/core/src/do-param-cap.test.ts +161 -0
  4. package/core/src/links.ts +8 -13
  5. package/core/src/mcp.ts +1 -1
  6. package/core/src/notes.ts +33 -15
  7. package/core/src/onboarding.ts +14 -296
  8. package/core/src/schema.ts +5 -5
  9. package/core/src/seed-packs.test.ts +357 -0
  10. package/core/src/seed-packs.ts +823 -0
  11. package/core/src/sql-in.test.ts +58 -0
  12. package/core/src/sql-in.ts +76 -0
  13. package/core/src/tag-hierarchy.ts +1 -1
  14. package/core/src/tag-schemas.ts +2 -2
  15. package/core/src/transcription/provider.ts +141 -0
  16. package/core/src/types.ts +1 -1
  17. package/core/src/vault-projection.ts +1 -1
  18. package/core/src/wikilinks.ts +10 -4
  19. package/package.json +1 -1
  20. package/src/add-pack.test.ts +142 -0
  21. package/src/admin-spa.ts +2 -1
  22. package/src/auth.ts +1 -1
  23. package/src/cli.ts +806 -7
  24. package/src/export-watch.ts +1 -1
  25. package/src/live-frame-parity.test.ts +201 -0
  26. package/src/module-manifest.ts +8 -0
  27. package/src/onboarding-seed.test.ts +188 -40
  28. package/src/onboarding-seed.ts +41 -46
  29. package/src/routes.ts +20 -3
  30. package/src/routing.test.ts +2 -2
  31. package/src/routing.ts +18 -6
  32. package/src/self-register.test.ts +19 -0
  33. package/src/self-register.ts +6 -1
  34. package/src/server.ts +133 -31
  35. package/src/services-manifest.ts +8 -0
  36. package/src/subscriptions.ts +100 -42
  37. package/src/tag-scope.ts +3 -3
  38. package/src/test-support/live-frame-corpus.ts +72 -0
  39. package/src/token-store.ts +2 -2
  40. package/src/transcription/build.test.ts +305 -0
  41. package/src/transcription/build.ts +332 -0
  42. package/src/transcription/capability.test.ts +118 -0
  43. package/src/transcription/capability.ts +96 -0
  44. package/src/transcription/download.test.ts +101 -0
  45. package/src/transcription/download.ts +71 -0
  46. package/src/transcription/install-python.test.ts +366 -0
  47. package/src/transcription/install-python.ts +471 -0
  48. package/src/transcription/install.test.ts +167 -0
  49. package/src/transcription/install.ts +296 -0
  50. package/src/transcription/providers/onnx-asr.test.ts +229 -0
  51. package/src/transcription/providers/onnx-asr.ts +239 -0
  52. package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
  53. package/src/transcription/providers/parakeet-mlx.ts +242 -0
  54. package/src/transcription/providers/scribe-http.test.ts +195 -0
  55. package/src/transcription/providers/scribe-http.ts +144 -0
  56. package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
  57. package/src/transcription/providers/transcribe-cpp.ts +293 -0
  58. package/src/transcription/select.test.ts +299 -0
  59. package/src/transcription/select.ts +397 -0
  60. package/src/transcription/tiers.test.ts +197 -0
  61. package/src/transcription/tiers.ts +184 -0
  62. package/src/transcription-worker.test.ts +44 -0
  63. package/src/transcription-worker.ts +57 -122
  64. package/src/vault-create.test.ts +43 -10
  65. package/src/vault.test.ts +49 -1
  66. package/src/ws-server.ts +408 -0
  67. package/src/ws-subscribe.test.ts +474 -0
  68. package/src/ws-subscribe.ts +242 -0
  69. package/src/subscribe.test.ts +0 -609
  70. package/src/subscribe.ts +0 -248
@@ -0,0 +1,197 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { selectDefaultProvider, NOMINAL_SLACK_GB, type TierPlan } from "./tiers.ts";
3
+ import { DEFAULT_ONNX_ASR_MODEL, DEFAULT_PARAKEET_MLX_MODEL } from "./select.ts";
4
+
5
+ /**
6
+ * The ratified RAM/arch/OS tier matrix (scribe-fold Phase 2b, 2026-07-03).
7
+ * Pure decision layer — no host probes, no network. The load-bearing
8
+ * invariants:
9
+ *
10
+ * - macOS Apple Silicon 8GB+ → parakeet-mlx
11
+ * - Linux 4GB+ → onnx-asr
12
+ * - Linux ~2GB → transcribe-cpp with a SMALL WHISPER GGUF, never a Parakeet
13
+ * variant (scribe#82: Parakeet peak RAM exceeds 2GB on meeting-length
14
+ * audio → OOM)
15
+ * - Linux ~1GB → remote default, tiny local model only as explicit opt-in
16
+ * - below floor / unknown host → remote guidance, never a forced local pick
17
+ */
18
+
19
+ const GB = 2 ** 30;
20
+
21
+ function pick(platform: string, arch: string, gb: number): TierPlan {
22
+ return selectDefaultProvider({ platform, arch, totalRamBytes: gb * GB });
23
+ }
24
+
25
+ /** True when the plan's model is any Parakeet variant. */
26
+ function isParakeet(plan: TierPlan): boolean {
27
+ return !!plan.model && /parakeet/i.test(plan.model);
28
+ }
29
+
30
+ describe("selectDefaultProvider — macOS Apple Silicon", () => {
31
+ test("darwin/arm64 8GB → parakeet-mlx (ratified default)", () => {
32
+ const plan = pick("darwin", "arm64", 8);
33
+ expect(plan.provider).toBe("parakeet-mlx");
34
+ expect(plan.model).toBe(DEFAULT_PARAKEET_MLX_MODEL);
35
+ expect(plan.approxDiskMb).toBe(2500);
36
+ });
37
+
38
+ test("darwin/arm64 16GB → parakeet-mlx", () => {
39
+ expect(pick("darwin", "arm64", 16).provider).toBe("parakeet-mlx");
40
+ });
41
+
42
+ test("darwin/arm64 just under 8GB nominal (7.7GB reported) → still parakeet-mlx (slack)", () => {
43
+ expect(pick("darwin", "arm64", 8 - NOMINAL_SLACK_GB).provider).toBe("parakeet-mlx");
44
+ });
45
+
46
+ test("darwin/arm64 well under 8GB → transcribe-cpp fallback, NOT parakeet-mlx", () => {
47
+ const plan = pick("darwin", "arm64", 6);
48
+ expect(plan.provider).toBe("transcribe-cpp");
49
+ });
50
+
51
+ test("darwin/x64 (Intel — no MLX) 16GB → transcribe-cpp fallback, never parakeet-mlx", () => {
52
+ const plan = pick("darwin", "x64", 16);
53
+ expect(plan.provider).toBe("transcribe-cpp");
54
+ });
55
+ });
56
+
57
+ describe("selectDefaultProvider — Linux", () => {
58
+ test("linux/x64 4GB → onnx-asr (ratified default)", () => {
59
+ const plan = pick("linux", "x64", 4);
60
+ expect(plan.provider).toBe("onnx-asr");
61
+ expect(plan.model).toBe(DEFAULT_ONNX_ASR_MODEL);
62
+ expect(plan.approxDiskMb).toBe(670);
63
+ });
64
+
65
+ test("linux/x64 8GB → onnx-asr, NOT parakeet-mlx (MLX is Apple-only)", () => {
66
+ expect(pick("linux", "x64", 8).provider).toBe("onnx-asr");
67
+ });
68
+
69
+ test("linux/arm64 4GB → onnx-asr", () => {
70
+ expect(pick("linux", "arm64", 4).provider).toBe("onnx-asr");
71
+ });
72
+
73
+ test("nominal-4GB box reporting 3.8GB (kernel reservation) → still onnx-asr (slack)", () => {
74
+ expect(pick("linux", "x64", 3.8).provider).toBe("onnx-asr");
75
+ });
76
+
77
+ test("linux 3.4GB (below the 4GB tier even with slack) → transcribe-cpp small whisper", () => {
78
+ const plan = pick("linux", "x64", 3.4);
79
+ expect(plan.provider).toBe("transcribe-cpp");
80
+ expect(plan.model).toBe("whisper-small.en");
81
+ });
82
+ });
83
+
84
+ describe("selectDefaultProvider — the 2GB-not-Parakeet floor (scribe#82)", () => {
85
+ test("linux 2GB → transcribe-cpp whisper-small.en — NEVER a Parakeet variant", () => {
86
+ const plan = pick("linux", "x64", 2);
87
+ expect(plan.provider).toBe("transcribe-cpp");
88
+ expect(plan.model).toBe("whisper-small.en");
89
+ expect(isParakeet(plan)).toBe(false);
90
+ expect(plan.reason).toContain("scribe#82");
91
+ });
92
+
93
+ test("nominal-2GB box reporting 1.9GB → still the whisper-small tier (slack), not tiny/remote", () => {
94
+ const plan = pick("linux", "x64", 1.9);
95
+ expect(plan.provider).toBe("transcribe-cpp");
96
+ expect(plan.model).toBe("whisper-small.en");
97
+ });
98
+
99
+ test("the whole 2–4GB Linux band never defaults to any Parakeet variant", () => {
100
+ for (const gb of [1.7, 2, 2.5, 3, 3.5]) {
101
+ const plan = pick("linux", "x64", gb);
102
+ expect(isParakeet(plan)).toBe(false);
103
+ expect(plan.provider).not.toBe("onnx-asr");
104
+ expect(plan.provider).not.toBe("parakeet-mlx");
105
+ }
106
+ });
107
+
108
+ // Regression for the double-applied-slack bug (reviewer, PR #537): the
109
+ // transcribe-cpp fallback used to add NOMINAL_SLACK_GB a SECOND time before
110
+ // install.ts's 4GB Parakeet-GGUF floor, so a real-3.6GB Darwin box picked a
111
+ // ~3.5GB-peak Parakeet model (~0.1GB headroom — the scribe#82 OOM class).
112
+ // Slack belongs to tier-entry boundaries only, never to model-floor checks.
113
+ test("darwin/x64 in the 3.6–3.9GB band → whisper-small.en, NEVER a Parakeet variant", () => {
114
+ for (const gb of [3.6, 3.8, 3.9]) {
115
+ const plan = pick("darwin", "x64", gb);
116
+ expect(plan.provider).toBe("transcribe-cpp");
117
+ expect(plan.model).toBe("whisper-small.en");
118
+ expect(isParakeet(plan)).toBe(false);
119
+ }
120
+ });
121
+
122
+ test("darwin/arm64 in the 3.6–3.9GB band → whisper-small.en, NEVER a Parakeet variant", () => {
123
+ for (const gb of [3.6, 3.9]) {
124
+ const plan = pick("darwin", "arm64", gb);
125
+ expect(plan.provider).toBe("transcribe-cpp");
126
+ expect(plan.model).toBe("whisper-small.en");
127
+ expect(isParakeet(plan)).toBe(false);
128
+ }
129
+ });
130
+
131
+ test("darwin just over a TRUE 4GB → the un-inflated Phase 2a pick (parakeet GGUF)", () => {
132
+ // At a genuine ≥4GB, install.ts's own matrix governs — same model an
133
+ // explicit `--provider transcribe-cpp` install would choose.
134
+ for (const [arch, gb] of [["x64", 4], ["x64", 4.1], ["arm64", 4]] as const) {
135
+ const plan = pick("darwin", arch, gb);
136
+ expect(plan.provider).toBe("transcribe-cpp");
137
+ expect(plan.model).toBe("parakeet-tdt-0.6b-v3");
138
+ }
139
+ });
140
+ });
141
+
142
+ describe("selectDefaultProvider — the ~1GB remote tier", () => {
143
+ test("linux 1GB → scribe-http by default (never a forced local install)", () => {
144
+ const plan = pick("linux", "x64", 1);
145
+ expect(plan.provider).toBe("scribe-http");
146
+ });
147
+
148
+ test("linux 1GB carries the explicit whisper-tiny local opt-in", () => {
149
+ const plan = pick("linux", "x64", 1);
150
+ expect(plan.localOptIn).toBeDefined();
151
+ expect(plan.localOptIn!.provider).toBe("transcribe-cpp");
152
+ expect(plan.localOptIn!.model).toBe("whisper-tiny.en");
153
+ });
154
+
155
+ test("nominal-1GB box reporting 0.95GB → still the opt-in tier", () => {
156
+ expect(pick("linux", "x64", 0.95).localOptIn).toBeDefined();
157
+ });
158
+
159
+ test("linux 1.4GB (below the 2GB tier) → remote default + tiny opt-in", () => {
160
+ const plan = pick("linux", "x64", 1.4);
161
+ expect(plan.provider).toBe("scribe-http");
162
+ expect(plan.localOptIn?.model).toBe("whisper-tiny.en");
163
+ });
164
+ });
165
+
166
+ describe("selectDefaultProvider — below floor / unknown hosts", () => {
167
+ test("linux 0.5GB → remote guidance with NO local opt-in", () => {
168
+ const plan = pick("linux", "x64", 0.5);
169
+ expect(plan.provider).toBe("scribe-http");
170
+ expect(plan.localOptIn).toBeUndefined();
171
+ });
172
+
173
+ test("unknown platform (win32) → remote guidance regardless of RAM", () => {
174
+ const plan = pick("win32", "x64", 32);
175
+ expect(plan.provider).toBe("scribe-http");
176
+ expect(plan.localOptIn).toBeUndefined();
177
+ expect(plan.reason).toContain("win32");
178
+ });
179
+
180
+ test("unknown arch (linux/riscv64) → remote guidance", () => {
181
+ expect(pick("linux", "riscv64", 8).provider).toBe("scribe-http");
182
+ });
183
+ });
184
+
185
+ describe("selectDefaultProvider — plan shape", () => {
186
+ test("carries the host facts for the human print", () => {
187
+ const plan = pick("linux", "x64", 4);
188
+ expect(plan.platform).toBe("linux");
189
+ expect(plan.arch).toBe("x64");
190
+ expect(plan.totalRamGb).toBe(4);
191
+ expect(plan.reason.length).toBeGreaterThan(0);
192
+ });
193
+
194
+ test("rounds detected RAM to 1dp", () => {
195
+ expect(selectDefaultProvider({ platform: "linux", arch: "x64", totalRamBytes: 3.84 * GB }).totalRamGb).toBe(3.8);
196
+ });
197
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * RAM/arch/OS tier selection for `transcription install` (scribe-fold
3
+ * Phase 2b — the ratified tier table, 2026-07-03).
4
+ *
5
+ * This is the PURE decision layer for the install verb's AUTO-DEFAULT: given
6
+ * host facts (OS, arch, total RAM), which local provider should this box run?
7
+ * No side effects — the CLI probes the host, prints the pick + footprint, and
8
+ * the operator confirms or overrides with `--provider`/`--model`.
9
+ *
10
+ * ## The ratified table
11
+ *
12
+ * | Host | Default |
13
+ * |-----------------------------|--------------------------------------------|
14
+ * | macOS Apple Silicon, 8GB+ | parakeet-mlx (parakeet-tdt-0.6b-v3; |
15
+ * | | ~2.5GB disk, ~2.5–3GB peak) |
16
+ * | Linux 4GB+ | onnx-asr (parakeet-0.6b int8; ~670MB disk, |
17
+ * | | ~1.2–2GB+ peak) |
18
+ * | Linux ~2GB | transcribe-cpp + whisper-small GGUF — NOT |
19
+ * | | Parakeet (scribe#82: Parakeet peak RAM |
20
+ * | | exceeds 2GB on meeting-length audio) |
21
+ * | Linux ~1GB | REMOTE (scribe-http) by default; local |
22
+ * | | whisper-tiny only as explicit opt-in |
23
+ * | Below floor / unknown host | remote guidance — never force local |
24
+ *
25
+ * Hosts the table doesn't name fall back to transcribe-cpp with its own
26
+ * Phase 2a RAM tiers (e.g. Intel macs; a hypothetical <8GB Apple-Silicon
27
+ * box) — that matrix already carries the scribe#82 Parakeet-at-4GB floor.
28
+ *
29
+ * ## Nominal-RAM slack
30
+ *
31
+ * Tier boundaries are NOMINAL sizes ("a 4GB droplet"), but the kernel/firmware
32
+ * reserve memory before userspace sees it — a nominal-4GB Linux box reports
33
+ * ~3.6–3.9GB via MemTotal. `NOMINAL_SLACK_GB` absorbs that so the box the
34
+ * table targets actually lands in its tier. The 1GB remote floor uses a
35
+ * smaller slack: below-floor boxes must steer remote, never get a local
36
+ * install forced on them.
37
+ */
38
+
39
+ import {
40
+ MODELS,
41
+ selectAsset,
42
+ selectModelForRam,
43
+ type ModelChoice,
44
+ } from "./install.ts";
45
+ import { DEFAULT_ONNX_ASR_MODEL, DEFAULT_PARAKEET_MLX_MODEL } from "./select.ts";
46
+
47
+ const GB = 2 ** 30;
48
+
49
+ /** Kernel/firmware reservation tolerance on nominal tier boundaries. */
50
+ export const NOMINAL_SLACK_GB = 0.4;
51
+
52
+ /** Slack at the 1GB remote floor (a nominal-1GB box reports ~0.94–0.97GB). */
53
+ const FLOOR_SLACK_GB = 0.1;
54
+
55
+ /** A provider name the tier table can select. */
56
+ export type TierProviderName = "parakeet-mlx" | "onnx-asr" | "transcribe-cpp" | "scribe-http";
57
+
58
+ /** The resolved tier pick for a host. */
59
+ export interface TierPlan {
60
+ /** The tier-default provider for this host. */
61
+ provider: TierProviderName;
62
+ platform: string;
63
+ arch: string;
64
+ /** Detected total RAM, GB (rounded to 1dp) for the human print. */
65
+ totalRamGb: number;
66
+ /**
67
+ * Model id for the pick: an HF repo (parakeet-mlx), an onnx-asr registry
68
+ * name, or a transcribe-cpp GGUF id from `MODELS`. Absent for scribe-http.
69
+ */
70
+ model?: string;
71
+ /** Approx download footprint, MB (model + runtime), for the confirm prompt. */
72
+ approxDiskMb?: number;
73
+ /** Human peak-RAM note for the confirm prompt. */
74
+ peakRamNote?: string;
75
+ /** One-line rationale for the pick (printed to the operator). */
76
+ reason: string;
77
+ /**
78
+ * Set on the remote-default tier when a local install is still POSSIBLE as
79
+ * an explicit opt-in (Linux ~1GB → transcribe-cpp + whisper-tiny). The CLI
80
+ * prints it as guidance; it is never auto-installed.
81
+ */
82
+ localOptIn?: { provider: "transcribe-cpp"; model: string; note: string };
83
+ }
84
+
85
+ /** Host facts the tier decision needs. */
86
+ export interface TierInput {
87
+ /** Node `os.platform()` value ("darwin" / "linux" / …). */
88
+ platform: string;
89
+ /** Node `os.arch()` value ("arm64" / "x64" / …). */
90
+ arch: string;
91
+ /** Detected total physical RAM in bytes. */
92
+ totalRamBytes: number;
93
+ }
94
+
95
+ /**
96
+ * Pick the tier-default provider for a host. Pure — see the module header for
97
+ * the ratified table. The scribe#82 invariant this encodes: **no Parakeet
98
+ * variant is ever the default below 4GB** (and on Linux, below-4GB boxes get
99
+ * transcribe-cpp whisper models or the remote steer — never onnx-asr).
100
+ */
101
+ export function selectDefaultProvider(input: TierInput): TierPlan {
102
+ const { platform, arch, totalRamBytes } = input;
103
+ const gb = totalRamBytes / GB;
104
+ const base = { platform, arch, totalRamGb: Math.round(gb * 10) / 10 };
105
+
106
+ // macOS Apple Silicon, 8GB+ → parakeet-mlx (MLX uses the GPU/unified memory;
107
+ // the best quality/speed on this hardware).
108
+ if (platform === "darwin" && arch === "arm64" && gb >= 8 - NOMINAL_SLACK_GB) {
109
+ return {
110
+ ...base,
111
+ provider: "parakeet-mlx",
112
+ model: DEFAULT_PARAKEET_MLX_MODEL,
113
+ approxDiskMb: 2500,
114
+ peakRamNote: "~2.5–3GB peak while transcribing",
115
+ reason: "Apple Silicon with 8GB+ RAM — parakeet-mlx (MLX) is the best local quality/speed here.",
116
+ };
117
+ }
118
+
119
+ // Linux 4GB+ → onnx-asr (Parakeet int8 ONNX — the proven scribe Linux
120
+ // path). Arch-gated: onnxruntime publishes wheels for x64/arm64 only.
121
+ if (platform === "linux" && (arch === "x64" || arch === "arm64") && gb >= 4 - NOMINAL_SLACK_GB) {
122
+ return {
123
+ ...base,
124
+ provider: "onnx-asr",
125
+ model: DEFAULT_ONNX_ASR_MODEL,
126
+ approxDiskMb: 670,
127
+ peakRamNote: "~1.2–2GB+ peak while transcribing (meeting-length audio uses the high end)",
128
+ reason: "Linux with 4GB+ RAM — onnx-asr runs Parakeet (int8 ONNX) comfortably at this size.",
129
+ };
130
+ }
131
+
132
+ // transcribe-cpp fallback tiers — only where a release asset exists for the
133
+ // host. Covers Linux ~2GB (whisper-small — NOT Parakeet, scribe#82) and the
134
+ // hosts the ratified table doesn't name (Intel macs etc.), reusing the
135
+ // Phase 2a RAM matrix (which already floors Parakeet-GGUF at 4GB).
136
+ const asset = selectAsset(platform, arch);
137
+ if (asset && gb >= 2 - NOMINAL_SLACK_GB) {
138
+ // Model pick: slack belongs to TIER-ENTRY boundaries, never to model-floor
139
+ // checks. The 4GB Parakeet-GGUF floor is checked against the UN-INFLATED
140
+ // RAM (adding slack here once picked a ~3.5GB-peak model for a real-3.6GB
141
+ // Intel Mac — ~0.1GB headroom, the scribe#82 OOM class resurrected).
142
+ // Below a true 4GB, the ratified "~2GB → small whisper" row covers the
143
+ // whole band (entry already vetted ≥ ~2GB nominal above).
144
+ const model: ModelChoice =
145
+ gb >= 4
146
+ ? (selectModelForRam(totalRamBytes) ?? MODELS["whisper-small.en"]!)
147
+ : MODELS["whisper-small.en"]!;
148
+ return {
149
+ ...base,
150
+ provider: "transcribe-cpp",
151
+ model: model.name,
152
+ approxDiskMb: model.approxSizeMb,
153
+ peakRamNote: `~${model.approxRuntimeGb}GB peak while transcribing`,
154
+ reason:
155
+ gb < 4
156
+ ? `~${base.totalRamGb}GB RAM — a small whisper GGUF via transcribe-cpp fits; Parakeet's peak RAM exceeds 2GB on meeting-length audio (scribe#82).`
157
+ : `${platform}/${arch} isn't a parakeet-mlx/onnx-asr tier host — transcribe-cpp with a RAM-tier GGUF is the local default.`,
158
+ };
159
+ }
160
+
161
+ // Linux ~1GB (and any asset-supported host just above the floor) → remote
162
+ // by default; whisper-tiny via transcribe-cpp only as an explicit opt-in.
163
+ if (asset && gb >= 1 - FLOOR_SLACK_GB) {
164
+ return {
165
+ ...base,
166
+ provider: "scribe-http",
167
+ reason: `only ~${base.totalRamGb}GB RAM — a remote provider is the reliable default at this size.`,
168
+ localOptIn: {
169
+ provider: "transcribe-cpp",
170
+ model: "whisper-tiny.en",
171
+ note: "a tiny local model is possible but slow/low-quality: `transcription install --provider transcribe-cpp --model whisper-tiny.en`",
172
+ },
173
+ };
174
+ }
175
+
176
+ // Below the floor, or a host with no local runtime at all → remote guidance.
177
+ return {
178
+ ...base,
179
+ provider: "scribe-http",
180
+ reason: asset
181
+ ? `only ~${base.totalRamGb}GB RAM — below the 1GB local floor; use a remote provider.`
182
+ : `no local transcription runtime for ${platform}/${arch} — use a remote provider.`,
183
+ };
184
+ }
@@ -140,6 +140,50 @@ describe("transcription worker", () => {
140
140
  expect(att.metadata?.transcript).toBe("hello world transcript");
141
141
  });
142
142
 
143
+ test("injected provider (scribe-fold 2a): worker routes audio→text through it, not scribe-http", async () => {
144
+ const note = await store.createNote(
145
+ "# 🎙️ Voice memo\n\n_Transcript pending._\n",
146
+ { id: "n1cpp", metadata: { transcribe_stub: true } },
147
+ );
148
+ seedAudio("memos/cpp.webm");
149
+ await store.addAttachment(note.id, "memos/cpp.webm", "audio/webm", {
150
+ transcribe_status: "pending",
151
+ });
152
+
153
+ // A fake local provider (stands in for transcribe-cpp). If the worker used
154
+ // it, the note gets this text; a `fetchImpl` that throws proves scribe-http
155
+ // was NOT called.
156
+ let called = 0;
157
+ const provider = {
158
+ name: "transcribe-cpp",
159
+ available: async () => ({ ok: true }),
160
+ transcribe: async () => {
161
+ called++;
162
+ return { text: "local transcript" };
163
+ },
164
+ };
165
+ const worker = startTranscriptionWorker({
166
+ vaultList: () => ["default"],
167
+ getStore: () => store as unknown as Store,
168
+ resolveAssetsDir: () => assetsRoot,
169
+ provider,
170
+ fetchImpl: (() => {
171
+ throw new Error("scribe-http must not be called when a provider is injected");
172
+ }) as unknown as typeof fetch,
173
+ pollIntervalMs: 10_000_000,
174
+ logger: silentLogger,
175
+ });
176
+ try {
177
+ expect(await worker.tick()).toBe(1);
178
+ } finally {
179
+ await worker.stop();
180
+ }
181
+
182
+ expect(called).toBe(1);
183
+ const updated = await store.getNote("n1cpp");
184
+ expect(updated!.content).toBe("# 🎙️ Voice memo\n\nlocal transcript\n");
185
+ });
186
+
143
187
  test("no-clobber: stub flag absent → does not touch note content", async () => {
144
188
  await store.createNote("my own edit", { id: "n2" });
145
189
  seedAudio("memos/b.webm");
@@ -55,9 +55,14 @@ import { join, normalize } from "path";
55
55
  import { existsSync, readFileSync, unlinkSync } from "fs";
56
56
  import type { Store, Attachment, Note } from "../core/src/types.ts";
57
57
  import type { HookRegistry } from "../core/src/hooks.ts";
58
- import { appendContextPart, fetchContextEntries, type ContextPayload } from "./context.ts";
58
+ import { fetchContextEntries, type ContextPayload } from "./context.ts";
59
59
  import type { TriggerIncludeContext } from "./config.ts";
60
60
  import { upsertTranscriptNote } from "./transcript-note.ts";
61
+ import {
62
+ TranscriptionError,
63
+ type TranscriptionProvider,
64
+ } from "../core/src/transcription/provider.ts";
65
+ import { ScribeHttpProvider } from "./transcription/providers/scribe-http.ts";
61
66
 
62
67
  /** Placeholder pattern written by the voice-memo capture stub. */
63
68
  const TRANSCRIPT_PLACEHOLDER = /_Transcript pending\._/;
@@ -118,8 +123,12 @@ export interface TranscriptionWorkerOpts {
118
123
  vaultList: () => string[];
119
124
  /** Get a store for a vault name. */
120
125
  getStore: (name: string) => Store;
121
- /** Scribe base URL (no trailing slash). */
122
- scribeUrl: string;
126
+ /**
127
+ * Scribe base URL (no trailing slash). Only used to build the default
128
+ * `scribe-http` provider when no `provider` is injected — a `transcribe-cpp`
129
+ * (or any injected) provider needs no scribe URL, so this is optional.
130
+ */
131
+ scribeUrl?: string;
123
132
  /** Optional bearer token for scribe. */
124
133
  scribeToken?: string;
125
134
  /** Resolve the assets root for a vault name. */
@@ -138,6 +147,15 @@ export interface TranscriptionWorkerOpts {
138
147
  maxAttempts?: number;
139
148
  timeoutMs?: number;
140
149
  fetchImpl?: typeof fetch;
150
+ /**
151
+ * Transcription provider (scribe-fold Phase 1). When omitted, a behavior-
152
+ * preserving `scribe-http` provider is built from `scribeUrl` / `scribeToken`
153
+ * / `timeoutMs` / `fetchImpl` — reproducing exactly the former `callScribe`.
154
+ * Phase 2+ (local-ASR backends, the cloud Workers-AI provider) inject one
155
+ * here; the worker's queue/backoff/retry/transcript-note/retention logic is
156
+ * unchanged regardless.
157
+ */
158
+ provider?: TranscriptionProvider;
141
159
  logger?: { info?: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
142
160
  }
143
161
 
@@ -180,25 +198,6 @@ interface PendingMeta {
180
198
  [k: string]: unknown;
181
199
  }
182
200
 
183
- /**
184
- * Structured error thrown when scribe returns a 4xx with a recognized
185
- * `error_code` — we surface the code on the transcript note's frontmatter
186
- * so callers can branch on stable strings instead of regex-matching message
187
- * text. Today the canonical code is `missing_provider` (scribe#47).
188
- */
189
- class ScribeApiError extends Error {
190
- readonly errorCode?: string;
191
- readonly httpStatus: number;
192
- readonly retriable: boolean;
193
- constructor(message: string, opts: { errorCode?: string; httpStatus: number; retriable: boolean }) {
194
- super(message);
195
- this.name = "ScribeApiError";
196
- this.errorCode = opts.errorCode;
197
- this.httpStatus = opts.httpStatus;
198
- this.retriable = opts.retriable;
199
- }
200
- }
201
-
202
201
  /**
203
202
  * Start the worker loop. Returns a handle with `stop()` + `tick()`.
204
203
  * Tests should build the worker and call `tick()` directly; production
@@ -217,6 +216,18 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
217
216
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
218
217
  const retentionFor = opts.getAudioRetention ?? (() => "keep" as const);
219
218
 
219
+ // Resolve the transcription provider (scribe-fold Phase 1). Default: the
220
+ // behavior-preserving `scribe-http` provider built from the same
221
+ // scribeUrl/token/timeout/fetch the worker was already constructed with —
222
+ // so existing installs transcribe byte-identically. Callers may inject a
223
+ // different provider without touching any of the queue/retry logic below.
224
+ const provider: TranscriptionProvider = opts.provider ?? new ScribeHttpProvider({
225
+ url: opts.scribeUrl,
226
+ token: opts.scribeToken,
227
+ timeoutMs,
228
+ fetchImpl,
229
+ });
230
+
220
231
  let stopped = false;
221
232
  let inflight: Promise<void> = Promise.resolve();
222
233
  let timer: ReturnType<typeof setTimeout> | null = null;
@@ -474,30 +485,35 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
474
485
 
475
486
  let scribeResult: { text: string; durationMs: number };
476
487
  try {
477
- scribeResult = await callScribe({
478
- url: opts.scribeUrl,
479
- token: opts.scribeToken,
480
- filePath,
488
+ // Read the audio + call the provider inside this try so a read failure
489
+ // (a race deleting the file between existsSync and read) is handled the
490
+ // same way as a transcription failure — as a retriable error — exactly
491
+ // as the former `callScribe` (which read the file internally) did. The
492
+ // worker owns the wall-clock timing (`durationMs`); the provider owns
493
+ // only the audio→text step.
494
+ const audio = readFileSync(filePath);
495
+ const startedAt = Date.now();
496
+ const result = await provider.transcribe({
497
+ audio,
481
498
  filename: attachment.path.split("/").pop() ?? "audio",
482
499
  mimeType: attachment.mimeType,
483
500
  context,
484
- timeoutMs,
485
- fetchImpl,
486
501
  });
502
+ scribeResult = { text: result.text, durationMs: Date.now() - startedAt };
487
503
  } catch (err) {
488
504
  const errMsg = err instanceof Error ? err.message : String(err);
489
- const apiErr = err instanceof ScribeApiError ? err : null;
490
- // 4xx with structured error code terminal immediately. Re-POSTing the
491
- // same audio at a scribe with no provider configured (or that rejects
492
- // our bearer) will keep failing the operator has to act, retries don't
493
- // help. This is the "graceful first-boot path" from design Q5.
505
+ const apiErr = err instanceof TranscriptionError ? err : null;
506
+ // A non-retriable provider error (a 4xx the operator must fix — no
507
+ // provider configured, bad auth) is terminal immediately. Re-POSTing the
508
+ // same audio would keep failing; retries don't help. This is the
509
+ // "graceful first-boot path" from design Q5.
494
510
  const nonRetriable = apiErr !== null && !apiErr.retriable;
495
511
  const nextAttempts = attempts + 1;
496
512
  const terminal = nonRetriable || nextAttempts >= maxAttempts;
497
513
 
498
514
  if (terminal) {
499
515
  if (nonRetriable) {
500
- logger.error(`[transcribe] non-retriable scribe error on attachment ${attachment.id} (status ${apiErr!.httpStatus}${apiErr!.errorCode ? `, ${apiErr!.errorCode}` : ""}):`, errMsg);
516
+ logger.error(`[transcribe] non-retriable provider error on attachment ${attachment.id} (status ${apiErr!.httpStatus ?? "n/a"}${apiErr!.code ? `, ${apiErr!.code}` : ""}):`, errMsg);
501
517
  } else {
502
518
  logger.error(`[transcribe] giving up on attachment ${attachment.id} after ${nextAttempts} attempts:`, errMsg);
503
519
  }
@@ -506,10 +522,10 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
506
522
  transcribe_status: "failed",
507
523
  transcribe_attempts: nextAttempts,
508
524
  transcribe_error: errMsg,
509
- ...(apiErr?.errorCode ? { transcribe_error_code: apiErr.errorCode } : {}),
525
+ ...(apiErr?.code ? { transcribe_error_code: apiErr.code } : {}),
510
526
  });
511
527
  if (isAutoOrigin) {
512
- await writeFailureTranscriptNote(store, attachment, errMsg, apiErr?.errorCode, undefined);
528
+ await writeFailureTranscriptNote(store, attachment, errMsg, apiErr?.code, undefined);
513
529
  } else {
514
530
  await applyFailureMarker(store, attachment.noteId);
515
531
  }
@@ -744,90 +760,9 @@ export function registerTranscriptionHook(
744
760
  }
745
761
 
746
762
  /**
747
- * Call scribe's `POST /v1/audio/transcriptions` with the audio file + optional
748
- * context part. Returns the transcript text plus the wall-clock duration of
749
- * the request, so the worker can surface `transcript_duration_ms` on the
750
- * transcript note.
751
- *
752
- * Failure modes (encoded as throws):
753
- * - 4xx with a JSON body carrying `error_code`: throws `ScribeApiError`
754
- * with the code (`missing_provider` etc.). Treated as a non-retriable
755
- * terminal failure — re-POSTing the same audio at the same broken scribe
756
- * would just fail the same way; the operator has to act.
757
- * - 4xx without `error_code` (auth, malformed multipart): throws
758
- * `ScribeApiError` with the body text. Non-retriable.
759
- * - 5xx, network error, or timeout: throws a plain `Error`. Retriable —
760
- * the worker's backoff path picks it up.
761
- * - 200 with missing/invalid `text` field: throws a plain `Error`.
762
- * Retriable (could be a transient provider-output glitch).
763
- */
764
- async function callScribe(args: {
765
- url: string;
766
- token?: string;
767
- filePath: string;
768
- filename: string;
769
- mimeType: string;
770
- context: ContextPayload | null;
771
- timeoutMs: number;
772
- fetchImpl: typeof fetch;
773
- }): Promise<{ text: string; durationMs: number }> {
774
- const controller = new AbortController();
775
- const timer = setTimeout(() => controller.abort(), args.timeoutMs);
776
- const startedAt = Date.now();
777
- try {
778
- const fileBuffer = readFileSync(args.filePath);
779
- const file = new File([fileBuffer], args.filename, { type: args.mimeType });
780
- const form = new FormData();
781
- form.append("file", file);
782
- if (args.context) appendContextPart(form, args.context);
783
-
784
- const endpoint = `${args.url.replace(/\/$/, "")}/v1/audio/transcriptions`;
785
- const headers: Record<string, string> = {};
786
- if (args.token) headers["Authorization"] = `Bearer ${args.token}`;
787
-
788
- const resp = await args.fetchImpl(endpoint, {
789
- method: "POST",
790
- headers,
791
- body: form,
792
- signal: controller.signal,
793
- });
794
- if (!resp.ok) {
795
- const body = await resp.text().catch(() => "");
796
- // Try to extract structured error_code from JSON body (scribe#47).
797
- let errorCode: string | undefined;
798
- let errorMessage: string | undefined;
799
- try {
800
- const parsed = JSON.parse(body) as { error?: string; error_code?: string; message?: string };
801
- if (typeof parsed.error_code === "string") errorCode = parsed.error_code;
802
- if (typeof parsed.error === "string") errorMessage = parsed.error;
803
- else if (typeof parsed.message === "string") errorMessage = parsed.message;
804
- } catch {
805
- // Not JSON — leave errorCode undefined; the raw body becomes the message.
806
- }
807
- // 4xx is terminal (re-POSTing the same audio at the same broken scribe
808
- // will just fail again). 5xx is retriable — provider hiccup, will likely
809
- // succeed on backoff.
810
- const retriable = resp.status >= 500;
811
- const message = errorMessage
812
- ?? (errorCode ? `scribe ${errorCode}` : `scribe returned ${resp.status}: ${body}`);
813
- throw new ScribeApiError(message, {
814
- errorCode,
815
- httpStatus: resp.status,
816
- retriable,
817
- });
818
- }
819
- const result = await resp.json() as { text?: string };
820
- if (typeof result.text !== "string") {
821
- throw new Error("scribe response missing text field");
822
- }
823
- return { text: result.text, durationMs: Date.now() - startedAt };
824
- } finally {
825
- clearTimeout(timer);
826
- }
827
- }
828
-
829
- /**
830
- * Re-export the structured error type so tests + callers can `instanceof`-check
831
- * for terminal-failure semantics.
763
+ * Re-export the structured provider error so tests + callers can
764
+ * `instanceof`-check for terminal-failure semantics. The scribe HTTP call
765
+ * itself now lives in `src/transcription/providers/scribe-http.ts` behind the
766
+ * `TranscriptionProvider` seam (scribe-fold Phase 1).
832
767
  */
833
- export { ScribeApiError };
768
+ export { TranscriptionError };