@openparachute/vault 0.1.0

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 (103) hide show
  1. package/.claude/settings.local.json +31 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +9 -0
  4. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
  5. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
  6. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
  7. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
  8. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
  10. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
  11. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
  12. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
  13. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
  14. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
  15. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
  16. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
  17. package/CLAUDE.md +115 -0
  18. package/Caddyfile +3 -0
  19. package/Dockerfile +22 -0
  20. package/LICENSE +661 -0
  21. package/README.md +356 -0
  22. package/bun.lock +219 -0
  23. package/bunfig.toml +2 -0
  24. package/core/package.json +7 -0
  25. package/core/src/core.test.ts +940 -0
  26. package/core/src/hooks.test.ts +361 -0
  27. package/core/src/hooks.ts +234 -0
  28. package/core/src/links.ts +352 -0
  29. package/core/src/mcp.ts +672 -0
  30. package/core/src/notes.ts +520 -0
  31. package/core/src/obsidian.test.ts +380 -0
  32. package/core/src/obsidian.ts +322 -0
  33. package/core/src/paths.test.ts +197 -0
  34. package/core/src/paths.ts +53 -0
  35. package/core/src/schema.ts +331 -0
  36. package/core/src/store.ts +303 -0
  37. package/core/src/tag-schemas.ts +104 -0
  38. package/core/src/test-preload.ts +8 -0
  39. package/core/src/types.ts +140 -0
  40. package/core/src/wikilinks.test.ts +277 -0
  41. package/core/src/wikilinks.ts +402 -0
  42. package/deploy/parachute-vault.service +20 -0
  43. package/docker-compose.yml +50 -0
  44. package/docs/HTTP_API.md +328 -0
  45. package/fly.toml +24 -0
  46. package/package.json +32 -0
  47. package/railway.json +14 -0
  48. package/religions-abrahamic-filter.png +0 -0
  49. package/religions-buddhism-v2.png +0 -0
  50. package/religions-buddhism.png +0 -0
  51. package/religions-final.png +0 -0
  52. package/religions-v1.png +0 -0
  53. package/religions-v2.png +0 -0
  54. package/religions-zen.png +0 -0
  55. package/scripts/migrate-audio-to-opus.test.ts +237 -0
  56. package/scripts/migrate-audio-to-opus.ts +499 -0
  57. package/src/auth.ts +170 -0
  58. package/src/cli.ts +1131 -0
  59. package/src/config-triggers.test.ts +83 -0
  60. package/src/config.test.ts +125 -0
  61. package/src/config.ts +716 -0
  62. package/src/db.ts +14 -0
  63. package/src/launchd.ts +109 -0
  64. package/src/mcp-http.ts +113 -0
  65. package/src/mcp-tools.ts +155 -0
  66. package/src/oauth.test.ts +1242 -0
  67. package/src/oauth.ts +729 -0
  68. package/src/owner-auth.ts +159 -0
  69. package/src/prompt.ts +141 -0
  70. package/src/published.test.ts +214 -0
  71. package/src/qrcode-terminal.d.ts +9 -0
  72. package/src/routes.ts +822 -0
  73. package/src/server.ts +450 -0
  74. package/src/systemd.ts +84 -0
  75. package/src/token-store.test.ts +174 -0
  76. package/src/token-store.ts +241 -0
  77. package/src/triggers.test.ts +397 -0
  78. package/src/triggers.ts +412 -0
  79. package/src/two-factor.test.ts +246 -0
  80. package/src/two-factor.ts +222 -0
  81. package/src/vault-store.ts +47 -0
  82. package/src/vault.test.ts +1309 -0
  83. package/tsconfig.json +29 -0
  84. package/web/README.md +73 -0
  85. package/web/bun.lock +827 -0
  86. package/web/eslint.config.js +23 -0
  87. package/web/index.html +15 -0
  88. package/web/package.json +36 -0
  89. package/web/public/favicon.svg +1 -0
  90. package/web/public/icons.svg +24 -0
  91. package/web/src/App.tsx +149 -0
  92. package/web/src/Graph.tsx +200 -0
  93. package/web/src/NoteView.tsx +155 -0
  94. package/web/src/Sidebar.tsx +186 -0
  95. package/web/src/api.ts +21 -0
  96. package/web/src/index.css +50 -0
  97. package/web/src/main.tsx +10 -0
  98. package/web/src/types.ts +37 -0
  99. package/web/src/utils.ts +107 -0
  100. package/web/tsconfig.app.json +25 -0
  101. package/web/tsconfig.json +7 -0
  102. package/web/tsconfig.node.json +24 -0
  103. package/web/vite.config.ts +15 -0
