@openparachute/vault 0.6.5-rc.1 → 0.6.5-rc.11

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 (46) hide show
  1. package/core/src/do-param-cap.test.ts +161 -0
  2. package/core/src/links.ts +8 -13
  3. package/core/src/notes.ts +33 -15
  4. package/core/src/onboarding.ts +14 -296
  5. package/core/src/seed-packs.test.ts +191 -0
  6. package/core/src/seed-packs.ts +559 -0
  7. package/core/src/sql-in.test.ts +58 -0
  8. package/core/src/sql-in.ts +76 -0
  9. package/core/src/transcription/provider.ts +141 -0
  10. package/core/src/txn.test.ts +68 -0
  11. package/core/src/txn.ts +31 -4
  12. package/core/src/vault-projection.ts +1 -1
  13. package/core/src/wikilinks.ts +10 -4
  14. package/package.json +1 -1
  15. package/src/add-pack.test.ts +142 -0
  16. package/src/cli.ts +806 -7
  17. package/src/onboarding-seed.test.ts +126 -40
  18. package/src/onboarding-seed.ts +41 -46
  19. package/src/routes.ts +17 -0
  20. package/src/server.ts +108 -31
  21. package/src/transcription/build.test.ts +305 -0
  22. package/src/transcription/build.ts +332 -0
  23. package/src/transcription/capability.test.ts +118 -0
  24. package/src/transcription/capability.ts +96 -0
  25. package/src/transcription/download.test.ts +101 -0
  26. package/src/transcription/download.ts +71 -0
  27. package/src/transcription/install-python.test.ts +366 -0
  28. package/src/transcription/install-python.ts +471 -0
  29. package/src/transcription/install.test.ts +167 -0
  30. package/src/transcription/install.ts +296 -0
  31. package/src/transcription/providers/onnx-asr.test.ts +229 -0
  32. package/src/transcription/providers/onnx-asr.ts +239 -0
  33. package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
  34. package/src/transcription/providers/parakeet-mlx.ts +242 -0
  35. package/src/transcription/providers/scribe-http.test.ts +195 -0
  36. package/src/transcription/providers/scribe-http.ts +144 -0
  37. package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
  38. package/src/transcription/providers/transcribe-cpp.ts +293 -0
  39. package/src/transcription/select.test.ts +299 -0
  40. package/src/transcription/select.ts +397 -0
  41. package/src/transcription/tiers.test.ts +197 -0
  42. package/src/transcription/tiers.ts +184 -0
  43. package/src/transcription-worker.test.ts +44 -0
  44. package/src/transcription-worker.ts +57 -122
  45. package/src/vault-create.test.ts +38 -10
  46. package/src/vault.test.ts +48 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Behavioral proof that the id-list query paths stay under the Cloudflare
