@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
@@ -1,10 +1,16 @@
1
1
  /**
2
- * Unit tests for the onboarding-guide seeding (demo-prep Workstream A — A1/A2/A3).
2
+ * Unit tests for the default-pack seeding (originally demo-prep Workstream A —
3
+ * A1/A2/A3; reshaped for named seed packs).
3
4
  *
4
5
  * Store-direct (no CLI subprocess) so they're fast. Covers:
5
- * - A1: seedOnboardingNotes writes Getting Started + Surface Starter.
6
- * - A3: Surface Starter is linked from Getting Started (wikilink).
6
+ * - default seed = `welcome` (3-note web + the capture tag) + `getting-started`
7
+ * and NOT `surface-starter` (out of the default seed, ratified 2026-07-02).
8
+ * - A1: the Getting Started doctrine markers.
9
+ * - A3 (reshaped): the welcome web's wikilinks resolve; Getting Started
10
+ * points at the surface-starter PACK without a dangling wikilink.
7
11
  * - idempotency: a re-run never clobbers an edited note.
12
+ * - applySeedPack(surface-starter): the add-pack path works + is idempotent,
13
+ * and the guide carries the surface doctrine (GAP 1/2 markers).
8
14
  * - A2: the projection / vault-info pointer steers the AI at Getting Started.
9
15
  */
10
16
 
@@ -16,14 +22,28 @@ import { tmpdir } from "os";
16
22
  import { BunStore } from "./vault-store.ts";
17
23
  import { seedOnboardingNotes } from "./onboarding-seed.ts";
18
24
  import {
25
+ applySeedPack,
26
+ CONNECT_AI_PATH,
19
27
  GETTING_STARTED_PATH,
28
+ NOTES_REQUIRED_TAGS,
29
+ SURFACE_STARTER_PACK,
20
30
  SURFACE_STARTER_PATH,
21
- } from "../core/src/onboarding.ts";
31
+ TRY_LINKING_PATH,
32
+ WELCOME_PATH,
33
+ } from "../core/src/seed-packs.ts";
22
34
  import {
23
35
  buildVaultProjection,
24
36
  projectionToMarkdown,
25
37
  } from "../core/src/vault-projection.ts";
26
38
 
39
+ /** Every note path the default seed writes on a blank vault. */
40
+ const DEFAULT_SEED_PATHS = [
41
+ WELCOME_PATH,
42
+ TRY_LINKING_PATH,
43
+ CONNECT_AI_PATH,
44
+ GETTING_STARTED_PATH,
45
+ ];
46
+
27
47
  let db: Database;
28
48
  let store: BunStore;
29
49
  let tmpDir: string;
@@ -40,14 +60,15 @@ afterEach(() => {
40
60
  rmSync(tmpDir, { recursive: true, force: true });
41
61
  });
42
62
 