@@ -0,0 +1,352 @@
1
+ import { Database } from "bun:sqlite";
2
+ import type { Link, NoteSummary, HydratedLink } from "./types.js";
3
+ import { getNoteTags } from "./notes.js";
4
+
5
+ export function createLink(
6
+ db: Database,
7
+ sourceId: string,
8
+ targetId: string,
9
+ relationship: string,
10
+ metadata?: Record<string, unknown>,
11
+ ): Link {
12
+ const now = new Date().toISOString();
13
+ const metadataJson = metadata ? JSON.stringify(metadata) : "{}";
14
+
15
+ db.prepare(
16
+ `INSERT OR IGNORE INTO links (source_id, target_id, relationship, metadata, created_at)
17
+ VALUES (?, ?, ?, ?, ?)`,
18
+ ).run(sourceId, targetId, relationship, metadataJson, now);
19
+
20
+ const row = db.prepare(
21
+ `SELECT * FROM links WHERE source_id = ? AND target_id = ? AND relationship = ?`,
22
+ ).get(sourceId, targetId, relationship) as LinkRow;
23
+ return rowToLink(row);
24
+ }
25
+
26
+ export function deleteLink(
27
+ db: Database,
28
+ sourceId: string,
29
+ targetId: string,
30
+ relationship: string,
31
+ ): void {
32
+ db.prepare(
33
+ "DELETE FROM links WHERE source_id = ? AND target_id = ? AND relationship = ?",
34
+ ).run(sourceId, targetId, relationship);
35
+ }
36
+
37
+ export function getLinks(
38
+ db: Database,
39
+ noteId: string,
40
+ opts?: { direction?: "outbound" | "inbound" | "both" },
41
+ ): Link[] {
42
+ return listLinks(db, { noteId, direction: opts?.direction });
43
+ }
44
+
45
+ /**
46
+ * List links with optional filters.
47
+ * - If `noteId` is provided: restricts to links touching that note
48
+ * (respects `direction`: outbound, inbound, or both).
49
+ * - If `relationship` is provided: restricts to links of that type.
50
+ * - Without filters: returns every link in the vault.
51
+ *
52
+ * Returns bare `Link[]` (no hydration). Callers that need note details
53
+ * should pair the result with `getNote` / `getNotes`.
54
+ */
55
+ export function listLinks(
56
+ db: Database,
57
+ opts?: {
58
+ noteId?: string;
59
+ direction?: "outbound" | "inbound" | "both";
60
+ relationship?: string;
61
+ },
62
+ ): Link[] {
63
+ const conditions: string[] = [];
64
+ const params: unknown[] = [];
65
+
66
+ if (opts?.noteId) {
67
+ const direction = opts.direction ?? "both";
68
+ if (direction === "outbound") {
69
+ conditions.push("source_id = ?");
70
+ params.push(opts.noteId);
71
+ } else if (direction === "inbound") {
72
+ conditions.push("target_id = ?");
73
+ params.push(opts.noteId);
74
+ } else {
75
+ conditions.push("(source_id = ? OR target_id = ?)");
76
+ params.push(opts.noteId, opts.noteId);
77
+ }
78
+ }
79
+
80
+ if (opts?.relationship) {
81
+ conditions.push("relationship = ?");
82
+ params.push(opts.relationship);
83
+ }
84
+
85
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
86
+ const sql = `SELECT * FROM links ${where} ORDER BY created_at DESC`;
87
+ const rows = db.prepare(sql).all(...params) as LinkRow[];
88
+ return rows.map(rowToLink);
89
+ }
90
+
91
+ // ---- Note Summaries (for hydrated results) ----
92
+
93
+ interface SummaryRow {
94
+ id: string;
95
+ path: string | null;
96
+ metadata: string | null;
97
+ created_at: string;
98
+ updated_at: string | null;
99
+ }
100
+
101
+ function parseMetadata(raw: string | null): Record<string, unknown> | undefined {
102
+ if (!raw || raw === "{}") return undefined;
103
+ try { return JSON.parse(raw); } catch { return undefined; }
104
+ }
105
+
106
+ function getNoteSummary(db: Database, noteId: string): NoteSummary | undefined {
107
+ const row = db.prepare(
108
+ "SELECT id, path, metadata, created_at, updated_at FROM notes WHERE id = ?",
109
+ ).get(noteId) as SummaryRow | undefined;
110
+ if (!row) return undefined;
111
+ return {
112
+ id: row.id,
113
+ path: row.path ?? undefined,
114
+ metadata: parseMetadata(row.metadata),
115
+ createdAt: row.created_at,
116
+ updatedAt: row.updated_at ?? undefined,
117
+ tags: getNoteTags(db, row.id),
118
+ };
119
+ }
120
+
121
+ function getNoteSummaries(db: Database, noteIds: string[]): Map<string, NoteSummary> {
122
+ const map = new Map<string, NoteSummary>();
123
+ if (noteIds.length === 0) return map;
124
+ const placeholders = noteIds.map(() => "?").join(", ");
125
+ const rows = db.prepare(
126
+ `SELECT id, path, metadata, created_at, updated_at FROM notes WHERE id IN (${placeholders})`,
127
+ ).all(...noteIds) as SummaryRow[];
128
+ for (const row of rows) {
129
+ map.set(row.id, {
130
+ id: row.id,
131
+ path: row.path ?? undefined,
132
+ metadata: parseMetadata(row.metadata),
133
+ createdAt: row.created_at,
134
+ updatedAt: row.updated_at ?? undefined,
135
+ tags: getNoteTags(db, row.id),
136
+ });
137
+ }
138
+ return map;
139
+ }
140
+
141
+ /**
142
+ * Get links for a note with hydrated note summaries.
143
+ * Always includes note path/tags. Optionally includes content.
144
+ */
145
+ export function getLinksHydrated(
146
+ db: Database,
147
+ noteId: string,
148
+ opts?: { direction?: "outbound" | "inbound" | "both"; include_content?: boolean },
149
+ ): HydratedLink[] {
150
+ const links = getLinks(db, noteId, opts);
151
+
152
+ // Collect all note IDs we need to hydrate
153
+ const noteIds = new Set<string>();
154
+ for (const link of links) {
155
+ noteIds.add(link.sourceId);
156
+ noteIds.add(link.targetId);
157
+ }
158
+
159
+ const summaries = getNoteSummaries(db, [...noteIds]);
160
+
161
+ return links.map((link) => ({
162
+ ...link,
163
+ sourceNote: summaries.get(link.sourceId),
164
+ targetNote: summaries.get(link.targetId),
165
+ }));
166
+ }
167
+
168
+ // ---- Deeper Link Queries ----
169
+
170
+ export interface TraversalNode {
171
+ noteId: string;
172
+ depth: number;
173
+ relationship: string;
174
+ direction: "outbound" | "inbound";
175
+ note?: NoteSummary;
176
+ }
177
+
178
+ /**
179
+ * Traverse the link graph from a starting note up to `maxDepth` hops.
180
+ * Returns all reachable notes with their depth and how they were reached.
181
+ */
182
+ export function traverseLinks(
183
+ db: Database,
184
+ noteId: string,
185
+ opts?: { max_depth?: number; relationship?: string },
186
+ ): TraversalNode[] {
187
+ const maxDepth = opts?.max_depth ?? 2;
188
+ const relFilter = opts?.relationship;
189
+ const visited = new Set<string>([noteId]);
190
+ const results: TraversalNode[] = [];
191
+ let frontier = [noteId];
192
+
193
+ for (let depth = 1; depth <= maxDepth; depth++) {
194
+ const nextFrontier: string[] = [];
195
+
196
+ for (const currentId of frontier) {
197
+ // Outbound links
198
+ let outbound: LinkRow[];
199
+ if (relFilter) {
200
+ outbound = db.prepare(
201
+ "SELECT * FROM links WHERE source_id = ? AND relationship = ?",
202
+ ).all(currentId, relFilter) as LinkRow[];
203
+ } else {
204
+ outbound = db.prepare(
205
+ "SELECT * FROM links WHERE source_id = ?",
206
+ ).all(currentId) as LinkRow[];
207
+ }
208
+
209
+ for (const row of outbound) {
210
+ if (!visited.has(row.target_id)) {
211
+ visited.add(row.target_id);
212
+ nextFrontier.push(row.target_id);
213
+ results.push({
214
+ noteId: row.target_id,
215
+ depth,
216
+ relationship: row.relationship,
217
+ direction: "outbound",
218
+ });
219
+ }
220
+ }
221
+
222
+ // Inbound links
223
+ let inbound: LinkRow[];
224
+ if (relFilter) {
225
+ inbound = db.prepare(
226
+ "SELECT * FROM links WHERE target_id = ? AND relationship = ?",
227
+ ).all(currentId, relFilter) as LinkRow[];
228
+ } else {
229
+ inbound = db.prepare(
230
+ "SELECT * FROM links WHERE target_id = ?",
231
+ ).all(currentId) as LinkRow[];
232
+ }
233
+
234
+ for (const row of inbound) {
235
+ if (!visited.has(row.source_id)) {
236
+ visited.add(row.source_id);
237
+ nextFrontier.push(row.source_id);
238
+ results.push({
239
+ noteId: row.source_id,
240
+ depth,
241
+ relationship: row.relationship,
242
+ direction: "inbound",
243
+ });
244
+ }
245
+ }
246
+ }
247
+
248
+ frontier = nextFrontier;
249
+ if (frontier.length === 0) break;
250
+ }
251
+
252
+ // Hydrate with note summaries
253
+ const noteIds = results.map((r) => r.noteId);
254
+ const summaries = getNoteSummaries(db, noteIds);
255
+ for (const result of results) {
256
+ result.note = summaries.get(result.noteId);
257
+ }
258
+
259
+ return results;
260
+ }
261
+
262
+ /**
263
+ * Find a path between two notes in the link graph.
264
+ * Returns the sequence of note IDs from source to target, or null if no path exists.
265
+ */
266
+ export function findPath(
267
+ db: Database,
268
+ sourceId: string,
269
+ targetId: string,
270
+ opts?: { max_depth?: number },
271
+ ): { path: string[]; relationships: string[] } | null {
272
+ const maxDepth = opts?.max_depth ?? 5;
273
+
274
+ if (sourceId === targetId) {
275
+ return { path: [sourceId], relationships: [] };
276
+ }
277
+
278
+ // BFS from source
279
+ const visited = new Map<string, { parent: string; relationship: string }>();
280
+ visited.set(sourceId, { parent: "", relationship: "" });
281
+ let frontier = [sourceId];
282
+
283
+ for (let depth = 0; depth < maxDepth; depth++) {
284
+ const nextFrontier: string[] = [];
285
+
286
+ for (const currentId of frontier) {
287
+ // Check all neighbors (both directions)
288
+ const outbound = db.prepare(
289
+ "SELECT * FROM links WHERE source_id = ?",
290
+ ).all(currentId) as LinkRow[];
291
+
292
+ const inbound = db.prepare(
293
+ "SELECT * FROM links WHERE target_id = ?",
294
+ ).all(currentId) as LinkRow[];
295
+
296
+ const neighbors: { id: string; rel: string }[] = [
297
+ ...outbound.map((r) => ({ id: r.target_id, rel: r.relationship })),
298
+ ...inbound.map((r) => ({ id: r.source_id, rel: r.relationship })),
299
+ ];
300
+
301
+ for (const neighbor of neighbors) {
302
+ if (visited.has(neighbor.id)) continue;
303
+ visited.set(neighbor.id, { parent: currentId, relationship: neighbor.rel });
304
+ nextFrontier.push(neighbor.id);
305
+
306
+ if (neighbor.id === targetId) {
307
+ // Reconstruct path
308
+ const path: string[] = [];
309
+ const relationships: string[] = [];
310
+ let current = targetId;
311
+ while (current !== sourceId) {
312
+ path.unshift(current);
313
+ const entry = visited.get(current)!;
314
+ relationships.unshift(entry.relationship);
315
+ current = entry.parent;
316
+ }
317
+ path.unshift(sourceId);
318
+ return { path, relationships };
319
+ }
320
+ }
321
+ }
322
+
323
+ frontier = nextFrontier;
324
+ if (frontier.length === 0) break;
325
+ }
326
+
327
+ return null;
328
+ }
329
+
330
+ // ---- Internal ----
331
+
332
+ interface LinkRow {
333
+ source_id: string;
334
+ target_id: string;
335
+ relationship: string;
336
+ metadata: string | null;
337
+ created_at: string;
338
+ }
339
+
340
+ function rowToLink(row: LinkRow): Link {
341
+ let metadata: Record<string, unknown> | undefined;
342
+ if (row.metadata && row.metadata !== "{}") {
343
+ try { metadata = JSON.parse(row.metadata); } catch {}
344
+ }
345
+ return {
346
+ sourceId: row.source_id,
347
+ targetId: row.target_id,
348
+ relationship: row.relationship,
349
+ metadata,
350
+ createdAt: row.created_at,
351
+ };
352
+ }