@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,303 @@
1
+ import { Database } from "bun:sqlite";
2
+ import type { Store, Note, Link, Attachment, QueryOpts } from "./types.js";
3
+ import { initSchema } from "./schema.js";
4
+ import * as noteOps from "./notes.js";
5
+ import * as linkOps from "./links.js";
6
+ import * as tagSchemaOps from "./tag-schemas.js";
7
+ import { syncWikilinks, resolveUnresolvedWikilinks } from "./wikilinks.js";
8
+ import { normalizePath, pathTitle } from "./paths.js";
9
+ import { HookRegistry } from "./hooks.js";
10
+
11
+ /**
12
+ * SQLite-backed Store implementation.
13
+ */
14
+ export class SqliteStore implements Store {
15
+ public readonly hooks: HookRegistry;
16
+
17
+ constructor(public readonly db: Database, opts?: { hooks?: HookRegistry }) {
18
+ initSchema(db);
19
+ this.hooks = opts?.hooks ?? new HookRegistry();
20
+ }
21
+
22
+ // ---- Notes ----
23
+
24
+ createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Note {
25
+ const note = noteOps.createNote(this.db, content, opts);
26
+
27
+ // Auto-sync wikilinks from content
28
+ if (content) {
29
+ syncWikilinks(this.db, note.id, content);
30
+ }
31
+
32
+ // If this note has a path, resolve any pending wikilinks targeting it
33
+ if (note.path) {
34
+ resolveUnresolvedWikilinks(this.db, note.path, note.id);
35
+ }
36
+
37
+ // Dispatch async hooks post-commit. Never blocks the caller.
38
+ this.hooks.dispatch("created", note, this);
39
+
40
+ return note;
41
+ }
42
+
43
+ getNote(id: string): Note | null {
44
+ return noteOps.getNote(this.db, id);
45
+ }
46
+
47
+ getNoteByPath(path: string): Note | null {
48
+ return noteOps.getNoteByPath(this.db, path);
49
+ }
50
+
51
+ getNotes(ids: string[]): Note[] {
52
+ return noteOps.getNotes(this.db, ids);
53
+ }
54
+
55
+ updateNote(id: string, updates: { content?: string; path?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean }): Note {
56
+ // Capture old path before update for rename cascading
57
+ let oldPath: string | undefined;
58
+ if (updates.path !== undefined) {
59
+ const existing = noteOps.getNote(this.db, id);
60
+ oldPath = existing?.path;
61
+ }
62
+
63
+ const note = noteOps.updateNote(this.db, id, updates);
64
+
65
+ // Re-sync wikilinks if content changed
66
+ if (updates.content !== undefined) {
67
+ syncWikilinks(this.db, id, updates.content);
68
+ }
69
+
70
+ // If path changed, cascade rename through wikilinks in other notes
71
+ if (updates.path !== undefined && note.path) {
72
+ if (oldPath && oldPath !== note.path) {
73
+ this.cascadeRename(oldPath, note.path);
74
+ }
75
+ resolveUnresolvedWikilinks(this.db, note.path, id);
76
+ }
77
+
78
+ // Dispatch async hooks post-commit. Never blocks the caller.
79
+ this.hooks.dispatch("updated", note, this);
80
+
81
+ return note;
82
+ }
83
+
84
+ /**
85
+ * When a note is renamed, update [[wikilinks]] in other notes that referenced the old path.
86
+ * Matches both full path and basename references.
87
+ */
88
+ private cascadeRename(oldPath: string, newPath: string): void {
89
+ const oldTitle = pathTitle(oldPath);
90
+ const newTitle = pathTitle(newPath);
91
+
92
+ // Find notes whose content contains a likely wikilink to the old path
93
+ // Search for both the full old path and just the old basename
94
+ const candidates = this.db.prepare(`
95
+ SELECT id, content FROM notes
96
+ WHERE content LIKE ? OR content LIKE ?
97
+ `).all(`%[[${oldPath}%`, `%[[${oldTitle}%`) as { id: string; content: string }[];
98
+
99
+ for (const row of candidates) {
100
+ let updated = row.content;
101
+
102
+ // Replace [[OldPath...]] with [[NewPath...]] (preserving aliases and anchors)
103
+ updated = updated.replace(
104
+ new RegExp(`\\[\\[${escapeRegex(oldPath)}([#|\\]])`, "g"),
105
+ `[[${newPath}$1`,
106
+ );
107
+
108
+ // Replace [[OldTitle...]] with [[NewTitle...]] (basename references)
109
+ // Only if old title !== new title and old title !== old path (avoid double-replace)
110
+ if (oldTitle !== newTitle && oldTitle !== oldPath) {
111
+ updated = updated.replace(
112
+ new RegExp(`\\[\\[${escapeRegex(oldTitle)}([#|\\]])`, "g"),
113
+ `[[${newTitle}$1`,
114
+ );
115
+ }
116
+
117
+ if (updated !== row.content) {
118
+ // Call noteOps directly (not this.updateNote) to avoid recursive cascading.
119
+ // Only content changes here, so path normalization/cascading aren't needed.
120
+ noteOps.updateNote(this.db, row.id, { content: updated });
121
+ syncWikilinks(this.db, row.id, updated);
122
+ }
123
+ }
124
+ }
125
+
126
+ deleteNote(id: string): void {
127
+ noteOps.deleteNote(this.db, id);
128
+ }
129
+
130
+ queryNotes(opts: QueryOpts): Note[] {
131
+ return noteOps.queryNotes(this.db, opts);
132
+ }
133
+
134
+ searchNotes(query: string, opts?: { tags?: string[]; limit?: number }): Note[] {
135
+ return noteOps.searchNotes(this.db, query, opts);
136
+ }
137
+
138
+ // ---- Tags ----
139
+
140
+ tagNote(noteId: string, tags: string[]): void {
141
+ noteOps.tagNote(this.db, noteId, tags);
142
+ }
143
+
144
+ untagNote(noteId: string, tags: string[]): void {
145
+ noteOps.untagNote(this.db, noteId, tags);
146
+ }
147
+
148
+ listTags(): { name: string; count: number }[] {
149
+ return noteOps.listTags(this.db);
150
+ }
151
+
152
+ deleteTag(name: string): { deleted: boolean; notes_untagged: number } {
153
+ return noteOps.deleteTag(this.db, name);
154
+ }
155
+
156
+ // ---- Vault Stats ----
157
+
158
+ getVaultStats(opts?: { topTagsLimit?: number }) {
159
+ return noteOps.getVaultStats(this.db, opts);
160
+ }
161
+
162
+ // ---- Links ----
163
+
164
+ createLink(sourceId: string, targetId: string, relationship: string, metadata?: Record<string, unknown>): Link {
165
+ return linkOps.createLink(this.db, sourceId, targetId, relationship, metadata);
166
+ }
167
+
168
+ deleteLink(sourceId: string, targetId: string, relationship: string): void {
169
+ linkOps.deleteLink(this.db, sourceId, targetId, relationship);
170
+ }
171
+
172
+ getLinks(noteId: string, opts?: { direction?: "outbound" | "inbound" | "both" }): Link[] {
173
+ return linkOps.getLinks(this.db, noteId, opts);
174
+ }
175
+
176
+ listLinks(opts?: { noteId?: string; direction?: "outbound" | "inbound" | "both"; relationship?: string }): Link[] {
177
+ return linkOps.listLinks(this.db, opts);
178
+ }
179
+
180
+ // ---- Bulk Operations ----
181
+
182
+ createNotes(inputs: noteOps.BulkNoteInput[]): Note[] {
183
+ const notes = noteOps.createNotes(this.db, inputs);
184
+ // Dispatch hooks for each created note — AFTER the bulk transaction
185
+ // commits inside noteOps.createNotes. Dispatch itself is async-safe
186
+ // (queueMicrotask), so the loop returns immediately.
187
+ for (const note of notes) {
188
+ this.hooks.dispatch("created", note, this);
189
+ }
190
+ return notes;
191
+ }
192
+
193
+ batchTag(noteIds: string[], tags: string[]): number {
194
+ return noteOps.batchTag(this.db, noteIds, tags);
195
+ }
196
+
197
+ batchUntag(noteIds: string[], tags: string[]): number {
198
+ return noteOps.batchUntag(this.db, noteIds, tags);
199
+ }
200
+
201
+ // ---- Deeper Link Queries ----
202
+
203
+ traverseLinks(noteId: string, opts?: { max_depth?: number; relationship?: string }) {
204
+ return linkOps.traverseLinks(this.db, noteId, opts);
205
+ }
206
+
207
+ findPath(sourceId: string, targetId: string, opts?: { max_depth?: number }) {
208
+ return linkOps.findPath(this.db, sourceId, targetId, opts);
209
+ }
210
+
211
+ // ---- Tag Schemas ----
212
+
213
+ listTagSchemas() {
214
+ return tagSchemaOps.listTagSchemas(this.db);
215
+ }
216
+
217
+ getTagSchema(tag: string) {
218
+ return tagSchemaOps.getTagSchema(this.db, tag);
219
+ }
220
+
221
+ upsertTagSchema(tag: string, schema: { description?: string; fields?: Record<string, tagSchemaOps.TagFieldSchema> }) {
222
+ return tagSchemaOps.upsertTagSchema(this.db, tag, schema);
223
+ }
224
+
225
+ deleteTagSchema(tag: string) {
226
+ return tagSchemaOps.deleteTagSchema(this.db, tag);
227
+ }
228
+
229
+ getTagSchemaMap() {
230
+ return tagSchemaOps.getTagSchemaMap(this.db);
231
+ }
232
+
233
+ // ---- Batch Wikilink Sync ----
234
+
235
+ /**
236
+ * Create a note without triggering wikilink sync.
237
+ * Use this during bulk imports, then call syncAllWikilinks() after.
238
+ */
239
+ createNoteRaw(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Note {
240
+ return noteOps.createNote(this.db, content, opts);
241
+ }
242
+
243
+ /**
244
+ * Sync wikilinks for all notes in the vault.
245
+ * Efficient for bulk imports — call once after importing all notes.
246
+ */
247
+ syncAllWikilinks(): { synced: number; totalAdded: number; totalRemoved: number } {
248
+ const allNotes = noteOps.queryNotes(this.db, { limit: 1000000 });
249
+ let synced = 0;
250
+ let totalAdded = 0;
251
+ let totalRemoved = 0;
252
+
253
+ for (const note of allNotes) {
254
+ if (!note.content) continue;
255
+ const result = syncWikilinks(this.db, note.id, note.content);
256
+ if (result.added > 0 || result.removed > 0) {
257
+ synced++;
258
+ totalAdded += result.added;
259
+ totalRemoved += result.removed;
260
+ }
261
+ }
262
+
263
+ return { synced, totalAdded, totalRemoved };
264
+ }
265
+
266
+ // ---- Attachments ----
267
+
268
+ addAttachment(noteId: string, filePath: string, mimeType: string, metadata?: Record<string, unknown>): Attachment {
269
+ const id = noteOps.generateId();
270
+ const now = new Date().toISOString();
271
+ const metadataJson = metadata ? JSON.stringify(metadata) : "{}";
272
+ this.db.prepare(
273
+ "INSERT INTO attachments (id, note_id, path, mime_type, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?)",
274
+ ).run(id, noteId, filePath, mimeType, metadataJson, now);
275
+
276
+ return { id, noteId, path: filePath, mimeType, metadata, createdAt: now };
277
+ }
278
+
279
+ getAttachments(noteId: string): Attachment[] {
280
+ const rows = this.db.prepare(
281
+ "SELECT * FROM attachments WHERE note_id = ? ORDER BY created_at",
282
+ ).all(noteId) as { id: string; note_id: string; path: string; mime_type: string; metadata: string | null; created_at: string }[];
283
+
284
+ return rows.map((r) => {
285
+ let metadata: Record<string, unknown> | undefined;
286
+ if (r.metadata && r.metadata !== "{}") {
287
+ try { metadata = JSON.parse(r.metadata); } catch {}
288
+ }
289
+ return {
290
+ id: r.id,
291
+ noteId: r.note_id,
292
+ path: r.path,
293
+ mimeType: r.mime_type,
294
+ metadata,
295
+ createdAt: r.created_at,
296
+ };
297
+ });
298
+ }
299
+ }
300
+
301
+ function escapeRegex(s: string): string {
302
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
303
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Tag schema CRUD — DB-backed storage for tag metadata schemas.
3
+ *
4
+ * Each tag can optionally have a schema that describes expected metadata
5
+ * fields for notes with that tag. Schemas drive auto-population of defaults
6
+ * and soft warnings on create/tag operations.
7
+ */
8
+
9
+ import { Database } from "bun:sqlite";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Types
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface TagFieldSchema {
16
+ type: string;
17
+ description?: string;
18
+ enum?: string[];
19
+ }
20
+
21
+ export interface TagSchema {
22
+ tag: string;
23
+ description?: string;
24
+ fields?: Record<string, TagFieldSchema>;
25
+ }
26
+
27
+ // DB row shape
28
+ interface TagSchemaRow {
29
+ tag_name: string;
30
+ description: string | null;
31
+ fields: string | null;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // CRUD
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /** List all tag schemas. */
39
+ export function listTagSchemas(db: Database): TagSchema[] {
40
+ const rows = db.prepare("SELECT * FROM tag_schemas ORDER BY tag_name").all() as TagSchemaRow[];
41
+ return rows.map(rowToSchema);
42
+ }
43
+
44
+ /** Get a single tag's schema, or null if none defined. */
45
+ export function getTagSchema(db: Database, tag: string): TagSchema | null {
46
+ const row = db.prepare("SELECT * FROM tag_schemas WHERE tag_name = ?").get(tag) as TagSchemaRow | undefined;
47
+ return row ? rowToSchema(row) : null;
48
+ }
49
+
50
+ /** Get all schemas as a lookup map (tag → schema). Used by schema effects. */
51
+ export function getTagSchemaMap(db: Database): Record<string, { description?: string; fields?: Record<string, TagFieldSchema> }> {
52
+ const schemas = listTagSchemas(db);
53
+ const map: Record<string, { description?: string; fields?: Record<string, TagFieldSchema> }> = {};
54
+ for (const s of schemas) {
55
+ map[s.tag] = { description: s.description, fields: s.fields };
56
+ }
57
+ return map;
58
+ }
59
+
60
+ /**
61
+ * Create or replace a tag schema (upsert).
62
+ * Ensures the tag exists in the tags table first.
63
+ */
64
+ export function upsertTagSchema(
65
+ db: Database,
66
+ tag: string,
67
+ schema: { description?: string; fields?: Record<string, TagFieldSchema> },
68
+ ): TagSchema {
69
+ // Ensure tag exists
70
+ db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(tag);
71
+
72
+ const fieldsJson = schema.fields ? JSON.stringify(schema.fields) : null;
73
+ db.prepare(`
74
+ INSERT INTO tag_schemas (tag_name, description, fields)
75
+ VALUES (?, ?, ?)
76
+ ON CONFLICT(tag_name) DO UPDATE SET
77
+ description = excluded.description,
78
+ fields = excluded.fields
79
+ `).run(tag, schema.description ?? null, fieldsJson);
80
+
81
+ return getTagSchema(db, tag)!;
82
+ }
83
+
84
+ /** Delete a tag's schema. Returns true if a schema was deleted. */
85
+ export function deleteTagSchema(db: Database, tag: string): boolean {
86
+ const result = db.prepare("DELETE FROM tag_schemas WHERE tag_name = ?").run(tag);
87
+ return result.changes > 0;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Helpers
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function rowToSchema(row: TagSchemaRow): TagSchema {
95
+ let fields: Record<string, TagFieldSchema> | undefined;
96
+ if (row.fields) {
97
+ try { fields = JSON.parse(row.fields); } catch {}
98
+ }
99
+ return {
100
+ tag: row.tag_name,
101
+ description: row.description ?? undefined,
102
+ fields,
103
+ };
104
+ }
@@ -0,0 +1,8 @@
1
+ // Isolate PARACHUTE_HOME so tests never touch the real ~/.parachute directory.
2
+ // This must run before any `./config.ts` import resolves CONFIG_DIR.
3
+ import { mkdtempSync } from "fs";
4
+ import { tmpdir } from "os";
5
+ import { join } from "path";
6
+ if (!process.env.PARACHUTE_HOME) {
7
+ process.env.PARACHUTE_HOME = mkdtempSync(join(tmpdir(), "parachute-test-home-"));
8
+ }
@@ -0,0 +1,140 @@
1
+ // ---- Note ----
2
+
3
+ export interface Note {
4
+ id: string;
5
+ content: string;
6
+ path?: string;
7
+ metadata?: Record<string, unknown>;
8
+ createdAt: string; // ISO-8601
9
+ updatedAt?: string;
10
+ tags?: string[];
11
+ links?: Link[];
12
+ }
13
+
14
+ // ---- Link ----
15
+
16
+ export interface Link {
17
+ sourceId: string;
18
+ targetId: string;
19
+ relationship: string;
20
+ metadata?: Record<string, unknown>;
21
+ createdAt: string;
22
+ }
23
+
24
+ // ---- Attachment ----
25
+
26
+ export interface Attachment {
27
+ id: string;
28
+ noteId: string;
29
+ path: string;
30
+ mimeType: string;
31
+ metadata?: Record<string, unknown>;
32
+ createdAt: string;
33
+ }
34
+
35
+ // ---- Vault Stats ----
36
+
37
+ export interface VaultStats {
38
+ totalNotes: number;
39
+ earliestNote: { id: string; createdAt: string } | null;
40
+ latestNote: { id: string; createdAt: string } | null;
41
+ notesByMonth: { month: string; count: number }[];
42
+ topTags: { tag: string; count: number }[];
43
+ tagCount: number;
44
+ }
45
+
46
+ // ---- Query Options ----
47
+
48
+ export interface QueryOpts {
49
+ tags?: string[];
50
+ tagMatch?: "all" | "any"; // "all" = must have ALL tags (default), "any" = must have ANY tag
51
+ excludeTags?: string[];
52
+ path?: string; // exact path match (case-insensitive)
53
+ pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
54
+ metadata?: Record<string, unknown>; // filter by metadata values (exact match on each key)
55
+ dateFrom?: string; // ISO date
56
+ dateTo?: string; // ISO date
57
+ sort?: "asc" | "desc";
58
+ limit?: number;
59
+ offset?: number;
60
+ }
61
+
62
+ /** Note summary — everything except content. Used in link results. */
63
+ export interface NoteSummary {
64
+ id: string;
65
+ path?: string;
66
+ metadata?: Record<string, unknown>;
67
+ createdAt: string;
68
+ updatedAt?: string;
69
+ tags?: string[];
70
+ }
71
+
72
+ /**
73
+ * Lean note index entry — summary + byteSize + single-line preview.
74
+ * Used by query-notes (index mode), GET /notes (list default), and /graph.
75
+ */
76
+ export interface NoteIndex {
77
+ id: string;
78
+ path?: string;
79
+ createdAt: string;
80
+ updatedAt?: string;
81
+ tags?: string[];
82
+ metadata?: Record<string, unknown>;
83
+ byteSize: number;
84
+ preview: string;
85
+ }
86
+
87
+ /** Link with hydrated note summaries. */
88
+ export interface HydratedLink extends Link {
89
+ sourceNote?: NoteSummary;
90
+ targetNote?: NoteSummary;
91
+ }
92
+
93
+ // ---- Store Interface ----
94
+
95
+ export interface Store {
96
+ // Notes
97
+ createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Note;
98
+ getNote(id: string): Note | null;
99
+ getNoteByPath(path: string): Note | null;
100
+ getNotes(ids: string[]): Note[];
101
+ updateNote(id: string, updates: { content?: string; path?: string; metadata?: Record<string, unknown>; skipUpdatedAt?: boolean }): Note;
102
+ deleteNote(id: string): void;
103
+ queryNotes(opts: QueryOpts): Note[];
104
+ searchNotes(query: string, opts?: { tags?: string[]; limit?: number }): Note[];
105
+
106
+ // Tags
107
+ tagNote(noteId: string, tags: string[]): void;
108
+ untagNote(noteId: string, tags: string[]): void;
109
+ listTags(): { name: string; count: number }[];
110
+ deleteTag(name: string): { deleted: boolean; notes_untagged: number };
111
+
112
+ // Vault stats (aggregate, read-only)
113
+ getVaultStats(opts?: { topTagsLimit?: number }): VaultStats;
114
+
115
+ // Links
116
+ createLink(sourceId: string, targetId: string, relationship: string, metadata?: Record<string, unknown>): Link;
117
+ deleteLink(sourceId: string, targetId: string, relationship: string): void;
118
+ getLinks(noteId: string, opts?: { direction?: "outbound" | "inbound" | "both" }): Link[];
119
+ listLinks(opts?: { noteId?: string; direction?: "outbound" | "inbound" | "both"; relationship?: string }): Link[];
120
+
121
+ // Bulk operations
122
+ createNotes(inputs: { content: string; id?: string; path?: string; tags?: string[] }[]): Note[];
123
+ batchTag(noteIds: string[], tags: string[]): number;
124
+ batchUntag(noteIds: string[], tags: string[]): number;
125
+
126
+ // Deeper link queries
127
+ traverseLinks(noteId: string, opts?: { max_depth?: number; relationship?: string }): { noteId: string; depth: number; relationship: string; direction: "outbound" | "inbound" }[];
128
+ findPath(sourceId: string, targetId: string, opts?: { max_depth?: number }): { path: string[]; relationships: string[] } | null;
129
+
130
+ // Tag schemas
131
+ listTagSchemas(): { tag: string; description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[] }> }[];
132
+ getTagSchema(tag: string): { tag: string; description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[] }> } | null;
133
+ upsertTagSchema(tag: string, schema: { description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[] }> }): { tag: string; description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[] }> };
134
+ deleteTagSchema(tag: string): boolean;
135
+ getTagSchemaMap(): Record<string, { description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[] }> }>;
136
+
137
+ // Attachments
138
+ addAttachment(noteId: string, path: string, mimeType: string, metadata?: Record<string, unknown>): Attachment;
139
+ getAttachments(noteId: string): Attachment[];
140
+ }