@openparachute/vault 0.6.4 → 0.6.5-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/__fixtures__/golden-vault.ts +125 -0
- package/core/src/__fixtures__/portable-export-golden.json +10 -0
- package/core/src/do-param-cap.test.ts +161 -0
- package/core/src/links.ts +8 -13
- package/core/src/mcp.ts +306 -314
- package/core/src/notes.ts +54 -62
- package/core/src/onboarding.ts +14 -296
- package/core/src/portable-md-batching.test.ts +67 -0
- package/core/src/portable-md-golden.test.ts +55 -0
- package/core/src/portable-md.ts +579 -435
- package/core/src/schema.ts +21 -43
- package/core/src/seed-packs.test.ts +191 -0
- package/core/src/seed-packs.ts +559 -0
- package/core/src/sql-in.test.ts +58 -0
- package/core/src/sql-in.ts +76 -0
- package/core/src/store.ts +14 -9
- package/core/src/transcription/provider.ts +141 -0
- package/core/src/txn.test.ts +229 -0
- package/core/src/txn.ts +105 -0
- package/core/src/types.ts +9 -0
- package/core/src/vault-projection.ts +1 -1
- package/core/src/wikilinks.ts +10 -4
- package/package.json +1 -1
- package/src/add-pack.test.ts +142 -0
- package/src/cli.ts +778 -7
- package/src/onboarding-seed.test.ts +126 -40
- package/src/onboarding-seed.ts +41 -46
- package/src/routes.ts +25 -7
- package/src/server.ts +108 -31
- package/src/transcription/build.test.ts +224 -0
- package/src/transcription/build.ts +252 -0
- package/src/transcription/capability.test.ts +118 -0
- package/src/transcription/capability.ts +96 -0
- package/src/transcription/install-python.test.ts +366 -0
- package/src/transcription/install-python.ts +471 -0
- package/src/transcription/install.test.ts +167 -0
- package/src/transcription/install.ts +296 -0
- package/src/transcription/providers/onnx-asr.test.ts +229 -0
- package/src/transcription/providers/onnx-asr.ts +239 -0
- package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
- package/src/transcription/providers/parakeet-mlx.ts +242 -0
- package/src/transcription/providers/scribe-http.test.ts +195 -0
- package/src/transcription/providers/scribe-http.ts +144 -0
- package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
- package/src/transcription/providers/transcribe-cpp.ts +293 -0
- package/src/transcription/select.test.ts +259 -0
- package/src/transcription/select.ts +334 -0
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/transcription-worker.test.ts +44 -0
- package/src/transcription-worker.ts +57 -122
- package/src/vault-create.test.ts +38 -10
- package/src/vault.test.ts +48 -0
package/core/src/notes.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Database, type SQLQueryBindings } from "bun:sqlite";
|
|
2
2
|
import type { Note, NoteIndex, QueryOpts, QueryNotesPage, VaultStats } from "./types.js";
|
|
3
3
|
import { normalizePath } from "./paths.js";
|
|
4
|
+
import { transaction } from "./txn.js";
|
|
4
5
|
import {
|
|
5
6
|
buildOperatorClause,
|
|
6
7
|
isOperatorObject,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
} from "./cursor.js";
|
|
21
22
|
import { getIndexedField, releaseField } from "./indexed-fields.js";
|
|
22
23
|
import { stripTagHash } from "./tag-hierarchy.js";
|
|
24
|
+
import { chunkForInClause, IN_VIA_JSON_EACH, jsonEachParam } from "./sql-in.js";
|
|
23
25
|
|
|
24
26
|
let idCounter = 0;
|
|
25
27
|
|
|
@@ -177,10 +179,26 @@ export function getNoteByPath(db: Database, path: string, extension?: string): N
|
|
|
177
179
|
|
|
178
180
|
export function getNotes(db: Database, ids: string[]): Note[] {
|
|
179
181
|
if (ids.length === 0) return [];
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
);
|
|
184
202
|
return notesWithTags(db, rows);
|
|
185
203
|
}
|
|
186
204
|
|
|
@@ -755,9 +773,13 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
755
773
|
// with an always-false condition; building `IN ()` would be a SQL error.
|
|
756
774
|
conditions.push("0 = 1");
|
|
757
775
|
} else {
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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));
|
|
761
783
|
}
|
|
762
784
|
}
|
|
763
785
|
|
|
@@ -1043,10 +1065,6 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
1043
1065
|
return fetchNotesByIdsOrdered(db, idRows.map((r) => r.id));
|
|
1044
1066
|
}
|
|
1045
1067
|
|
|
1046
|
-
/** Chunk size for IN-list queries — comfortably under SQLite's conservative
|
|
1047
|
-
* 999 bound-variable floor (older builds), matching getLinkCounts. */
|
|
1048
|
-
const IN_CHUNK = 900;
|
|
1049
|
-
|
|
1050
1068
|
/**
|
|
1051
1069
|
* Fetch full note rows for `ids`, preserving the input order, with tags
|
|
1052
1070
|
* hydrated via ONE batched query per chunk (not one per note). Ids not
|
|
@@ -1055,8 +1073,9 @@ const IN_CHUNK = 900;
|
|
|
1055
1073
|
function fetchNotesByIdsOrdered(db: Database, ids: string[]): Note[] {
|
|
1056
1074
|
if (ids.length === 0) return [];
|
|
1057
1075
|
const rowsById = new Map<string, NoteRow>();
|
|
1058
|
-
|
|
1059
|
-
|
|
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)) {
|
|
1060
1079
|
const placeholders = chunk.map(() => "?").join(", ");
|
|
1061
1080
|
const rows = db.prepare(
|
|
1062
1081
|
`SELECT * FROM notes WHERE id IN (${placeholders})`,
|
|
@@ -1083,8 +1102,8 @@ export function getNoteTagsForNotes(db: Database, noteIds: string[]): Map<string
|
|
|
1083
1102
|
if (noteIds.length === 0) return map;
|
|
1084
1103
|
const ids = [...new Set(noteIds)];
|
|
1085
1104
|
for (const id of ids) map.set(id, []);
|
|
1086
|
-
|
|
1087
|
-
|
|
1105
|
+
// Chunk under the DO 100-bound-param cap (see sql-in.ts).
|
|
1106
|
+
for (const chunk of chunkForInClause(ids)) {
|
|
1088
1107
|
const placeholders = chunk.map(() => "?").join(", ");
|
|
1089
1108
|
const rows = db.prepare(
|
|
1090
1109
|
`SELECT note_id, tag_name FROM note_tags WHERE note_id IN (${placeholders}) ORDER BY tag_name`,
|
|
@@ -1438,8 +1457,7 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1438
1457
|
return { error: "target_exists", conflicting };
|
|
1439
1458
|
}
|
|
1440
1459
|
|
|
1441
|
-
db
|
|
1442
|
-
try {
|
|
1460
|
+
const result = transaction(db, (): RenameTagSuccess => {
|
|
1443
1461
|
let renamedNoteTags = 0;
|
|
1444
1462
|
let pathsRenamed = 0;
|
|
1445
1463
|
|
|
@@ -1600,9 +1618,7 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1600
1618
|
}
|
|
1601
1619
|
}
|
|
1602
1620
|
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
const result: RenameTagSuccess = {
|
|
1621
|
+
return {
|
|
1606
1622
|
renamed: renamedNoteTags,
|
|
1607
1623
|
sub_tags_renamed: renames.length - 1,
|
|
1608
1624
|
parent_refs_updated: parentRefsUpdated,
|
|
@@ -1611,21 +1627,18 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1611
1627
|
notes_rewritten: notesRewritten,
|
|
1612
1628
|
paths_renamed: pathsRenamed,
|
|
1613
1629
|
};
|
|
1630
|
+
});
|
|
1614
1631
|
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1632
|
+
// Audit log: single line so operators searching `[vault] tag rename`
|
|
1633
|
+
// can correlate cascades after the fact. Includes the stats and the
|
|
1634
|
+
// mapping for non-trivial sub-tag cases.
|
|
1635
|
+
console.error(
|
|
1636
|
+
`[vault] tag rename cascade: ${oldName} → ${newName}` +
|
|
1637
|
+
(renames.length > 1 ? ` (+${renames.length - 1} sub-tags)` : "") +
|
|
1638
|
+
` — note_tags:${result.renamed} parent_refs:${result.parent_refs_updated} tokens:${result.tokens_updated} indexed:${result.indexed_field_declarers_updated} notes:${result.notes_rewritten} paths:${result.paths_renamed}`,
|
|
1639
|
+
);
|
|
1623
1640
|
|
|
1624
|
-
|
|
1625
|
-
} catch (err) {
|
|
1626
|
-
db.exec("ROLLBACK");
|
|
1627
|
-
throw err;
|
|
1628
|
-
}
|
|
1641
|
+
return result;
|
|
1629
1642
|
}
|
|
1630
1643
|
|
|
1631
1644
|
function emptyCascadeResult(): RenameTagSuccess {
|
|
@@ -1768,8 +1781,7 @@ export function mergeTags(
|
|
|
1768
1781
|
|
|
1769
1782
|
const merged: Record<string, number> = {};
|
|
1770
1783
|
|
|
1771
|
-
db
|
|
1772
|
-
try {
|
|
1784
|
+
transaction(db, () => {
|
|
1773
1785
|
// Target might not exist yet. Seed it so INSERT OR IGNORE into note_tags
|
|
1774
1786
|
// can reference it; leave any existing schema on target untouched.
|
|
1775
1787
|
db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(target);
|
|
@@ -1796,12 +1808,7 @@ export function mergeTags(
|
|
|
1796
1808
|
deleteTagStmt.run(source);
|
|
1797
1809
|
merged[source] = before;
|
|
1798
1810
|
}
|
|
1799
|
-
|
|
1800
|
-
db.exec("COMMIT");
|
|
1801
|
-
} catch (err) {
|
|
1802
|
-
db.exec("ROLLBACK");
|
|
1803
|
-
throw err;
|
|
1804
|
-
}
|
|
1811
|
+
});
|
|
1805
1812
|
|
|
1806
1813
|
return { merged, target };
|
|
1807
1814
|
}
|
|
@@ -1999,8 +2006,7 @@ export interface BulkNoteInput {
|
|
|
1999
2006
|
export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
|
|
2000
2007
|
const results: Note[] = [];
|
|
2001
2008
|
|
|
2002
|
-
db
|
|
2003
|
-
try {
|
|
2009
|
+
transaction(db, () => {
|
|
2004
2010
|
for (const input of inputs) {
|
|
2005
2011
|
results.push(
|
|
2006
2012
|
createNote(db, input.content, {
|
|
@@ -2015,11 +2021,7 @@ export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
|
|
|
2015
2021
|
}),
|
|
2016
2022
|
);
|
|
2017
2023
|
}
|
|
2018
|
-
|
|
2019
|
-
} catch (err) {
|
|
2020
|
-
db.exec("ROLLBACK");
|
|
2021
|
-
throw err;
|
|
2022
|
-
}
|
|
2024
|
+
});
|
|
2023
2025
|
|
|
2024
2026
|
return results;
|
|
2025
2027
|
}
|
|
@@ -2032,8 +2034,7 @@ export function batchTag(db: Database, noteIds: string[], tags: string[]): numbe
|
|
|
2032
2034
|
const bareTags = tags.map(stripTagHash).filter((t) => t !== "");
|
|
2033
2035
|
let count = 0;
|
|
2034
2036
|
|
|
2035
|
-
db
|
|
2036
|
-
try {
|
|
2037
|
+
transaction(db, () => {
|
|
2037
2038
|
for (const tag of bareTags) {
|
|
2038
2039
|
insertTag.run(tag);
|
|
2039
2040
|
}
|
|
@@ -2043,11 +2044,7 @@ export function batchTag(db: Database, noteIds: string[], tags: string[]): numbe
|
|
|
2043
2044
|
count++;
|
|
2044
2045
|
}
|
|
2045
2046
|
}
|
|
2046
|
-
|
|
2047
|
-
} catch (err) {
|
|
2048
|
-
db.exec("ROLLBACK");
|
|
2049
|
-
throw err;
|
|
2050
|
-
}
|
|
2047
|
+
});
|
|
2051
2048
|
|
|
2052
2049
|
return count;
|
|
2053
2050
|
}
|
|
@@ -2059,19 +2056,14 @@ export function batchUntag(db: Database, noteIds: string[], tags: string[]): num
|
|
|
2059
2056
|
const bareTags = tags.map(stripTagHash).filter((t) => t !== "");
|
|
2060
2057
|
let count = 0;
|
|
2061
2058
|
|
|
2062
|
-
db
|
|
2063
|
-
try {
|
|
2059
|
+
transaction(db, () => {
|
|
2064
2060
|
for (const noteId of noteIds) {
|
|
2065
2061
|
for (const tag of bareTags) {
|
|
2066
2062
|
stmt.run(noteId, tag);
|
|
2067
2063
|
count++;
|
|
2068
2064
|
}
|
|
2069
2065
|
}
|
|
2070
|
-
|
|
2071
|
-
} catch (err) {
|
|
2072
|
-
db.exec("ROLLBACK");
|
|
2073
|
-
throw err;
|
|
2074
|
-
}
|
|
2066
|
+
});
|
|
2075
2067
|
|
|
2076
2068
|
return count;
|
|
2077
2069
|
}
|
package/core/src/onboarding.ts
CHANGED
|
@@ -1,300 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
300
|
-
|
|
13
|
+
export {
|
|
14
|
+
GETTING_STARTED_PATH,
|
|
15
|
+
GETTING_STARTED_CONTENT,
|
|
16
|
+
SURFACE_STARTER_PATH,
|
|
17
|
+
SURFACE_STARTER_CONTENT,
|
|
18
|
+
} from "./seed-packs.ts";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batched-export coverage (Phase-1 streaming refactor). The export walk
|
|
3
|
+
* streams notes in windows of `EXPORT_BATCH_SIZE` (500) instead of one
|
|
4
|
+
* full-corpus query. These tests prove the windowing walks EVERY note across
|
|
5
|
+
* the batch boundaries — both a non-multiple remainder (1,203 = 500 + 500 +
|
|
6
|
+
* 203, so the final partial batch is exercised) and an exact multiple (1,000
|
|
7
|
+
* = 500 + 500 + a terminating empty query), with a sentinel note pinned in
|
|
8
|
+
* the final batch so an off-by-one that dropped the tail would fail loudly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
12
|
+
import { Database } from "bun:sqlite";
|
|
13
|
+
import { existsSync, mkdtempSync, rmSync } from "fs";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
|
|
17
|
+
import { SqliteStore } from "./store.js";
|
|
18
|
+
import { exportVaultToDir } from "./portable-md.js";
|
|
19
|
+
|
|
20
|
+
/** Zero-padded so lexicographic id order == numeric order (the export walks
|
|
21
|
+
* `created_at ASC, id ASC`; fresh notes share a created_at so id breaks the
|
|
22
|
+
* tie — this makes the batch membership of each note deterministic). */
|
|
23
|
+
const pad = (i: number): string => String(i).padStart(5, "0");
|
|
24
|
+
|
|
25
|
+
async function seed(store: SqliteStore, n: number): Promise<void> {
|
|
26
|
+
const inputs = [];
|
|
27
|
+
for (let i = 0; i < n; i++) {
|
|
28
|
+
inputs.push({ content: `note ${i}`, id: `n${pad(i)}`, path: `notes/n${pad(i)}` });
|
|
29
|
+
}
|
|
30
|
+
await store.createNotes(inputs);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("exportVaultToDir — batched walk across boundaries", () => {
|
|
34
|
+
let store: SqliteStore;
|
|
35
|
+
let outDir: string;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
store = new SqliteStore(new Database(":memory:"));
|
|
39
|
+
outDir = mkdtempSync(join(tmpdir(), "portable-batch-"));
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
try { rmSync(outDir, { recursive: true, force: true }); } catch {}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("walks all 1,203 notes (2 full batches + a 203-note remainder)", async () => {
|
|
46
|
+
await seed(store, 1203);
|
|
47
|
+
const stats = await exportVaultToDir(store, { outDir, exportedAt: "2026-07-02T00:00:00.000Z" });
|
|
48
|
+
|
|
49
|
+
expect(stats.notes).toBe(1203);
|
|
50
|
+
// First note, both interior batch boundaries, and the sentinel in the
|
|
51
|
+
// final partial batch must all be on disk.
|
|
52
|
+
expect(existsSync(join(outDir, "notes/n00000.md"))).toBe(true); // batch 1 head
|
|
53
|
+
expect(existsSync(join(outDir, "notes/n00500.md"))).toBe(true); // batch 2 head
|
|
54
|
+
expect(existsSync(join(outDir, "notes/n01000.md"))).toBe(true); // batch 3 head
|
|
55
|
+
expect(existsSync(join(outDir, "notes/n01202.md"))).toBe(true); // last partial-batch sentinel
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("walks all notes on an exact multiple of the batch size (1,000 = 2×500 + terminating empty query)", async () => {
|
|
59
|
+
await seed(store, 1000);
|
|
60
|
+
const stats = await exportVaultToDir(store, { outDir, exportedAt: "2026-07-02T00:00:00.000Z" });
|
|
61
|
+
|
|
62
|
+
expect(stats.notes).toBe(1000);
|
|
63
|
+
expect(existsSync(join(outDir, "notes/n00000.md"))).toBe(true);
|
|
64
|
+
expect(existsSync(join(outDir, "notes/n00500.md"))).toBe(true);
|
|
65
|
+
expect(existsSync(join(outDir, "notes/n00999.md"))).toBe(true); // last note of the final full batch
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Golden-bytes regression for the portable-md streaming export refactor
|
|
3
|
+
* (Phase-1 shared-core, vault cloud design §4).
|
|
4
|
+
*
|
|
5
|
+
* `core/src/__fixtures__/portable-export-golden.json` was captured from the
|
|
6
|
+
* PRE-refactor (full-corpus, sync-fs) exporter by
|
|
7
|
+
* `scripts/capture-portable-golden.ts`. This test builds the SAME
|
|
8
|
+
* deterministic vault and asserts the CURRENT exporter reproduces those
|
|
9
|
+
* bytes exactly — the streaming/source-sink rewrite must be byte-for-byte
|
|
10
|
+
* indistinguishable on the fs backend. A diff here is a real format drift,
|
|
11
|
+
* not test flake (both sides share `buildGoldenVault` + `serializeExportTree`
|
|
12
|
+
* from the fixture module, so a fixture-vs-test mismatch is impossible).
|
|
13
|
+
*
|
|
14
|
+
* If a format change is ever intentional, re-run the capture script against
|
|
15
|
+
* the new code, review the JSON diff, and commit both together.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from "bun:test";
|
|
19
|
+
import { Database } from "bun:sqlite";
|
|
20
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
21
|
+
import { tmpdir } from "os";
|
|
22
|
+
import { join } from "path";
|
|
23
|
+
|
|
24
|
+
import { SqliteStore } from "./store.js";
|
|
25
|
+
import { exportVaultToDir } from "./portable-md.js";
|
|
26
|
+
import {
|
|
27
|
+
buildGoldenVault,
|
|
28
|
+
serializeExportTree,
|
|
29
|
+
GOLDEN_EXPORTED_AT,
|
|
30
|
+
} from "./__fixtures__/golden-vault.js";
|
|
31
|
+
import goldenTree from "./__fixtures__/portable-export-golden.json";
|
|
32
|
+
|
|
33
|
+
describe("portable-md golden bytes — new exporter == old-exporter fixture", () => {
|
|
34
|
+
it("reproduces the captured export tree byte-for-byte", async () => {
|
|
35
|
+
const store = new SqliteStore(new Database(":memory:"));
|
|
36
|
+
await buildGoldenVault(store);
|
|
37
|
+
|
|
38
|
+
const outDir = mkdtempSync(join(tmpdir(), "portable-golden-test-"));
|
|
39
|
+
try {
|
|
40
|
+
await exportVaultToDir(store, {
|
|
41
|
+
outDir,
|
|
42
|
+
vaultName: "golden",
|
|
43
|
+
vaultDescription: "portable-md byte-stability fixture",
|
|
44
|
+
exportedAt: GOLDEN_EXPORTED_AT,
|
|
45
|
+
caseSensitiveOverride: true,
|
|
46
|
+
});
|
|
47
|
+
const tree = serializeExportTree(outDir);
|
|
48
|
+
// Compare as objects (per-file byte equality) — a keyed diff points
|
|
49
|
+
// straight at the drifting file if this ever fails.
|
|
50
|
+
expect(tree).toEqual(goldenTree as Record<string, string>);
|
|
51
|
+
} finally {
|
|
52
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|