@openparachute/vault 0.6.5-rc.9 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.parachute/module.json +1 -0
  2. package/core/src/core.test.ts +7 -7
  3. package/core/src/mcp.ts +1 -1
  4. package/core/src/schema.ts +5 -5
  5. package/core/src/seed-packs.test.ts +222 -56
  6. package/core/src/seed-packs.ts +334 -70
  7. package/core/src/tag-hierarchy.ts +1 -1
  8. package/core/src/tag-schemas.ts +2 -2
  9. package/core/src/types.ts +1 -1
  10. package/package.json +1 -1
  11. package/src/admin-spa.ts +2 -1
  12. package/src/auth.ts +1 -1
  13. package/src/cli.ts +317 -53
  14. package/src/export-watch.ts +1 -1
  15. package/src/live-frame-parity.test.ts +201 -0
  16. package/src/module-manifest.ts +8 -0
  17. package/src/onboarding-seed.test.ts +82 -20
  18. package/src/onboarding-seed.ts +2 -2
  19. package/src/routes.ts +3 -3
  20. package/src/routing.test.ts +2 -2
  21. package/src/routing.ts +18 -6
  22. package/src/self-register.test.ts +19 -0
  23. package/src/self-register.ts +6 -1
  24. package/src/server.ts +56 -0
  25. package/src/services-manifest.ts +8 -0
  26. package/src/subscriptions.ts +100 -42
  27. package/src/tag-scope.ts +3 -3
  28. package/src/test-support/live-frame-corpus.ts +72 -0
  29. package/src/token-store.ts +2 -2
  30. package/src/transcription/build.test.ts +86 -5
  31. package/src/transcription/build.ts +87 -7
  32. package/src/transcription/capability.test.ts +25 -0
  33. package/src/transcription/capability.ts +27 -2
  34. package/src/transcription/download.test.ts +101 -0
  35. package/src/transcription/download.ts +71 -0
  36. package/src/transcription/install-python.test.ts +366 -0
  37. package/src/transcription/install-python.ts +471 -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/select.test.ts +166 -1
  43. package/src/transcription/select.ts +234 -1
  44. package/src/transcription/tiers.test.ts +197 -0
  45. package/src/transcription/tiers.ts +184 -0
  46. package/src/vault-create.test.ts +19 -14
  47. package/src/vault.test.ts +1 -1
  48. package/src/ws-server.ts +408 -0
  49. package/src/ws-subscribe.test.ts +474 -0
  50. package/src/ws-subscribe.ts +242 -0
  51. package/src/subscribe.test.ts +0 -609
  52. package/src/subscribe.ts +0 -248
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Live-query frame PARITY + the pure WS helpers (WS-hibernation migration,
3
+ * self-host door).
4
+ *
5
+ * The load-bearing CROSS-DOOR conformance check: for the shared frame corpus
6
+ * (mirrored byte-for-byte from the cloud door), the self-host WS message folds
7
+ * the event name into a `type` discriminator (plus the snapshot `done` chunk
8
+ * flag) over an inner payload (`notes` / `note` / `id`) that serializes
9
+ * byte-IDENTICALLY to the corpus. Because the corpus AND the `wsFrame` formatter
10
+ * are byte-shaped identical to cloud's, this transitively proves: self-host WS
11
+ * bytes === corpus === cloud WS bytes. (SSE was the original transport; it was
12
+ * removed in Phase 5 — WebSocket is now the sole live transport, polling the
13
+ * client floor.)
14
+ *
15
+ * Plus unit coverage of the pure helpers that back the Bun.serve integration:
16
+ * snapshot chunking, first-message parsing, verb-rank, and the query rejects.
17
+ */
18
+ import { describe, it, expect } from "bun:test";
19
+ import { wsFrame, WsSink } from "./subscriptions.ts";
20
+ import {
21
+ buildSnapshotFrames,
22
+ parseClientMessage,
23
+ vaultVerbRank,
24
+ sameTagScope,
25
+ validateWsSubscribeQuery,
26
+ subscriptionCapResponse,
27
+ urlFromQuery,
28
+ SNAPSHOT_CHUNK_MAX_NOTES,
29
+ WS_CLOSE,
30
+ } from "./ws-subscribe.ts";
31
+ import { unsupportedSubscriptionReason } from "./live-match.ts";
32
+ import { FRAME_CORPUS, NOTE_A } from "./test-support/live-frame-corpus.ts";
33
+
34
+ describe("live-frame parity — self-host WS bytes === corpus === cloud WS bytes", () => {
35
+ for (const c of FRAME_CORPUS) {
36
+ it(`${c.name}: WS message folds the event into \`type\` over the corpus payload`, () => {
37
+ const ws = wsFrame(c.event, c.data);
38
+
39
+ // WS message is `{ type, ...data }` — the event name folds into `type`.
40
+ const parsedWs = JSON.parse(ws) as Record<string, unknown>;
41
+ expect(parsedWs.type).toBe(c.event);
42
+ const { type, ...rest } = parsedWs;
43
+ void type;
44
+ expect(rest).toEqual(c.data);
45
+
46
+ // BYTE parity of the inner payload: whatever value the event carries
47
+ // (`notes` / `note` / `id`) serializes IDENTICALLY to the corpus
48
+ // serialization (the cross-door invariant).
49
+ const innerKey = c.event === "snapshot" ? "notes" : c.event === "upsert" ? "note" : "id";
50
+ const innerBytes = JSON.stringify((c.data as Record<string, unknown>)[innerKey]);
51
+ expect(ws.includes(innerBytes)).toBe(true);
52
+ });
53
+ }
54
+ });
55
+
56
+ describe("sink classes render through the ONE formatter", () => {
57
+ it("WsSink.send emits exactly wsFrame(event, data)", () => {
58
+ const sent: string[] = [];
59
+ const sink = new WsSink({ send: (d: string) => sent.push(d), close: () => {} });
60
+ for (const c of FRAME_CORPUS) {
61
+ sink.send(c.event, c.data);
62
+ expect(sent.at(-1)).toBe(wsFrame(c.event, c.data));
63
+ }
64
+ });
65
+ });
66
+
67
+ describe("buildSnapshotFrames — chunking + done flag", () => {
68
+ it("empty set → exactly one done:true frame", () => {
69
+ const frames = buildSnapshotFrames([]);
70
+ expect(frames.length).toBe(1);
71
+ expect(JSON.parse(frames[0]!)).toEqual({ type: "snapshot", notes: [], done: true });
72
+ });
73
+
74
+ it("small set → one done:true frame carrying every note", () => {
75
+ const frames = buildSnapshotFrames([NOTE_A as any, NOTE_A as any]);
76
+ expect(frames.length).toBe(1);
77
+ const f = JSON.parse(frames[0]!);
78
+ expect(f.done).toBe(true);
79
+ expect(f.notes.length).toBe(2);
80
+ });
81
+
82
+ it("> SNAPSHOT_CHUNK_MAX_NOTES → multiple frames, only the last done:true, concat = full set", () => {
83
+ const many = Array.from({ length: SNAPSHOT_CHUNK_MAX_NOTES + 5 }, (_, i) => ({ ...NOTE_A, id: `n-${i}` }));
84
+ const frames = buildSnapshotFrames(many as any);
85
+ expect(frames.length).toBeGreaterThan(1);
86
+ const parsed = frames.map((f) => JSON.parse(f));
87
+ expect(parsed.filter((p) => p.done).length).toBe(1);
88
+ expect(parsed[parsed.length - 1]!.done).toBe(true);
89
+ parsed.slice(0, -1).forEach((p) => expect(p.done).toBe(false));
90
+ const concat = parsed.flatMap((p) => p.notes);
91
+ expect(concat.map((n: any) => n.id)).toEqual(many.map((n) => n.id));
92
+ });
93
+
94
+ it("each frame stays under the WS message ceiling with large notes", () => {
95
+ const big = Array.from({ length: 8 }, (_, i) => ({ ...NOTE_A, id: `big-${i}`, content: "x".repeat(200_000) }));
96
+ const frames = buildSnapshotFrames(big as any);
97
+ expect(frames.length).toBeGreaterThan(1);
98
+ for (const f of frames) expect(new TextEncoder().encode(f).byteLength).toBeLessThan(1_000_000);
99
+ expect(frames.flatMap((f) => JSON.parse(f).notes).length).toBe(8);
100
+ });
101
+ });
102
+
103
+ describe("parseClientMessage", () => {
104
+ it("valid auth", () => {
105
+ expect(parseClientMessage(JSON.stringify({ type: "auth", token: "t" }))).toEqual({ kind: "auth", token: "t" });
106
+ });
107
+ it("auth without a token → malformed", () => {
108
+ expect(parseClientMessage(JSON.stringify({ type: "auth" })).kind).toBe("malformed");
109
+ expect(parseClientMessage(JSON.stringify({ type: "auth", token: "" })).kind).toBe("malformed");
110
+ });
111
+ it("non-json / non-object → malformed", () => {
112
+ expect(parseClientMessage("not json").kind).toBe("malformed");
113
+ expect(parseClientMessage(JSON.stringify([1, 2])).kind).toBe("malformed");
114
+ });
115
+ it("other typed message → other", () => {
116
+ expect(parseClientMessage(JSON.stringify({ type: "hello" }))).toEqual({ kind: "other", type: "hello" });
117
+ });
118
+ it("decodes a Uint8Array frame", () => {
119
+ const bytes = new TextEncoder().encode(JSON.stringify({ type: "auth", token: "b" }));
120
+ expect(parseClientMessage(bytes)).toEqual({ kind: "auth", token: "b" });
121
+ });
122
+ });
123
+
124
+ describe("vaultVerbRank — narrow-or-equal ordering", () => {
125
+ it("ranks read < write < admin, -1 for none/other-vault", () => {
126
+ expect(vaultVerbRank(["vault:v:read"], "v")).toBe(0);
127
+ expect(vaultVerbRank(["vault:v:write"], "v")).toBe(1);
128
+ expect(vaultVerbRank(["vault:v:admin"], "v")).toBe(2);
129
+ expect(vaultVerbRank(["vault:other:read"], "v")).toBe(-1);
130
+ expect(vaultVerbRank([], "v")).toBe(-1);
131
+ });
132
+ it("a widen is strictly greater (the 4403 trigger)", () => {
133
+ expect(vaultVerbRank(["vault:v:admin"], "v") > vaultVerbRank(["vault:v:read"], "v")).toBe(true);
134
+ expect(vaultVerbRank(["vault:v:read"], "v") > vaultVerbRank(["vault:v:admin"], "v")).toBe(false);
135
+ });
136
+ });
137
+
138
+ describe("sameTagScope — the WS re-auth tag-scope-delta gate", () => {
139
+ it("both unscoped (null) → same", () => {
140
+ expect(sameTagScope(null, null)).toBe(true);
141
+ });
142
+ it("null vs scoped → differ (a drop/gain of scoping)", () => {
143
+ expect(sameTagScope(null, ["work/eng"])).toBe(false);
144
+ expect(sameTagScope(["work/eng"], null)).toBe(false);
145
+ });
146
+ it("same set (order/dupe-insensitive) → same", () => {
147
+ expect(sameTagScope(["a", "b"], ["b", "a"])).toBe(true);
148
+ expect(sameTagScope(["a", "a", "b"], ["b", "a"])).toBe(true);
149
+ });
150
+ it("different membership → differ", () => {
151
+ expect(sameTagScope(["work/eng"], ["work/sales"])).toBe(false);
152
+ expect(sameTagScope(["a"], ["a", "b"])).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe("validateWsSubscribeQuery — same rejects as the SSE route (byte-identical bodies)", () => {
157
+ const reject = async (q: string) => {
158
+ const v = validateWsSubscribeQuery(new URL(`http://x/vault/v/api/subscribe?${q}`));
159
+ expect("error" in v).toBe(true);
160
+ const body = await (v as { error: Response }).error.json();
161
+ expect((v as { error: Response }).error.status).toBe(400);
162
+ expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
163
+ };
164
+ it("rejects the URL-reachable unsupported shapes: search / near / cursor / has_links", async () => {
165
+ await reject("search=hi");
166
+ await reject("near[note_id]=abc");
167
+ await reject("cursor=xyz");
168
+ await reject("has_links=true");
169
+ });
170
+ it("accepts a supported query", () => {
171
+ const v = validateWsSubscribeQuery(new URL("http://x/vault/v/api/subscribe?tag=chat&path_prefix=meetings/"));
172
+ expect("queryOpts" in v).toBe(true);
173
+ });
174
+ it("shares the SSE route's queryOpts-level guard (cursor/has_links/date filters)", () => {
175
+ // The belt-and-suspenders layer both doors + the SSE route route through:
176
+ // a date filter isn't expressible as a flat URL param (removed 0.6.4), but
177
+ // if one ever reaches queryOpts it is rejected identically.
178
+ expect(unsupportedSubscriptionReason({ cursor: "x" } as any)).toBeTruthy();
179
+ expect(unsupportedSubscriptionReason({ hasLinks: true } as any)).toBeTruthy();
180
+ expect(unsupportedSubscriptionReason({ dateFrom: "2026-01-01" } as any)).toBeTruthy();
181
+ expect(unsupportedSubscriptionReason({ dateFilter: { field: "created_at" } } as any)).toBeTruthy();
182
+ expect(unsupportedSubscriptionReason({ tags: ["chat"] } as any)).toBeNull();
183
+ });
184
+ });
185
+
186
+ describe("misc pure helpers", () => {
187
+ it("subscriptionCapResponse → 503 SUBSCRIPTION_CAP_REACHED", async () => {
188
+ const res = subscriptionCapResponse();
189
+ expect(res.status).toBe(503);
190
+ expect((await res.json()).code).toBe("SUBSCRIPTION_CAP_REACHED");
191
+ });
192
+ it("urlFromQuery reconstructs searchParams from a raw query string", () => {
193
+ expect(urlFromQuery("?tag=chat&a=b").searchParams.get("tag")).toBe("chat");
194
+ expect(urlFromQuery("").search).toBe("");
195
+ });
196
+ it("WS_CLOSE carries the four application close codes", () => {
197
+ expect([WS_CLOSE.PROTOCOL, WS_CLOSE.UNAUTHORIZED, WS_CLOSE.FORBIDDEN, WS_CLOSE.AUTH_TIMEOUT]).toEqual([
198
+ 4400, 4401, 4403, 4408,
199
+ ]);
200
+ });
201
+ });
@@ -48,6 +48,14 @@ export interface VaultModuleManifest {
48
48
  readonly paths: readonly string[];
49
49
  readonly health: string;
50
50
  readonly stripPrefix?: boolean;
51
+ /**
52
+ * When `true`, vault's daemon accepts WebSocket upgrades (the live-query WS
53
+ * binding) and the hub's ws-bridge forwards `Upgrade: websocket` requests on
54
+ * vault's mounts. DENY BY DEFAULT on the hub side. Carried onto the
55
+ * self-registered services.json row too (`ServiceEntry.websocket`); the hub
56
+ * honors either source. See `parachute-hub/src/ws-bridge.ts`.
57
+ */
58
+ readonly websocket?: boolean;
51
59
  }
52
60
 
53
61
  /**
@@ -23,24 +23,29 @@ import { BunStore } from "./vault-store.ts";
23
23
  import { seedOnboardingNotes } from "./onboarding-seed.ts";
24
24
  import {
25
25
  applySeedPack,
26
+ CAPTURE_ANYTHING_PATH,
26
27
  CONNECT_AI_PATH,
27
28
  GETTING_STARTED_PATH,
28
29
  NOTES_REQUIRED_TAGS,
29
30
  SURFACE_STARTER_PACK,
30
31
  SURFACE_STARTER_PATH,
31
- TRY_LINKING_PATH,
32
+ TAGS_GRAPH_PATH,
32
33
  WELCOME_PATH,
34
+ YOURS_TO_KEEP_PATH,
33
35
  } from "../core/src/seed-packs.ts";
34
36
  import {
35
37
  buildVaultProjection,
36
38
  projectionToMarkdown,
37
39
  } from "../core/src/vault-projection.ts";
38
40
 
39
- /** Every note path the default seed writes on a blank vault. */
41
+ /** Every note path the default seed writes on a blank vault (the five-guide
42
+ * welcome ring + the AI-facing Getting Started). */
40
43
  const DEFAULT_SEED_PATHS = [
41
44
  WELCOME_PATH,
42
- TRY_LINKING_PATH,
45
+ CAPTURE_ANYTHING_PATH,
46
+ TAGS_GRAPH_PATH,
43
47
  CONNECT_AI_PATH,
48
+ YOURS_TO_KEEP_PATH,
44
49
  GETTING_STARTED_PATH,
45
50
  ];
46
51
 
@@ -77,18 +82,30 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
77
82
  expect(gs!.content).toContain("Tags = types");
78
83
  expect(gs!.content).toContain("Paths = organization");
79
84
  expect(gs!.content).toContain("Schemas = typed metadata fields");
80
- expect(gs!.content).toContain("parachute-vault import");
85
+ // Data-in leads with the import UI + MCP bridge; the old `parachute-vault
86
+ // import` CLI line (a live bug on cloud) is gone (2026-07-06 rewrite).
87
+ expect(gs!.content).not.toContain("parachute-vault import");
88
+ expect(gs!.content).toContain("Bringing existing notes in");
81
89
  expect(gs!.content).toContain("Adapt this note");
82
90
 
83
- // The welcome web: three person-voiced notes.
84
- for (const path of [WELCOME_PATH, TRY_LINKING_PATH, CONNECT_AI_PATH]) {
91
+ // The welcome ring: five person-voiced guide notes.
92
+ for (const path of [
93
+ WELCOME_PATH,
94
+ CAPTURE_ANYTHING_PATH,
95
+ TAGS_GRAPH_PATH,
96
+ CONNECT_AI_PATH,
97
+ YOURS_TO_KEEP_PATH,
98
+ ]) {
85
99
  expect(await store.getNoteByPath(path)).not.toBeNull();
86
100
  }
87
101
  });
88
102
 
89
- test("seeds the ONE capture tag Notes requires (byte-equal semantics)", async () => {
103
+ test("seeds the capture tag Notes requires (byte-equal semantics) + the guide tag", async () => {
90
104
  const result = await seedOnboardingNotes(store);
91
- expect(result.tags).toEqual(["capture"]);
105
+ // welcome upserts [capture, guide]; getting-started re-upserts [guide]
106
+ // (converges). applySeedPack reports every declared tag per pack, so the
107
+ // concatenation carries guide twice — deterministic.
108
+ expect(result.tags).toEqual(["capture", "guide", "guide"]);
92
109
 
93
110
  for (const decl of NOTES_REQUIRED_TAGS) {
94
111
  const record = await store.getTagRecord(decl.name);
@@ -97,6 +114,15 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
97
114
  expect(record!.parent_names ?? []).toEqual(decl.parent_names ?? []);
98
115
  }
99
116
 
117
+ // The guide tag lands with no metadata schema — guides are for you and your
118
+ // AI alike, plain markdown, so no per-guide audience field (written_for dropped).
119
+ const guide = await store.getTagRecord("guide");
120
+ expect(guide).not.toBeNull();
121
+ expect(guide!.fields?.written_for).toBeUndefined();
122
+ // #pinned is NOT seeded (dropped 2026-07-07 — did nothing visible on a fresh
123
+ // vault). Pinning still works when the user pins a note in the app.
124
+ expect(await store.getTagRecord("pinned")).toBeNull();
125
+
100
126
  // The retired subtype tags are NOT seeded — entry method is note
101
127
  // metadata.source (text|voice), not taxonomy (2026-07-03). Existing
102
128
  // vaults carrying these tags stay valid; new vaults don't get them.
@@ -104,6 +130,29 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
104
130
  expect(await store.getTagRecord("capture/voice")).toBeNull();
105
131
  });
106
132
 
133
+ test("seeded guides carry #guide (create-time tag write-through)", async () => {
134
+ await seedOnboardingNotes(store);
135
+
136
+ // Welcome guide: tagged guide (no longer pinned).
137
+ const welcome = await store.getNoteByPath(WELCOME_PATH);
138
+ expect([...(welcome!.tags ?? [])].sort()).toEqual(["guide"]);
139
+
140
+ // The four sibling guides: guide only.
141
+ for (const path of [
142
+ CAPTURE_ANYTHING_PATH,
143
+ TAGS_GRAPH_PATH,
144
+ CONNECT_AI_PATH,
145
+ YOURS_TO_KEEP_PATH,
146
+ ]) {
147
+ const note = await store.getNoteByPath(path);
148
+ expect(note!.tags).toEqual(["guide"]);
149
+ }
150
+
151
+ // The AI-facing Getting Started guide: guide.
152
+ const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
153
+ expect(gs!.tags).toEqual(["guide"]);
154
+ });
155
+
107
156
  test("GAP 4: Getting Started shows a concrete update-tag fields example + write gotchas", async () => {
108
157
  await seedOnboardingNotes(store);
109
158
  const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
@@ -121,26 +170,38 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
121
170
  expect(gs!.content).toContain("first listed value");
122
171
  });
123
172
 
124
- test("A3 (reshaped): the welcome web's wikilinks resolve; Getting Started has no dangling Surface Starter link", async () => {
173
+ test("A3 (reshaped): the welcome ring resolves to real link edges; Getting Started has no dangling Surface Starter link", async () => {
125
174
  await seedOnboardingNotes(store);
126
175
 
127
- // welcome → try-linking, try-linking → welcome, connect-AI → welcome.
128
176
  const welcome = await store.getNoteByPath(WELCOME_PATH);
129
- const tryLinking = await store.getNoteByPath(TRY_LINKING_PATH);
177
+ const capture = await store.getNoteByPath(CAPTURE_ANYTHING_PATH);
178
+ const tags = await store.getNoteByPath(TAGS_GRAPH_PATH);
130
179
  const connectAi = await store.getNoteByPath(CONNECT_AI_PATH);
180
+ const yours = await store.getNoteByPath(YOURS_TO_KEEP_PATH);
181
+ const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
131
182
 
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);
183
+ const outbound = async (id: string) =>
184
+ (await store.getLinks(id, { direction: "outbound" })).map((l) => l.targetId);
185
+
186
+ // Welcome all four siblings.
187
+ const welcomeOut = await outbound(welcome!.id);
188
+ for (const t of [capture!.id, tags!.id, connectAi!.id, yours!.id]) {
189
+ expect(welcomeOut).toContain(t);
190
+ }
191
+ // The chain forward: capture → tags → connect → yours → welcome.
192
+ expect(await outbound(capture!.id)).toContain(tags!.id);
193
+ expect(await outbound(tags!.id)).toContain(connectAi!.id);
194
+ const connectOut = await outbound(connectAi!.id);
195
+ expect(connectOut).toContain(yours!.id);
196
+ // Connect your AI also links the AI-facing Getting Started note (seeded by
197
+ // the getting-started pack) — the ring's cross-link resolves, no dangle.
198
+ expect(connectOut).toContain(gs!.id);
199
+ expect(await outbound(yours!.id)).toContain(welcome!.id);
138
200
 
139
201
  // Getting Started mentions the surface-starter PACK — not a wikilink to a
140
202
  // note that the default seed no longer creates.
141
- const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
142
203
  expect(gs!.content).not.toContain("[[Surface Starter]]");
143
- expect(gs!.content).toContain("add-pack surface-starter");
204
+ expect(gs!.content).toContain("add-pack");
144
205
  });
145
206
 
146
207
  test("idempotent: a second seed run skips all notes (does not duplicate)", async () => {
@@ -179,7 +240,8 @@ describe("applySeedPack — surface-starter via add-pack", () => {
179
240
  expect(result.pack).toBe("surface-starter");
180
241
  expect(result.seededNotes).toEqual([SURFACE_STARTER_PATH]);
181
242
  expect(result.skippedNotes).toEqual([]);
182
- expect(result.tags).toEqual([]);
243
+ // surface-starter now declares GUIDE_TAG (it's a guide).
244
+ expect(result.tags).toEqual(["guide"]);
183
245
 
184
246
  const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
185
247
  expect(ss).not.toBeNull();
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Seed the default packs on vault creation.
3
3
  *
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
4
+ * A freshly-created vault gets the `welcome` pack (the five-guide welcome ring
5
+ * + the `capture` / `guide` / `pinned` tags the Notes surface expects) and
6
6
  * the `getting-started` pack (the AI-legible start-here doctrine) — so the
7
7
  * first minute shows a small living graph, and a connected AI can read the
8
8
  * guide and help the operator set the vault up.
package/src/routes.ts CHANGED
@@ -2089,7 +2089,7 @@ export async function handleTags(
2089
2089
 
2090
2090
  // PUT /tags/:name — upsert tag identity row. Body accepts any combination
2091
2091
  // of { description, fields, relationships, parent_names }; omitted keys
2092
- // are preserved, explicit null clears. See patterns/tag-data-model.md.
2092
+ // are preserved, explicit null clears. See docs/contracts/tag-data-model.md.
2093
2093
  if (req.method === "PUT") {
2094
2094
  // Canonical-bare-tag guard (vault#XXX): normalize the upserted tag NAME so
2095
2095
  // the existing-field merge read (store.getTagSchema below) and the upsert
@@ -2114,7 +2114,7 @@ export async function handleTags(
2114
2114
  * the MCP `update-tag` tool relies on (omitted keys preserved). The
2115
2115
  * Schema editor (vault#283) sends the full map + `replace_fields: true`
2116
2116
  * so removing a field row actually deletes the field. See
2117
- * patterns/tag-data-model.md.
2117
+ * docs/contracts/tag-data-model.md.
2118
2118
  */
2119
2119
  replace_fields?: unknown;
2120
2120
  };
@@ -2211,7 +2211,7 @@ export async function handleTags(
2211
2211
  // Tag-scoped tokens reference root tags by name; deleting a referenced
2212
2212
  // tag would silently orphan the token's allowlist. Fail closed (409)
2213
2213
  // and name the offending tokens so the operator can revoke or re-mint
2214
- // before retrying. patterns/tag-scoped-tokens.md §Dependencies.
2214
+ // before retrying. docs/contracts/tag-scoped-tokens.md §Dependencies.
2215
2215
  const referenced_by = findTokensReferencingTag(store.db, tagName);
2216
2216
  if (referenced_by.length > 0) {
2217
2217
  return json(
@@ -1631,7 +1631,7 @@ describe("scope enforcement on /api/*", () => {
1631
1631
  expect(res.status).toBe(401);
1632
1632
  });
1633
1633
 
1634
- // ----- tag-scoped tokens (patterns/tag-scoped-tokens.md) -----------------
1634
+ // ----- tag-scoped tokens (docs/contracts/tag-scoped-tokens.md) -----------------
1635
1635
 
1636
1636
  /**
1637
1637
  * Mint a tag-scoped token. Mirrors `mintToken` above but threads
@@ -1745,7 +1745,7 @@ describe("scope enforcement on /api/*", () => {
1745
1745
  });
1746
1746
 
1747
1747
  // ----- Q6: orphan-sub-tag fail-open ------------------------------------
1748
- // patterns/tag-scoped-tokens.md §Storage: when a sub-tag has no declared
1748
+ // docs/contracts/tag-scoped-tokens.md §Storage: when a sub-tag has no declared
1749
1749
  // schema, the string-form root authorizes. Token allowlisted for `health`
1750
1750
  // must see `#health/food` even when no `_tags/health/food` schema exists.
1751
1751
 
package/src/routing.ts CHANGED
@@ -73,7 +73,6 @@ import {
73
73
  type TagScopeCtx,
74
74
  type WriteCtx,
75
75
  } from "./routes.ts";
76
- import { handleSubscribe } from "./subscribe.ts";
77
76
  import { handleTriggers } from "./triggers-api.ts";
78
77
  import { expandTokenTagScope } from "./tag-scope.ts";
79
78
  import {
@@ -856,7 +855,7 @@ export async function route(
856
855
 
857
856
  const apiPath = apiMatch[1] ?? "";
858
857
 
859
- // Tag-scoped tokens (patterns/tag-scoped-tokens.md): expand the token's
858
+ // Tag-scoped tokens (docs/contracts/tag-scoped-tokens.md): expand the token's
860
859
  // root-tag allowlist into `{root} ∪ descendants(root)` once per request,
861
860
  // so handlers can intersect against the note's tag set without re-walking
862
861
  // the `_tags/<name>` hierarchy on every check. `tagScope.allowed` is null
@@ -883,10 +882,23 @@ export async function route(
883
882
  };
884
883
 
885
884
  if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope, writeCtx);
886
- // Live-query SSE subscription (design 2026-06-08). Snapshot + scoped live
887
- // upsert/remove events over text/event-stream. Auth + tag-scope already
888
- // resolved above and threaded through, mirroring the /notes branch.
889
- if (apiPath === "/subscribe") return handleSubscribe(req, store, vaultName, tagScope);
885
+ // Live-query SSE transport REMOVED (WS-hibernation migration Phase 5). The
886
+ // WebSocket binding (an `Upgrade: websocket` GET /subscribe, handled in
887
+ // server.ts before this pipeline) is now the SOLE live transport; polling
888
+ // GET /notes is the client floor beneath it. A non-WS GET /subscribe — a
889
+ // straggler on a cached pre-WS notes-ui bundle — gets a clean 410 Gone
890
+ // pointing at the WS binding so its consumer degrades to polling gracefully;
891
+ // NEVER a 500 or an unhandled path.
892
+ if (apiPath === "/subscribe") {
893
+ return Response.json(
894
+ {
895
+ error:
896
+ "The live-query SSE transport has been removed. Reconnect with an `Upgrade: websocket` request to GET /api/subscribe, or poll GET /notes.",
897
+ code: "SSE_TRANSPORT_REMOVED",
898
+ },
899
+ { status: 410 },
900
+ );
901
+ }
890
902
  if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope);
891
903
  if (apiPath === "/find-path") return handleFindPath(req, store, tagScope);
892
904
  if (apiPath === "/vault") {
@@ -382,6 +382,25 @@ describe("self-register", () => {
382
382
  });
383
383
  });
384
384
 
385
+ test("websocket capability flows from manifest to row when set (hub ws-bridge gate)", () => {
386
+ withParachuteHome((home) => {
387
+ const { log, warn } = captureLogs();
388
+ selfRegister({
389
+ version: "0.4.8-rc.3",
390
+ log,
391
+ warn,
392
+ readManifest: () => ({ ...TEST_MANIFEST, websocket: true }),
393
+ resolvePackageRoot: () => "/fake/install/dir",
394
+ listVaults: () => [],
395
+ readGlobalConfig: () => ({ port: 1940 }),
396
+ });
397
+
398
+ const raw = readFileSync(join(home, "services.json"), "utf8");
399
+ const parsed = JSON.parse(raw) as { services: Record<string, unknown>[] };
400
+ expect(parsed.services[0]!.websocket).toBe(true);
401
+ });
402
+ });
403
+
385
404
  test("changed=true when installDir differs from prior row", () => {
386
405
  withParachuteHome(() => {
387
406
  const { log, warn } = captureLogs();
@@ -191,6 +191,7 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
191
191
  displayName?: string;
192
192
  tagline?: string;
193
193
  stripPrefix?: boolean;
194
+ websocket?: boolean;
194
195
  } = {
195
196
  name: manifest.manifestName,
196
197
  port,
@@ -202,6 +203,9 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
202
203
  if (manifest.displayName !== undefined) entry.displayName = manifest.displayName;
203
204
  if (manifest.tagline !== undefined) entry.tagline = manifest.tagline;
204
205
  if (manifest.stripPrefix !== undefined) entry.stripPrefix = manifest.stripPrefix;
206
+ // Carry the live-query WS capability onto the row so the hub ws-bridge admits
207
+ // upgrades without having to read module.json (the row is its first source).
208
+ if (manifest.websocket !== undefined) entry.websocket = manifest.websocket;
205
209
 
206
210
  // Detect whether the existing row already matches (no-op idempotency
207
211
  // signal). We don't gate the write on this — `upsertService` itself is
@@ -232,7 +236,8 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
232
236
  priorRow.health !== health ||
233
237
  (priorRow as { displayName?: string }).displayName !== manifest.displayName ||
234
238
  (priorRow as { tagline?: string }).tagline !== manifest.tagline ||
235
- (priorRow as { stripPrefix?: boolean }).stripPrefix !== manifest.stripPrefix;
239
+ (priorRow as { stripPrefix?: boolean }).stripPrefix !== manifest.stripPrefix ||
240
+ (priorRow as { websocket?: boolean }).websocket !== manifest.websocket;
236
241
 
237
242
  try {
238
243
  upsertImpl(entry);
package/src/server.ts CHANGED
@@ -31,10 +31,18 @@ import { assetsDir } from "./routes.ts";
31
31
  import { resolveScribeAuthToken, ensureScribeBearer } from "./scribe-env.ts";
32
32
  import { getCachedScribeUrl } from "./scribe-discovery.ts";
33
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";
34
36
  import {
35
37
  resolveTranscriptionProviderName,
36
38
  resolveTranscribeCppPaths,
37
39
  transcribeCppInstalled,
40
+ resolveParakeetMlxBin,
41
+ resolveParakeetMlxModel,
42
+ resolveOnnxAsrBin,
43
+ resolveOnnxAsrModel,
44
+ parakeetMlxInstalled,
45
+ onnxAsrInstalled,
38
46
  } from "./transcription/select.ts";
39
47
  import { readEnvFile, setEnvVar } from "./config.ts";
40
48
  import { resolveBindHostname } from "./bind.ts";
@@ -53,6 +61,7 @@ import {
53
61
  } from "./mirror-config.ts";
54
62
  import { GLOBAL_CONFIG_PATH } from "./config.ts";
55
63
  import { selfRegister } from "./self-register.ts";
64
+ import { createSubscribeWsBinding, isWebSocketUpgrade } from "./ws-server.ts";
56
65
  import { warnLegacyGlobalApiKeys } from "./auth.ts";
57
66
  import pkg from "../package.json" with { type: "json" };
58
67
 
@@ -158,6 +167,29 @@ if (providerName === "transcribe-cpp") {
158
167
  "[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)",
159
168
  );
160
169
  }
170
+ } else if (providerName === "parakeet-mlx" || providerName === "onnx-asr") {
171
+ // Python-based local providers (scribe-fold Phase 2b): subprocess the
172
+ // parakeet-mlx / onnx-asr CLI. Only start the worker when a runnable binary
173
+ // resolves (env override → managed venv → PATH) — otherwise every pending
174
+ // item would terminal-fail with `missing_provider`. Cheap existsSync check
175
+ // (no spawn), matching the providers' `available()`.
176
+ const installed = providerName === "parakeet-mlx" ? parakeetMlxInstalled() : onnxAsrInstalled();
177
+ if (installed) {
178
+ const provider =
179
+ providerName === "parakeet-mlx"
180
+ ? new ParakeetMlxProvider({
181
+ binPath: resolveParakeetMlxBin(),
182
+ model: resolveParakeetMlxModel(),
183
+ })
184
+ : new OnnxAsrProvider({ binPath: resolveOnnxAsrBin(), model: resolveOnnxAsrModel() });
185
+ transcriptionWorker = startTranscriptionWorker({ ...commonWorkerOpts, provider });
186
+ wireTranscriptionWorker(transcriptionWorker);
187
+ console.log(`[transcribe] worker started → ${providerName}`);
188
+ } else {
189
+ console.log(
190
+ `[transcribe] TRANSCRIPTION_PROVIDER=${providerName} but no runnable ${providerName} binary found — run \`parachute-vault transcription install\` (see \`transcription status\`)`,
191
+ );
192
+ }
161
193
  } else {
162
194
  // Default scribe-http path — behavior-preserving (Phase 1). Start the worker
163
195
  // if scribe is discoverable; otherwise transcription is a no-op.
@@ -459,10 +491,18 @@ const hostname = resolveBindHostname();
459
491
  }
460
492
  }
461
493
 
494
+ // Live-query WebSocket binding (WS-hibernation migration Phase 3). Same
495
+ // subscription contract as the SSE route, WebSocket transport. Handlers +
496
+ // upgrade decision live in ws-server.ts; the per-vault WS cap is closure state
497
+ // there. Advertised via `"websocket": true` in `.parachute/module.json` so the
498
+ // hub ws-bridge admits the upgrade.
499
+ const subscribeWs = createSubscribeWsBinding();
500
+
462
501
  const server = Bun.serve({
463
502
  port,
464
503
  hostname,
465
504
  idleTimeout: 120, // seconds — webhook triggers can take a while
505
+ websocket: subscribeWs.handlers,
466
506
  async fetch(req, server) {
467
507
  const url = new URL(req.url);
468
508
  const path = url.pathname;
@@ -486,6 +526,22 @@ const server = Bun.serve({
486
526
  return new Response(null, { status: 204, headers: corsHeaders });
487
527
  }
488
528
 
529
+ // Live-query WebSocket upgrade — detected BEFORE the fetch pipeline because
530
+ // WS upgrades don't traverse `route()`. Non-upgrade requests (incl. a
531
+ // straggler non-WS GET /subscribe, which now gets a 410 Gone in routing.ts —
532
+ // the SSE transport was removed in Phase 5) fall straight through unchanged.
533
+ if (isWebSocketUpgrade(req)) {
534
+ const verdict = subscribeWs.tryUpgrade(req, server, path);
535
+ if (verdict.kind === "upgraded") return undefined; // Bun owns the socket now
536
+ if (verdict.kind === "response") {
537
+ for (const [k, v] of Object.entries(corsHeaders)) {
538
+ verdict.response.headers.set(k, v);
539
+ }
540
+ return verdict.response;
541
+ }
542
+ // "pass" → not a subscribe WS upgrade; fall through to the pipeline.
543
+ }
544
+
489
545
  try {
490
546
  const start = Date.now();
491
547
  const response = await route(req, path);