3
+ * Durable Object SQLite 100-bound-param cap over a real >100-note vault.
4
+ *
5
+ * bun:sqlite tolerates 999+ bound params, so it CANNOT reproduce the DO
6
+ * `too many SQL variables` 500 directly — the ultimate DO-constraint proof is
7
+ * the cloud staging export re-verify (orchestrator's post-merge step). What we
8
+ * CAN prove locally, and what actually catches the regression that let the bug
9
+ * ship, is the *shape* of the SQL each path issues: no single prepared
10
+ * statement binds more than the chunk size (chunked lists) or a handful of
11
+ * params (json_each id-sets). A prepare-spy records the max bind count per
12
+ * statement; if a future edit raises the chunk size back over the cap, the
13
+ * placeholder-IN assertion here fails.
14
+ *
15
+ * Root cause recap: DO SQLite caps bound params at 100/statement. The export
16
+ * walk pages 500 notes at a time and hydrated each page's rows + tags with an
17
+ * `id IN (?×page)` list sized for standard SQLite's 999 floor → >100 params →
18
+ * DO 500. Fix: chunk id-lists at 90 (sql-in.ts) and bind embedded id-sets
19
+ * (e.g. `near` neighborhoods) as one json_each param.
20
+ */
21
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
22
+ import { Database } from "bun:sqlite";
23
+ import { existsSync, mkdtempSync, rmSync } from "fs";
24
+ import { tmpdir } from "os";
25
+ import { join } from "path";
26
+
27
+ import { SqliteStore } from "./store.js";
28
+ import { exportVaultToDir } from "./portable-md.js";
29
+ import { IN_PARAM_CHUNK } from "./sql-in.js";
30
+
31
+ /** Zero-padded so lexicographic id order == numeric order. */
32
+ const pad = (i: number): string => String(i).padStart(5, "0");
33
+
34
+ /** Wrap `db.prepare` to record the max bind-arg count seen per SQL string.
35
+ * Install AFTER seeding so bulk-insert params don't pollute the sample. */
36
+ function installBindSpy(db: Database) {
37
+ const perSql = new Map<string, number>();
38
+ const origPrepare = db.prepare.bind(db);
39
+ (db as unknown as { prepare: (sql: string) => unknown }).prepare = (sql: string) => {
40
+ const stmt = origPrepare(sql) as Record<string, unknown>;
41
+ const record = (n: number) => perSql.set(sql, Math.max(perSql.get(sql) ?? 0, n));
42
+ for (const m of ["all", "get", "run", "values", "iterate"] as const) {
43
+ const orig = stmt[m];
44
+ if (typeof orig === "function") {
45
+ stmt[m] = (...args: unknown[]) => {
46
+ record(args.length);
47
+ return (orig as (...a: unknown[]) => unknown).apply(stmt, args);
48
+ };
49
+ }
50
+ }
51
+ return stmt;
52
+ };
53
+ return {
54
+ restore() {
55
+ (db as unknown as { prepare: unknown }).prepare = origPrepare;
56
+ },
57
+ /** Max binds over statements carrying a literal `IN (?, …)` placeholder
58
+ * list — the chunked kind that must never exceed the chunk size. */
59
+ maxPlaceholderInBinds(): number {
60
+ let max = 0;
61
+ for (const [sql, n] of perSql) if (/\bIN \(\?/.test(sql)) max = Math.max(max, n);
62
+ return max;
63
+ },
64
+ /** Max binds over ALL statements — must stay under the DO cap. */
65
+ maxAnyBinds(): number {
66
+ let max = 0;
67
+ for (const n of perSql.values()) max = Math.max(max, n);
68
+ return max;
69
+ },
70
+ usedJsonEach(): boolean {
71
+ for (const sql of perSql.keys()) if (/json_each\(/.test(sql)) return true;
72
+ return false;
73
+ },
74
+ };
75
+ }
76
+
77
+ const DO_PARAM_CAP = 100;
78
+
79
+ describe("id-list query paths stay under the DO 100-bound-param cap", () => {
80
+ let store: SqliteStore;
81
+ let db: Database;
82
+
83
+ beforeEach(async () => {
84
+ db = new Database(":memory:");
85
+ store = new SqliteStore(db);
86
+ // 250 notes > the DO cap and > 2× the chunk size, so chunking spans
87
+ // multiple chunks (250 = 90 + 90 + 70).
88
+ const inputs = [];
89
+ for (let i = 0; i < 250; i++) {
90
+ inputs.push({ content: `note ${i}`, id: `n${pad(i)}`, path: `notes/n${pad(i)}` });
91
+ }
92
+ await store.createNotes(inputs);
93
+ });
94
+
95
+ it("getNotes chunks a >100-id fetch (correct rows, no statement over the cap)", async () => {
96
+ const ids = Array.from({ length: 250 }, (_, i) => `n${pad(i)}`);
97
+ const spy = installBindSpy(db);
98
+ try {
99
+ const notes = await store.getNotes(ids);
100
+ // Correctness: every requested note returned, ordered by created_at then id.
101
+ expect(notes.length).toBe(250);
102
+ expect(notes.map((n) => n.id)).toEqual(ids);
103
+ } finally {
104
+ spy.restore();
105
+ }
106
+ // Regression guard: no IN-list statement binds more than the chunk size...
107
+ expect(spy.maxPlaceholderInBinds()).toBeLessThanOrEqual(IN_PARAM_CHUNK);
108
+ // ...and nothing at all trips the DO cap.
109
+ expect(spy.maxAnyBinds()).toBeLessThanOrEqual(DO_PARAM_CAP);
110
+ });
111
+
112
+ it("queryNotes over a >100-id set (the `near` path) binds the set as one json_each param", async () => {
113
+ const ids = Array.from({ length: 150 }, (_, i) => `n${pad(i)}`);
114
+ const spy = installBindSpy(db);
115
+ let notes;
116
+ try {
117
+ notes = await store.queryNotes({ ids, limit: 1000, sort: "asc" });
118
+ } finally {
119
+ spy.restore();
120
+ }
121
+ // Correctness: all 150 in-set notes returned.
122
+ expect(notes.length).toBe(150);
123
+ // The id-set filter rode a single json_each param, not 150 placeholders...
124
+ expect(spy.usedJsonEach()).toBe(true);
125
+ // ...so no statement — including the phase-1 id select carrying the set —
126
+ // exceeds the DO cap, and the phase-2 chunked hydration stays under it.
127
+ expect(spy.maxPlaceholderInBinds()).toBeLessThanOrEqual(IN_PARAM_CHUNK);
128
+ expect(spy.maxAnyBinds()).toBeLessThanOrEqual(DO_PARAM_CAP);
129
+ });
130
+
131
+ it("empty id-set still short-circuits (no json_each, no rows)", async () => {
132
+ const notes = await store.queryNotes({ ids: [], limit: 10 });
133
+ expect(notes.length).toBe(0);
134
+ });
135
+
136
+ describe("export a >100-note vault", () => {
137
+ let outDir: string;
138
+ beforeEach(() => { outDir = mkdtempSync(join(tmpdir(), "do-cap-export-")); });
139
+ afterEach(() => { try { rmSync(outDir, { recursive: true, force: true }); } catch {} });
140
+
141
+ it("exports all 250 notes with no statement over the cap (the shipped bug)", async () => {
142
+ const spy = installBindSpy(db);
143
+ let stats;
144
+ try {
145
+ stats = await exportVaultToDir(store, { outDir, exportedAt: "2026-07-03T00:00:00.000Z" });
146
+ } finally {
147
+ spy.restore();
148
+ }
149
+ // Correctness: every note on disk.
150
+ expect(stats.notes).toBe(250);
151
+ expect(existsSync(join(outDir, "notes/n00000.md"))).toBe(true);
152
+ expect(existsSync(join(outDir, "notes/n00090.md"))).toBe(true); // first chunk boundary
153
+ expect(existsSync(join(outDir, "notes/n00249.md"))).toBe(true); // tail
154
+ // The bug: the export page's `id IN (?×page)` hydration exceeded 100 on
155
+ // DO. Now every IN-list statement stays under the chunk size...
156
+ expect(spy.maxPlaceholderInBinds()).toBeLessThanOrEqual(IN_PARAM_CHUNK);
157
+ // ...and no statement trips the DO cap.
158
+ expect(spy.maxAnyBinds()).toBeLessThanOrEqual(DO_PARAM_CAP);
159
+ });
160
+ });
161
+ });
package/core/src/links.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import type { Link, NoteSummary, HydratedLink } from "./types.js";
3
3
  import { getNoteTagsForNotes } from "./notes.js";
4
+ import { chunkForInClause } from "./sql-in.js";
4
5
 
5
6
  export function createLink(
6
7
  db: Database,
@@ -103,16 +104,13 @@ function parseMetadata(raw: string | null): Record<string, unknown> | undefined
103
104
  try { return JSON.parse(raw); } catch { return undefined; }
104
105
  }
105
106
 
106
- /** IN-list chunk size — matches getLinkCounts' conservative bound-variable floor. */
107
- const IN_CHUNK = 900;
108
-
109
107
  function getNoteSummaries(db: Database, noteIds: string[]): Map<string, NoteSummary> {
110
108
  const map = new Map<string, NoteSummary>();
111
109
  if (noteIds.length === 0) return map;
112
110
  const ids = [...new Set(noteIds)];
113
111
  const rows: SummaryRow[] = [];
114
- for (let i = 0; i < ids.length; i += IN_CHUNK) {
115
- const chunk = ids.slice(i, i + IN_CHUNK);
112
+ // Chunk under the DO 100-bound-param cap (see sql-in.ts).
113
+ for (const chunk of chunkForInClause(ids)) {
116
114
  const placeholders = chunk.map(() => "?").join(", ");
117
115
  rows.push(...db.prepare(
118
116
  `SELECT id, path, metadata, created_at, updated_at FROM notes WHERE id IN (${placeholders})`,
@@ -193,8 +191,8 @@ export function getLinksHydratedForNotes(
193
191
  // (source, target, relationship) primary key so a link whose endpoints
194
192
  // are both on the page is fetched once.
195
193
  const rowsByKey = new Map<string, LinkRow>();
196
- for (let i = 0; i < ids.length; i += IN_CHUNK) {
197
- const chunk = ids.slice(i, i + IN_CHUNK);
194
+ // Chunk under the DO 100-bound-param cap (see sql-in.ts).
195
+ for (const chunk of chunkForInClause(ids)) {
198
196
  const placeholders = chunk.map(() => "?").join(", ");
199
197
  for (const column of ["source_id", "target_id"] as const) {
200
198
  const rows = db.prepare(
@@ -265,12 +263,9 @@ export function getLinkCounts(
265
263
  const wantOutbound = direction === "outbound" || direction === "both";
266
264
  const wantInbound = direction === "inbound" || direction === "both";
267
265
 
268
- // SQLite's default SQLITE_MAX_VARIABLE_NUMBER is 999 on older builds and
269
- // 32766 on newer ones; chunk well under the conservative floor so the
270
- // IN-list never trips the bind limit on a large page.
271
- const CHUNK = 900;
272
- for (let i = 0; i < ids.length; i += CHUNK) {
273
- const chunk = ids.slice(i, i + CHUNK);
266
+ // Chunk under the Cloudflare Durable Object SQLite 100-bound-param cap
267
+ // (bun:sqlite tolerates 999+, DO SQLite rejects >100). See sql-in.ts.
268
+ for (const chunk of chunkForInClause(ids)) {
274
269
  const placeholders = chunk.map(() => "?").join(", ");
275
270
 
276
271
  if (wantOutbound) {
package/core/src/notes.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  } from "./cursor.js";
22
22
  import { getIndexedField, releaseField } from "./indexed-fields.js";
23
23
  import { stripTagHash } from "./tag-hierarchy.js";
24
+ import { chunkForInClause, IN_VIA_JSON_EACH, jsonEachParam } from "./sql-in.js";
24
25
 
25
26
  let idCounter = 0;
26
27
 
@@ -178,10 +179,26 @@ export function getNoteByPath(db: Database, path: string, extension?: string): N
178
179
 
179
180
  export function getNotes(db: Database, ids: string[]): Note[] {
180
181
  if (ids.length === 0) return [];
181
- const placeholders = ids.map(() => "?").join(", ");
182
- const rows = db.prepare(
183
- `SELECT * FROM notes WHERE id IN (${placeholders}) ORDER BY created_at`,
184
- ).all(...ids) as NoteRow[];
182
+ // Dedupe before chunking: a duplicate id straddling two chunk boundaries
183
+ // would otherwise be fetched (and returned) twice, unlike the old single
184
+ // IN-list which returned each matching row once regardless of duplicate
185
+ // params. Matches getNoteTagsForNotes / getNoteSummaries.
186
+ const uniqueIds = [...new Set(ids)];
187
+ // Chunk under the DO 100-bound-param cap (see sql-in.ts). Each chunk is its
188
+ // own IN-list query; results are merged and re-sorted by created_at (with id
189
+ // as a deterministic tiebreak) to preserve the single-statement ORDER BY.
190
+ const rows: NoteRow[] = [];
191
+ for (const chunk of chunkForInClause(uniqueIds)) {
192
+ const placeholders = chunk.map(() => "?").join(", ");
193
+ rows.push(...db.prepare(
194
+ `SELECT * FROM notes WHERE id IN (${placeholders})`,
195
+ ).all(...chunk) as NoteRow[]);
196
+ }
197
+ rows.sort((a, b) =>
198
+ a.created_at < b.created_at ? -1
199
+ : a.created_at > b.created_at ? 1
200
+ : a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
201
+ );
185
202
  return notesWithTags(db, rows);
186
203
  }
187
204
 
@@ -756,9 +773,13 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
756
773
  // with an always-false condition; building `IN ()` would be a SQL error.
757
774
  conditions.push("0 = 1");
758
775
  } else {
759
- const placeholders = opts.ids.map(() => "?").join(", ");
760
- conditions.push(`n.id IN (${placeholders})`);
761
- params.push(...opts.ids);
776
+ // Bind the id-set as ONE json_each param, not one `?` per id: this IN is
777
+ // embedded in a paginated statement (shared ORDER BY / LIMIT / OFFSET),
778
+ // so it can't be chunked, and `near` neighborhoods routinely exceed the
779
+ // DO 100-bound-param cap. json_each keeps the whole set at a single
780
+ // bound param. See sql-in.ts.
781
+ conditions.push(`n.id IN ${IN_VIA_JSON_EACH}`);
782
+ params.push(jsonEachParam(opts.ids));
762
783
  }
763
784
  }
764
785
 
@@ -1044,10 +1065,6 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
1044
1065
  return fetchNotesByIdsOrdered(db, idRows.map((r) => r.id));
1045
1066
  }
1046
1067
 
1047
- /** Chunk size for IN-list queries — comfortably under SQLite's conservative
1048
- * 999 bound-variable floor (older builds), matching getLinkCounts. */
1049
- const IN_CHUNK = 900;
1050
-
1051
1068
  /**
1052
1069
  * Fetch full note rows for `ids`, preserving the input order, with tags
1053
1070
  * hydrated via ONE batched query per chunk (not one per note). Ids not
@@ -1056,8 +1073,9 @@ const IN_CHUNK = 900;
1056
1073
  function fetchNotesByIdsOrdered(db: Database, ids: string[]): Note[] {
1057
1074
  if (ids.length === 0) return [];
1058
1075
  const rowsById = new Map<string, NoteRow>();
1059
- for (let i = 0; i < ids.length; i += IN_CHUNK) {
1060
- const chunk = ids.slice(i, i + IN_CHUNK);
1076
+ // Chunk under the DO 100-bound-param cap (see sql-in.ts) a single export
1077
+ // page (EXPORT_BATCH_SIZE=500) or a large query result exceeds it otherwise.
1078
+ for (const chunk of chunkForInClause(ids)) {
1061
1079
  const placeholders = chunk.map(() => "?").join(", ");
1062
1080
  const rows = db.prepare(
1063
1081
  `SELECT * FROM notes WHERE id IN (${placeholders})`,
@@ -1084,8 +1102,8 @@ export function getNoteTagsForNotes(db: Database, noteIds: string[]): Map<string
1084
1102
  if (noteIds.length === 0) return map;
1085
1103
  const ids = [...new Set(noteIds)];
1086
1104
  for (const id of ids) map.set(id, []);
1087
- for (let i = 0; i < ids.length; i += IN_CHUNK) {
1088
- const chunk = ids.slice(i, i + IN_CHUNK);
1105
+ // Chunk under the DO 100-bound-param cap (see sql-in.ts).
1106
+ for (const chunk of chunkForInClause(ids)) {
1089
1107
  const placeholders = chunk.map(() => "?").join(", ");
1090
1108
  const rows = db.prepare(
1091
1109
  `SELECT note_id, tag_name FROM note_tags WHERE note_id IN (${placeholders}) ORDER BY tag_name`,
@@ -1,300 +1,18 @@
1
1
  /**
2
- * Onboarding doctrine — the in-vault guide a freshly-created vault seeds so a
3
- * connected AI can self-orient and set the vault up on the operator's behalf.
2
+ * DEPRECATED back-compat shim — the onboarding content moved to
3
+ * `seed-packs.ts` (named seed packs: `welcome` / `getting-started` /
4
+ * `surface-starter`, one canonical content module for both the bun and cloud
5
+ * runtimes).
4
6
  *
5
- * This module is the single source of truth for the *content* + the *paths* of
6
- * the seeded notes. It lives in `core` (not `src`) so both:
7
- * - the seeding path (`src/onboarding-seed.ts`, called from `createVault`), and
8
- * - the projection/`vault-info` pointer (`core/src/vault-projection.ts`)
9
- * can share the canonical note paths without a cross-layer import.
10
- *
11
- * The notes are AI-legible, practical (SKILL.md-style) guides addressed to the
12
- * assistant that connects to the vault. They are a starting point, not gospel —
13
- * Getting Started tells the AI it can adapt them as the vault matures. Seeding is
14
- * create-time only and idempotent (never clobbers a note the operator/AI has
15
- * since edited).
16
- *
17
- * See the demo-prep Workstream A (A1/A2/A3).
18
- */
19
-
20
- /** Canonical path of the seeded onboarding guide. Top-level, title-cased so it
21
- * reads as a doc and sorts to the top of a casual file listing. */
22
- export const GETTING_STARTED_PATH = "Getting Started";
23
-
24
- /** Canonical path of the seeded surface-build starter, linked from Getting
25
- * Started. */
26
- export const SURFACE_STARTER_PATH = "Surface Starter";
27
-
28
- /**
29
- * Body of the seeded `Getting Started` note.
30
- *
31
- * Voice: addressed to the connected AI ("you"), practical, SKILL.md-style.
32
- * Covers (a) what a Parachute vault is, (b) tags-vs-paths-vs-schemas design,
33
- * (c) importing existing notes, (d) that it's an adaptable starting point.
34
- */
35
- export const GETTING_STARTED_CONTENT = `# Getting Started
36
-
37
- This is the **start-here guide** for this Parachute vault — think of it like a
38
- \`SKILL.md\`: practical instructions for setting up and growing the vault. Read it
39
- when you're **getting the vault started** or orienting yourself to it — you don't
40
- need to re-read it every session. It's a **starting point, not a script**, and
41
- it's adaptable: edit it (see "Adapt this note") as the vault takes shape.
42
-
43
- When the operator says something like *"help me set up my parachute,"* this is
44
- your brief: design their structure with them, import what they already have, and
45
- shape the vault around how they actually think and work.
46
-
47
- ## What a Parachute vault is
48
-
49
- A vault is **notes + tags + links** in one graph, reachable over MCP (you, now),
50
- a REST API (scripts), and any surface (a UI). It ships *blank* — no predefined
51
- tags or schema. You and the operator design the structure that fits *their*
52
- life and work. The vault is the engine; the meaning is yours to bring.
53
-
54
- Core moves you already have as MCP tools:
55
- - \`create-note\` / \`update-note\` / \`delete-note\` — write notes (single or batch).
56
- - \`query-notes\` — by id/path, by tag, full-text \`search\`, or graph \`near\` a note.
57
- - \`list-tags\` / \`update-tag\` / \`delete-tag\` — manage the tag vocabulary + schemas.
58
- - \`find-path\` — shortest link path between two notes.
59
- - \`vault-info\` — refresh the live schema/stats projection any time.
60
-
61
- \`[[wikilinks]]\` in note content auto-link to the note at that path — use them
62
- freely; they resolve even if the target is created later.
63
-
64
- ## Tags vs paths vs schemas — the design vocabulary
65
-
66
- These three axes are the heart of vault design. Use the right one for the job:
67
-
68
- - **Tags = types / membership.** A tag answers *"what kind of thing is this?"*
69
- (\`#person\`, \`#meeting\`, \`#project\`). Queries **expand over tags**: a tag can
70
- declare \`parent_names\` so \`tag:X\` also returns its subtypes (e.g. tagging a
71
- note \`#meeting/standup\` with \`parent_names: [meeting]\` means \`query-notes
72
- { tag: "meeting" }\` finds it). Tags are how you ask *"show me all my people."*
73
- This is the primary structure — reach for a tag first.
74
-
75
- - **Paths = organization / filing.** A path (\`Projects/Acme/Kickoff\`) is *where*
76
- a note lives — a human-browsable address, unique per note. Paths are for
77
- folders and named docs (like this one). They do **not** drive type queries;
78
- don't encode meaning in a path that a tag should carry. A note can have a
79
- path, tags, or both.
80
-
81
- - **Schemas = typed metadata fields.** Attach a schema to a tag (via
82
- \`update-tag\`) to declare typed metadata fields — e.g. \`#meeting\` with a
83
- \`held_on\` date, \`#person\` with an \`email\`. Each field can **optionally** be
84
- marked \`indexed: true\` to make it **queryable with operators** (\`query-notes
85
- { tag: "meeting", metadata: { held_on: { gte: "2026-01-01" } } }\`); indexing
86
- is opt-in per field, not automatic. Add a schema (and index a field) when you
87
- find yourself wanting to filter or sort on a value, not before.
88
-
89
- Rule of thumb: **type with tags, file with paths, make-it-queryable with
90
- schemas.** Start minimal — invent tags as real notes need them, declare a
91
- schema only when a query demands it. Over-designing an empty vault is the
92
- common mistake.
93
-
94
- Declaring a schema is one \`update-tag\` call — the \`fields\` object maps each
95
- field name to \`{ type, enum?, indexed? }\` (\`type\` is \`"string"\`, \`"boolean"\`,
96
- or \`"integer"\`):
97
-
98
- \`\`\`
99
- update-tag {
100
- tag: "meeting",
101
- description: "A meeting with notes",
102
- fields: {
103
- held_on: { type: "string", indexed: true }, // queryable with operators
104
- status: { type: "string", enum: ["scheduled", "done"] }, // first enum value is the default
105
- rating: { type: "integer" }
106
- }
107
- }
108
- \`\`\`
109
-
110
- \`fields\` is **merged** (new keys added, existing replaced); \`parent_names\` and
111
- \`relationships\` are replaced wholesale when passed. Only \`indexed: true\` fields
112
- support operator queries (\`metadata: { held_on: { gte: "..." } }\`) and
113
- \`order_by\`; all tags declaring the same field must agree on its \`type\` and
114
- \`indexed\` flag.
115
-
116
- ## Write gotchas
117
-
118
- A few behaviors worth knowing before you write at scale:
119
-
120
- - **\`update-note\` requires optimistic concurrency by default.** Pass
121
- \`if_updated_at\` with the \`updated_at\` you last read; a mismatch returns a
122
- conflict error (re-read, reconcile, retry). For bulk/scripted writes where
123
- concurrency is known-safe, pass \`force: true\` to waive the *requirement to
124
- supply* it. \`append\`/\`prepend\`-only updates are exempt (no-conflict-by-design).
125
- - **A schema field's default is filled in on write, so it shows up even when you
126
- didn't set it.** When a note gets a tag whose schema declares a field, the
127
- missing field is back-filled: an \`enum\` field → its **first listed value**, an
128
- \`integer\` → \`0\`, a \`boolean\` → \`false\`, a plain string → \`""\`. So a
129
- \`rating: { type: "integer" }\` reads as \`0\` on notes nobody rated — that \`0\`
130
- is "unset," not "rated zero." Order an \`enum\`'s values so the first is a sane
131
- default, and don't read a back-filled \`0\`/\`""\`/\`false\` as a real value.
132
- - **Validation is advisory, never blocking.** A type/enum mismatch comes back as
133
- a \`validation_status\` warning on the write response — the write still lands.
134
- Read those warnings and self-correct on the next turn.
135
-
136
- (Full design guide, with copy-paste examples: https://parachute.computer/scripting/)
137
-
138
- ## Importing existing notes
139
-
140
- If the operator already keeps notes (Obsidian, Markdown, etc.), bring them in
141
- rather than starting cold:
142
-
143
- - **Obsidian / a Markdown folder:** \`parachute-vault import <path>\` — preserves
144
- frontmatter, tags, \`[[wikilinks]]\`, and file paths.
145
- - **A portable Parachute export** (a dir with \`.parachute/vault.yaml\`): the same
146
- \`import\` command auto-detects it and does a lossless round-trip (ids, typed
147
- links, tag schemas, attachments).
148
- - **Ad hoc / pasted content:** just \`create-note\` it. Then help the operator tag
149
- and schematize: read a sample of imported notes, propose a small tag
150
- vocabulary, and apply it.
151
-
152
- After an import, orient yourself: \`vault-info\` for the new schema picture,
153
- \`list-tags\` to see what vocabulary arrived, \`query-notes { search: "..." }\` to
154
- spot-check. Then propose structure — don't impose it silently.
155
-
156
- ## Later: a custom surface
157
-
158
- Building a custom UI over the vault (a dashboard, a notes app) is usually **not**
159
- the starting point — get the notes and structure right first. If and when the
160
- operator wants one, see **[[Surface Starter]]** (built with
161
- \`@openparachute/surface-client\` + \`@openparachute/surface-render\`).
162
-
163
- ## Adapt this note
164
-
165
- This guide is a **default starting point, not gospel** — edit it to fit this
166
- vault. As you and the operator settle on a tag vocabulary, conventions, or a
167
- surface, you can record that here so a future session inherits the current shape
168
- of the vault instead of this blank-slate default. Useful things to capture:
169
- - the tag vocabulary you've settled on and what each tag means;
170
- - naming/path conventions for this vault;
171
- - which schemas exist and why;
172
- - anything a fresh AI would need to be immediately useful.
173
-
174
- Treat setup as a relationship, not a one-time install.
175
- `;
176
-
177
- /**
178
- * Body of the seeded `Surface Starter` note. Linked from Getting Started.
179
- *
180
- * A concise, *living* starter prompt for building a custom surface (UI) over
181
- * the vault using the published surface packages. Tells the AI to import the
182
- * packages rather than hand-roll OAuth/API/rendering.
7
+ * Kept so existing importers (including out-of-repo consumers of core, e.g.
8
+ * parachute-cloud's `file:` dep) keep resolving. New code imports from
9
+ * `seed-packs.ts` directly prefer the `SeedPack` objects + `applySeedPack`
10
+ * over these raw constants.
183
11
  */
184
- export const SURFACE_STARTER_CONTENT = `# Surface Starter
185
-
186
- A **surface** is a custom UI over this vault — a dashboard, a notes app, a
187
- single-purpose tool. This note is a living starter for building one *with the
188
- operator*. Update it as you settle on a stack, conventions, or a deployed
189
- surface for this vault.
190
-
191
- ## ⚠️ Build a surface in your editor, not from this session
192
-
193
- A surface runs **in a browser**: it needs a real OAuth round-trip (a redirect to
194
- the hub's consent screen and back), a dev server to serve the app, and a CORS
195
- origin the hub trusts. **None of that exists in this MCP/chat session** — there's
196
- no browser, no redirect, no dev server. So **don't try to "run" a surface from
197
- the vault session.** Build it in **Claude Code (or your editor)** against a local
198
- dev server (\`vite\`/\`bun dev\`), sign in through the browser there, and iterate.
199
- From *this* session you design the vault structure the surface will consume and
200
- write the code — you can't exercise the OAuth/render loop here.
201
-
202
- ## Don't hand-roll the plumbing
203
-
204
- Two published packages do the heavy lifting — import them instead of writing
205
- OAuth, the vault API client, or note rendering by hand:
206
-
207
- - **\`@openparachute/surface-client\`** — \`createVaultSurface(...)\` wires up
208
- Parachute OAuth (sign-in on first connect) and a typed vault API client
209
- (query/create/update notes, tags, links) so your app code just calls methods.
210
- - **\`@openparachute/surface-render\`** — \`<NoteRenderer>\` and friends render note
211
- content (Markdown, wikilinks, embeds) the way the rest of the ecosystem does,
212
- so your surface looks native without re-implementing the renderer.
213
-
214
- ## Minimal end-to-end (config → sign-in → query → render)
215
-
216
- A React sketch wiring all four steps. \`createVaultSurface\` is the only required
217
- config (its \`clientName\` is the sole required option; \`hubUrl\` defaults to the
218
- page origin, \`vaultName\` to \`"default"\`, \`scope\` to \`"vault:read vault:write"\`).
219
- \`getClient()\` returns a \`VaultClient\` (or \`null\` until signed in) whose
220
- \`queryNotes()\` takes the same query grammar you use over MCP. See
221
- [[Getting Started]] / \`vault-info\` for this vault's NAME and hub origin.
222
-
223
- \`\`\`tsx
224
- import { useEffect, useState } from "react";
225
- import { createVaultSurface, type Note } from "@openparachute/surface-client";
226
- import { NoteRenderer } from "@openparachute/surface-render";
227
-
228
- // One surface per (hub, vault) config. clientName shows on the consent screen.
229
- const surface = createVaultSurface({
230
- clientName: "My Vault Surface",
231
- hubUrl: "https://your-hub.example", // omit to default to window.location.origin
232
- vaultName: "default", // this vault's name (see vault-info)
233
- });
234
-
235
- export function App() {
236
- const [notes, setNotes] = useState<Note[] | null>(null);
237
-
238
- useEffect(() => {
239
- (async () => {
240
- // OAuth: finish a redirect callback if we're on it, else send the browser
241
- // off to sign in. handleCallback() needs BOTH code + state, so guard on
242
- // both. (Real apps route /oauth/callback to its own component.)
243
- const q = new URLSearchParams(location.search);
244
- if (q.get("code") && q.get("state")) await surface.handleCallback();
245
- // getClient() builds a FRESH VaultClient on each call — fine here (one-shot
246
- // effect); in a real component keep it in state/ref, don't call it per render.
247
- const client = surface.getClient(); // VaultClient | null (null = not signed in)
248
- if (!client) return void surface.login();
249
- setNotes(await client.queryNotes({ tag: "note", limit: 20 }));
250
- })();
251
- }, []);
252
-
253
- if (!notes) return <p>Connecting…</p>;
254
- return (
255
- <>
256
- {notes.map((n) => (
257
- // resolve maps a [[wikilink]] target → { href, exists } (or null = inert).
258
- // You own this href's trust boundary — keep it a fragment (or validate the
259
- // target). Don't build a raw passthrough href: a vault note could carry a
260
- // javascript: target.
261
- <NoteRenderer
262
- key={n.id}
263
- note={n}
264
- resolve={(target) => ({ href: \`#/n/\${encodeURIComponent(target)}\`, exists: true })}
265
- />
266
- ))}
267
- </>
268
- );
269
- }
270
- \`\`\`
271
-
272
- That's the whole spine. \`<NoteRenderer>\` also takes \`linkComponent\` (your
273
- router's \`<Link>\`) and \`fetchBlob\` (\`(url) => Promise<Blob>\`, for auth'd
274
- image/audio embeds) when you need them — both optional.
275
-
276
- ## Build order
277
-
278
- 1. **Auth + data first.** Stand up \`createVaultSurface\` pointed at this vault;
279
- confirm you can sign in and \`queryNotes\` round-trips before any UI polish.
280
- 2. **Render next.** Drop in \`<NoteRenderer>\` to display note content; wire
281
- wikilink/embed resolution through the package, not by hand.
282
- 3. **UX last.** Layout, navigation, and the surface's actual purpose — now that
283
- auth, data, and rendering are solid.
284
-
285
- ## Design it around this vault's structure
286
-
287
- A good surface is shaped by the vault's tags + schemas (see [[Getting Started]]
288
- for the tags-vs-paths-vs-schemas design vocabulary). Query by the tags that
289
- matter to the operator; surface the indexed fields they filter on. If the vault
290
- doesn't yet have the structure a surface wants, that's a signal to design tags +
291
- schemas first.
292
-
293
- ## Adapt this note
294
-
295
- When you build a surface for this vault, record it here: what it's for, the
296
- stack, how to run it, the queries it depends on. The next session should be able
297
- to pick it up from this note.
298
12
 
299
- (More on the surface stack + examples: https://parachute.computer/scripting/)
300
- `;
13
+ export {
14
+ GETTING_STARTED_PATH,
15
+ GETTING_STARTED_CONTENT,
16
+ SURFACE_STARTER_PATH,
17
+ SURFACE_STARTER_CONTENT,
18
+ } from "./seed-packs.ts";