43
- describe("seedOnboardingNotes (A1/A3)", () => {
44
- test("seeds Getting Started + Surface Starter on a blank vault", async () => {
63
+ describe("seedOnboardingNotes — default packs (welcome + getting-started)", () => {
64
+ test("seeds the welcome web + Getting Started on a blank vault — and NOT Surface Starter", async () => {
45
65
  const result = await seedOnboardingNotes(store);
46
- expect(result.seeded.sort()).toEqual(
47
- [GETTING_STARTED_PATH, SURFACE_STARTER_PATH].sort(),
48
- );
66
+ expect(result.seeded.sort()).toEqual([...DEFAULT_SEED_PATHS].sort());
49
67
  expect(result.skipped).toEqual([]);
50
68
 
69
+ // Surface Starter is OUT of the default seed (button/CLI only).
70
+ expect(await store.getNoteByPath(SURFACE_STARTER_PATH)).toBeNull();
71
+
51
72
  const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
52
73
  expect(gs).not.toBeNull();
53
74
  expect(gs!.content).toContain("# Getting Started");
@@ -59,26 +80,28 @@ describe("seedOnboardingNotes (A1/A3)", () => {
59
80
  expect(gs!.content).toContain("parachute-vault import");
60
81
  expect(gs!.content).toContain("Adapt this note");
61
82
 
62
- const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
63
- expect(ss).not.toBeNull();
64
- expect(ss!.content).toContain("# Surface Starter");
65
- // A3: the two surface packages by name.
66
- expect(ss!.content).toContain("@openparachute/surface-client");
67
- expect(ss!.content).toContain("@openparachute/surface-render");
68
- expect(ss!.content).toContain("createVaultSurface");
69
- expect(ss!.content).toContain("NoteRenderer");
83
+ // The welcome web: three person-voiced notes.
84
+ for (const path of [WELCOME_PATH, TRY_LINKING_PATH, CONNECT_AI_PATH]) {
85
+ expect(await store.getNoteByPath(path)).not.toBeNull();
86
+ }
87
+ });
70
88
 
71
- // GAP 2: warn that a surface can't be run/developed from this MCP session.
72
- expect(ss!.content).toContain("not from this");
73
- expect(ss!.content.toLowerCase()).toContain("browser");
89
+ test("seeds the ONE capture tag Notes requires (byte-equal semantics)", async () => {
90
+ const result = await seedOnboardingNotes(store);
91
+ expect(result.tags).toEqual(["capture"]);
74
92
 
75
- // GAP 1: a real, copy-paste end-to-end snippet — every load-bearing symbol
76
- // present, verified against the surface-client / surface-render source.
77
- expect(ss!.content).toContain("clientName"); // the one required createVaultSurface option
78
- expect(ss!.content).toContain("getClient");
79
- expect(ss!.content).toContain("queryNotes");
80
- expect(ss!.content).toContain("handleCallback");
81
- expect(ss!.content).toContain("resolve="); // NoteRenderer wikilink resolver prop
93
+ for (const decl of NOTES_REQUIRED_TAGS) {
94
+ const record = await store.getTagRecord(decl.name);
95
+ expect(record).not.toBeNull();
96
+ expect(record!.description).toBe(decl.description);
97
+ expect(record!.parent_names ?? []).toEqual(decl.parent_names ?? []);
98
+ }
99
+
100
+ // The retired subtype tags are NOT seeded — entry method is note
101
+ // metadata.source (text|voice), not taxonomy (2026-07-03). Existing
102
+ // vaults carrying these tags stay valid; new vaults don't get them.
103
+ expect(await store.getTagRecord("capture/text")).toBeNull();
104
+ expect(await store.getTagRecord("capture/voice")).toBeNull();
82
105
  });
83
106
 
84
107
  test("GAP 4: Getting Started shows a concrete update-tag fields example + write gotchas", async () => {
@@ -98,29 +121,39 @@ describe("seedOnboardingNotes (A1/A3)", () => {
98
121
  expect(gs!.content).toContain("first listed value");
99
122
  });
100
123
 
101
- test("A3: Getting Started links to Surface Starter via a resolved wikilink", async () => {
124
+ test("A3 (reshaped): the welcome web's wikilinks resolve; Getting Started has no dangling Surface Starter link", async () => {
102
125
  await seedOnboardingNotes(store);
103
- const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
104
- expect(gs!.content).toContain("[[Surface Starter]]");
105
126
 
106
- // Wikilink auto-resolution: Getting Started Surface Starter link row.
107
- const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
108
- const outbound = await store.getLinks(gs!.id, { direction: "outbound" });
109
- expect(outbound.some((l) => l.targetId === ss!.id)).toBe(true);
127
+ // welcome → try-linking, try-linkingwelcome, connect-AI welcome.
128
+ const welcome = await store.getNoteByPath(WELCOME_PATH);
129
+ const tryLinking = await store.getNoteByPath(TRY_LINKING_PATH);
130
+ const connectAi = await store.getNoteByPath(CONNECT_AI_PATH);
131
+
132
+ const welcomeOut = await store.getLinks(welcome!.id, { direction: "outbound" });
133
+ expect(welcomeOut.some((l) => l.targetId === tryLinking!.id)).toBe(true);
134
+ const tryOut = await store.getLinks(tryLinking!.id, { direction: "outbound" });
135
+ expect(tryOut.some((l) => l.targetId === welcome!.id)).toBe(true);
136
+ const connectOut = await store.getLinks(connectAi!.id, { direction: "outbound" });
137
+ expect(connectOut.some((l) => l.targetId === welcome!.id)).toBe(true);
138
+
139
+ // Getting Started mentions the surface-starter PACK — not a wikilink to a
140
+ // note that the default seed no longer creates.
141
+ const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
142
+ expect(gs!.content).not.toContain("[[Surface Starter]]");
143
+ expect(gs!.content).toContain("add-pack surface-starter");
110
144
  });
111
145
 
112
- test("idempotent: a second seed run skips both (does not duplicate)", async () => {
146
+ test("idempotent: a second seed run skips all notes (does not duplicate)", async () => {
113
147
  await seedOnboardingNotes(store);
114
148
  const second = await seedOnboardingNotes(store);
115
149
  expect(second.seeded).toEqual([]);
116
- expect(second.skipped.sort()).toEqual(
117
- [GETTING_STARTED_PATH, SURFACE_STARTER_PATH].sort(),
118
- );
150
+ expect(second.skipped.sort()).toEqual([...DEFAULT_SEED_PATHS].sort());
119
151
 
120
152
  // Exactly one note per path — no duplicates.
121
153
  const all = await store.queryNotes({});
122
- expect(all.filter((n) => n.path === GETTING_STARTED_PATH)).toHaveLength(1);
123
- expect(all.filter((n) => n.path === SURFACE_STARTER_PATH)).toHaveLength(1);
154
+ for (const path of DEFAULT_SEED_PATHS) {
155
+ expect(all.filter((n) => n.path === path)).toHaveLength(1);
156
+ }
124
157
  });
125
158
 
126
159
  test("idempotent: never clobbers an operator-edited Getting Started", async () => {
@@ -139,6 +172,59 @@ describe("seedOnboardingNotes (A1/A3)", () => {
139
172
  });
140
173
  });
141
174
 
175
+ describe("applySeedPack — surface-starter via add-pack", () => {
176
+ test("applies the Surface Starter guide onto a default-seeded vault", async () => {
177
+ await seedOnboardingNotes(store);
178
+ const result = await applySeedPack(store, SURFACE_STARTER_PACK);
179
+ expect(result.pack).toBe("surface-starter");
180
+ expect(result.seededNotes).toEqual([SURFACE_STARTER_PATH]);
181
+ expect(result.skippedNotes).toEqual([]);
182
+ expect(result.tags).toEqual([]);
183
+
184
+ const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
185
+ expect(ss).not.toBeNull();
186
+ expect(ss!.content).toContain("# Surface Starter");
187
+ // A3: the two surface packages by name.
188
+ expect(ss!.content).toContain("@openparachute/surface-client");
189
+ expect(ss!.content).toContain("@openparachute/surface-render");
190
+ expect(ss!.content).toContain("createVaultSurface");
191
+ expect(ss!.content).toContain("NoteRenderer");
192
+
193
+ // GAP 2: warn that a surface can't be run/developed from this MCP session.
194
+ expect(ss!.content).toContain("not from this");
195
+ expect(ss!.content.toLowerCase()).toContain("browser");
196
+
197
+ // GAP 1: a real, copy-paste end-to-end snippet — every load-bearing symbol
198
+ // present, verified against the surface-client / surface-render source.
199
+ expect(ss!.content).toContain("clientName"); // the one required createVaultSurface option
200
+ expect(ss!.content).toContain("getClient");
201
+ expect(ss!.content).toContain("queryNotes");
202
+ expect(ss!.content).toContain("handleCallback");
203
+ expect(ss!.content).toContain("resolve="); // NoteRenderer wikilink resolver prop
204
+
205
+ // Its [[Getting Started]] wikilink resolves against the default seed.
206
+ const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
207
+ const outbound = await store.getLinks(ss!.id, { direction: "outbound" });
208
+ expect(outbound.some((l) => l.targetId === gs!.id)).toBe(true);
209
+ });
210
+
211
+ test("idempotent: a re-apply skips and never clobbers an edited note", async () => {
212
+ await applySeedPack(store, SURFACE_STARTER_PACK);
213
+ const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
214
+ const edited = "# Surface Starter\n\nWe built ours with Solid.";
215
+ await store.updateNote(ss!.id, { content: edited });
216
+
217
+ const rerun = await applySeedPack(store, SURFACE_STARTER_PACK);
218
+ expect(rerun.seededNotes).toEqual([]);
219
+ expect(rerun.skippedNotes).toEqual([SURFACE_STARTER_PATH]);
220
+
221
+ const after = await store.getNoteByPath(SURFACE_STARTER_PATH);
222
+ expect(after!.content).toBe(edited);
223
+ const all = await store.queryNotes({});
224
+ expect(all.filter((n) => n.path === SURFACE_STARTER_PATH)).toHaveLength(1);
225
+ });
226
+ });
227
+
142
228
  describe("vault-info / projection pointer (A2)", () => {
143
229
  test("projection carries getting_started when the note exists", async () => {
144
230
  // Absent before seeding → no pointer.
@@ -1,74 +1,69 @@
1
1
  /**
2
- * Seed the in-vault onboarding guide on vault creation.
2
+ * Seed the default packs on vault creation.
3
3
  *
4
- * A freshly-created vault gets a `Getting Started` note (AI-legible doctrine)
5
- * and a linked `Surface Starter` note, so a connected AI can read the guide and
6
- * help the operator set the vault upand keep growing it over time.
4
+ * A freshly-created vault gets the `welcome` pack (the person-voiced
5
+ * three-note welcome web + the `capture` tag the Notes surface expects) and
6
+ * the `getting-started` pack (the AI-legible start-here doctrine) so the
7
+ * first minute shows a small living graph, and a connected AI can read the
8
+ * guide and help the operator set the vault up.
7
9
  *
8
- * IDEMPOTENT + create-time only: each note is written ONLY when absent. A
9
- * re-init, restart, or re-run never clobbers a note the operator/AI has since
10
- * edited. Existing vaults are unaffected this runs on the create path, not as
11
- * a sweep over existing vaults.
10
+ * The `surface-starter` pack is deliberately NOT part of the default seed
11
+ * (ratified 2026-07-02): it's added on demand via
12
+ * `parachute-vault add-pack surface-starter` or a console affordance.
12
13
  *
13
- * Best-effort + non-fatal: seeding must NEVER fail a vault create. Any error is
14
- * caught and logged; the vault is still usable without the guide.
14
+ * IDEMPOTENT + create-time only: each note is written ONLY when absent (tag
15
+ * upserts converge on the same rows). A re-init, restart, or re-run never
16
+ * clobbers a note the operator/AI has since edited. Existing vaults are
17
+ * unaffected — this runs on the create path, not as a sweep over existing
18
+ * vaults.
15
19
  *
16
- * See the demo-prep Workstream A (A1/A2/A3).
20
+ * Best-effort + non-fatal: seeding must NEVER fail a vault create. Any error
21
+ * is caught and logged; the vault is still usable without the starter content.
22
+ *
23
+ * Pack contents live in core/src/seed-packs.ts (shared with the cloud vault).
17
24
  */
18
25
 
19
26
  import {
20
- GETTING_STARTED_PATH,
21
- GETTING_STARTED_CONTENT,
22
- SURFACE_STARTER_PATH,
23
- SURFACE_STARTER_CONTENT,
24
- } from "../core/src/onboarding.ts";
27
+ applySeedPack,
28
+ GETTING_STARTED_PACK,
29
+ welcomePack,
30
+ } from "../core/src/seed-packs.ts";
25
31
  import type { Store } from "../core/src/types.ts";
26
32
 
27
33
  interface SeedResult {
28
- /** Paths actually written this run (absent ones). Empty when both pre-existed. */
34
+ /** Note paths actually written this run (absent ones). Empty when all pre-existed. */
29
35
  seeded: string[];
30
- /** Paths skipped because the note already existed (idempotency). */
36
+ /** Note paths skipped because the note already existed (idempotency). */
31
37
  skipped: string[];
38
+ /** Tag names upserted (idempotent by nature). */
39
+ tags: string[];
32
40
  }
33
41
 
34
42
  /**
35
- * Write a single onboarding note only if no note already lives at `path`.
36
- * Returns true when it wrote (was absent), false when it skipped (present).
37
- */
38
- async function seedNoteIfAbsent(
39
- store: Store,
40
- path: string,
41
- content: string,
42
- ): Promise<boolean> {
43
- const existing = await store.getNoteByPath(path);
44
- if (existing) return false;
45
- await store.createNote(content, { path });
46
- return true;
47
- }
48
-
49
- /**
50
- * Seed the onboarding notes into `store`, idempotently. Surface Starter is
51
- * created first so the `[[Surface Starter]]` wikilink in Getting Started
52
- * resolves immediately (the create path also auto-resolves later, but this
53
- * keeps the link live from the first write).
43
+ * Seed the default packs (`welcome` + `getting-started`) into `store`,
44
+ * idempotently. Welcome first so the welcome web exists before the guide
45
+ * lands (wikilinks auto-resolve either way; this just keeps the seed's
46
+ * unresolved-link window minimal).
54
47
  */
55
48
  export async function seedOnboardingNotes(store: Store): Promise<SeedResult> {
56
49
  const seeded: string[] = [];
57
50
  const skipped: string[] = [];
51
+ const tags: string[] = [];
58
52
 
59
- for (const [path, content] of [
60
- [SURFACE_STARTER_PATH, SURFACE_STARTER_CONTENT],
61
- [GETTING_STARTED_PATH, GETTING_STARTED_CONTENT],
62
- ] as const) {
63
- const wrote = await seedNoteIfAbsent(store, path, content);
64
- (wrote ? seeded : skipped).push(path);
53
+ // No consoleOrigin: create-time runs are often pre-expose, and a baked-in
54
+ // loopback origin would go stale — the generic line is the safe default.
55
+ for (const pack of [welcomePack(), GETTING_STARTED_PACK]) {
56
+ const result = await applySeedPack(store, pack);
57
+ seeded.push(...result.seededNotes);
58
+ skipped.push(...result.skippedNotes);
59
+ tags.push(...result.tags);
65
60
  }
66
61
 
67
- return { seeded, skipped };
62
+ return { seeded, skipped, tags };
68
63
  }
69
64
 
70
65
  /**
71
- * Best-effort wrapper for the create path: seed the onboarding notes, swallow
66
+ * Best-effort wrapper for the create path: seed the default packs, swallow
72
67
  * and log any error so a seeding failure never fails the vault create.
73
68
  */
74
69
  export async function seedOnboardingNotesBestEffort(store: Store): Promise<void> {
@@ -76,7 +71,7 @@ export async function seedOnboardingNotesBestEffort(store: Store): Promise<void>
76
71
  await seedOnboardingNotes(store);
77
72
  } catch (err) {
78
73
  console.error(
79
- `Note: onboarding guide not seeded (${(err as Error)?.message ?? err}). ` +
74
+ `Note: starter content not seeded (${(err as Error)?.message ?? err}). ` +
80
75
  `The vault was still created; you can connect an AI and ask it to set things up.`,
81
76
  );
82
77
  }
package/src/routes.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  import type { Store, Note, QueryOpts } from "../core/src/types.ts";
15
15
  import { TAG_EXPAND_MODES, stripTagHash, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
16
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
17
+ import { transactionAsync } from "../core/src/txn.ts";
17
18
  import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
18
19
  import {
19
20
  parseContentRange,
@@ -28,6 +29,10 @@ import * as linkOps from "../core/src/links.ts";
28
29
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
29
30
  import { IndexedFieldError } from "../core/src/indexed-fields.ts";
30
31
  import { buildVaultProjection, resolveTagInheritance } from "../core/src/vault-projection.ts";
32
+ import {
33
+ resolveTranscriptionCapability,
34
+ type TranscriptionCapability,
35
+ } from "./transcription/capability.ts";
31
36
  import { loadSchemaConfig } from "../core/src/schema-defaults.ts";
32
37
  import {
33
38
  buildExpandVisibility,
@@ -1143,17 +1148,16 @@ async function handleNotesInner(
1143
1148
  // don't collide with concurrent single-item callers on the shared
1144
1149
  // bun:sqlite connection.
1145
1150
  const batched = items.length > 1;
1146
- if (batched) db.exec("BEGIN");
1147
- try {
1151
+ const runBatch = async (): Promise<void> => {
1148
1152
  for (const item of items) {
1149
1153
  // Validate extension before reaching the Store (vault#328).
1150
- // Thrown inside the BEGIN blockouter catch rolls the batch
1151
- // back, same shape as the path-conflict path.
1154
+ // Thrown inside the batch transaction — rolls the batch back,
1155
+ // same shape as the path-conflict path.
1152
1156
  const extension = item.extension !== undefined
1153
1157
  ? validateExtension(item.extension)
1154
1158
  : undefined;
1155
1159
  // Strict-schema gate (vault#299) — reject before any write so a
1156
- // mid-batch violation rolls back via the outer BEGIN/ROLLBACK.
1160
+ // mid-batch violation rolls back the batch transaction.
1157
1161
  gateStrictWrite(store, writeCtx, {
1158
1162
  path: item.path,
1159
1163
  tags: item.tags,
@@ -1181,9 +1185,10 @@ async function handleNotesInner(
1181
1185
 
1182
1186
  created.push((await store.getNote(note.id)) ?? note);
1183
1187
  }
1184
- if (batched) db.exec("COMMIT");
1188
+ };
1189
+ try {
1190
+ await (batched ? transactionAsync(db, runBatch) : runBatch());
1185
1191
  } catch (e: any) {
1186
- if (batched) db.exec("ROLLBACK");
1187
1192
  // Duck-type for module-boundary robustness (matches the PATCH branch).
1188
1193
  if (e && e.code === "PATH_CONFLICT") {
1189
1194
  return json(
@@ -2327,11 +2332,24 @@ export async function handleVault(
2327
2332
  store: Store,
2328
2333
  vaultConfig: VaultConfigLike,
2329
2334
  persist?: () => void,
2335
+ /**
2336
+ * Injection seam (scribe-fold Phase 1) for the transcription capability
2337
+ * probe on the GET landing. Production omits it and resolves the live
2338
+ * default (scribe-http) provider's availability; tests inject a deterministic
2339
+ * resolver. Kept separate from the `auto_transcribe` POLICY toggle: this
2340
+ * reports whether transcription is actually POSSIBLE (a provider is
2341
+ * configured AND available), which is what a surface gates its mic on.
2342
+ */
2343
+ resolveCapability: () => Promise<TranscriptionCapability> = resolveTranscriptionCapability,
2330
2344
  ): Promise<Response> {
2331
2345
  const url = new URL(req.url);
2332
2346
 
2333
2347
  if (req.method === "GET") {
2334
2348
  const result: Record<string, unknown> = vaultResponse(vaultConfig);
2349
+ // Transcription capability flag — `enabled` iff a provider is configured
2350
+ // and available. `minutes_remaining` is omitted (cloud/plan concern;
2351
+ // self-host is unmetered). This is the field Notes gates the mic on.
2352
+ result.transcription = await resolveCapability();
2335
2353
  if (parseBool(parseQuery(url, "include_stats"), false)) {
2336
2354
  result.stats = await store.getVaultStats();
2337
2355
  }
package/src/server.ts CHANGED
@@ -30,6 +30,20 @@ import { setTranscriptionWorker } from "./transcription-registry.ts";
30
30
  import { assetsDir } from "./routes.ts";
31
31
  import { resolveScribeAuthToken, ensureScribeBearer } from "./scribe-env.ts";
32
32
  import { getCachedScribeUrl } from "./scribe-discovery.ts";
33
+ import { TranscribeCppProvider } from "./transcription/providers/transcribe-cpp.ts";
34
+ import { ParakeetMlxProvider } from "./transcription/providers/parakeet-mlx.ts";
35
+ import { OnnxAsrProvider } from "./transcription/providers/onnx-asr.ts";
36
+ import {
37
+ resolveTranscriptionProviderName,
38
+ resolveTranscribeCppPaths,
39
+ transcribeCppInstalled,
40
+ resolveParakeetMlxBin,
41
+ resolveParakeetMlxModel,
42
+ resolveOnnxAsrBin,
43
+ resolveOnnxAsrModel,
44
+ parakeetMlxInstalled,
45
+ onnxAsrInstalled,
46
+ } from "./transcription/select.ts";
33
47
  import { readEnvFile, setEnvVar } from "./config.ts";
34
48
  import { resolveBindHostname } from "./bind.ts";
35
49
  import { MirrorManager } from "./mirror-manager.ts";
@@ -107,40 +121,102 @@ registerConfiguredTriggers();
107
121
  * Authorization header on a 401 and transcription fails with a friendly
108
122
  * error captured on the transcript note.
109
123
  */
110
- const scribeUrl = getCachedScribeUrl();
111
124
  let transcriptionWorker: TranscriptionWorker | null = null;
112
- if (scribeUrl) {
113
- // Generate + persist the shared bearer on first boot. Subsequent boots
114
- // pick up the existing value and don't rotate. Loading the .env back
115
- // into process.env happens above (`loadEnvFile()`); we re-load here to
116
- // pick up the just-written value without restart.
117
- const { created, token } = ensureScribeBearer(readEnvFile, setEnvVar);
118
- if (created) {
119
- process.env.SCRIBE_AUTH_TOKEN = token;
120
- console.log("[transcribe] generated SCRIBE_AUTH_TOKEN (32 bytes, base64url) — mirror this value into scribe's config");
121
- }
122
- transcriptionWorker = startTranscriptionWorker({
123
- vaultList: () => listVaults(),
124
- getStore: (name) => getVaultStore(name),
125
- scribeUrl,
126
- scribeToken: resolveScribeAuthToken(),
127
- resolveAssetsDir: (vault) => assetsDir(vault),
128
- getAudioRetention: (vault) => readVaultConfig(vault)?.audio_retention ?? "keep",
129
- getContextPredicates: (vault) => readVaultConfig(vault)?.transcription?.context,
130
- });
131
- // Event-driven hot path — the `attachment:created` hook fires the worker
132
- // in a microtask instead of waiting for the 30s sweep.
125
+
126
+ // Shared worker wiring: the event-driven `attachment:created` hot path + the
127
+ // REST-retry registry. Same for whichever provider the worker was built with.
128
+ function wireTranscriptionWorker(worker: TranscriptionWorker): void {
133
129
  registerTranscriptionHook(
134
130
  defaultHookRegistry,
135
- transcriptionWorker,
131
+ worker,
136
132
  (store) => getVaultNameForStore(store as never),
137
133
  );
138
- // Expose the worker to the REST retry endpoint so retries kick immediately
139
- // instead of waiting for the sweep. Idempotent on second boot.
140
- setTranscriptionWorker(transcriptionWorker);
141
- console.log(`[transcribe] worker started ${scribeUrl}`);
134
+ setTranscriptionWorker(worker);
135
+ }
136
+
137
+ // Fields the worker needs regardless of provider (queue/retention/context).
138
+ const commonWorkerOpts = {
139
+ vaultList: () => listVaults(),
140
+ getStore: (name: string) => getVaultStore(name),
141
+ resolveAssetsDir: (vault: string) => assetsDir(vault),
142
+ getAudioRetention: (vault: string) => readVaultConfig(vault)?.audio_retention ?? "keep",
143
+ getContextPredicates: (vault: string) => readVaultConfig(vault)?.transcription?.context,
144
+ };
145
+
146
+ // Provider selection (scribe-fold Phase 2a). Default is `scribe-http` — unset
147
+ // TRANSCRIPTION_PROVIDER means the existing scribe-http path runs unchanged.
148
+ const providerName = resolveTranscriptionProviderName();
149
+ if (providerName === "transcribe-cpp") {
150
+ // Local, no-Python provider: subprocess a transcribe-cli. Only start the
151
+ // worker when a runnable CLI + model are actually present — otherwise every
152
+ // pending item would terminal-fail with `missing_provider`. (v0.1.1 ships a
153
+ // library, not a CLI, so this gate stays closed until TRANSCRIBE_CPP_BIN or a
154
+ // build-from-source CLI exists.) `transcribeCppInstalled` is a cheap
155
+ // existsSync check (no spawn), matching `available()`.
156
+ const tcPaths = resolveTranscribeCppPaths();
157
+ if (transcribeCppInstalled(tcPaths)) {
158
+ transcriptionWorker = startTranscriptionWorker({
159
+ ...commonWorkerOpts,
160
+ provider: new TranscribeCppProvider({ binPath: tcPaths.binPath, modelPath: tcPaths.modelPath }),
161
+ });
162
+ wireTranscriptionWorker(transcriptionWorker);
163
+ console.log(`[transcribe] worker started → transcribe-cpp (${tcPaths.modelPath})`);
164
+ } else {
165
+ console.log(
166
+ "[transcribe] TRANSCRIPTION_PROVIDER=transcribe-cpp but no runnable transcribe-cli + model installed — see `parachute-vault transcription status` (v0.1.1 ships a library, not a CLI; set TRANSCRIBE_CPP_BIN or build one)",
167
+ );
168
+ }
169
+ } else if (providerName === "parakeet-mlx" || providerName === "onnx-asr") {
170
+ // Python-based local providers (scribe-fold Phase 2b): subprocess the
171
+ // parakeet-mlx / onnx-asr CLI. Only start the worker when a runnable binary
172
+ // resolves (env override → managed venv → PATH) — otherwise every pending
173
+ // item would terminal-fail with `missing_provider`. Cheap existsSync check
174
+ // (no spawn), matching the providers' `available()`.
175
+ const installed = providerName === "parakeet-mlx" ? parakeetMlxInstalled() : onnxAsrInstalled();
176
+ if (installed) {
177
+ const provider =
178
+ providerName === "parakeet-mlx"
179
+ ? new ParakeetMlxProvider({
180
+ binPath: resolveParakeetMlxBin(),
181
+ model: resolveParakeetMlxModel(),
182
+ })
183
+ : new OnnxAsrProvider({ binPath: resolveOnnxAsrBin(), model: resolveOnnxAsrModel() });
184
+ transcriptionWorker = startTranscriptionWorker({ ...commonWorkerOpts, provider });
185
+ wireTranscriptionWorker(transcriptionWorker);
186
+ console.log(`[transcribe] worker started → ${providerName}`);
187
+ } else {
188
+ console.log(
189
+ `[transcribe] TRANSCRIPTION_PROVIDER=${providerName} but no runnable ${providerName} binary found — run \`parachute-vault transcription install\` (see \`transcription status\`)`,
190
+ );
191
+ }
142
192
  } else {
143
- console.log("[transcribe] worker disabled (no scribe in services.json and SCRIBE_URL unset)");
193
+ // Default scribe-http path behavior-preserving (Phase 1). Start the worker
194
+ // if scribe is discoverable; otherwise transcription is a no-op.
195
+ //
196
+ // Scribe URL resolution order (per `scribe-discovery.ts`): `SCRIBE_URL` env
197
+ // var, then `~/.parachute/services.json` `parachute-scribe` entry, then none.
198
+ //
199
+ // Bearer generation (vault#353): if neither `SCRIBE_AUTH_TOKEN` nor the
200
+ // legacy `SCRIBE_TOKEN` is set, generate a fresh 32-byte base64url bearer
201
+ // and persist it to vault's `.env` so subsequent restarts use the same
202
+ // value. The operator (or hub install) mirrors this bearer to scribe.
203
+ const scribeUrl = getCachedScribeUrl();
204
+ if (scribeUrl) {
205
+ const { created, token } = ensureScribeBearer(readEnvFile, setEnvVar);
206
+ if (created) {
207
+ process.env.SCRIBE_AUTH_TOKEN = token;
208
+ console.log("[transcribe] generated SCRIBE_AUTH_TOKEN (32 bytes, base64url) — mirror this value into scribe's config");
209
+ }
210
+ transcriptionWorker = startTranscriptionWorker({
211
+ ...commonWorkerOpts,
212
+ scribeUrl,
213
+ scribeToken: resolveScribeAuthToken(),
214
+ });
215
+ wireTranscriptionWorker(transcriptionWorker);
216
+ console.log(`[transcribe] worker started → ${scribeUrl}`);
217
+ } else {
218
+ console.log("[transcribe] worker disabled (no scribe in services.json and SCRIBE_URL unset)");
219
+ }
144
220
  }
145
221
 
146
222
  if (process.env.VAULT_AUTH_TOKEN?.trim()) {
@@ -206,9 +282,10 @@ if (listVaults().length === 0) {
206
282
  }
207
283
  writeGlobalConfig(globalConfig);
208
284
  console.log(`Auto-created vault "${vaultName}" (API key: ${fullKey})`);
209
- // Seed the in-vault onboarding guide so a connected AI can self-orient and
210
- // help set the vault up same as the `create`/`init` CLI path. Idempotent
211
- // + best-effort (never fails first boot). Mirrors createVault() in cli.ts.
285
+ // Seed the default packs (welcome web + capture tag, Getting Started
286
+ // guide) so a connected AI can self-orient and help set the vault up
287
+ // same as the `create`/`init` CLI path. Idempotent + best-effort (never
288
+ // fails first boot). Mirrors createVault() in cli.ts.
212
289
  await seedOnboardingNotesBestEffort(getVaultStore(vaultName));
213
290
  } else if (firstBoot.source === "env-invalid") {
214
291
  // PARACHUTE_VAULT_NAME was set but failed validation — do NOT silently