@openparachute/vault 0.6.5-rc.13 → 0.6.5-rc.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/seed-packs.test.ts +209 -55
- package/core/src/seed-packs.ts +208 -35
- package/package.json +1 -1
- package/src/live-frame-parity.test.ts +16 -41
- package/src/onboarding-seed.test.ts +79 -18
- package/src/onboarding-seed.ts +2 -2
- package/src/routing.ts +17 -5
- package/src/server.ts +3 -2
- package/src/subscriptions.ts +20 -50
- package/src/vault-create.test.ts +16 -11
- package/src/ws-server.ts +1 -1
- package/src/subscribe.test.ts +0 -609
- package/src/subscribe.ts +0 -249
package/core/src/seed-packs.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* A pack is a named bundle of `tags` (upserted) + `notes` (created only when
|
|
6
6
|
* no note already lives at the path). Three packs ship today:
|
|
7
7
|
*
|
|
8
|
-
* - `welcome` — the
|
|
9
|
-
*
|
|
8
|
+
* - `welcome` — the five-guide welcome ring (Welcome, Capture anything, Tags
|
|
9
|
+
* and the graph, Connect your AI, Yours to keep) + the `capture` / `guide`
|
|
10
|
+
* / `pinned` tags the Notes surface expects. Default-seeded on creation.
|
|
10
11
|
* - `getting-started` — the AI-facing start-here guide (SKILL.md-style
|
|
11
12
|
* doctrine addressed to a connected assistant). Default-seeded.
|
|
12
13
|
* - `surface-starter` — the living starter guide for building a custom
|
|
@@ -14,6 +15,11 @@
|
|
|
14
15
|
* Surface Starter is out of the default seed) — added on demand via
|
|
15
16
|
* `parachute-vault add-pack surface-starter` or a console affordance.
|
|
16
17
|
*
|
|
18
|
+
* Guides are the vault's skill files — the Parachute equivalent of a
|
|
19
|
+
* `SKILL.md`. They're tagged `#guide` (GUIDE_TAG), and a `written_for` schema
|
|
20
|
+
* field on that tag records who each guide is written for (`ai` | `human` |
|
|
21
|
+
* `both`; `ai` first = the default, since guides lean AI).
|
|
22
|
+
*
|
|
17
23
|
* This module is the single source of truth for pack content across BOTH
|
|
18
24
|
* runtimes: the bun vault (`src/onboarding-seed.ts` default seed + the
|
|
19
25
|
* `add-pack` CLI verb) and the cloud vault DO (parachute-cloud
|
|
@@ -26,23 +32,37 @@
|
|
|
26
32
|
* Getting Started / Surface Starter path + content constants.
|
|
27
33
|
*/
|
|
28
34
|
|
|
29
|
-
import type { Store } from "./types.ts";
|
|
35
|
+
import type { Store, TagFieldSchema } from "./types.ts";
|
|
30
36
|
|
|
31
37
|
// ---------------------------------------------------------------------------
|
|
32
38
|
// Pack shape
|
|
33
39
|
// ---------------------------------------------------------------------------
|
|
34
40
|
|
|
35
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* A tag declaration a pack upserts (identity row, not a schema migration).
|
|
43
|
+
*
|
|
44
|
+
* `fields` carries an optional typed-metadata schema — the same
|
|
45
|
+
* `Record<field, { type, enum?, indexed? }>` shape `update-tag` accepts — so a
|
|
46
|
+
* pack can declare a schema-carrying tag (e.g. GUIDE_TAG's `written_for`
|
|
47
|
+
* enum). The applier passes it straight through to `upsertTagRecord`.
|
|
48
|
+
*/
|
|
36
49
|
export interface SeedPackTag {
|
|
37
50
|
name: string;
|
|
38
51
|
description: string;
|
|
39
52
|
parent_names?: string[];
|
|
53
|
+
fields?: Record<string, TagFieldSchema>;
|
|
40
54
|
}
|
|
41
55
|
|
|
42
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* A note a pack seeds — created only when no note exists at `path`. `tags` +
|
|
58
|
+
* `metadata` are applied at create time (both runtimes' `createNote` accepts
|
|
59
|
+
* them); a guide note carries `tags: ["guide"]` + `metadata: { written_for }`.
|
|
60
|
+
*/
|
|
43
61
|
export interface SeedPackNote {
|
|
44
62
|
path: string;
|
|
45
63
|
content: string;
|
|
64
|
+
tags?: string[];
|
|
65
|
+
metadata?: Record<string, unknown>;
|
|
46
66
|
}
|
|
47
67
|
|
|
48
68
|
/** A named bundle of starter tags + notes. */
|
|
@@ -83,58 +103,188 @@ export const NOTES_REQUIRED_TAGS: ReadonlyArray<SeedPackTag> = [
|
|
|
83
103
|
},
|
|
84
104
|
];
|
|
85
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The `guide` tag — the vault's skill-file tag. Guides are the Parachute
|
|
108
|
+
* equivalent of a `SKILL.md`: notes that teach a connected AI (and the human)
|
|
109
|
+
* how this vault works and how to work it. The `written_for` schema field says
|
|
110
|
+
* who a guide is written for; `ai` is the enum's FIRST value, so it's the
|
|
111
|
+
* schema default — guides lean AI.
|
|
112
|
+
*
|
|
113
|
+
* Declared by every pack that ships guide notes (welcome / getting-started /
|
|
114
|
+
* surface-starter). The upserts converge on one row, so each pack stays
|
|
115
|
+
* self-sufficient — applying any one of them alone still lands the tag.
|
|
116
|
+
*/
|
|
117
|
+
export const GUIDE_TAG: SeedPackTag = {
|
|
118
|
+
name: "guide",
|
|
119
|
+
description:
|
|
120
|
+
"Guides — the vault's skill files. Notes that teach a connected AI (and the human) how this vault works and how to work it. `written_for` says who a guide is written for.",
|
|
121
|
+
fields: {
|
|
122
|
+
// First enum value is the schema default — guides lean AI.
|
|
123
|
+
written_for: { type: "string", enum: ["ai", "human", "both"] },
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** The `pinned` tag — notes pinned to the top of the Notes app. No schema. */
|
|
128
|
+
export const PINNED_TAG: SeedPackTag = {
|
|
129
|
+
name: "pinned",
|
|
130
|
+
description: "Notes pinned to the top of the Notes app.",
|
|
131
|
+
};
|
|
132
|
+
|
|
86
133
|
export const WELCOME_PATH = "Welcome to your vault 🪂";
|
|
87
|
-
export const
|
|
134
|
+
export const CAPTURE_ANYTHING_PATH = "Capture anything";
|
|
135
|
+
export const TAGS_GRAPH_PATH = "Tags and the graph";
|
|
88
136
|
export const CONNECT_AI_PATH = "Connect your AI";
|
|
137
|
+
export const YOURS_TO_KEEP_PATH = "Yours to keep";
|
|
89
138
|
|
|
90
139
|
/**
|
|
91
|
-
* Build the `welcome` pack:
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
140
|
+
* Build the `welcome` pack: the five-guide welcome ring — Welcome, Capture
|
|
141
|
+
* anything, Tags and the graph, Connect your AI, Yours to keep — plus the
|
|
142
|
+
* `capture` / `guide` / `pinned` tags. The guides are ordinary notes tagged
|
|
143
|
+
* `#guide` (the vault's skill-file tag), each `metadata.written_for: "human"`;
|
|
144
|
+
* Welcome is `#pinned` so it sits at the top of the Notes app. They form a
|
|
145
|
+
* small linked web (Welcome → all four; the rest chain 2→3→4→5→1; Connect
|
|
146
|
+
* your AI also links the AI-facing [[Getting Started]]) so the graph view
|
|
147
|
+
* shows a connected structure from minute one. Deletable like anything else.
|
|
95
148
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* origin) the line stays generic.
|
|
149
|
+
* These five bodies are the ratified guides (already live at
|
|
150
|
+
* parachute.computer/guides). `consoleOrigin` is the origin of the operator's
|
|
151
|
+
* console (the cloud console, or a hub portal) — when known, the Connect-your-AI
|
|
152
|
+
* note names it; when omitted (the bun vault seeds at create time, often
|
|
153
|
+
* pre-expose, and must never bake in a loopback origin) the line stays generic.
|
|
102
154
|
*/
|
|
103
155
|
export function welcomePack(opts: { consoleOrigin?: string } = {}): SeedPack {
|
|
104
156
|
const consoleLine = opts.consoleOrigin
|
|
105
157
|
? `Grab the connection URL from your console at ${opts.consoleOrigin}.`
|
|
106
158
|
: `Grab the connection URL from your console.`;
|
|
159
|
+
const guideHuman = { tags: ["guide"], metadata: { written_for: "human" } };
|
|
107
160
|
return {
|
|
108
161
|
name: "welcome",
|
|
109
162
|
description:
|
|
110
|
-
"
|
|
111
|
-
|
|
163
|
+
"The five-guide welcome ring (Welcome, Capture anything, Tags and the graph, Connect your AI, Yours to keep) — the vault's #guide skill files — plus the capture/guide/pinned tags. Seeded by default on new vaults.",
|
|
164
|
+
// capture (Notes surface) + guide (the skill-file tag, carries the
|
|
165
|
+
// written_for schema) + pinned (top-of-app). Upserts are idempotent.
|
|
166
|
+
tags: [...NOTES_REQUIRED_TAGS, GUIDE_TAG, PINNED_TAG],
|
|
112
167
|
// `[[wikilinks]]` resolve by note path — pending links auto-resolve when
|
|
113
168
|
// the target is created, so order only affects how briefly a link sits
|
|
114
|
-
// unresolved during the seed.
|
|
169
|
+
// unresolved during the seed. Every [[target]] here resolves to a note the
|
|
170
|
+
// default seed writes (the four siblings + [[Getting Started]] from the
|
|
171
|
+
// getting-started pack) — no dangling links on a fresh vault.
|
|
115
172
|
notes: [
|
|
116
173
|
{
|
|
117
174
|
path: WELCOME_PATH,
|
|
175
|
+
tags: ["guide", "pinned"],
|
|
176
|
+
metadata: { written_for: "human" },
|
|
118
177
|
content: `# ${WELCOME_PATH}
|
|
119
178
|
|
|
120
179
|
This vault is yours.
|
|
121
|
-
|
|
122
|
-
Notes can link to each
|
|
180
|
+
|
|
181
|
+
Write anything — ideas, people, plans, wifi passwords. Notes can link to each
|
|
182
|
+
other, like this: [[${CAPTURE_ANYTHING_PATH}]]. Follow that link and keep going — these
|
|
183
|
+
guides form a small web you can wander:
|
|
184
|
+
|
|
185
|
+
- [[${CAPTURE_ANYTHING_PATH}]] — get thoughts in and link them together
|
|
186
|
+
- [[${TAGS_GRAPH_PATH}]] — structure that grows out of what you write
|
|
187
|
+
- [[${CONNECT_AI_PATH}]] — any AI can read and write your vault
|
|
188
|
+
- [[${YOURS_TO_KEEP_PATH}]] — export everything, anytime
|
|
189
|
+
|
|
190
|
+
They're ordinary notes, tagged #guide. Edit them, add to them, or delete the
|
|
191
|
+
lot — the vault is yours, remember.
|
|
192
|
+
`,
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
path: CAPTURE_ANYTHING_PATH,
|
|
196
|
+
...guideHuman,
|
|
197
|
+
content: `# ${CAPTURE_ANYTHING_PATH}
|
|
198
|
+
|
|
199
|
+
The one habit that makes a vault work: when a thought strikes, write it down.
|
|
200
|
+
Don't sort it. Don't file it. Let it land.
|
|
201
|
+
|
|
202
|
+
A note can be two words or two pages. The blog idea on a trail, the follow-up
|
|
203
|
+
from a meeting, the dream at 2am — capture first. Organizing can happen later,
|
|
204
|
+
or never; that's what tags and your AI are for.
|
|
205
|
+
|
|
206
|
+
To connect two notes, wrap a note's name in double square brackets:
|
|
207
|
+
[[${WELCOME_PATH}]] is a link back to where you started. If the note
|
|
208
|
+
doesn't exist yet, the link waits patiently and connects the moment it does.
|
|
209
|
+
|
|
210
|
+
That's the whole trick. Storage is easy — connection is everything. A linked
|
|
211
|
+
note is a thought your future self, and your AI, can find again.
|
|
212
|
+
|
|
213
|
+
Next: [[${TAGS_GRAPH_PATH}]].
|
|
123
214
|
`,
|
|
124
215
|
},
|
|
125
216
|
{
|
|
126
|
-
path:
|
|
127
|
-
|
|
217
|
+
path: TAGS_GRAPH_PATH,
|
|
218
|
+
...guideHuman,
|
|
219
|
+
content: `# ${TAGS_GRAPH_PATH}
|
|
220
|
+
|
|
221
|
+
Notes and links come first. Tags come later, when you notice a pattern.
|
|
222
|
+
|
|
223
|
+
A tag answers "what kind of thing is this?" — #person, #recipe, #idea. Tag a
|
|
224
|
+
few notes the same way and a view lights up: all your people, all your recipes,
|
|
225
|
+
everything half-finished. Your vault starts with just two:
|
|
128
226
|
|
|
129
|
-
|
|
227
|
+
- #capture — the Notes app puts it on whatever you write or speak in directly,
|
|
228
|
+
marking your own raw words.
|
|
229
|
+
- #guide — these notes.
|
|
230
|
+
|
|
231
|
+
Underneath, everything is one graph: every note a node, every link an edge.
|
|
232
|
+
Open the graph view and watch it grow; ask a connected AI "what's near this
|
|
233
|
+
note?" and it walks the edges for you.
|
|
234
|
+
|
|
235
|
+
The structure isn't a filing system you design on day one. It grows out of what
|
|
236
|
+
you actually write — so don't over-organize an empty vault. Write, link, and
|
|
237
|
+
let the shape appear.
|
|
238
|
+
|
|
239
|
+
Next: [[${CONNECT_AI_PATH}]].
|
|
130
240
|
`,
|
|
131
241
|
},
|
|
132
242
|
{
|
|
133
243
|
path: CONNECT_AI_PATH,
|
|
244
|
+
...guideHuman,
|
|
134
245
|
content: `# ${CONNECT_AI_PATH}
|
|
135
246
|
|
|
136
|
-
Your vault speaks MCP
|
|
137
|
-
|
|
247
|
+
Your vault speaks MCP — an open standard — so any AI can read and write it:
|
|
248
|
+
Claude, ChatGPT, Claude Code, Cursor, or an agent you build. ${consoleLine}
|
|
249
|
+
|
|
250
|
+
Once you're connected, try:
|
|
251
|
+
|
|
252
|
+
- "Read the Getting Started note and help me set this vault up."
|
|
253
|
+
- "What did I want to write about?"
|
|
254
|
+
- "Tag my untagged notes."
|
|
255
|
+
|
|
256
|
+
Your AI sees what you see — the same notes, links, and tags — and what it
|
|
257
|
+
writes lands here, as notes you can read, edit, or delete. There's even a note
|
|
258
|
+
in this vault written for it: [[${GETTING_STARTED_PATH}]], the vault-design brief your
|
|
259
|
+
AI reads so you don't have to.
|
|
260
|
+
|
|
261
|
+
One memory, shared with every AI you choose to connect. That's the point.
|
|
262
|
+
|
|
263
|
+
Next: [[${YOURS_TO_KEEP_PATH}]].
|
|
264
|
+
`,
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
path: YOURS_TO_KEEP_PATH,
|
|
268
|
+
...guideHuman,
|
|
269
|
+
content: `# ${YOURS_TO_KEEP_PATH}
|
|
270
|
+
|
|
271
|
+
Everything here — every note, tag, and link — exports as plain markdown files
|
|
272
|
+
with the structure intact. On Parachute Cloud, it's the Export button in your
|
|
273
|
+
console. Self-hosting, it's one command: \`parachute-vault export <dir>\`.
|
|
274
|
+
|
|
275
|
+
What you get is a folder any app can open, and another Parachute can import
|
|
276
|
+
losslessly. Move from our cloud to your own machine, or the other way — same
|
|
277
|
+
software, same format, nothing held hostage between the doors.
|
|
278
|
+
|
|
279
|
+
We build it this way on purpose. A connection you can't leave isn't a
|
|
280
|
+
relationship. You can always leave — which is exactly why you can trust
|
|
281
|
+
staying.
|
|
282
|
+
|
|
283
|
+
Export isn't a feature we grudgingly support; it's the moral center of the
|
|
284
|
+
design. We test it constantly, because a promise you don't test is just a
|
|
285
|
+
hope.
|
|
286
|
+
|
|
287
|
+
Back to [[${WELCOME_PATH}]].
|
|
138
288
|
`,
|
|
139
289
|
},
|
|
140
290
|
],
|
|
@@ -313,8 +463,17 @@ export const GETTING_STARTED_PACK: SeedPack = {
|
|
|
313
463
|
name: "getting-started",
|
|
314
464
|
description:
|
|
315
465
|
"The AI-facing start-here guide: vault design vocabulary (tags/paths/schemas), imports, write gotchas. Seeded by default on new vaults.",
|
|
316
|
-
|
|
317
|
-
|
|
466
|
+
// Declares GUIDE_TAG too — each pack is self-sufficient; the upsert converges
|
|
467
|
+
// with the welcome pack's. This guide is written FOR the AI.
|
|
468
|
+
tags: [GUIDE_TAG],
|
|
469
|
+
notes: [
|
|
470
|
+
{
|
|
471
|
+
path: GETTING_STARTED_PATH,
|
|
472
|
+
tags: ["guide"],
|
|
473
|
+
metadata: { written_for: "ai" },
|
|
474
|
+
content: GETTING_STARTED_CONTENT,
|
|
475
|
+
},
|
|
476
|
+
],
|
|
318
477
|
};
|
|
319
478
|
|
|
320
479
|
// ---------------------------------------------------------------------------
|
|
@@ -450,8 +609,17 @@ export const SURFACE_STARTER_PACK: SeedPack = {
|
|
|
450
609
|
name: "surface-starter",
|
|
451
610
|
description:
|
|
452
611
|
"A living starter guide for building a custom surface (UI) over this vault with the published surface packages. Opt-in — not seeded by default.",
|
|
453
|
-
|
|
454
|
-
|
|
612
|
+
// A guide too (written for the AI) — declares GUIDE_TAG so the opt-in
|
|
613
|
+
// add-pack path lands the tag even if this pack is applied on its own.
|
|
614
|
+
tags: [GUIDE_TAG],
|
|
615
|
+
notes: [
|
|
616
|
+
{
|
|
617
|
+
path: SURFACE_STARTER_PATH,
|
|
618
|
+
tags: ["guide"],
|
|
619
|
+
metadata: { written_for: "ai" },
|
|
620
|
+
content: SURFACE_STARTER_CONTENT,
|
|
621
|
+
},
|
|
622
|
+
],
|
|
455
623
|
};
|
|
456
624
|
|
|
457
625
|
// ---------------------------------------------------------------------------
|
|
@@ -541,18 +709,23 @@ export async function applySeedPack(
|
|
|
541
709
|
await store.upsertTagRecord(decl.name, {
|
|
542
710
|
description: decl.description,
|
|
543
711
|
...(decl.parent_names ? { parent_names: decl.parent_names } : {}),
|
|
712
|
+
...(decl.fields ? { fields: decl.fields } : {}),
|
|
544
713
|
});
|
|
545
714
|
result.tags.push(decl.name);
|
|
546
715
|
}
|
|
547
716
|
|
|
548
|
-
for (const
|
|
549
|
-
const existing = await store.getNoteByPath(path);
|
|
717
|
+
for (const note of pack.notes) {
|
|
718
|
+
const existing = await store.getNoteByPath(note.path);
|
|
550
719
|
if (existing) {
|
|
551
|
-
result.skippedNotes.push(path);
|
|
720
|
+
result.skippedNotes.push(note.path);
|
|
552
721
|
continue;
|
|
553
722
|
}
|
|
554
|
-
await store.createNote(content, {
|
|
555
|
-
|
|
723
|
+
await store.createNote(note.content, {
|
|
724
|
+
path: note.path,
|
|
725
|
+
tags: note.tags,
|
|
726
|
+
metadata: note.metadata,
|
|
727
|
+
});
|
|
728
|
+
result.seededNotes.push(note.path);
|
|
556
729
|
}
|
|
557
730
|
|
|
558
731
|
return result;
|
package/package.json
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Live-query frame PARITY + the pure WS helpers (WS-hibernation migration
|
|
3
|
-
*
|
|
2
|
+
* Live-query frame PARITY + the pure WS helpers (WS-hibernation migration,
|
|
3
|
+
* self-host door).
|
|
4
4
|
*
|
|
5
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
|
|
7
|
-
*
|
|
8
|
-
* payload (`notes` / `note` / `id`)
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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.)
|
|
13
14
|
*
|
|
14
15
|
* Plus unit coverage of the pure helpers that back the Bun.serve integration:
|
|
15
16
|
* snapshot chunking, first-message parsing, verb-rank, and the query rejects.
|
|
16
17
|
*/
|
|
17
18
|
import { describe, it, expect } from "bun:test";
|
|
18
|
-
import {
|
|
19
|
+
import { wsFrame, WsSink } from "./subscriptions.ts";
|
|
19
20
|
import {
|
|
20
21
|
buildSnapshotFrames,
|
|
21
22
|
parseClientMessage,
|
|
@@ -30,23 +31,11 @@ import {
|
|
|
30
31
|
import { unsupportedSubscriptionReason } from "./live-match.ts";
|
|
31
32
|
import { FRAME_CORPUS, NOTE_A } from "./test-support/live-frame-corpus.ts";
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
function sseDataBody(frame: string): string {
|
|
35
|
-
const line = frame.split("\n").find((l) => l.startsWith("data:"))!;
|
|
36
|
-
return line.slice("data:".length).replace(/^ /, "");
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
describe("live-frame parity — self-host WS bytes === corpus === SSE data body", () => {
|
|
34
|
+
describe("live-frame parity — self-host WS bytes === corpus === cloud WS bytes", () => {
|
|
40
35
|
for (const c of FRAME_CORPUS) {
|
|
41
|
-
it(`${c.name}:
|
|
42
|
-
const sse = sseFrame(c.event, c.data);
|
|
36
|
+
it(`${c.name}: WS message folds the event into \`type\` over the corpus payload`, () => {
|
|
43
37
|
const ws = wsFrame(c.event, c.data);
|
|
44
38
|
|
|
45
|
-
// SSE frame shape (byte-identical to the pre-WS-migration contract).
|
|
46
|
-
expect(sse).toBe(`event: ${c.event}\ndata: ${JSON.stringify(c.data)}\n\n`);
|
|
47
|
-
// SSE data body parses back to exactly the payload.
|
|
48
|
-
expect(JSON.parse(sseDataBody(sse))).toEqual(c.data);
|
|
49
|
-
|
|
50
39
|
// WS message is `{ type, ...data }` — the event name folds into `type`.
|
|
51
40
|
const parsedWs = JSON.parse(ws) as Record<string, unknown>;
|
|
52
41
|
expect(parsedWs.type).toBe(c.event);
|
|
@@ -55,21 +44,16 @@ describe("live-frame parity — self-host WS bytes === corpus === SSE data body"
|
|
|
55
44
|
expect(rest).toEqual(c.data);
|
|
56
45
|
|
|
57
46
|
// BYTE parity of the inner payload: whatever value the event carries
|
|
58
|
-
// (`notes` / `note` / `id`) serializes IDENTICALLY
|
|
59
|
-
//
|
|
47
|
+
// (`notes` / `note` / `id`) serializes IDENTICALLY to the corpus
|
|
48
|
+
// serialization (the cross-door invariant).
|
|
60
49
|
const innerKey = c.event === "snapshot" ? "notes" : c.event === "upsert" ? "note" : "id";
|
|
61
50
|
const innerBytes = JSON.stringify((c.data as Record<string, unknown>)[innerKey]);
|
|
62
|
-
expect(sse.includes(innerBytes)).toBe(true);
|
|
63
51
|
expect(ws.includes(innerBytes)).toBe(true);
|
|
64
52
|
});
|
|
65
53
|
}
|
|
66
|
-
|
|
67
|
-
it("snapshotFrame() is the SSE snapshot frame for a note set", () => {
|
|
68
|
-
expect(snapshotFrame([NOTE_A as any])).toBe(sseFrame("snapshot", { notes: [NOTE_A] }));
|
|
69
|
-
});
|
|
70
54
|
});
|
|
71
55
|
|
|
72
|
-
describe("sink classes render through the ONE formatter
|
|
56
|
+
describe("sink classes render through the ONE formatter", () => {
|
|
73
57
|
it("WsSink.send emits exactly wsFrame(event, data)", () => {
|
|
74
58
|
const sent: string[] = [];
|
|
75
59
|
const sink = new WsSink({ send: (d: string) => sent.push(d), close: () => {} });
|
|
@@ -78,15 +62,6 @@ describe("sink classes render through the ONE formatter each", () => {
|
|
|
78
62
|
expect(sent.at(-1)).toBe(wsFrame(c.event, c.data));
|
|
79
63
|
}
|
|
80
64
|
});
|
|
81
|
-
|
|
82
|
-
it("SseSink.send emits exactly sseFrame(event, data); comment() emits `:\\n\\n`", () => {
|
|
83
|
-
const pushed: string[] = [];
|
|
84
|
-
const sink = new SseSink((f) => (pushed.push(f), true), () => {});
|
|
85
|
-
sink.send("upsert", { note: NOTE_A });
|
|
86
|
-
expect(pushed.at(-1)).toBe(sseFrame("upsert", { note: NOTE_A }));
|
|
87
|
-
sink.comment();
|
|
88
|
-
expect(pushed.at(-1)).toBe(":\n\n");
|
|
89
|
-
});
|
|
90
65
|
});
|
|
91
66
|
|
|
92
67
|
describe("buildSnapshotFrames — chunking + done flag", () => {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -80,15 +85,24 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
80
85
|
expect(gs!.content).toContain("parachute-vault import");
|
|
81
86
|
expect(gs!.content).toContain("Adapt this note");
|
|
82
87
|
|
|
83
|
-
// The welcome
|
|
84
|
-
for (const path of [
|
|
88
|
+
// The welcome ring: five person-voiced guide notes.
|
|
89
|
+
for (const path of [
|
|
90
|
+
WELCOME_PATH,
|
|
91
|
+
CAPTURE_ANYTHING_PATH,
|
|
92
|
+
TAGS_GRAPH_PATH,
|
|
93
|
+
CONNECT_AI_PATH,
|
|
94
|
+
YOURS_TO_KEEP_PATH,
|
|
95
|
+
]) {
|
|
85
96
|
expect(await store.getNoteByPath(path)).not.toBeNull();
|
|
86
97
|
}
|
|
87
98
|
});
|
|
88
99
|
|
|
89
|
-
test("seeds the
|
|
100
|
+
test("seeds the capture tag Notes requires (byte-equal semantics) + the guide/pinned tags", async () => {
|
|
90
101
|
const result = await seedOnboardingNotes(store);
|
|
91
|
-
|
|
102
|
+
// welcome upserts [capture, guide, pinned]; getting-started re-upserts
|
|
103
|
+
// [guide] (converges). applySeedPack reports every declared tag per pack,
|
|
104
|
+
// so the concatenation carries guide twice — deterministic.
|
|
105
|
+
expect(result.tags).toEqual(["capture", "guide", "pinned", "guide"]);
|
|
92
106
|
|
|
93
107
|
for (const decl of NOTES_REQUIRED_TAGS) {
|
|
94
108
|
const record = await store.getTagRecord(decl.name);
|
|
@@ -97,6 +111,14 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
97
111
|
expect(record!.parent_names ?? []).toEqual(decl.parent_names ?? []);
|
|
98
112
|
}
|
|
99
113
|
|
|
114
|
+
// The guide tag lands with its written_for enum schema (applier passes
|
|
115
|
+
// `fields` through to upsertTagRecord). "ai" is the default (first value).
|
|
116
|
+
const guide = await store.getTagRecord("guide");
|
|
117
|
+
expect(guide).not.toBeNull();
|
|
118
|
+
expect(guide!.fields?.written_for?.enum).toEqual(["ai", "human", "both"]);
|
|
119
|
+
const pinned = await store.getTagRecord("pinned");
|
|
120
|
+
expect(pinned).not.toBeNull();
|
|
121
|
+
|
|
100
122
|
// The retired subtype tags are NOT seeded — entry method is note
|
|
101
123
|
// metadata.source (text|voice), not taxonomy (2026-07-03). Existing
|
|
102
124
|
// vaults carrying these tags stay valid; new vaults don't get them.
|
|
@@ -104,6 +126,32 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
104
126
|
expect(await store.getTagRecord("capture/voice")).toBeNull();
|
|
105
127
|
});
|
|
106
128
|
|
|
129
|
+
test("seeded guides carry #guide + metadata.written_for (create-time tag/metadata write-through)", async () => {
|
|
130
|
+
await seedOnboardingNotes(store);
|
|
131
|
+
|
|
132
|
+
// Human-facing welcome guide: tagged guide + pinned, written_for human.
|
|
133
|
+
const welcome = await store.getNoteByPath(WELCOME_PATH);
|
|
134
|
+
expect([...(welcome!.tags ?? [])].sort()).toEqual(["guide", "pinned"]);
|
|
135
|
+
expect(welcome!.metadata?.written_for).toBe("human");
|
|
136
|
+
|
|
137
|
+
// The four sibling guides: guide only, written_for human.
|
|
138
|
+
for (const path of [
|
|
139
|
+
CAPTURE_ANYTHING_PATH,
|
|
140
|
+
TAGS_GRAPH_PATH,
|
|
141
|
+
CONNECT_AI_PATH,
|
|
142
|
+
YOURS_TO_KEEP_PATH,
|
|
143
|
+
]) {
|
|
144
|
+
const note = await store.getNoteByPath(path);
|
|
145
|
+
expect(note!.tags).toEqual(["guide"]);
|
|
146
|
+
expect(note!.metadata?.written_for).toBe("human");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The AI-facing Getting Started guide: guide, written_for ai.
|
|
150
|
+
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
151
|
+
expect(gs!.tags).toEqual(["guide"]);
|
|
152
|
+
expect(gs!.metadata?.written_for).toBe("ai");
|
|
153
|
+
});
|
|
154
|
+
|
|
107
155
|
test("GAP 4: Getting Started shows a concrete update-tag fields example + write gotchas", async () => {
|
|
108
156
|
await seedOnboardingNotes(store);
|
|
109
157
|
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
@@ -121,24 +169,36 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
121
169
|
expect(gs!.content).toContain("first listed value");
|
|
122
170
|
});
|
|
123
171
|
|
|
124
|
-
test("A3 (reshaped): the welcome
|
|
172
|
+
test("A3 (reshaped): the welcome ring resolves to real link edges; Getting Started has no dangling Surface Starter link", async () => {
|
|
125
173
|
await seedOnboardingNotes(store);
|
|
126
174
|
|
|
127
|
-
// welcome → try-linking, try-linking → welcome, connect-AI → welcome.
|
|
128
175
|
const welcome = await store.getNoteByPath(WELCOME_PATH);
|
|
129
|
-
const
|
|
176
|
+
const capture = await store.getNoteByPath(CAPTURE_ANYTHING_PATH);
|
|
177
|
+
const tags = await store.getNoteByPath(TAGS_GRAPH_PATH);
|
|
130
178
|
const connectAi = await store.getNoteByPath(CONNECT_AI_PATH);
|
|
179
|
+
const yours = await store.getNoteByPath(YOURS_TO_KEEP_PATH);
|
|
180
|
+
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
131
181
|
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
182
|
+
const outbound = async (id: string) =>
|
|
183
|
+
(await store.getLinks(id, { direction: "outbound" })).map((l) => l.targetId);
|
|
184
|
+
|
|
185
|
+
// Welcome → all four siblings.
|
|
186
|
+
const welcomeOut = await outbound(welcome!.id);
|
|
187
|
+
for (const t of [capture!.id, tags!.id, connectAi!.id, yours!.id]) {
|
|
188
|
+
expect(welcomeOut).toContain(t);
|
|
189
|
+
}
|
|
190
|
+
// The chain forward: capture → tags → connect → yours → welcome.
|
|
191
|
+
expect(await outbound(capture!.id)).toContain(tags!.id);
|
|
192
|
+
expect(await outbound(tags!.id)).toContain(connectAi!.id);
|
|
193
|
+
const connectOut = await outbound(connectAi!.id);
|
|
194
|
+
expect(connectOut).toContain(yours!.id);
|
|
195
|
+
// Connect your AI also links the AI-facing Getting Started note (seeded by
|
|
196
|
+
// the getting-started pack) — the ring's cross-link resolves, no dangle.
|
|
197
|
+
expect(connectOut).toContain(gs!.id);
|
|
198
|
+
expect(await outbound(yours!.id)).toContain(welcome!.id);
|
|
138
199
|
|
|
139
200
|
// Getting Started mentions the surface-starter PACK — not a wikilink to a
|
|
140
201
|
// note that the default seed no longer creates.
|
|
141
|
-
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
142
202
|
expect(gs!.content).not.toContain("[[Surface Starter]]");
|
|
143
203
|
expect(gs!.content).toContain("add-pack surface-starter");
|
|
144
204
|
});
|
|
@@ -179,7 +239,8 @@ describe("applySeedPack — surface-starter via add-pack", () => {
|
|
|
179
239
|
expect(result.pack).toBe("surface-starter");
|
|
180
240
|
expect(result.seededNotes).toEqual([SURFACE_STARTER_PATH]);
|
|
181
241
|
expect(result.skippedNotes).toEqual([]);
|
|
182
|
-
|
|
242
|
+
// surface-starter now declares GUIDE_TAG (it's a guide, written_for ai).
|
|
243
|
+
expect(result.tags).toEqual(["guide"]);
|
|
183
244
|
|
|
184
245
|
const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
|
|
185
246
|
expect(ss).not.toBeNull();
|
package/src/onboarding-seed.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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.
|