@openparachute/vault 0.6.4 → 0.6.5-rc.10
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/core/src/__fixtures__/golden-vault.ts +125 -0
- package/core/src/__fixtures__/portable-export-golden.json +10 -0
- package/core/src/do-param-cap.test.ts +161 -0
- package/core/src/links.ts +8 -13
- package/core/src/mcp.ts +306 -314
- package/core/src/notes.ts +54 -62
- package/core/src/onboarding.ts +14 -296
- package/core/src/portable-md-batching.test.ts +67 -0
- package/core/src/portable-md-golden.test.ts +55 -0
- package/core/src/portable-md.ts +579 -435
- package/core/src/schema.ts +21 -43
- package/core/src/seed-packs.test.ts +191 -0
- package/core/src/seed-packs.ts +559 -0
- package/core/src/sql-in.test.ts +58 -0
- package/core/src/sql-in.ts +76 -0
- package/core/src/store.ts +14 -9
- package/core/src/transcription/provider.ts +141 -0
- package/core/src/txn.test.ts +229 -0
- package/core/src/txn.ts +105 -0
- package/core/src/types.ts +9 -0
- package/core/src/vault-projection.ts +1 -1
- package/core/src/wikilinks.ts +10 -4
- package/package.json +1 -1
- package/src/add-pack.test.ts +142 -0
- package/src/cli.ts +778 -7
- package/src/onboarding-seed.test.ts +126 -40
- package/src/onboarding-seed.ts +41 -46
- package/src/routes.ts +25 -7
- package/src/server.ts +108 -31
- package/src/transcription/build.test.ts +224 -0
- package/src/transcription/build.ts +252 -0
- package/src/transcription/capability.test.ts +118 -0
- package/src/transcription/capability.ts +96 -0
- package/src/transcription/install-python.test.ts +366 -0
- package/src/transcription/install-python.ts +471 -0
- package/src/transcription/install.test.ts +167 -0
- package/src/transcription/install.ts +296 -0
- package/src/transcription/providers/onnx-asr.test.ts +229 -0
- package/src/transcription/providers/onnx-asr.ts +239 -0
- package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
- package/src/transcription/providers/parakeet-mlx.ts +242 -0
- package/src/transcription/providers/scribe-http.test.ts +195 -0
- package/src/transcription/providers/scribe-http.ts +144 -0
- package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
- package/src/transcription/providers/transcribe-cpp.ts +293 -0
- package/src/transcription/select.test.ts +259 -0
- package/src/transcription/select.ts +334 -0
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/transcription-worker.test.ts +44 -0
- package/src/transcription-worker.ts +57 -122
- package/src/vault-create.test.ts +38 -10
- package/src/vault.test.ts +48 -0
|
@@ -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 {
|
|
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
|
-
/**
|
|
122
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
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
|
|
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?.
|
|
525
|
+
...(apiErr?.code ? { transcribe_error_code: apiErr.code } : {}),
|
|
510
526
|
});
|
|
511
527
|
if (isAutoOrigin) {
|
|
512
|
-
await writeFailureTranscriptNote(store, attachment, errMsg, apiErr?.
|
|
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
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
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 {
|
|
768
|
+
export { TranscriptionError };
|