@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.
Files changed (53) hide show
  1. package/core/src/__fixtures__/golden-vault.ts +125 -0
  2. package/core/src/__fixtures__/portable-export-golden.json +10 -0
  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 +306 -314
  6. package/core/src/notes.ts +54 -62
  7. package/core/src/onboarding.ts +14 -296
  8. package/core/src/portable-md-batching.test.ts +67 -0
  9. package/core/src/portable-md-golden.test.ts +55 -0
  10. package/core/src/portable-md.ts +579 -435
  11. package/core/src/schema.ts +21 -43
  12. package/core/src/seed-packs.test.ts +191 -0
  13. package/core/src/seed-packs.ts +559 -0
  14. package/core/src/sql-in.test.ts +58 -0
  15. package/core/src/sql-in.ts +76 -0
  16. package/core/src/store.ts +14 -9
  17. package/core/src/transcription/provider.ts +141 -0
  18. package/core/src/txn.test.ts +229 -0
  19. package/core/src/txn.ts +105 -0
  20. package/core/src/types.ts +9 -0
  21. package/core/src/vault-projection.ts +1 -1
  22. package/core/src/wikilinks.ts +10 -4
  23. package/package.json +1 -1
  24. package/src/add-pack.test.ts +142 -0
  25. package/src/cli.ts +778 -7
  26. package/src/onboarding-seed.test.ts +126 -40
  27. package/src/onboarding-seed.ts +41 -46
  28. package/src/routes.ts +25 -7
  29. package/src/server.ts +108 -31
  30. package/src/transcription/build.test.ts +224 -0
  31. package/src/transcription/build.ts +252 -0
  32. package/src/transcription/capability.test.ts +118 -0
  33. package/src/transcription/capability.ts +96 -0
  34. package/src/transcription/install-python.test.ts +366 -0
  35. package/src/transcription/install-python.ts +471 -0
  36. package/src/transcription/install.test.ts +167 -0
  37. package/src/transcription/install.ts +296 -0
  38. package/src/transcription/providers/onnx-asr.test.ts +229 -0
  39. package/src/transcription/providers/onnx-asr.ts +239 -0
  40. package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
  41. package/src/transcription/providers/parakeet-mlx.ts +242 -0
  42. package/src/transcription/providers/scribe-http.test.ts +195 -0
  43. package/src/transcription/providers/scribe-http.ts +144 -0
  44. package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
  45. package/src/transcription/providers/transcribe-cpp.ts +293 -0
  46. package/src/transcription/select.test.ts +259 -0
  47. package/src/transcription/select.ts +334 -0
  48. package/src/transcription/tiers.test.ts +197 -0
  49. package/src/transcription/tiers.ts +184 -0
  50. package/src/transcription-worker.test.ts +44 -0
  51. package/src/transcription-worker.ts +57 -122
  52. package/src/vault-create.test.ts +38 -10
  53. package/src/vault.test.ts +48 -0
package/src/cli.ts CHANGED
@@ -17,9 +17,9 @@
17
17
  * parachute-vault status — show full status
18
18
  */
19
19
 
20
- import { resolve } from "path";
20
+ import { resolve, join, dirname } from "path";
21
21
  import { homedir } from "os";
22
- import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from "fs";
22
+ import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, copyFileSync, chmodSync, readdirSync, statSync, renameSync } from "fs";
23
23
  // JSON import — resolved at module load, works for both dev runs
24
24
  // (`bun src/cli.ts …`) and the published package (`bunx @openparachute/vault`)
25
25
  // because package.json ships at the root next to src/.
@@ -140,6 +140,46 @@ import {
140
140
  verifyAndConsumeBackupCode,
141
141
  getTotpSecret,
142
142
  } from "./two-factor.ts";
143
+ import {
144
+ planInstall,
145
+ detectTotalRamBytes,
146
+ isSharedLibFile,
147
+ MODELS,
148
+ type InstallPlan,
149
+ type ModelChoice,
150
+ } from "./transcription/install.ts";
151
+ import {
152
+ resolveTranscribeCppPaths,
153
+ resolveTranscriptionProviderName,
154
+ transcribeCppInstalled,
155
+ readManifest,
156
+ transcriptionHomeDir,
157
+ pythonVenvDir,
158
+ pythonManifestPath,
159
+ readPythonManifest,
160
+ resolveParakeetMlxBin,
161
+ resolveParakeetMlxModel,
162
+ resolveOnnxAsrBin,
163
+ resolveOnnxAsrModel,
164
+ parakeetMlxInstalled,
165
+ onnxAsrInstalled,
166
+ type TranscribeCppManifest,
167
+ type PythonInstallManifest,
168
+ } from "./transcription/select.ts";
169
+ import {
170
+ buildTranscribeCli,
171
+ cliSourceUrl,
172
+ compilerInstallHint,
173
+ TRANSCRIBE_CPP_SOURCE_REF,
174
+ type CliBuildResult,
175
+ } from "./transcription/build.ts";
176
+ import { selectDefaultProvider, NOMINAL_SLACK_GB, type TierPlan } from "./transcription/tiers.ts";
177
+ import {
178
+ PYTHON_PROVIDERS,
179
+ buildDefaultPythonDeps,
180
+ installPythonBackend,
181
+ type PythonProviderName,
182
+ } from "./transcription/install-python.ts";
143
183
 
144
184
  // ---------------------------------------------------------------------------
145
185
  // Argument parsing
