@openparachute/vault 0.6.5-rc.14 → 0.6.5-rc.16
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 +240 -56
- package/core/src/seed-packs.ts +350 -70
- package/package.json +1 -1
- package/src/onboarding-seed.test.ts +84 -20
- package/src/onboarding-seed.ts +2 -2
- package/src/vault-create.test.ts +17 -12
|
@@ -1,35 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Content + registry pins for the named seed packs (core/src/seed-packs.ts).
|
|
3
3
|
*
|
|
4
|
-
* Pure content tests — no DB. The applier's behavior (idempotence, seeding
|
|
5
|
-
* is exercised store-level in
|
|
6
|
-
* src/add-pack.test.ts.
|
|
4
|
+
* Pure content tests — no DB. The applier's behavior (idempotence, seeding,
|
|
5
|
+
* tag/metadata write-through) is exercised store-level in
|
|
6
|
+
* src/onboarding-seed.test.ts and CLI-level in src/add-pack.test.ts.
|
|
7
7
|
*
|
|
8
|
-
* The load-bearing
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* The load-bearing pins:
|
|
9
|
+
* - the `welcome` pack's tags must CONTAIN notes-ui's NOTES_REQUIRED_SCHEMA
|
|
10
|
+
* byte-for-byte — the Notes PWA's connect-time audit compares `description`
|
|
11
|
+
* verbatim, and its schema banner only clears when the capture tag genuinely
|
|
12
|
+
* carries the semantics Notes declares. (Containment, not equality: the
|
|
13
|
+
* welcome pack now also ships the #guide + #pinned tags.)
|
|
14
|
+
* - `#guide` is the vault's skill-file tag, carrying a `written_for` enum
|
|
15
|
+
* schema (`ai` | `human` | `both`, `ai` first = default). Every seeded guide
|
|
16
|
+
* note is tagged `#guide` + `metadata.written_for`.
|
|
17
|
+
* - the five-guide welcome ring links into a connected web with no dangling
|
|
18
|
+
* wikilinks across the default seed.
|
|
14
19
|
*/
|
|
15
20
|
|
|
16
21
|
import { describe, test, expect } from "bun:test";
|
|
17
22
|
import {
|
|
18
23
|
applySeedPack,
|
|
24
|
+
CAPTURE_ANYTHING_PATH,
|
|
19
25
|
CONNECT_AI_PATH,
|
|
26
|
+
DEFAULT_VAULT_DESCRIPTION,
|
|
27
|
+
IMPORTED_VAULT_DESCRIPTION,
|
|
28
|
+
YOURS_TO_KEEP_PATH,
|
|
20
29
|
GETTING_STARTED_CONTENT,
|
|
21
30
|
GETTING_STARTED_PACK,
|
|
22
31
|
GETTING_STARTED_PATH,
|
|
23
32
|
getSeedPack,
|
|
33
|
+
GUIDE_TAG,
|
|
24
34
|
listSeedPacks,
|
|
25
35
|
NOTES_REQUIRED_TAGS,
|
|
36
|
+
PINNED_TAG,
|
|
26
37
|
SEED_PACK_NAMES,
|
|
27
38
|
SURFACE_STARTER_CONTENT,
|
|
28
39
|
SURFACE_STARTER_PACK,
|
|
29
40
|
SURFACE_STARTER_PATH,
|
|
30
|
-
|
|
41
|
+
TAGS_GRAPH_PATH,
|
|
31
42
|
WELCOME_PATH,
|
|
32
43
|
welcomePack,
|
|
44
|
+
YOURS_TO_KEEP_PATH,
|
|
45
|
+
type SeedPack,
|
|
46
|
+
type SeedPackNote,
|
|
33
47
|
} from "./seed-packs.ts";
|
|
34
48
|
import * as onboardingShim from "./onboarding.ts";
|
|
35
49
|
|
|
@@ -47,17 +61,74 @@ const NOTES_UI_REQUIRED_SCHEMA_TAGS = [
|
|
|
47
61
|
},
|
|
48
62
|
];
|
|
49
63
|
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Extract every REAL `[[wikilink]]` target from note content — mirroring the
|
|
66
|
+
* store's parser (core/src/wikilinks.ts), which ignores wikilinks inside
|
|
67
|
+
* fenced/inline code. So an illustrative `[[wikilinks]]` in a code span (as in
|
|
68
|
+
* the Getting Started guide) is not counted — it never becomes a link.
|
|
69
|
+
*/
|
|
70
|
+
function wikilinkTargets(content: string): string[] {
|
|
71
|
+
const stripped = content
|
|
72
|
+
.replace(/```[\s\S]*?```/g, (m) => " ".repeat(m.length)) // fenced code
|
|
73
|
+
.replace(/`[^`]*`/g, (m) => " ".repeat(m.length)); // inline code
|
|
74
|
+
return [...stripped.matchAll(/\[\[([^\]]+)\]\]/g)].map((m) => m[1]!);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A note in a pack, by path (fails loudly if absent). */
|
|
78
|
+
function noteAt(pack: SeedPack, path: string): SeedPackNote {
|
|
79
|
+
const note = pack.notes.find((n) => n.path === path);
|
|
80
|
+
if (!note) throw new Error(`no note at path ${path}`);
|
|
81
|
+
return note;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("guide tag — the vault's skill-file tag", () => {
|
|
85
|
+
test("GUIDE_TAG carries the written_for enum, ai first (= default)", () => {
|
|
86
|
+
expect(GUIDE_TAG.name).toBe("guide");
|
|
87
|
+
// The description's last sentence names the schema field.
|
|
88
|
+
expect(GUIDE_TAG.description).toContain(
|
|
89
|
+
"`written_for` says who a guide is written for.",
|
|
90
|
+
);
|
|
91
|
+
const wf = GUIDE_TAG.fields?.written_for;
|
|
92
|
+
expect(wf).toBeDefined();
|
|
93
|
+
expect(wf!.type).toBe("string");
|
|
94
|
+
expect(wf!.enum).toEqual(["ai", "human", "both"]);
|
|
95
|
+
// First enum value is the schema default — guides lean AI.
|
|
96
|
+
expect(wf!.enum![0]).toBe("ai");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("PINNED_TAG is a plain identity tag (no schema)", () => {
|
|
100
|
+
expect(PINNED_TAG.name).toBe("pinned");
|
|
101
|
+
expect(PINNED_TAG.description).toBe(
|
|
102
|
+
"Notes pinned to the top of the Notes app.",
|
|
103
|
+
);
|
|
104
|
+
expect(PINNED_TAG.fields).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("welcome pack — notes-ui schema parity (containment)", () => {
|
|
109
|
+
test("welcome tags CONTAIN notes-ui's NOTES_REQUIRED_SCHEMA byte-for-byte", () => {
|
|
110
|
+
// The independent copy still matches, and the welcome pack still ships it.
|
|
52
111
|
expect(NOTES_REQUIRED_TAGS).toEqual(NOTES_UI_REQUIRED_SCHEMA_TAGS);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
expect(
|
|
112
|
+
for (const req of NOTES_UI_REQUIRED_SCHEMA_TAGS) {
|
|
113
|
+
const found = welcomePack().tags.find((t) => t.name === req.name);
|
|
114
|
+
expect(found).toBeDefined();
|
|
115
|
+
// Field-level byte pins (toEqual ignores key order; these don't).
|
|
116
|
+
expect(found!.name).toBe(req.name);
|
|
117
|
+
expect(found!.description).toBe(req.description);
|
|
118
|
+
expect(found!.parent_names).toBeUndefined();
|
|
58
119
|
}
|
|
59
120
|
});
|
|
60
121
|
|
|
122
|
+
test("welcome tags = capture + guide + pinned (the guide/pinned tags ride along)", () => {
|
|
123
|
+
expect(welcomePack().tags.map((t) => t.name)).toEqual([
|
|
124
|
+
"capture",
|
|
125
|
+
"guide",
|
|
126
|
+
"pinned",
|
|
127
|
+
]);
|
|
128
|
+
expect(welcomePack().tags).toContain(GUIDE_TAG);
|
|
129
|
+
expect(welcomePack().tags).toContain(PINNED_TAG);
|
|
130
|
+
});
|
|
131
|
+
|
|
61
132
|
test("exactly ONE capture tag — no subtype tags, no parent_names (2026-07-03)", () => {
|
|
62
133
|
// Entry method is metadata.source (text|voice), not taxonomy. The old
|
|
63
134
|
// capture/text + capture/voice subtypes must not creep back into the seed
|
|
@@ -68,84 +139,197 @@ describe("welcome pack — notes-ui schema parity", () => {
|
|
|
68
139
|
});
|
|
69
140
|
});
|
|
70
141
|
|
|
71
|
-
describe("welcome pack —
|
|
72
|
-
test("
|
|
142
|
+
describe("welcome pack — the five-guide ring", () => {
|
|
143
|
+
test("five guides at the ratified paths, in order", () => {
|
|
73
144
|
const pack = welcomePack();
|
|
74
145
|
expect(pack.name).toBe("welcome");
|
|
75
146
|
expect(pack.notes.map((n) => n.path)).toEqual([
|
|
76
147
|
WELCOME_PATH,
|
|
77
|
-
|
|
148
|
+
CAPTURE_ANYTHING_PATH,
|
|
149
|
+
TAGS_GRAPH_PATH,
|
|
78
150
|
CONNECT_AI_PATH,
|
|
151
|
+
YOURS_TO_KEEP_PATH,
|
|
79
152
|
]);
|
|
80
|
-
|
|
81
|
-
const [welcome, tryLinking, connectAi] = pack.notes;
|
|
82
|
-
expect(welcome!.content).toContain(`[[${TRY_LINKING_PATH}]]`);
|
|
83
|
-
expect(tryLinking!.content).toContain(`[[${WELCOME_PATH}]]`);
|
|
84
|
-
expect(connectAi!.content).toContain(`[[${WELCOME_PATH}]]`);
|
|
85
153
|
});
|
|
86
154
|
|
|
87
|
-
test("
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
const
|
|
92
|
-
|
|
155
|
+
test("the link web: welcome → all four; 2→3→4→5→1; connect → Getting Started", () => {
|
|
156
|
+
const pack = welcomePack();
|
|
157
|
+
|
|
158
|
+
// Welcome wikilinks to all four siblings.
|
|
159
|
+
const welcomeLinks = wikilinkTargets(noteAt(pack, WELCOME_PATH).content);
|
|
160
|
+
for (const target of [
|
|
161
|
+
CAPTURE_ANYTHING_PATH,
|
|
162
|
+
TAGS_GRAPH_PATH,
|
|
163
|
+
CONNECT_AI_PATH,
|
|
164
|
+
YOURS_TO_KEEP_PATH,
|
|
165
|
+
]) {
|
|
166
|
+
expect(welcomeLinks).toContain(target);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The chain forward: 2→3, 3→4, 4→5, 5→1.
|
|
170
|
+
expect(wikilinkTargets(noteAt(pack, CAPTURE_ANYTHING_PATH).content)).toContain(
|
|
171
|
+
TAGS_GRAPH_PATH,
|
|
172
|
+
);
|
|
173
|
+
expect(wikilinkTargets(noteAt(pack, TAGS_GRAPH_PATH).content)).toContain(
|
|
174
|
+
CONNECT_AI_PATH,
|
|
175
|
+
);
|
|
176
|
+
expect(wikilinkTargets(noteAt(pack, CONNECT_AI_PATH).content)).toContain(
|
|
177
|
+
YOURS_TO_KEEP_PATH,
|
|
178
|
+
);
|
|
179
|
+
expect(wikilinkTargets(noteAt(pack, YOURS_TO_KEEP_PATH).content)).toContain(
|
|
180
|
+
WELCOME_PATH,
|
|
181
|
+
);
|
|
93
182
|
|
|
94
|
-
|
|
183
|
+
// Connect your AI points the human at the AI-facing Getting Started note.
|
|
184
|
+
expect(wikilinkTargets(noteAt(pack, CONNECT_AI_PATH).content)).toContain(
|
|
185
|
+
GETTING_STARTED_PATH,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
95
188
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
189
|
+
test("NO dangling wikilinks across the default seed (welcome + getting-started)", () => {
|
|
190
|
+
// Both packs auto-seed on create, so every [[target]] across their notes
|
|
191
|
+
// must resolve to a note one of them writes — including [[Getting Started]].
|
|
192
|
+
const seededPaths = new Set<string>([
|
|
193
|
+
...welcomePack().notes.map((n) => n.path),
|
|
194
|
+
...GETTING_STARTED_PACK.notes.map((n) => n.path),
|
|
195
|
+
]);
|
|
196
|
+
for (const pack of [welcomePack(), GETTING_STARTED_PACK]) {
|
|
197
|
+
for (const note of pack.notes) {
|
|
198
|
+
for (const target of wikilinkTargets(note.content)) {
|
|
199
|
+
expect(seededPaths.has(target)).toBe(true);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
});
|
|
101
204
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
205
|
+
test("every guide is tagged #guide with metadata.written_for = human", () => {
|
|
206
|
+
for (const note of welcomePack().notes) {
|
|
207
|
+
expect(note.tags).toContain("guide");
|
|
208
|
+
expect(note.metadata).toEqual({ written_for: "human" });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
105
211
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
212
|
+
test("Welcome is the ONLY pinned note", () => {
|
|
213
|
+
const pinned = welcomePack().notes.filter((n) => n.tags?.includes("pinned"));
|
|
214
|
+
expect(pinned.map((n) => n.path)).toEqual([WELCOME_PATH]);
|
|
215
|
+
// Welcome carries both guide + pinned.
|
|
216
|
+
expect(noteAt(welcomePack(), WELCOME_PATH).tags).toEqual(["guide", "pinned"]);
|
|
109
217
|
});
|
|
110
218
|
|
|
111
|
-
test("
|
|
112
|
-
const
|
|
113
|
-
expect(
|
|
114
|
-
|
|
219
|
+
test("content anchors: the ratified copy is present (guards body drift)", () => {
|
|
220
|
+
const pack = welcomePack();
|
|
221
|
+
expect(noteAt(pack, WELCOME_PATH).content).toContain("This vault is yours.");
|
|
222
|
+
expect(noteAt(pack, WELCOME_PATH).content).toContain("tagged #guide");
|
|
223
|
+
expect(noteAt(pack, CAPTURE_ANYTHING_PATH).content).toContain(
|
|
224
|
+
"The one habit that makes a vault work",
|
|
225
|
+
);
|
|
226
|
+
expect(noteAt(pack, TAGS_GRAPH_PATH).content).toContain(
|
|
227
|
+
"Notes and links come first. Tags come later",
|
|
228
|
+
);
|
|
229
|
+
expect(noteAt(pack, YOURS_TO_KEEP_PATH).content).toContain(
|
|
230
|
+
"the moral center of the",
|
|
231
|
+
);
|
|
232
|
+
// Yours to keep carries the self-host export command (backtick-wrapped).
|
|
233
|
+
expect(noteAt(pack, YOURS_TO_KEEP_PATH).content).toContain(
|
|
234
|
+
"`parachute-vault export <dir>`",
|
|
115
235
|
);
|
|
116
|
-
|
|
117
|
-
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("consoleOrigin renders both modes on the Connect-your-AI note", () => {
|
|
239
|
+
const origin = "https://cloud.parachute.computer";
|
|
240
|
+
|
|
241
|
+
// With origin: the console sentence names it, no `undefined`, no `at .`.
|
|
242
|
+
const withOrigin = noteAt(welcomePack({ consoleOrigin: origin }), CONNECT_AI_PATH);
|
|
243
|
+
expect(withOrigin.content).toContain(
|
|
244
|
+
`Grab the connection URL from your console at ${origin}.`,
|
|
245
|
+
);
|
|
246
|
+
expect(withOrigin.content).not.toContain("undefined");
|
|
247
|
+
|
|
248
|
+
// Without origin: the line stays generic — no baked loopback, no `at .`.
|
|
249
|
+
const generic = noteAt(welcomePack(), CONNECT_AI_PATH);
|
|
250
|
+
expect(generic.content).toContain(
|
|
251
|
+
"Grab the connection URL from your console.",
|
|
252
|
+
);
|
|
253
|
+
expect(generic.content).not.toContain("undefined");
|
|
254
|
+
expect(generic.content).not.toContain("at .");
|
|
255
|
+
expect(generic.content).not.toContain(origin);
|
|
118
256
|
});
|
|
119
257
|
});
|
|
120
258
|
|
|
121
259
|
describe("getting-started pack", () => {
|
|
122
|
-
test("carries the
|
|
260
|
+
test("carries the AI-facing guide note, tagged #guide / written_for ai", () => {
|
|
123
261
|
expect(GETTING_STARTED_PACK.name).toBe("getting-started");
|
|
124
|
-
|
|
262
|
+
// Declares the guide tag (self-sufficient — converges with the welcome pack).
|
|
263
|
+
expect(GETTING_STARTED_PACK.tags).toEqual([GUIDE_TAG]);
|
|
125
264
|
expect(GETTING_STARTED_PACK.notes).toEqual([
|
|
126
|
-
{
|
|
265
|
+
{
|
|
266
|
+
path: GETTING_STARTED_PATH,
|
|
267
|
+
tags: ["guide"],
|
|
268
|
+
metadata: { written_for: "ai" },
|
|
269
|
+
content: GETTING_STARTED_CONTENT,
|
|
270
|
+
},
|
|
127
271
|
]);
|
|
128
272
|
});
|
|
129
273
|
|
|
274
|
+
test("the Getting Started body is byte-unchanged", () => {
|
|
275
|
+
// The AI-facing doctrine copy is not touched by the ring rewrite.
|
|
276
|
+
expect(noteAt(GETTING_STARTED_PACK, GETTING_STARTED_PATH).content).toBe(
|
|
277
|
+
GETTING_STARTED_CONTENT,
|
|
278
|
+
);
|
|
279
|
+
expect(GETTING_STARTED_CONTENT.startsWith("# Getting Started\n")).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
130
282
|
test("no dangling [[Surface Starter]] wikilink — points at the add-pack flow instead", () => {
|
|
131
283
|
// Surface Starter is out of the default seed (ratified 2026-07-02), so the
|
|
132
284
|
// default-seeded guide must not carry a wikilink to a note that doesn't
|
|
133
285
|
// exist. It names the pack + the way to add it.
|
|
134
286
|
expect(GETTING_STARTED_CONTENT).not.toContain("[[Surface Starter]]");
|
|
135
287
|
expect(GETTING_STARTED_CONTENT).toContain("Surface Starter");
|
|
136
|
-
|
|
288
|
+
// Points at the add-pack flow (console affordance / CLI) without spelling
|
|
289
|
+
// out a terminal command — de-emphasise the CLI (Aaron 2026-07-06).
|
|
290
|
+
expect(GETTING_STARTED_CONTENT).toContain("add-pack");
|
|
137
291
|
// The surface packages are still named for discoverability.
|
|
138
292
|
expect(GETTING_STARTED_CONTENT).toContain("@openparachute/surface-client");
|
|
139
293
|
expect(GETTING_STARTED_CONTENT).toContain("@openparachute/surface-render");
|
|
140
294
|
});
|
|
295
|
+
|
|
296
|
+
test("Getting Started is onboarding-first + carries no dead CLI guidance (2026-07-06 rewrite)", () => {
|
|
297
|
+
// Two-part structure: onboarding conversation leads, mechanics reference follows.
|
|
298
|
+
expect(GETTING_STARTED_CONTENT).toContain("## Part 1 — Onboarding the person");
|
|
299
|
+
expect(GETTING_STARTED_CONTENT).toContain("## Part 2 — Vault mechanics");
|
|
300
|
+
// Points the person at the five human guides by name (all resolve to seeds).
|
|
301
|
+
for (const g of [WELCOME_PATH, CAPTURE_ANYTHING_PATH, TAGS_GRAPH_PATH, CONNECT_AI_PATH, YOURS_TO_KEEP_PATH]) {
|
|
302
|
+
expect(GETTING_STARTED_CONTENT).toContain("[[" + g + "]]");
|
|
303
|
+
}
|
|
304
|
+
// The old live bug: it told cloud AIs to run a CLI that doesn't exist there.
|
|
305
|
+
expect(GETTING_STARTED_CONTENT).not.toContain("parachute-vault import");
|
|
306
|
+
// Data-in leads with the import UI + MCP bridge, not the terminal.
|
|
307
|
+
expect(GETTING_STARTED_CONTENT).toContain("Import button");
|
|
308
|
+
expect(GETTING_STARTED_CONTENT).toContain("over MCP");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("default vault-description constants orient + tell the AI to self-replace them", () => {
|
|
312
|
+
for (const d of [DEFAULT_VAULT_DESCRIPTION, IMPORTED_VAULT_DESCRIPTION]) {
|
|
313
|
+
expect(d.length).toBeGreaterThan(100);
|
|
314
|
+
expect(d).toContain("Getting Started");
|
|
315
|
+
expect(d).toContain("vault-info { description:"); // the self-replace instruction
|
|
316
|
+
}
|
|
317
|
+
expect(DEFAULT_VAULT_DESCRIPTION).toContain("brand-new");
|
|
318
|
+
expect(IMPORTED_VAULT_DESCRIPTION).toContain("imported");
|
|
319
|
+
});
|
|
141
320
|
});
|
|
142
321
|
|
|
143
322
|
describe("surface-starter pack", () => {
|
|
144
|
-
test("carries the surface guide
|
|
323
|
+
test("carries the surface guide, tagged #guide / written_for ai (opt-in, not default)", () => {
|
|
145
324
|
expect(SURFACE_STARTER_PACK.name).toBe("surface-starter");
|
|
146
|
-
expect(SURFACE_STARTER_PACK.tags).toEqual([]);
|
|
325
|
+
expect(SURFACE_STARTER_PACK.tags).toEqual([GUIDE_TAG]);
|
|
147
326
|
expect(SURFACE_STARTER_PACK.notes).toEqual([
|
|
148
|
-
{
|
|
327
|
+
{
|
|
328
|
+
path: SURFACE_STARTER_PATH,
|
|
329
|
+
tags: ["guide"],
|
|
330
|
+
metadata: { written_for: "ai" },
|
|
331
|
+
content: SURFACE_STARTER_CONTENT,
|
|
332
|
+
},
|
|
149
333
|
]);
|
|
150
334
|
expect(SURFACE_STARTER_PACK.description).toContain("not seeded by default");
|
|
151
335
|
});
|
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,63 @@
|
|
|
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";
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Default vault descriptions — the single source of truth
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The `vault-info` description a **brand-new** vault ships with. It's the first
|
|
43
|
+
* thing a connected AI reads about the vault, so it doubles as a nudge: orient
|
|
44
|
+
* the person, read the Getting Started brief, do one small real thing, and — once
|
|
45
|
+
* real structure lands — REPLACE this text with a current picture of the vault.
|
|
46
|
+
*
|
|
47
|
+
* Exported as the single source of truth; the runtimes (bun vault default seed +
|
|
48
|
+
* cloud vault DO) consume it in a follow-up PR. Not wired into `applySeedPack`
|
|
49
|
+
* here — a description is vault metadata, not a pack note.
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_VAULT_DESCRIPTION =
|
|
52
|
+
'This is a brand-new personal vault — just its starter guides so far; the person it belongs to is probably new to Parachute. Before helping them set it up, read the "Getting Started" note — it\'s your onboarding brief. Short version: start with a conversation, not a filing system; learn how they want to use this and where their notes live today; do one small real thing first. When the vault has real structure, replace this description (vault-info { description: "..." }) with a current picture of what lives here and how to work it — so every future session, yours or another AI\'s, starts oriented.';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The `vault-info` description a vault gets when it was **imported/restored** —
|
|
56
|
+
* it arrived with content. Flips the AI's first job from seed-it to learn-it:
|
|
57
|
+
* orient over the existing shape, reflect it back, and only then replace this
|
|
58
|
+
* text with a current picture.
|
|
59
|
+
*/
|
|
60
|
+
export const IMPORTED_VAULT_DESCRIPTION =
|
|
61
|
+
'This vault was imported — it arrived with its own notes, tags, and history. Orient before you write: vault-info { include_stats: true }, list-tags, read a sample, and read "Getting Started" if present. Your first job is to learn the shape that\'s already here and reflect it back to the person — not to propose new structure cold. When you understand it, replace this description (vault-info { description: "..." }) with a current picture.';
|
|
30
62
|
|
|
31
63
|
// ---------------------------------------------------------------------------
|
|
32
64
|
// Pack shape
|
|
33
65
|
// ---------------------------------------------------------------------------
|
|
34
66
|
|
|
35
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* A tag declaration a pack upserts (identity row, not a schema migration).
|
|
69
|
+
*
|
|
70
|
+
* `fields` carries an optional typed-metadata schema — the same
|
|
71
|
+
* `Record<field, { type, enum?, indexed? }>` shape `update-tag` accepts — so a
|
|
72
|
+
* pack can declare a schema-carrying tag (e.g. GUIDE_TAG's `written_for`
|
|
73
|
+
* enum). The applier passes it straight through to `upsertTagRecord`.
|
|
74
|
+
*/
|
|
36
75
|
export interface SeedPackTag {
|
|
37
76
|
name: string;
|
|
38
77
|
description: string;
|
|
39
78
|
parent_names?: string[];
|
|
79
|
+
fields?: Record<string, TagFieldSchema>;
|
|
40
80
|
}
|
|
41
81
|
|
|
42
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* A note a pack seeds — created only when no note exists at `path`. `tags` +
|
|
84
|
+
* `metadata` are applied at create time (both runtimes' `createNote` accepts
|
|
85
|
+
* them); a guide note carries `tags: ["guide"]` + `metadata: { written_for }`.
|
|
86
|
+
*/
|
|
43
87
|
export interface SeedPackNote {
|
|
44
88
|
path: string;
|
|
45
89
|
content: string;
|
|
90
|
+
tags?: string[];
|
|
91
|
+
metadata?: Record<string, unknown>;
|
|
46
92
|
}
|
|
47
93
|
|
|
48
94
|
/** A named bundle of starter tags + notes. */
|
|
@@ -83,58 +129,188 @@ export const NOTES_REQUIRED_TAGS: ReadonlyArray<SeedPackTag> = [
|
|
|
83
129
|
},
|
|
84
130
|
];
|
|
85
131
|
|
|
132
|
+
/**
|
|
133
|
+
* The `guide` tag — the vault's skill-file tag. Guides are the Parachute
|
|
134
|
+
* equivalent of a `SKILL.md`: notes that teach a connected AI (and the human)
|
|
135
|
+
* how this vault works and how to work it. The `written_for` schema field says
|
|
136
|
+
* who a guide is written for; `ai` is the enum's FIRST value, so it's the
|
|
137
|
+
* schema default — guides lean AI.
|
|
138
|
+
*
|
|
139
|
+
* Declared by every pack that ships guide notes (welcome / getting-started /
|
|
140
|
+
* surface-starter). The upserts converge on one row, so each pack stays
|
|
141
|
+
* self-sufficient — applying any one of them alone still lands the tag.
|
|
142
|
+
*/
|
|
143
|
+
export const GUIDE_TAG: SeedPackTag = {
|
|
144
|
+
name: "guide",
|
|
145
|
+
description:
|
|
146
|
+
"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.",
|
|
147
|
+
fields: {
|
|
148
|
+
// First enum value is the schema default — guides lean AI.
|
|
149
|
+
written_for: { type: "string", enum: ["ai", "human", "both"] },
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** The `pinned` tag — notes pinned to the top of the Notes app. No schema. */
|
|
154
|
+
export const PINNED_TAG: SeedPackTag = {
|
|
155
|
+
name: "pinned",
|
|
156
|
+
description: "Notes pinned to the top of the Notes app.",
|
|
157
|
+
};
|
|
158
|
+
|
|
86
159
|
export const WELCOME_PATH = "Welcome to your vault 🪂";
|
|
87
|
-
export const
|
|
160
|
+
export const CAPTURE_ANYTHING_PATH = "Capture anything";
|
|
161
|
+
export const TAGS_GRAPH_PATH = "Tags and the graph";
|
|
88
162
|
export const CONNECT_AI_PATH = "Connect your AI";
|
|
163
|
+
export const YOURS_TO_KEEP_PATH = "Yours to keep";
|
|
89
164
|
|
|
90
165
|
/**
|
|
91
|
-
* Build the `welcome` pack:
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
166
|
+
* Build the `welcome` pack: the five-guide welcome ring — Welcome, Capture
|
|
167
|
+
* anything, Tags and the graph, Connect your AI, Yours to keep — plus the
|
|
168
|
+
* `capture` / `guide` / `pinned` tags. The guides are ordinary notes tagged
|
|
169
|
+
* `#guide` (the vault's skill-file tag), each `metadata.written_for: "human"`;
|
|
170
|
+
* Welcome is `#pinned` so it sits at the top of the Notes app. They form a
|
|
171
|
+
* small linked web (Welcome → all four; the rest chain 2→3→4→5→1; Connect
|
|
172
|
+
* your AI also links the AI-facing [[Getting Started]]) so the graph view
|
|
173
|
+
* shows a connected structure from minute one. Deletable like anything else.
|
|
95
174
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* origin) the line stays generic.
|
|
175
|
+
* These five bodies are the ratified guides (already live at
|
|
176
|
+
* parachute.computer/guides). `consoleOrigin` is the origin of the operator's
|
|
177
|
+
* console (the cloud console, or a hub portal) — when known, the Connect-your-AI
|
|
178
|
+
* note names it; when omitted (the bun vault seeds at create time, often
|
|
179
|
+
* pre-expose, and must never bake in a loopback origin) the line stays generic.
|
|
102
180
|
*/
|
|
103
181
|
export function welcomePack(opts: { consoleOrigin?: string } = {}): SeedPack {
|
|
104
182
|
const consoleLine = opts.consoleOrigin
|
|
105
183
|
? `Grab the connection URL from your console at ${opts.consoleOrigin}.`
|
|
106
184
|
: `Grab the connection URL from your console.`;
|
|
185
|
+
const guideHuman = { tags: ["guide"], metadata: { written_for: "human" } };
|
|
107
186
|
return {
|
|
108
187
|
name: "welcome",
|
|
109
188
|
description:
|
|
110
|
-
"
|
|
111
|
-
|
|
189
|
+
"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.",
|
|
190
|
+
// capture (Notes surface) + guide (the skill-file tag, carries the
|
|
191
|
+
// written_for schema) + pinned (top-of-app). Upserts are idempotent.
|
|
192
|
+
tags: [...NOTES_REQUIRED_TAGS, GUIDE_TAG, PINNED_TAG],
|
|
112
193
|
// `[[wikilinks]]` resolve by note path — pending links auto-resolve when
|
|
113
194
|
// the target is created, so order only affects how briefly a link sits
|
|
114
|
-
// unresolved during the seed.
|
|
195
|
+
// unresolved during the seed. Every [[target]] here resolves to a note the
|
|
196
|
+
// default seed writes (the four siblings + [[Getting Started]] from the
|
|
197
|
+
// getting-started pack) — no dangling links on a fresh vault.
|
|
115
198
|
notes: [
|
|
116
199
|
{
|
|
117
200
|
path: WELCOME_PATH,
|
|
201
|
+
tags: ["guide", "pinned"],
|
|
202
|
+
metadata: { written_for: "human" },
|
|
118
203
|
content: `# ${WELCOME_PATH}
|
|
119
204
|
|
|
120
205
|
This vault is yours.
|
|
121
|
-
|
|
122
|
-
Notes can link to each
|
|
206
|
+
|
|
207
|
+
Write anything — ideas, people, plans, wifi passwords. Notes can link to each
|
|
208
|
+
other, like this: [[${CAPTURE_ANYTHING_PATH}]]. Follow that link and keep going — these
|
|
209
|
+
guides form a small web you can wander:
|
|
210
|
+
|
|
211
|
+
- [[${CAPTURE_ANYTHING_PATH}]] — get thoughts in and link them together
|
|
212
|
+
- [[${TAGS_GRAPH_PATH}]] — structure that grows out of what you write
|
|
213
|
+
- [[${CONNECT_AI_PATH}]] — any AI can read and write your vault
|
|
214
|
+
- [[${YOURS_TO_KEEP_PATH}]] — export everything, anytime
|
|
215
|
+
|
|
216
|
+
They're ordinary notes, tagged #guide. Edit them, add to them, or delete the
|
|
217
|
+
lot — the vault is yours, remember.
|
|
123
218
|
`,
|
|
124
219
|
},
|
|
125
220
|
{
|
|
126
|
-
path:
|
|
127
|
-
|
|
221
|
+
path: CAPTURE_ANYTHING_PATH,
|
|
222
|
+
...guideHuman,
|
|
223
|
+
content: `# ${CAPTURE_ANYTHING_PATH}
|
|
224
|
+
|
|
225
|
+
The one habit that makes a vault work: when a thought strikes, write it down.
|
|
226
|
+
Don't sort it. Don't file it. Let it land.
|
|
128
227
|
|
|
129
|
-
|
|
228
|
+
A note can be two words or two pages. The blog idea on a trail, the follow-up
|
|
229
|
+
from a meeting, the dream at 2am — capture first. Organizing can happen later,
|
|
230
|
+
or never; that's what tags and your AI are for.
|
|
231
|
+
|
|
232
|
+
To connect two notes, wrap a note's name in double square brackets:
|
|
233
|
+
[[${WELCOME_PATH}]] is a link back to where you started. If the note
|
|
234
|
+
doesn't exist yet, the link waits patiently and connects the moment it does.
|
|
235
|
+
|
|
236
|
+
That's the whole trick. Storage is easy — connection is everything. A linked
|
|
237
|
+
note is a thought your future self, and your AI, can find again.
|
|
238
|
+
|
|
239
|
+
Next: [[${TAGS_GRAPH_PATH}]].
|
|
240
|
+
`,
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
path: TAGS_GRAPH_PATH,
|
|
244
|
+
...guideHuman,
|
|
245
|
+
content: `# ${TAGS_GRAPH_PATH}
|
|
246
|
+
|
|
247
|
+
Notes and links come first. Tags come later, when you notice a pattern.
|
|
248
|
+
|
|
249
|
+
A tag answers "what kind of thing is this?" — #person, #recipe, #idea. Tag a
|
|
250
|
+
few notes the same way and a view lights up: all your people, all your recipes,
|
|
251
|
+
everything half-finished. Your vault starts with just two:
|
|
252
|
+
|
|
253
|
+
- #capture — the Notes app puts it on whatever you write or speak in directly,
|
|
254
|
+
marking your own raw words.
|
|
255
|
+
- #guide — these notes.
|
|
256
|
+
|
|
257
|
+
Underneath, everything is one graph: every note a node, every link an edge.
|
|
258
|
+
Open the graph view and watch it grow; ask a connected AI "what's near this
|
|
259
|
+
note?" and it walks the edges for you.
|
|
260
|
+
|
|
261
|
+
The structure isn't a filing system you design on day one. It grows out of what
|
|
262
|
+
you actually write — so don't over-organize an empty vault. Write, link, and
|
|
263
|
+
let the shape appear.
|
|
264
|
+
|
|
265
|
+
Next: [[${CONNECT_AI_PATH}]].
|
|
130
266
|
`,
|
|
131
267
|
},
|
|
132
268
|
{
|
|
133
269
|
path: CONNECT_AI_PATH,
|
|
270
|
+
...guideHuman,
|
|
134
271
|
content: `# ${CONNECT_AI_PATH}
|
|
135
272
|
|
|
136
|
-
Your vault speaks MCP
|
|
137
|
-
|
|
273
|
+
Your vault speaks MCP — an open standard — so any AI can read and write it:
|
|
274
|
+
Claude, ChatGPT, Claude Code, Cursor, or an agent you build. ${consoleLine}
|
|
275
|
+
|
|
276
|
+
Once you're connected, try:
|
|
277
|
+
|
|
278
|
+
- "Read the Getting Started note and help me set this vault up."
|
|
279
|
+
- "What did I want to write about?"
|
|
280
|
+
- "Tag my untagged notes."
|
|
281
|
+
|
|
282
|
+
Your AI sees what you see — the same notes, links, and tags — and what it
|
|
283
|
+
writes lands here, as notes you can read, edit, or delete. There's even a note
|
|
284
|
+
in this vault written for it: [[${GETTING_STARTED_PATH}]], the vault-design brief your
|
|
285
|
+
AI reads so you don't have to.
|
|
286
|
+
|
|
287
|
+
One memory, shared with every AI you choose to connect. That's the point.
|
|
288
|
+
|
|
289
|
+
Next: [[${YOURS_TO_KEEP_PATH}]].
|
|
290
|
+
`,
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
path: YOURS_TO_KEEP_PATH,
|
|
294
|
+
...guideHuman,
|
|
295
|
+
content: `# ${YOURS_TO_KEEP_PATH}
|
|
296
|
+
|
|
297
|
+
Everything here — every note, tag, and link — exports as plain markdown files
|
|
298
|
+
with the structure intact. On Parachute Cloud, it's the Export button in your
|
|
299
|
+
console. Self-hosting, it's one command: \`parachute-vault export <dir>\`.
|
|
300
|
+
|
|
301
|
+
What you get is a folder any app can open, and another Parachute can import
|
|
302
|
+
losslessly. Move from our cloud to your own machine, or the other way — same
|
|
303
|
+
software, same format, nothing held hostage between the doors.
|
|
304
|
+
|
|
305
|
+
We build it this way on purpose. A connection you can't leave isn't a
|
|
306
|
+
relationship. You can always leave — which is exactly why you can trust
|
|
307
|
+
staying.
|
|
308
|
+
|
|
309
|
+
Export isn't a feature we grudgingly support; it's the moral center of the
|
|
310
|
+
design. We test it constantly, because a promise you don't test is just a
|
|
311
|
+
hope.
|
|
312
|
+
|
|
313
|
+
Back to [[${WELCOME_PATH}]].
|
|
138
314
|
`,
|
|
139
315
|
},
|
|
140
316
|
],
|
|
@@ -156,24 +332,98 @@ export const SURFACE_STARTER_PATH = "Surface Starter";
|
|
|
156
332
|
/**
|
|
157
333
|
* Body of the seeded `Getting Started` note.
|
|
158
334
|
*
|
|
159
|
-
* Voice: addressed to the connected AI ("you"), practical, SKILL.md-style.
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
335
|
+
* Voice: addressed to the connected AI ("you"), warm and practical, SKILL.md-style.
|
|
336
|
+
* ONE note, two labeled parts:
|
|
337
|
+
* - Part 1 — Onboarding the person: lead with a real conversation, leverage
|
|
338
|
+
* prior context, offer to survey their content, do one small real thing
|
|
339
|
+
* first, the data-in menu (import UI / MCP bridge / paste-and-file — NOT the
|
|
340
|
+
* CLI, NOT Obsidian-first), the arrived-with-content branch, and closing the
|
|
341
|
+
* loop by updating the vault description (see DEFAULT_VAULT_DESCRIPTION).
|
|
342
|
+
* - Part 2 — Vault mechanics: the preserved reference — what a vault is + the
|
|
343
|
+
* tool list, tags-vs-paths-vs-schemas, write gotchas, the custom-surface
|
|
344
|
+
* pointer, and "Adapt this note."
|
|
345
|
+
*
|
|
346
|
+
* Deliberately points AIs at the import UI + MCP bridge, never at a
|
|
347
|
+
* `parachute-vault import` CLI (that verb doesn't exist on cloud — the old copy
|
|
348
|
+
* shipped that live bug to every hosted user). Mentions the Surface Starter PACK
|
|
349
|
+
* by name (no wikilink — the note isn't seeded by default, and a dangling
|
|
350
|
+
* `[[Surface Starter]]` would sit unresolved on every fresh vault).
|
|
165
351
|
*/
|
|
166
352
|
export const GETTING_STARTED_CONTENT = `# Getting Started
|
|
167
353
|
|
|
168
|
-
This is the **start-here guide** for this Parachute vault —
|
|
169
|
-
\`SKILL.md
|
|
170
|
-
|
|
171
|
-
need to re-read it every session. It
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
354
|
+
This is the **start-here guide** for this Parachute vault — instructions for a
|
|
355
|
+
connected AI (you), like a \`SKILL.md\`. Read it when the vault is new, when
|
|
356
|
+
you're orienting yourself, or when the person says *"help me set up my
|
|
357
|
+
parachute."* You don't need to re-read it every session. It has two parts:
|
|
358
|
+
**how to onboard the person** (start there if they're new) and **vault
|
|
359
|
+
mechanics** (reference for operating the vault). It's a starting point, not a
|
|
360
|
+
script — and it's editable (see "Adapt this note" at the end).
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
## Part 1 — Onboarding the person
|
|
365
|
+
|
|
366
|
+
### Start with a conversation, not a filing system
|
|
367
|
+
|
|
368
|
+
If this vault is fresh, this is probably the person's first real session with
|
|
369
|
+
it. Don't start building — orient them, briefly, in your own words: this vault
|
|
370
|
+
is **their** memory, one place every AI they choose to connect can read and
|
|
371
|
+
write, and they can export all of it anytime as plain markdown. Then have a
|
|
372
|
+
real conversation:
|
|
373
|
+
|
|
374
|
+
- **How do they imagine using this?** A journal, a project brain, a people
|
|
375
|
+
directory, meeting notes, a commonplace book — or they don't know yet
|
|
376
|
+
(that's fine; capture-first works).
|
|
377
|
+
- **Where do their notes and data live now?** Another app, a pile of markdown,
|
|
378
|
+
another Parachute — or nowhere yet.
|
|
379
|
+
- **What's alive for them right now?** A project, a decision, a trip, a draft.
|
|
380
|
+
The first note should be about something real.
|
|
381
|
+
|
|
382
|
+
**Use what you already know.** If you have memory or context about this person
|
|
383
|
+
from working with them, bring it — propose the two or three kinds of
|
|
384
|
+
information *they specifically* would want here ("you mention a lot of book
|
|
385
|
+
ideas — want a place for those?"). Propose, confirm, then write; never
|
|
386
|
+
bulk-create structure they didn't agree to. You can also **offer to survey**
|
|
387
|
+
what they already have — "want me to look through what you've brought in and
|
|
388
|
+
suggest a few ways to organize it?" — then read a sample and propose, don't
|
|
389
|
+
impose.
|
|
390
|
+
|
|
391
|
+
### Do one small real thing first
|
|
392
|
+
|
|
393
|
+
The failure mode of every notes tool is over-organizing an empty vault. So:
|
|
394
|
+
**one small real thing this session.** Capture a thought they actually have,
|
|
395
|
+
file one document, or bring in one small batch — then stop and show them what
|
|
396
|
+
happened. Structure grows from real notes (that's Part 2's design vocabulary).
|
|
397
|
+
Setup is a relationship over many sessions, not an install step.
|
|
398
|
+
|
|
399
|
+
Five short guides ship in this vault for the person to read (tagged \`#guide\`):
|
|
400
|
+
[[Welcome to your vault 🪂]], [[Capture anything]], [[Tags and the graph]],
|
|
401
|
+
[[Connect your AI]], and [[Yours to keep]]. Point them there for anything you'd
|
|
402
|
+
otherwise lecture about — they're the return-point between sessions. If a
|
|
403
|
+
session ends mid-setup, leave a breadcrumb (update this note or the vault
|
|
404
|
+
description) so the next one picks up where you left off.
|
|
405
|
+
|
|
406
|
+
### If this vault already has content
|
|
407
|
+
|
|
408
|
+
Some vaults arrive full — an import, a restore, months of use. Then your first
|
|
409
|
+
job is to **learn it, not seed it**: \`vault-info { include_stats: true }\`,
|
|
410
|
+
\`list-tags\`, read a sample (\`query-notes { search: "..." }\`), then reflect the
|
|
411
|
+
shape back to the person and ask what's missing or wrong. Propose structure
|
|
412
|
+
only once you can describe what's already there.
|
|
413
|
+
|
|
414
|
+
### Close the loop: describe the vault
|
|
415
|
+
|
|
416
|
+
When the first real structure lands, update the vault description —
|
|
417
|
+
\`vault-info { description: "..." }\` — so every future session (yours or another
|
|
418
|
+
AI's) starts oriented: what this vault is for, its main tags, its conventions.
|
|
419
|
+
The default description says "brand-new vault"; once that stops being true,
|
|
420
|
+
replace it. Keep it a few sentences (the projection carries the schema detail,
|
|
421
|
+
and this note carries the richer conventions — see "Adapt this note"). Bringing
|
|
422
|
+
their existing data in is covered under "Bringing existing notes in" below.
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
## Part 2 — Vault mechanics
|
|
177
427
|
|
|
178
428
|
## What a Parachute vault is
|
|
179
429
|
|
|
@@ -267,33 +517,40 @@ A few behaviors worth knowing before you write at scale:
|
|
|
267
517
|
|
|
268
518
|
(Full design guide, with copy-paste examples: https://parachute.computer/scripting/)
|
|
269
519
|
|
|
270
|
-
##
|
|
520
|
+
## Bringing existing notes in
|
|
521
|
+
|
|
522
|
+
If the person already keeps notes somewhere, help them bring what matters —
|
|
523
|
+
start with what's alive for them, not the whole archive. What works today:
|
|
271
524
|
|
|
272
|
-
|
|
273
|
-
|
|
525
|
+
- **Connect the source over MCP (the flexible path).** If their AI client can
|
|
526
|
+
add other MCP servers — a Google Drive server, a Notion server, a filesystem
|
|
527
|
+
server — connect it alongside this vault in the same session. Then read from
|
|
528
|
+
there and \`create-note\` here: a selective, conversational migration, no
|
|
529
|
+
export file needed.
|
|
530
|
+
- **Paste and file.** They paste anything — a doc, a list, an export — and you
|
|
531
|
+
\`create-note\` it (batch mode for many at once).
|
|
532
|
+
- **Import a Parachute export.** Moving between Parachute vaults (cloud ⇄
|
|
533
|
+
self-host) is lossless — on Parachute Cloud, the console's Import button —
|
|
534
|
+
ids, tags, links, schemas, and attachments round-trip.
|
|
274
535
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
- **Ad hoc / pasted content:** just \`create-note\` it. Then help the operator tag
|
|
281
|
-
and schematize: read a sample of imported notes, propose a small tag
|
|
282
|
-
vocabulary, and apply it.
|
|
536
|
+
Bringing a whole Obsidian or Markdown library in is getting easier, and a
|
|
537
|
+
flexible import is on the way; for now the connect-over-MCP and paste paths
|
|
538
|
+
handle it without anyone touching a terminal. Whatever they bring, offer to
|
|
539
|
+
survey it and suggest a first structure — read a sample, propose a small tag
|
|
540
|
+
vocabulary, confirm, then apply. Don't impose structure silently.
|
|
283
541
|
|
|
284
|
-
After
|
|
285
|
-
\`list-tags\`
|
|
286
|
-
spot-check.
|
|
542
|
+
After bringing content in, orient: \`vault-info\` for the schema picture,
|
|
543
|
+
\`list-tags\` for the vocabulary that arrived, \`query-notes { search: "..." }\`
|
|
544
|
+
to spot-check.
|
|
287
545
|
|
|
288
546
|
## Later: a custom surface
|
|
289
547
|
|
|
290
548
|
Building a custom UI over the vault (a dashboard, a notes app) is usually **not**
|
|
291
|
-
the starting point — get the notes and structure right first.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
\`
|
|
295
|
-
|
|
296
|
-
\`@openparachute/surface-client\` + \`@openparachute/surface-render\`.
|
|
549
|
+
the starting point — get the notes and structure right first. When the person
|
|
550
|
+
wants one, add the **Surface Starter** guide to this vault — an optional pack
|
|
551
|
+
(not installed by default) that covers building a surface with
|
|
552
|
+
\`@openparachute/surface-client\` + \`@openparachute/surface-render\`. On Parachute
|
|
553
|
+
Cloud it's the console's add-pack affordance.
|
|
297
554
|
|
|
298
555
|
## Adapt this note
|
|
299
556
|
|
|
@@ -313,8 +570,17 @@ export const GETTING_STARTED_PACK: SeedPack = {
|
|
|
313
570
|
name: "getting-started",
|
|
314
571
|
description:
|
|
315
572
|
"The AI-facing start-here guide: vault design vocabulary (tags/paths/schemas), imports, write gotchas. Seeded by default on new vaults.",
|
|
316
|
-
|
|
317
|
-
|
|
573
|
+
// Declares GUIDE_TAG too — each pack is self-sufficient; the upsert converges
|
|
574
|
+
// with the welcome pack's. This guide is written FOR the AI.
|
|
575
|
+
tags: [GUIDE_TAG],
|
|
576
|
+
notes: [
|
|
577
|
+
{
|
|
578
|
+
path: GETTING_STARTED_PATH,
|
|
579
|
+
tags: ["guide"],
|
|
580
|
+
metadata: { written_for: "ai" },
|
|
581
|
+
content: GETTING_STARTED_CONTENT,
|
|
582
|
+
},
|
|
583
|
+
],
|
|
318
584
|
};
|
|
319
585
|
|
|
320
586
|
// ---------------------------------------------------------------------------
|
|
@@ -450,8 +716,17 @@ export const SURFACE_STARTER_PACK: SeedPack = {
|
|
|
450
716
|
name: "surface-starter",
|
|
451
717
|
description:
|
|
452
718
|
"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
|
-
|
|
719
|
+
// A guide too (written for the AI) — declares GUIDE_TAG so the opt-in
|
|
720
|
+
// add-pack path lands the tag even if this pack is applied on its own.
|
|
721
|
+
tags: [GUIDE_TAG],
|
|
722
|
+
notes: [
|
|
723
|
+
{
|
|
724
|
+
path: SURFACE_STARTER_PATH,
|
|
725
|
+
tags: ["guide"],
|
|
726
|
+
metadata: { written_for: "ai" },
|
|
727
|
+
content: SURFACE_STARTER_CONTENT,
|
|
728
|
+
},
|
|
729
|
+
],
|
|
455
730
|
};
|
|
456
731
|
|
|
457
732
|
// ---------------------------------------------------------------------------
|
|
@@ -541,18 +816,23 @@ export async function applySeedPack(
|
|
|
541
816
|
await store.upsertTagRecord(decl.name, {
|
|
542
817
|
description: decl.description,
|
|
543
818
|
...(decl.parent_names ? { parent_names: decl.parent_names } : {}),
|
|
819
|
+
...(decl.fields ? { fields: decl.fields } : {}),
|
|
544
820
|
});
|
|
545
821
|
result.tags.push(decl.name);
|
|
546
822
|
}
|
|
547
823
|
|
|
548
|
-
for (const
|
|
549
|
-
const existing = await store.getNoteByPath(path);
|
|
824
|
+
for (const note of pack.notes) {
|
|
825
|
+
const existing = await store.getNoteByPath(note.path);
|
|
550
826
|
if (existing) {
|
|
551
|
-
result.skippedNotes.push(path);
|
|
827
|
+
result.skippedNotes.push(note.path);
|
|
552
828
|
continue;
|
|
553
829
|
}
|
|
554
|
-
await store.createNote(content, {
|
|
555
|
-
|
|
830
|
+
await store.createNote(note.content, {
|
|
831
|
+
path: note.path,
|
|
832
|
+
tags: note.tags,
|
|
833
|
+
metadata: note.metadata,
|
|
834
|
+
});
|
|
835
|
+
result.seededNotes.push(note.path);
|
|
556
836
|
}
|
|
557
837
|
|
|
558
838
|
return result;
|
package/package.json
CHANGED
|
@@ -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
|
|
|
@@ -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
|
-
|
|
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
|
|
84
|
-
for (const path of [
|
|
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
|
|
103
|
+
test("seeds the capture tag Notes requires (byte-equal semantics) + the guide/pinned tags", async () => {
|
|
90
104
|
const result = await seedOnboardingNotes(store);
|
|
91
|
-
|
|
105
|
+
// welcome upserts [capture, guide, pinned]; getting-started re-upserts
|
|
106
|
+
// [guide] (converges). applySeedPack reports every declared tag per pack,
|
|
107
|
+
// so the concatenation carries guide twice — deterministic.
|
|
108
|
+
expect(result.tags).toEqual(["capture", "guide", "pinned", "guide"]);
|
|
92
109
|
|
|
93
110
|
for (const decl of NOTES_REQUIRED_TAGS) {
|
|
94
111
|
const record = await store.getTagRecord(decl.name);
|
|
@@ -97,6 +114,14 @@ 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 its written_for enum schema (applier passes
|
|
118
|
+
// `fields` through to upsertTagRecord). "ai" is the default (first value).
|
|
119
|
+
const guide = await store.getTagRecord("guide");
|
|
120
|
+
expect(guide).not.toBeNull();
|
|
121
|
+
expect(guide!.fields?.written_for?.enum).toEqual(["ai", "human", "both"]);
|
|
122
|
+
const pinned = await store.getTagRecord("pinned");
|
|
123
|
+
expect(pinned).not.toBeNull();
|
|
124
|
+
|
|
100
125
|
// The retired subtype tags are NOT seeded — entry method is note
|
|
101
126
|
// metadata.source (text|voice), not taxonomy (2026-07-03). Existing
|
|
102
127
|
// vaults carrying these tags stay valid; new vaults don't get them.
|
|
@@ -104,6 +129,32 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
104
129
|
expect(await store.getTagRecord("capture/voice")).toBeNull();
|
|
105
130
|
});
|
|
106
131
|
|
|
132
|
+
test("seeded guides carry #guide + metadata.written_for (create-time tag/metadata write-through)", async () => {
|
|
133
|
+
await seedOnboardingNotes(store);
|
|
134
|
+
|
|
135
|
+
// Human-facing welcome guide: tagged guide + pinned, written_for human.
|
|
136
|
+
const welcome = await store.getNoteByPath(WELCOME_PATH);
|
|
137
|
+
expect([...(welcome!.tags ?? [])].sort()).toEqual(["guide", "pinned"]);
|
|
138
|
+
expect(welcome!.metadata?.written_for).toBe("human");
|
|
139
|
+
|
|
140
|
+
// The four sibling guides: guide only, written_for human.
|
|
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
|
+
expect(note!.metadata?.written_for).toBe("human");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The AI-facing Getting Started guide: guide, written_for ai.
|
|
153
|
+
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
154
|
+
expect(gs!.tags).toEqual(["guide"]);
|
|
155
|
+
expect(gs!.metadata?.written_for).toBe("ai");
|
|
156
|
+
});
|
|
157
|
+
|
|
107
158
|
test("GAP 4: Getting Started shows a concrete update-tag fields example + write gotchas", async () => {
|
|
108
159
|
await seedOnboardingNotes(store);
|
|
109
160
|
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
@@ -121,26 +172,38 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
121
172
|
expect(gs!.content).toContain("first listed value");
|
|
122
173
|
});
|
|
123
174
|
|
|
124
|
-
test("A3 (reshaped): the welcome
|
|
175
|
+
test("A3 (reshaped): the welcome ring resolves to real link edges; Getting Started has no dangling Surface Starter link", async () => {
|
|
125
176
|
await seedOnboardingNotes(store);
|
|
126
177
|
|
|
127
|
-
// welcome → try-linking, try-linking → welcome, connect-AI → welcome.
|
|
128
178
|
const welcome = await store.getNoteByPath(WELCOME_PATH);
|
|
129
|
-
const
|
|
179
|
+
const capture = await store.getNoteByPath(CAPTURE_ANYTHING_PATH);
|
|
180
|
+
const tags = await store.getNoteByPath(TAGS_GRAPH_PATH);
|
|
130
181
|
const connectAi = await store.getNoteByPath(CONNECT_AI_PATH);
|
|
182
|
+
const yours = await store.getNoteByPath(YOURS_TO_KEEP_PATH);
|
|
183
|
+
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
131
184
|
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
185
|
+
const outbound = async (id: string) =>
|
|
186
|
+
(await store.getLinks(id, { direction: "outbound" })).map((l) => l.targetId);
|
|
187
|
+
|
|
188
|
+
// Welcome → all four siblings.
|
|
189
|
+
const welcomeOut = await outbound(welcome!.id);
|
|
190
|
+
for (const t of [capture!.id, tags!.id, connectAi!.id, yours!.id]) {
|
|
191
|
+
expect(welcomeOut).toContain(t);
|
|
192
|
+
}
|
|
193
|
+
// The chain forward: capture → tags → connect → yours → welcome.
|
|
194
|
+
expect(await outbound(capture!.id)).toContain(tags!.id);
|
|
195
|
+
expect(await outbound(tags!.id)).toContain(connectAi!.id);
|
|
196
|
+
const connectOut = await outbound(connectAi!.id);
|
|
197
|
+
expect(connectOut).toContain(yours!.id);
|
|
198
|
+
// Connect your AI also links the AI-facing Getting Started note (seeded by
|
|
199
|
+
// the getting-started pack) — the ring's cross-link resolves, no dangle.
|
|
200
|
+
expect(connectOut).toContain(gs!.id);
|
|
201
|
+
expect(await outbound(yours!.id)).toContain(welcome!.id);
|
|
138
202
|
|
|
139
203
|
// Getting Started mentions the surface-starter PACK — not a wikilink to a
|
|
140
204
|
// note that the default seed no longer creates.
|
|
141
|
-
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
142
205
|
expect(gs!.content).not.toContain("[[Surface Starter]]");
|
|
143
|
-
expect(gs!.content).toContain("add-pack
|
|
206
|
+
expect(gs!.content).toContain("add-pack");
|
|
144
207
|
});
|
|
145
208
|
|
|
146
209
|
test("idempotent: a second seed run skips all notes (does not duplicate)", async () => {
|
|
@@ -179,7 +242,8 @@ describe("applySeedPack — surface-starter via add-pack", () => {
|
|
|
179
242
|
expect(result.pack).toBe("surface-starter");
|
|
180
243
|
expect(result.seededNotes).toEqual([SURFACE_STARTER_PATH]);
|
|
181
244
|
expect(result.skippedNotes).toEqual([]);
|
|
182
|
-
|
|
245
|
+
// surface-starter now declares GUIDE_TAG (it's a guide, written_for ai).
|
|
246
|
+
expect(result.tags).toEqual(["guide"]);
|
|
183
247
|
|
|
184
248
|
const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
|
|
185
249
|
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.
|
package/src/vault-create.test.ts
CHANGED
|
@@ -358,11 +358,11 @@ describe("vault create — services.json registration (#208)", () => {
|
|
|
358
358
|
* Default-pack seeding on create (originally demo-prep Workstream A — A1/A3;
|
|
359
359
|
* reshaped for named seed packs).
|
|
360
360
|
*
|
|
361
|
-
* A freshly-created vault must contain the `welcome` pack (
|
|
362
|
-
*
|
|
363
|
-
* NOT the `surface-starter` pack, which is opt-in via `add-pack`
|
|
364
|
-
* 2026-07-02). Idempotent + best-effort: the seed never fails a
|
|
365
|
-
* never clobbers an edited note.
|
|
361
|
+
* A freshly-created vault must contain the `welcome` pack (the five-guide
|
|
362
|
+
* welcome ring + the capture/guide/pinned tags) and the `getting-started`
|
|
363
|
+
* guide — and NOT the `surface-starter` pack, which is opt-in via `add-pack`
|
|
364
|
+
* (ratified 2026-07-02). Idempotent + best-effort: the seed never fails a
|
|
365
|
+
* create and never clobbers an edited note.
|
|
366
366
|
*/
|
|
367
367
|
describe("vault create — default pack seeding (welcome + getting-started)", () => {
|
|
368
368
|
/** Read all (path, content) rows from a created vault's SQLite DB. */
|
|
@@ -400,23 +400,28 @@ describe("vault create — default pack seeding (welcome + getting-started)", ()
|
|
|
400
400
|
const notes = readNotes("guided");
|
|
401
401
|
const gs = notes.find((n) => n.path === "Getting Started");
|
|
402
402
|
const welcome = notes.find((n) => n.path === "Welcome to your vault 🪂");
|
|
403
|
-
const
|
|
403
|
+
const captureAnything = notes.find((n) => n.path === "Capture anything");
|
|
404
|
+
const tagsGraph = notes.find((n) => n.path === "Tags and the graph");
|
|
404
405
|
const connectAi = notes.find((n) => n.path === "Connect your AI");
|
|
406
|
+
const yoursToKeep = notes.find((n) => n.path === "Yours to keep");
|
|
405
407
|
expect(gs).toBeDefined();
|
|
406
408
|
expect(welcome).toBeDefined();
|
|
407
|
-
expect(
|
|
409
|
+
expect(captureAnything).toBeDefined();
|
|
410
|
+
expect(tagsGraph).toBeDefined();
|
|
408
411
|
expect(connectAi).toBeDefined();
|
|
412
|
+
expect(yoursToKeep).toBeDefined();
|
|
409
413
|
expect(gs!.content).toContain("# Getting Started");
|
|
410
414
|
// Surface Starter is out of the default seed — no note, no dangling link.
|
|
411
415
|
expect(notes.find((n) => n.path === "Surface Starter")).toBeUndefined();
|
|
412
416
|
expect(gs!.content).not.toContain("[[Surface Starter]]");
|
|
413
|
-
expect(gs!.content).toContain("add-pack
|
|
417
|
+
expect(gs!.content).toContain("add-pack");
|
|
414
418
|
|
|
415
|
-
// The
|
|
416
|
-
//
|
|
419
|
+
// The capture tag Notes requires arrives with the welcome pack, alongside
|
|
420
|
+
// the guide (skill-file) + pinned tags — and nothing else (fresh-vault
|
|
421
|
+
// seed = 6 notes: the 5-guide ring + Getting Started. The retired
|
|
417
422
|
// capture/text + capture/voice subtypes are no longer seeded).
|
|
418
|
-
expect(readTagNames("guided")).toEqual(["capture"]);
|
|
419
|
-
expect(notes).toHaveLength(
|
|
423
|
+
expect(readTagNames("guided").sort()).toEqual(["capture", "guide", "pinned"]);
|
|
424
|
+
expect(notes).toHaveLength(6);
|
|
420
425
|
});
|
|
421
426
|
|
|
422
427
|
test("seeding doesn't break --json stdout (notes seeded silently)", () => {
|