@@ -181,6 +221,9 @@ switch (command) {
181
221
  case "create":
182
222
  await cmdCreate(cmdArgs);
183
223
  break;
224
+ case "add-pack":
225
+ await cmdAddPack(cmdArgs);
226
+ break;
184
227
  case "list":
185
228
  case "ls":
186
229
  cmdList();
@@ -246,6 +289,9 @@ switch (command) {
246
289
  case "schema":
247
290
  await cmdSchema(cmdArgs);
248
291
  break;
292
+ case "transcription":
293
+ await cmdTranscription(cmdArgs);
294
+ break;
249
295
  case "help":
250
296
  case "--help":
251
297
  case "-h":
@@ -3496,6 +3542,603 @@ function printHistoryUsage(): void {
3496
3542
  console.error(" --json Emit the history as JSON instead of the human-readable list");
3497
3543
  }
3498
3544
 
3545
+ // ---------------------------------------------------------------------------
3546
+ // Transcription — `parachute-vault transcription <subcommand>` (scribe-fold 2a)
3547
+ // ---------------------------------------------------------------------------
3548
+
3549
+ /**
3550
+ * `parachute-vault transcription install` — set up a LOCAL transcription
3551
+ * provider. With no `--provider`, the ratified RAM/arch/OS tier table
3552
+ * (scribe-fold Phase 2b, `tiers.ts`) picks the host's default:
3553
+ *
3554
+ * - macOS Apple Silicon 8GB+ → parakeet-mlx (Python venv, MLX)
3555
+ * - Linux 4GB+ → onnx-asr (Python venv, int8 ONNX)
3556
+ * - Linux ~2GB → transcribe-cpp + small whisper GGUF
3557
+ * - ~1GB / below floor → remote steer (scribe-http), never forced
3558
+ *
3559
+ * The pick + footprint is printed and the operator confirms (or overrides
3560
+ * with `--provider`/`--model`). Every path is idempotent + non-fatal, and
3561
+ * activation is HONEST: `TRANSCRIPTION_PROVIDER` flips only when a runnable
3562
+ * binary actually exists (PRs #532/#533 precedent). `--dry-run`/`--plan`
3563
+ * prints the selection without downloading.
3564
+ *
3565
+ * `parachute-vault transcription status` — show the configured provider +
3566
+ * per-provider install state.
3567
+ */
3568
+ async function cmdTranscription(args: string[]) {
3569
+ const sub = args[0];
3570
+ if (sub === "install") {
3571
+ await cmdTranscriptionInstall(args.slice(1));
3572
+ return;
3573
+ }
3574
+ if (sub === "status" || sub === undefined) {
3575
+ cmdTranscriptionStatus();
3576
+ return;
3577
+ }
3578
+ console.error(`Unknown transcription subcommand: ${sub}`);
3579
+ console.error(
3580
+ "Usage: parachute-vault transcription install [--dry-run] [--model <id>] [--provider <transcribe-cpp|parakeet-mlx|onnx-asr|scribe-http>]",
3581
+ );
3582
+ console.error(" parachute-vault transcription status");
3583
+ process.exit(1);
3584
+ }
3585
+
3586
+ /** Render an install plan for the human (shared by --dry-run + the real run). */
3587
+ function printTranscriptionPlan(plan: InstallPlan): void {
3588
+ console.log(`Host: ${plan.platform}/${plan.arch}, ${plan.totalRamGb}GB RAM`);
3589
+ if (!plan.supported) {
3590
+ console.log(`\n ${plan.refused ? "Refused" : "Unsupported"}: ${plan.reason}`);
3591
+ return;
3592
+ }
3593
+ const m = plan.model!;
3594
+ console.log(`Binary: ${plan.asset!.asset}`);
3595
+ console.log(`Model: ${m.name} (${m.file})`);
3596
+ console.log(` ${m.note}`);
3597
+ console.log(` ~${m.approxSizeMb}MB download, ~${m.approxRuntimeGb}GB peak RAM while transcribing`);
3598
+ }
3599
+
3600
+ async function cmdTranscriptionInstall(args: string[]) {
3601
+ const dryRun = args.includes("--dry-run") || args.includes("--plan");
3602
+ const force = args.includes("--force");
3603
+ const yes = args.includes("--yes") || args.includes("-y");
3604
+ const overrideModel = takeArgValue(args, "--model").value;
3605
+ const providerArg = takeArgValue(args, "--provider").value;
3606
+
3607
+ if (providerArg === "scribe-http") {
3608
+ // Just flip config back to the remote provider — no download.
3609
+ if (dryRun) {
3610
+ console.log("Would set TRANSCRIPTION_PROVIDER=scribe-http in the vault .env (remote provider; no download).");
3611
+ return;
3612
+ }
3613
+ setEnvVar("TRANSCRIPTION_PROVIDER", "scribe-http");
3614
+ console.log("Set TRANSCRIPTION_PROVIDER=scribe-http. Restart vault to apply. (Remote provider — no local binary/model needed.)");
3615
+ return;
3616
+ }
3617
+
3618
+ let provider: string;
3619
+ let tierModel: string | undefined;
3620
+ if (providerArg) {
3621
+ if (!["transcribe-cpp", "parakeet-mlx", "onnx-asr"].includes(providerArg)) {
3622
+ console.error(
3623
+ `Unknown --provider "${providerArg}". Valid: transcribe-cpp, parakeet-mlx, onnx-asr, scribe-http.`,
3624
+ );
3625
+ process.exit(1);
3626
+ }
3627
+ provider = providerArg;
3628
+ } else {
3629
+ // Auto-default: the ratified RAM/arch/OS tier table (tiers.ts). Probe the
3630
+ // host, print the pick + footprint; the operator confirms below (or
3631
+ // re-runs with --provider/--model to override).
3632
+ const tier = selectDefaultProvider({
3633
+ platform: process.platform,
3634
+ arch: process.arch,
3635
+ totalRamBytes: detectTotalRamBytes(),
3636
+ });
3637
+ printTierPick(tier);
3638
+ if (tier.provider === "scribe-http") {
3639
+ // Remote steer — never force a local install on a below-tier box.
3640
+ console.log("\nNo local install performed. To point at a remote provider now:");
3641
+ console.log(" parachute-vault transcription install --provider scribe-http");
3642
+ if (tier.localOptIn) {
3643
+ console.log(`\nLocal opt-in (explicit only — not auto-installed): ${tier.localOptIn.note}`);
3644
+ }
3645
+ process.exit(dryRun ? 0 : 1);
3646
+ }
3647
+ provider = tier.provider;
3648
+ tierModel = tier.model;
3649
+ console.log("");
3650
+ }
3651
+
3652
+ if (provider === "transcribe-cpp") {
3653
+ // The tier's model pick (e.g. the Linux ~2GB whisper-small tier) seeds the
3654
+ // transcribe-cpp flow; an explicit --model always wins.
3655
+ await runTranscribeCppInstall({ dryRun, force, yes, overrideModel: overrideModel ?? tierModel });
3656
+ return;
3657
+ }
3658
+ await runPythonProviderInstall(provider as PythonProviderName, {
3659
+ dryRun,
3660
+ force,
3661
+ yes,
3662
+ overrideModel,
3663
+ });
3664
+ }
3665
+
3666
+ /** Print the tier-table pick for this host (the install auto-default). */
3667
+ function printTierPick(tier: TierPlan): void {
3668
+ console.log(`Host: ${tier.platform}/${tier.arch}, ${tier.totalRamGb}GB RAM`);
3669
+ console.log(`Tier default: ${tier.provider}${tier.model ? ` (${tier.model})` : ""}`);
3670
+ console.log(` ${tier.reason}`);
3671
+ if (tier.approxDiskMb) {
3672
+ console.log(` ~${tier.approxDiskMb}MB download${tier.peakRamNote ? `, ${tier.peakRamNote}` : ""}`);
3673
+ }
3674
+ console.log(" (override with --provider <transcribe-cpp|parakeet-mlx|onnx-asr|scribe-http>)");
3675
+ }
3676
+
3677
+ /**
3678
+ * Install a Python-based local provider (parakeet-mlx / onnx-asr) into the
3679
+ * managed venv under `~/.parachute/transcription/venv/` and — honestly —
3680
+ * activate it only when a runnable binary verified (`install-python.ts` owns
3681
+ * the apt/venv/warm-pull steps; activation + manifest stay here, mirroring
3682
+ * the transcribe-cpp flow).
3683
+ */
3684
+ async function runPythonProviderInstall(
3685
+ provider: PythonProviderName,
3686
+ opts: { dryRun: boolean; force: boolean; yes: boolean; overrideModel?: string },
3687
+ ): Promise<void> {
3688
+ const spec = PYTHON_PROVIDERS[provider];
3689
+ const venv = pythonVenvDir();
3690
+ // `--model` wins; else env/manifest/ratified default. The pick lands in the
3691
+ // install manifest, which the runtime model resolution reads back.
3692
+ const model =
3693
+ opts.overrideModel ??
3694
+ (provider === "parakeet-mlx" ? resolveParakeetMlxModel() : resolveOnnxAsrModel());
3695
+ const totalRamGb = Math.round((detectTotalRamBytes() / 2 ** 30) * 10) / 10;
3696
+
3697
+ console.log(`Transcription install plan (${provider} — local, Python venv):\n`);
3698
+ console.log(`Host: ${process.platform}/${process.arch}, ${totalRamGb}GB RAM`);
3699
+ console.log(`Package: ${spec.pipTarget} → ${venv}`);
3700
+ console.log(`Model: ${model}`);
3701
+ console.log(` ~${spec.approxDiskMb}MB model download, ${spec.peakRamNote}`);
3702
+
3703
+ if (opts.dryRun) {
3704
+ // Preview the guards the real run would hit — pure, no side effects.
3705
+ if (spec.platform && spec.platform !== process.platform) {
3706
+ console.log(`\n Refused: ${provider} only runs on ${spec.platform}; this host is ${process.platform}.`);
3707
+ } else if (spec.arch && spec.arch !== process.arch) {
3708
+ console.log(`\n Refused: ${provider} needs ${spec.arch} (Apple Silicon); this host is ${process.arch}.`);
3709
+ } else if (totalRamGb < spec.minRamGb - NOMINAL_SLACK_GB && !opts.force) {
3710
+ console.log(`\n Refused: ${totalRamGb}GB RAM < the ~${spec.minRamGb}GB ${provider} floor. ${spec.ramFloorRationale}`);
3711
+ }
3712
+ console.log("\n(--dry-run) Nothing installed.");
3713
+ return;
3714
+ }
3715
+
3716
+ if (!opts.yes) {
3717
+ const ok = await confirm(
3718
+ `Install ${spec.pipTarget} into ${venv} (model ~${spec.approxDiskMb}MB ${provider === "parakeet-mlx" ? "downloads on first use" : "warm-pulled now"})?`,
3719
+ true,
3720
+ );
3721
+ if (!ok) {
3722
+ console.log("Aborted.");
3723
+ return;
3724
+ }
3725
+ }
3726
+
3727
+ const outcome = await installPythonBackend(buildDefaultPythonDeps(), {
3728
+ provider,
3729
+ force: opts.force,
3730
+ model,
3731
+ });
3732
+
3733
+ if (!outcome.ok || !outcome.binPath) {
3734
+ console.error(`\n✗ ${outcome.summary}`);
3735
+ console.error(
3736
+ "Your current provider is unchanged — the verb is idempotent; fix the issue and re-run. Or use the remote provider: parachute-vault transcription install --provider scribe-http",
3737
+ );
3738
+ process.exit(1);
3739
+ }
3740
+
3741
+ // Manifest + honest activation (a runnable binary exists — verified above).
3742
+ const manifest: PythonInstallManifest = {
3743
+ provider,
3744
+ pipTarget: spec.pipTarget,
3745
+ bin: outcome.binPath,
3746
+ venv: outcome.venv ?? "",
3747
+ model,
3748
+ os: process.platform,
3749
+ arch: process.arch,
3750
+ ram_gb: totalRamGb,
3751
+ installedAt: new Date().toISOString(),
3752
+ };
3753
+ mkdirSync(transcriptionHomeDir(), { recursive: true });
3754
+ writeFileSync(pythonManifestPath(), JSON.stringify(manifest, null, 2));
3755
+
3756
+ setEnvVar("TRANSCRIPTION_PROVIDER", provider);
3757
+ console.log(`\n✓ Installed and activated → TRANSCRIPTION_PROVIDER=${provider} set in the vault .env.`);
3758
+ console.log(` Binary: ${outcome.binPath}`);
3759
+ console.log(` Model: ${model}`);
3760
+ if (provider === "parakeet-mlx") {
3761
+ console.log(` NOTE: the model (~${spec.approxDiskMb}MB) downloads into the HuggingFace cache on the first transcription.`);
3762
+ }
3763
+ console.log(` Restart vault to activate: parachute restart vault`);
3764
+ }
3765
+
3766
+ async function runTranscribeCppInstall(opts: {
3767
+ dryRun: boolean;
3768
+ force: boolean;
3769
+ yes: boolean;
3770
+ overrideModel?: string;
3771
+ }) {
3772
+ const { dryRun, force, yes, overrideModel } = opts;
3773
+ const plan = planInstall({
3774
+ platform: process.platform,
3775
+ arch: process.arch,
3776
+ totalRamBytes: detectTotalRamBytes(),
3777
+ overrideModel,
3778
+ });
3779
+
3780
+ console.log("Transcription install plan (transcribe-cpp — local, no Python):\n");
3781
+ printTranscriptionPlan(plan);
3782
+
3783
+ if (!plan.supported) {
3784
+ // Refused / unsupported is not a hard error — it's a steer to remote.
3785
+ console.log("\nNo local install performed. See the note above.");
3786
+ process.exit(dryRun ? 0 : 1);
3787
+ }
3788
+
3789
+ const paths = resolveTranscribeCppPaths();
3790
+ console.log(`\nInstall dir: ${paths.dir}`);
3791
+
3792
+ if (dryRun) {
3793
+ console.log("\n(--dry-run) No files downloaded.");
3794
+ return;
3795
+ }
3796
+
3797
+ if (!yes) {
3798
+ const ok = await confirm(`Download ~${plan.footprintMb}MB and install to ${paths.dir}?`, true);
3799
+ if (!ok) {
3800
+ console.log("Aborted.");
3801
+ return;
3802
+ }
3803
+ }
3804
+
3805
+ // ffmpeg hint (non-fatal — the transcode path checks at runtime).
3806
+ if (!Bun.which("ffmpeg")) {
3807
+ console.warn(
3808
+ "\n⚠ ffmpeg not found on PATH. transcribe-cli needs 16kHz mono WAV; non-WAV capture audio is transcoded via ffmpeg.\n" +
3809
+ " Install it: apt install ffmpeg (Debian/Ubuntu) | brew install ffmpeg (macOS)",
3810
+ );
3811
+ }
3812
+
3813
+ try {
3814
+ mkdirSync(paths.binDir, { recursive: true });
3815
+ mkdirSync(paths.libsDir, { recursive: true });
3816
+ mkdirSync(paths.modelsDir, { recursive: true });
3817
+
3818
+ // 1) Release library bundle → extract the runtime dylibs/sos into libs/.
3819
+ // transcribe.cpp v0.1.1 ships a LIBRARY, not a prebuilt transcribe-cli,
3820
+ // so a CLI is usually NOT placed here — `installTranscribeLibs` still
3821
+ // opportunistically places one if a (future) asset includes it.
3822
+ const existingLibs = (readdirSync(paths.libsDir) as string[]).filter(isSharedLibFile);
3823
+ let libFiles = existingLibs;
3824
+ if (existingLibs.length && !force) {
3825
+ console.log(`\n✓ runtime libraries already present (${paths.libsDir}) — skipping (use --force to re-download).`);
3826
+ } else {
3827
+ console.log(`\nDownloading ${plan.asset!.asset} …`);
3828
+ libFiles = await installTranscribeLibs(plan, paths);
3829
+ console.log(
3830
+ libFiles.length
3831
+ ? `✓ Extracted ${libFiles.length} runtime library file(s) → ${paths.libsDir}`
3832
+ : `⚠ no shared libraries found in ${plan.asset!.asset} (the asset layout may have changed).`,
3833
+ );
3834
+ }
3835
+
3836
+ // 2) Model GGUF.
3837
+ const modelDest = join(paths.modelsDir, plan.model!.file);
3838
+ if (existsSync(modelDest) && !force) {
3839
+ console.log(`✓ model already present (${modelDest}) — skipping (use --force to re-download).`);
3840
+ } else {
3841
+ console.log(`Downloading model ${plan.model!.file} (~${plan.model!.approxSizeMb}MB) …`);
3842
+ await downloadTo(plan.model!.url, modelDest);
3843
+ }
3844
+
3845
+ // 3) Build transcribe-cli from source against the extracted dylibs.
3846
+ // transcribe.cpp v0.1.1 ships a LIBRARY, not a prebuilt CLI, so we
3847
+ // compile examples/cli/main.cpp + examples/common/wav.cpp against
3848
+ // libtranscribe (a tiny ~127KB compile — no ggml rebuild). Non-fatal:
3849
+ // any failure keeps the current provider + prints guidance.
3850
+ const buildResult = await maybeBuildTranscribeCli(paths, libFiles, force);
3851
+
3852
+ // 4) Manifest.
3853
+ const manifest: TranscribeCppManifest = {
3854
+ version: plan.asset!.asset,
3855
+ asset: plan.asset!.asset,
3856
+ binFile: "transcribe-cli",
3857
+ model: plan.model!.name,
3858
+ modelFile: plan.model!.file,
3859
+ libFiles,
3860
+ binBuiltFrom: buildResult?.ok ? TRANSCRIBE_CPP_SOURCE_REF : undefined,
3861
+ os: plan.platform,
3862
+ arch: plan.arch,
3863
+ ram_gb: plan.totalRamGb,
3864
+ installedAt: new Date().toISOString(),
3865
+ };
3866
+ writeFileSync(paths.manifestPath, JSON.stringify(manifest, null, 2));
3867
+
3868
+ // 5) CLI resolution + provider switch — honest, no silent success. `binPath`
3869
+ // resolves to TRANSCRIBE_CPP_BIN or the built binary; we activate
3870
+ // transcribe-cpp only when one actually exists, else we keep the current
3871
+ // provider and report the acquisition gap rather than taking
3872
+ // transcription offline behind a provider that can't run.
3873
+ if (existsSync(paths.binPath)) {
3874
+ setEnvVar("TRANSCRIPTION_PROVIDER", "transcribe-cpp");
3875
+ console.log(`\n✓ Installed and activated → TRANSCRIPTION_PROVIDER=transcribe-cpp set in the vault .env.`);
3876
+ console.log(` CLI: ${paths.binPath}`);
3877
+ console.log(` Restart vault to activate: parachute restart vault`);
3878
+ } else {
3879
+ console.log(`\n✓ Libraries + model installed to ${paths.dir} — transcription is NOT yet activated.`);
3880
+ console.log(noRunnableCliGuidance(paths, buildResult));
3881
+ }
3882
+ } catch (err) {
3883
+ console.error(`\nInstall failed: ${err instanceof Error ? err.message : String(err)}`);
3884
+ console.error("The verb is idempotent — fix the issue and re-run. Or use the remote provider: parachute-vault transcription install --provider scribe-http");
3885
+ process.exit(1);
3886
+ }
3887
+ }
3888
+
3889
+ /** Download a URL to a destination file (streamed via fetch → Bun.write). */
3890
+ async function downloadTo(url: string, dest: string): Promise<void> {
3891
+ const resp = await fetch(url, { redirect: "follow" });
3892
+ if (!resp.ok) throw new Error(`download failed (${resp.status}) for ${url}`);
3893
+ await Bun.write(dest, resp);
3894
+ }
3895
+
3896
+ /**
3897
+ * Download the release tarball, extract it, and place its runtime shared
3898
+ * libraries (libtranscribe + libggml `.dylib`/`.so`) into `paths.libsDir`,
3899
+ * returning the extracted lib filenames. transcribe.cpp v0.1.1 ships a LIBRARY,
3900
+ * not a prebuilt `transcribe-cli` — so we also look for a CLI binary (a future
3901
+ * asset may include one) and, if present, place it at `paths.binPath` + chmod +x
3902
+ * (the caller decides activation from whether a CLI exists). Uses system `tar`
3903
+ * (present on Linux + macOS). Kept out of the plan/dry-run path so tests never
3904
+ * hit the network.
3905
+ */
3906
+ async function installTranscribeLibs(
3907
+ plan: InstallPlan,
3908
+ paths: ReturnType<typeof resolveTranscribeCppPaths>,
3909
+ ): Promise<string[]> {
3910
+ const tmpRoot = join(paths.dir, ".dl");
3911
+ mkdirSync(tmpRoot, { recursive: true });
3912
+ const tarball = join(tmpRoot, plan.asset!.asset);
3913
+ await downloadTo(plan.asset!.url, tarball);
3914
+
3915
+ const extractDir = join(tmpRoot, "x");
3916
+ rmSync(extractDir, { recursive: true, force: true });
3917
+ mkdirSync(extractDir, { recursive: true });
3918
+ const tarProc = Bun.spawnSync(["tar", "-xzf", tarball, "-C", extractDir]);
3919
+ if (tarProc.exitCode !== 0) {
3920
+ throw new Error(`tar extract failed: ${new TextDecoder().decode(tarProc.stderr)}`);
3921
+ }
3922
+
3923
+ // Walk the extracted tree: keep the shared libs, opportunistically place a CLI.
3924
+ mkdirSync(paths.libsDir, { recursive: true });
3925
+ const libFiles: string[] = [];
3926
+ for (const rel of readdirSync(extractDir, { recursive: true }) as string[]) {
3927
+ const abs = join(extractDir, rel);
3928
+ if (!statSync(abs).isFile()) continue;
3929
+ const base = rel.split("/").pop()!;
3930
+ if (isSharedLibFile(base)) {
3931
+ copyFileSync(abs, join(paths.libsDir, base));
3932
+ libFiles.push(base);
3933
+ } else if (base === "transcribe-cli") {
3934
+ copyFileSync(abs, paths.binPath);
3935
+ chmodSync(paths.binPath, 0o755);
3936
+ }
3937
+ }
3938
+ rmSync(tmpRoot, { recursive: true, force: true });
3939
+ return libFiles;
3940
+ }
3941
+
3942
+ /**
3943
+ * Build `transcribe-cli` from source against the extracted dylibs, honoring
3944
+ * idempotency + the `TRANSCRIBE_CPP_BIN` operator override. Returns the build
3945
+ * result, or `null` when the build was intentionally skipped (already built /
3946
+ * operator-provided binary / no libs to link). Non-fatal — logs progress and
3947
+ * warnings; failures come back as a typed `{ ok:false }` the caller reports.
3948
+ */
3949
+ async function maybeBuildTranscribeCli(
3950
+ paths: ReturnType<typeof resolveTranscribeCppPaths>,
3951
+ libFiles: string[],
3952
+ force: boolean,
3953
+ ): Promise<CliBuildResult | null> {
3954
+ if (process.env.TRANSCRIBE_CPP_BIN?.trim()) {
3955
+ console.log(
3956
+ existsSync(paths.binPath)
3957
+ ? `\n✓ Using operator-provided TRANSCRIBE_CPP_BIN=${paths.binPath} — skipping source build.`
3958
+ : `\n⚠ TRANSCRIBE_CPP_BIN=${paths.binPath} is set but no file exists there — skipping source build; nothing will be activated until it points at a real binary.`,
3959
+ );
3960
+ return null;
3961
+ }
3962
+ if (existsSync(paths.binPath) && !force) {
3963
+ console.log(`\n✓ transcribe-cli already built (${paths.binPath}) — skipping build (use --force to rebuild).`);
3964
+ return null;
3965
+ }
3966
+ if (libFiles.length === 0) {
3967
+ console.warn(`\n⚠ no runtime libraries were extracted — cannot build transcribe-cli. Skipping build.`);
3968
+ return null;
3969
+ }
3970
+
3971
+ console.log(`\nBuilding transcribe-cli from source (transcribe.cpp @ ${TRANSCRIBE_CPP_SOURCE_REF.slice(0, 12)}) …`);
3972
+ mkdirSync(paths.binDir, { recursive: true });
3973
+ const srcDir = join(paths.dir, ".src");
3974
+ const result = await buildTranscribeCli(
3975
+ { srcDir, libsDir: paths.libsDir, binPath: paths.binPath },
3976
+ {
3977
+ platform: process.platform,
3978
+ which: (c) => Bun.which(c),
3979
+ fetchSource: async (files, dir) => {
3980
+ rmSync(dir, { recursive: true, force: true });
3981
+ for (const rel of files) {
3982
+ const dest = join(dir, rel);
3983
+ mkdirSync(dirname(dest), { recursive: true });
3984
+ await downloadTo(cliSourceUrl(rel), dest);
3985
+ }
3986
+ },
3987
+ compile: (cmd) => {
3988
+ const p = Bun.spawnSync(cmd);
3989
+ return { exitCode: p.exitCode ?? 1, stderr: new TextDecoder().decode(p.stderr) };
3990
+ },
3991
+ exists: existsSync,
3992
+ removeBin: (p) => rmSync(p, { force: true }),
3993
+ rename: (from, to) => renameSync(from, to),
3994
+ },
3995
+ );
3996
+
3997
+ if (result.ok) {
3998
+ chmodSync(paths.binPath, 0o755); // belt-and-suspenders; the compiler emits 0755
3999
+ rmSync(srcDir, { recursive: true, force: true }); // tidy — source is re-fetchable
4000
+ console.log(`✓ Built transcribe-cli → ${result.binPath}`);
4001
+ } else {
4002
+ // Keep srcDir on failure so the printed compile command can be re-run by hand.
4003
+ console.warn(`⚠ transcribe-cli build failed (${result.stage}): ${result.message}`);
4004
+ if (result.command) console.warn(` compile command tried:\n ${result.command}`);
4005
+ }
4006
+ return result;
4007
+ }
4008
+
4009
+ /**
4010
+ * The "no runnable CLI" guidance printed when activation can't proceed —
4011
+ * tailored to WHY (build failed at toolchain vs compile, or was skipped).
4012
+ */
4013
+ function noRunnableCliGuidance(
4014
+ paths: ReturnType<typeof resolveTranscribeCppPaths>,
4015
+ build: CliBuildResult | null,
4016
+ ): string {
4017
+ const lines: string[] = [];
4018
+ if (build && !build.ok && build.stage === "toolchain") {
4019
+ lines.push(
4020
+ `\n⚠ Could not build transcribe-cli — ${build.message}`,
4021
+ " To finish, either:",
4022
+ ` • install a C++ compiler and re-run this verb (${compilerInstallHint(process.platform)}); OR`,
4023
+ " • build transcribe-cli yourself, point TRANSCRIBE_CPP_BIN at it, and re-run; OR",
4024
+ " • use the remote provider: parachute-vault transcription install --provider scribe-http",
4025
+ );
4026
+ } else if (build && !build.ok && build.stage === "fetch") {
4027
+ lines.push(
4028
+ `\n⚠ Could not fetch the transcribe-cli source — ${build.message}`,
4029
+ " The libraries + model are installed; only the source download failed (check network/proxy).",
4030
+ " To finish, either:",
4031
+ " • re-run this verb once connectivity is restored; OR",
4032
+ " • build transcribe-cli yourself, point TRANSCRIBE_CPP_BIN at it, and re-run; OR",
4033
+ " • use the remote provider: parachute-vault transcription install --provider scribe-http",
4034
+ );
4035
+ } else if (build && !build.ok) {
4036
+ lines.push(
4037
+ `\n⚠ transcribe-cli build failed (${build.stage}). The libraries + model are installed;`,
4038
+ " the CLI is not. To finish, either:",
4039
+ " • fix the toolchain and re-run this verb; OR",
4040
+ " • build transcribe-cli yourself (the compile command tried is printed above),",
4041
+ " point TRANSCRIBE_CPP_BIN at the result, and re-run; OR",
4042
+ " • use the remote provider: parachute-vault transcription install --provider scribe-http",
4043
+ );
4044
+ } else {
4045
+ // Build skipped (no libs, or an operator-provided binary that isn't present).
4046
+ lines.push(
4047
+ "\n⚠ No runnable transcribe-cli is available yet. To finish, either:",
4048
+ ` • re-run this verb so it builds a transcribe-cli against ${paths.libsDir} (needs a C++ compiler); OR`,
4049
+ " • point TRANSCRIBE_CPP_BIN at a transcribe-cli you built, and re-run; OR",
4050
+ " • use the remote provider: parachute-vault transcription install --provider scribe-http",
4051
+ );
4052
+ }
4053
+ lines.push(" Your current provider is unchanged — check with `parachute-vault transcription status`.");
4054
+ return lines.join("\n");
4055
+ }
4056
+
4057
+ /** `parachute-vault transcription status` — provider + per-provider install state. */
4058
+ function cmdTranscriptionStatus(): void {
4059
+ const active = resolveTranscriptionProviderName();
4060
+ console.log(`Configured provider: ${active}`);
4061
+
4062
+ // What the ratified tier table would pick for this host (install default).
4063
+ const tier = selectDefaultProvider({
4064
+ platform: process.platform,
4065
+ arch: process.arch,
4066
+ totalRamBytes: detectTotalRamBytes(),
4067
+ });
4068
+ console.log(
4069
+ `Tier default for this host (${tier.platform}/${tier.arch}, ${tier.totalRamGb}GB): ${tier.provider}${tier.model ? ` (${tier.model})` : ""}`,
4070
+ );
4071
+
4072
+ // transcribe-cpp — prebuilt libs + GGUF model + built CLI.
4073
+ const paths = resolveTranscribeCppPaths();
4074
+ const manifest = readManifest(paths.manifestPath);
4075
+ const cliPresent = existsSync(paths.binPath);
4076
+ const installed = transcribeCppInstalled(paths); // runnable CLI + model both present
4077
+ console.log(`\ntranscribe-cpp runnable: ${installed ? "yes" : "no"}`);
4078
+ if (manifest) {
4079
+ console.log(` model: ${manifest.model} (${manifest.modelFile})`);
4080
+ console.log(
4081
+ ` libs: ${paths.libsDir}${manifest.libFiles?.length ? ` (${manifest.libFiles.length} file(s))` : ""}`,
4082
+ );
4083
+ console.log(
4084
+ cliPresent
4085
+ ? ` cli: ${paths.binPath}${manifest.binBuiltFrom ? ` (built from source @ ${manifest.binBuiltFrom.slice(0, 12)})` : ""}`
4086
+ : ` cli: NOT FOUND — re-run \`transcription install\` to build one (needs a C++ compiler), or set TRANSCRIBE_CPP_BIN`,
4087
+ );
4088
+ console.log(` ram: ${manifest.ram_gb}GB at install (${manifest.os}/${manifest.arch})`);
4089
+ } else {
4090
+ console.log(` (not installed — run: parachute-vault transcription install --provider transcribe-cpp)`);
4091
+ }
4092
+
4093
+ // parakeet-mlx / onnx-asr — Python venv providers (scribe-fold Phase 2b).
4094
+ const pyManifest = readPythonManifest(pythonManifestPath());
4095
+ const pkBin = resolveParakeetMlxBin();
4096
+ const pkRunnable = parakeetMlxInstalled();
4097
+ console.log(`\nparakeet-mlx runnable: ${pkRunnable ? "yes" : "no"}`);
4098
+ if (pkRunnable) {
4099
+ console.log(` bin: ${pkBin}`);
4100
+ console.log(` model: ${resolveParakeetMlxModel()} (HF cache; downloads on first use)`);
4101
+ } else {
4102
+ console.log(
4103
+ process.platform === "darwin" && process.arch === "arm64"
4104
+ ? ` (not installed — run: parachute-vault transcription install${tier.provider === "parakeet-mlx" ? "" : " --provider parakeet-mlx"})`
4105
+ : " (macOS Apple Silicon only)",
4106
+ );
4107
+ }
4108
+
4109
+ const oxBin = resolveOnnxAsrBin();
4110
+ const oxRunnable = onnxAsrInstalled();
4111
+ console.log(`onnx-asr runnable: ${oxRunnable ? "yes" : "no"}`);
4112
+ if (oxRunnable) {
4113
+ console.log(` bin: ${oxBin}`);
4114
+ console.log(` model: ${resolveOnnxAsrModel()} (HF cache)`);
4115
+ } else {
4116
+ console.log(
4117
+ ` (not installed — run: parachute-vault transcription install${tier.provider === "onnx-asr" ? "" : " --provider onnx-asr"})`,
4118
+ );
4119
+ }
4120
+ if (pyManifest) {
4121
+ console.log(
4122
+ ` python install: ${pyManifest.provider} (${pyManifest.pipTarget}) @ ${pyManifest.installedAt}${pyManifest.venv ? ` — venv ${pyManifest.venv}` : ""}`,
4123
+ );
4124
+ }
4125
+
4126
+ // Offline warning when the ACTIVE provider isn't runnable.
4127
+ const activeRunnable =
4128
+ active === "scribe-http" ||
4129
+ (active === "transcribe-cpp" && installed) ||
4130
+ (active === "parakeet-mlx" && pkRunnable) ||
4131
+ (active === "onnx-asr" && oxRunnable);
4132
+ if (!activeRunnable) {
4133
+ console.log(
4134
+ `\n⚠ provider is ${active} but no runnable ${active} install was found — transcription is offline until one is available (\`parachute-vault transcription install\`).`,
4135
+ );
4136
+ }
4137
+ console.log(
4138
+ `\nAvailable transcribe-cpp models (override with --model): ${Object.values(MODELS).map((m: ModelChoice) => m.name).join(", ")}`,
4139
+ );
4140
+ }
4141
+
3499
4142
  // ---------------------------------------------------------------------------
3500
4143
  // Schema maintenance — `parachute-vault schema <subcommand>`
3501
4144
  // ---------------------------------------------------------------------------
@@ -3962,11 +4605,14 @@ async function createVault(
3962
4605
  // row is written — vault is a pure hub resource-server post-0.5.0.
3963
4606
  const seedStore = getVaultStore(name);
3964
4607
 
3965
- // Seed the in-vault onboarding guide (Getting Started + Surface Starter) so a
3966
- // connected AI can read it and help the operator set the vault up. Idempotent
3967
- // + best-effort: only writes notes that are absent, and never fails the
3968
- // create. Runs BEFORE the mirror bootstrap so the seeded notes land in the
3969
- // initial mirror commit. See src/onboarding-seed.ts + core/src/onboarding.ts.
4608
+ // Seed the default packs (welcome web + capture tag, Getting Started guide)
4609
+ // so the first minute shows a small living graph and a connected AI can read
4610
+ // the guide and help the operator set the vault up. Surface Starter is NOT
4611
+ // default-seeded `parachute-vault add-pack surface-starter` adds it later.
4612
+ // Idempotent + best-effort: only writes notes that are absent, and never
4613
+ // fails the create. Runs BEFORE the mirror bootstrap so the seeded content
4614
+ // lands in the initial mirror commit. See src/onboarding-seed.ts +
4615
+ // core/src/seed-packs.ts.
3970
4616
  await seedOnboardingNotesBestEffort(seedStore);
3971
4617
 
3972
4618
  // Default new vaults to an internal live mirror (local git backup of the
@@ -4088,6 +4734,99 @@ function installMcpConfig(opts: InstallMcpConfigOpts): void {
4088
4734
  writeFileSync(targetPath, JSON.stringify(config, null, 2) + "\n");
4089
4735
  }
4090
4736
 
4737
+ /**
4738
+ * `parachute-vault add-pack <pack> [--vault <name>]` — apply a named seed
4739
+ * pack (starter notes + tags) to a vault.
4740
+ *
4741
+ * New vaults default-seed the `welcome` + `getting-started` packs at create;
4742
+ * this verb adds the opt-in ones (today: `surface-starter`) — or re-applies a
4743
+ * default pack to an older vault that predates it. Idempotent per item: a
4744
+ * note is created only when no note lives at its path (a re-run never
4745
+ * clobbers an edited note); tag upserts converge on the same rows.
4746
+ *
4747
+ * Unlike `import` (bulk stream writes, vault#323 daemon-busy guard), this is
4748
+ * a handful of tiny writes against a WAL database, so it runs fine next to a
4749
+ * live daemon; a write-lock collision (SQLITE_BUSY) is caught with a
4750
+ * retry-is-safe message instead of pre-emptively demanding a daemon stop.
4751
+ */
4752
+ async function cmdAddPack(args: string[]) {
4753
+ let vaultName = "default";
4754
+ const positional: string[] = [];
4755
+ for (let i = 0; i < args.length; i++) {
4756
+ const arg = args[i]!;
4757
+ if (arg === "--vault") {
4758
+ const v = args[++i];
4759
+ if (!v) {
4760
+ console.error("--vault requires a value.");
4761
+ process.exit(1);
4762
+ }
4763
+ vaultName = v;
4764
+ } else {
4765
+ positional.push(arg);
4766
+ }
4767
+ }
4768
+ const packName = positional[0];
4769
+
4770
+ const { listSeedPacks, getSeedPack, applySeedPack } = await import(
4771
+ "../core/src/seed-packs.ts"
4772
+ );
4773
+
4774
+ const printPacks = () => {
4775
+ console.error("\nAvailable packs:");
4776
+ for (const p of listSeedPacks()) {
4777
+ console.error(` ${p.name.padEnd(17)} ${p.description}`);
4778
+ }
4779
+ };
4780
+
4781
+ if (!packName) {
4782
+ console.error("Usage: parachute-vault add-pack <pack> [--vault <name>]");
4783
+ printPacks();
4784
+ process.exit(1);
4785
+ }
4786
+
4787
+ const pack = getSeedPack(packName);
4788
+ if (!pack) {
4789
+ console.error(`Unknown pack: "${packName}"`);
4790
+ printPacks();
4791
+ process.exit(1);
4792
+ }
4793
+
4794
+ const config = readVaultConfig(vaultName);
4795
+ if (!config) {
4796
+ console.error(`Vault "${vaultName}" not found. Run: parachute-vault create ${vaultName}`);
4797
+ process.exit(1);
4798
+ }
4799
+
4800
+ const store = getVaultStore(vaultName);
4801
+ try {
4802
+ const result = await applySeedPack(store, pack);
4803
+ console.log(`Pack "${pack.name}" applied to vault "${vaultName}":`);
4804
+ for (const path of result.seededNotes) {
4805
+ console.log(` + note ${path}`);
4806
+ }
4807
+ for (const path of result.skippedNotes) {
4808
+ console.log(` = note ${path} (already exists — left untouched)`);
4809
+ }
4810
+ for (const tag of result.tags) {
4811
+ console.log(` ~ tag ${tag} (upserted)`);
4812
+ }
4813
+ if (result.seededNotes.length === 0 && result.tags.length === 0) {
4814
+ console.log(" Nothing to add — everything was already in place.");
4815
+ }
4816
+ } catch (err) {
4817
+ const message = (err as Error)?.message ?? String(err);
4818
+ if (message.includes("SQLITE_BUSY") || (err as { code?: string })?.code === "SQLITE_BUSY") {
4819
+ console.error(
4820
+ `error: the vault database is busy (a running daemon holds the write lock). ` +
4821
+ `add-pack is idempotent — re-run it, or stop the daemon first with: parachute stop vault`,
4822
+ );
4823
+ } else {
4824
+ console.error(`error: failed to apply pack "${pack.name}": ${message}`);
4825
+ }
4826
+ process.exit(1);
4827
+ }
4828
+ }
4829
+
4091
4830
  function usage() {
4092
4831
  console.log(`
4093
4832
  Parachute Vault — self-hosted knowledge graph
@@ -4153,6 +4892,14 @@ Vaults:
4153
4892
  opt-in upgrade). --no-mirror creates a bare vault with no mirror config.
4154
4893
  Operators can flip the server-wide default with 'default_mirror: off' in
4155
4894
  config.yaml (recommended for cloud / disk-constrained boxes).
4895
+ parachute-vault add-pack <pack> [--vault <name>]
4896
+ Add a named seed pack (starter notes + tags) to a vault
4897
+ (default vault: "default"). Run with no arguments to list
4898
+ the available packs. New vaults seed the "welcome" +
4899
+ "getting-started" packs automatically; "surface-starter"
4900
+ is opt-in via this command. Idempotent: notes are only
4901
+ created when absent — a re-run never clobbers an edited
4902
+ note.
4156
4903
  parachute-vault list List all vaults
4157
4904
  parachute-vault remove <name> [--yes] Remove a vault
4158
4905
  parachute-vault mcp-install [--mint|--token <t>]
@@ -4298,6 +5045,30 @@ Schema maintenance:
4298
5045
  Writes through strict enforcement (migrate
4299
5046
  bypass). See \`schema migrate-field --help\`.
4300
5047
 
5048
+ Transcription (scribe-fold):
5049
+ parachute-vault transcription install [--dry-run] [--model <id>] [--force] [--yes]
5050
+ Install this host's tier-default LOCAL provider (probes
5051
+ OS/arch + RAM, prints the pick + footprint, confirms):
5052
+ macOS Apple Silicon 8GB+ → parakeet-mlx (Python venv, MLX)
5053
+ Linux 4GB+ → onnx-asr (Python venv, int8 ONNX)
5054
+ Linux ~2GB → transcribe-cpp + small whisper GGUF
5055
+ ~1GB / below floor → remote steer (never forced)
5056
+ Activation is honest: TRANSCRIPTION_PROVIDER flips only when
5057
+ a runnable binary exists. Idempotent; --dry-run prints the
5058
+ pick without downloading; --force re-downloads / bypasses the
5059
+ RAM floor on an explicit --provider; --yes skips the confirm.
5060
+ parachute-vault transcription install --provider <name>
5061
+ Override the tier default. transcribe-cpp downloads the
5062
+ runtime libs + a GGUF (--model overrides the RAM-tier pick:
5063
+ whisper-tiny.en | whisper-small.en | parakeet-tdt-0.6b-v3;
5064
+ builds transcribe-cli from source — needs a C++ compiler).
5065
+ parakeet-mlx / onnx-asr install a Python venv under
5066
+ ~/.parachute/transcription/venv (needs python3; apt-installs
5067
+ python3/ffmpeg on Linux). scribe-http switches back to the
5068
+ remote provider (no download).
5069
+ parachute-vault transcription status Show the configured provider, this host's tier default, and
5070
+ per-provider install state.
5071
+
4301
5072
  ── Advanced / standalone ──────────────────────────────────────────────
4302
5073
 
4303
5074
  Direct daemon controls. For normal use, prefer the Parachute Hub wrappers