@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,940 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { SqliteStore } from "./store.js";
4
+ import { generateMcpTools } from "./mcp.js";
5
+
6
+ let store: SqliteStore;
7
+ let db: Database;
8
+
9
+ beforeEach(() => {
10
+ db = new Database(":memory:");
11
+ store = new SqliteStore(db);
12
+ });
13
+
14
+ // ---- Notes CRUD ----
15
+
16
+ describe("notes", () => {
17
+ it("creates a note", () => {
18
+ const note = store.createNote("Morning walk");
19
+ expect(note.content).toBe("Morning walk");
20
+ expect(note.id).toBeTruthy();
21
+ expect(note.createdAt).toBeTruthy();
22
+ });
23
+
24
+ it("creates a note with custom id", () => {
25
+ const note = store.createNote("Test", { id: "custom-id" });
26
+ expect(note.id).toBe("custom-id");
27
+ });
28
+
29
+ it("creates a note with path", () => {
30
+ const note = store.createNote("# Grocery List", { path: "Grocery List" });
31
+ expect(note.path).toBe("Grocery List");
32
+ });
33
+
34
+ it("creates a note with tags", () => {
35
+ const note = store.createNote("Voice memo", { tags: ["daily", "voice"] });
36
+ expect(note.tags).toContain("daily");
37
+ expect(note.tags).toContain("voice");
38
+ });
39
+
40
+ it("gets a note by id", () => {
41
+ const created = store.createNote("Test");
42
+ const found = store.getNote(created.id);
43
+ expect(found).toBeTruthy();
44
+ expect(found!.id).toBe(created.id);
45
+ expect(found!.content).toBe("Test");
46
+ });
47
+
48
+ it("returns null for missing note", () => {
49
+ expect(store.getNote("nonexistent")).toBeNull();
50
+ });
51
+
52
+ it("updates note content", () => {
53
+ const note = store.createNote("Original");
54
+ const updated = store.updateNote(note.id, { content: "Updated" });
55
+ expect(updated.content).toBe("Updated");
56
+ expect(updated.updatedAt).toBeTruthy();
57
+ });
58
+
59
+ it("updates note path", () => {
60
+ const note = store.createNote("Test");
61
+ const updated = store.updateNote(note.id, { path: "Notes/Test" });
62
+ expect(updated.path).toBe("Notes/Test");
63
+ });
64
+
65
+ it("updates created_at", () => {
66
+ const note = store.createNote("Test");
67
+ const newDate = "2025-01-15T12:00:00.000Z";
68
+ const updated = store.updateNote(note.id, { created_at: newDate });
69
+ expect(updated.createdAt).toBe(newDate);
70
+ expect(updated.content).toBe("Test"); // content unchanged
71
+ expect(updated.updatedAt).not.toBe(note.updatedAt); // updated_at bumped
72
+ });
73
+
74
+ it("updates metadata and created_at together", () => {
75
+ const note = store.createNote("Test");
76
+ const newDate = "2025-06-30T23:59:59.000Z";
77
+ const meta = { source: "import", version: 2 };
78
+ const updated = store.updateNote(note.id, { metadata: meta, created_at: newDate });
79
+ expect(updated.createdAt).toBe(newDate);
80
+ expect(updated.metadata).toEqual(meta);
81
+ expect(updated.content).toBe("Test");
82
+ });
83
+
84
+ it("leaves created_at unchanged when not provided", () => {
85
+ const note = store.createNote("Test");
86
+ const updated = store.updateNote(note.id, { content: "Updated" });
87
+ expect(updated.createdAt).toBe(note.createdAt);
88
+ });
89
+
90
+ it("deletes a note", () => {
91
+ const note = store.createNote("Delete me");
92
+ store.deleteNote(note.id);
93
+ expect(store.getNote(note.id)).toBeNull();
94
+ });
95
+
96
+ it("cascade deletes tags and links", () => {
97
+ store.createNote("A", { id: "a", tags: ["daily"] });
98
+ store.createNote("B", { id: "b" });
99
+ store.createLink("a", "b", "mentions");
100
+
101
+ store.deleteNote("a");
102
+ expect(store.getLinks("b")).toHaveLength(0);
103
+ });
104
+ });
105
+
106
+ // ---- Tags ----
107
+
108
+ describe("tags", () => {
109
+ it("starts with no tags", () => {
110
+ const tags = store.listTags();
111
+ expect(tags).toHaveLength(0);
112
+ });
113
+
114
+ it("tags a note", () => {
115
+ const note = store.createNote("Test");
116
+ store.tagNote(note.id, ["daily", "voice"]);
117
+ const found = store.getNote(note.id);
118
+ expect(found!.tags).toContain("daily");
119
+ expect(found!.tags).toContain("voice");
120
+ });
121
+
122
+ it("untags a note", () => {
123
+ const note = store.createNote("Test", { tags: ["daily", "voice"] });
124
+ store.untagNote(note.id, ["voice"]);
125
+ const found = store.getNote(note.id);
126
+ expect(found!.tags).toContain("daily");
127
+ expect(found!.tags).not.toContain("voice");
128
+ });
129
+
130
+ it("creates tags automatically", () => {
131
+ const note = store.createNote("Test");
132
+ store.tagNote(note.id, ["custom-tag"]);
133
+ const tags = store.listTags();
134
+ expect(tags.some((t) => t.name === "custom-tag")).toBe(true);
135
+ });
136
+
137
+ it("counts tag usage", () => {
138
+ store.createNote("A", { tags: ["daily"] });
139
+ store.createNote("B", { tags: ["daily"] });
140
+ store.createNote("C", { tags: ["doc"] });
141
+
142
+ const tags = store.listTags();
143
+ const daily = tags.find((t) => t.name === "daily");
144
+ expect(daily!.count).toBe(2);
145
+ });
146
+
147
+ it("tagging is idempotent", () => {
148
+ const note = store.createNote("Test", { tags: ["daily"] });
149
+ store.tagNote(note.id, ["daily"]); // duplicate
150
+ const found = store.getNote(note.id);
151
+ expect(found!.tags!.filter((t) => t === "daily")).toHaveLength(1);
152
+ });
153
+ });
154
+
155
+ // ---- Vault Stats ----
156
+
157
+ describe("vault stats", () => {
158
+ it("handles empty vault gracefully", () => {
159
+ const stats = store.getVaultStats();
160
+ expect(stats.totalNotes).toBe(0);
161
+ expect(stats.earliestNote).toBeNull();
162
+ expect(stats.latestNote).toBeNull();
163
+ expect(stats.notesByMonth).toEqual([]);
164
+ expect(stats.topTags).toEqual([]);
165
+ expect(stats.tagCount).toBe(0);
166
+ });
167
+
168
+ it("counts total notes and tagCount", () => {
169
+ store.createNote("A", { tags: ["daily", "voice"] });
170
+ store.createNote("B", { tags: ["daily"] });
171
+ store.createNote("C");
172
+
173
+ const stats = store.getVaultStats();
174
+ expect(stats.totalNotes).toBe(3);
175
+ expect(stats.tagCount).toBe(2); // "daily" and "voice"
176
+ });
177
+
178
+ it("reports earliest and latest notes correctly", () => {
179
+ store.createNote("oldest", { id: "n1", created_at: "2025-01-15T10:00:00.000Z" });
180
+ store.createNote("middle", { id: "n2", created_at: "2025-06-20T10:00:00.000Z" });
181
+ store.createNote("newest", { id: "n3", created_at: "2026-03-01T10:00:00.000Z" });
182
+
183
+ const stats = store.getVaultStats();
184
+ expect(stats.earliestNote).toEqual({ id: "n1", createdAt: "2025-01-15T10:00:00.000Z" });
185
+ expect(stats.latestNote).toEqual({ id: "n3", createdAt: "2026-03-01T10:00:00.000Z" });
186
+ });
187
+
188
+ it("groups notes by month across all present months", () => {
189
+ store.createNote("a", { created_at: "2025-02-28T12:00:00.000Z" });
190
+ store.createNote("b", { created_at: "2025-03-01T08:00:00.000Z" });
191
+ store.createNote("c", { created_at: "2025-03-15T09:00:00.000Z" });
192
+ store.createNote("d", { created_at: "2025-03-20T11:00:00.000Z" });
193
+ store.createNote("e", { created_at: "2026-01-10T10:00:00.000Z" });
194
+
195
+ const stats = store.getVaultStats();
196
+ expect(stats.notesByMonth).toEqual([
197
+ { month: "2025-02", count: 1 },
198
+ { month: "2025-03", count: 3 },
199
+ { month: "2026-01", count: 1 },
200
+ ]);
201
+ });
202
+
203
+ it("returns topTags ordered by count desc, capped", () => {
204
+ // Create notes with varying tag frequencies
205
+ for (let i = 0; i < 5; i++) store.createNote(`captured-${i}`, { tags: ["captured"] });
206
+ for (let i = 0; i < 3; i++) store.createNote(`reader-${i}`, { tags: ["reader"] });
207
+ store.createNote("one", { tags: ["rare"] });
208
+
209
+ const stats = store.getVaultStats();
210
+ expect(stats.topTags[0]).toEqual({ tag: "captured", count: 5 });
211
+ expect(stats.topTags[1]).toEqual({ tag: "reader", count: 3 });
212
+ expect(stats.topTags[2]).toEqual({ tag: "rare", count: 1 });
213
+ });
214
+
215
+ it("caps topTags at the requested limit", () => {
216
+ // 25 distinct tags, one per note
217
+ for (let i = 0; i < 25; i++) {
218
+ store.createNote(`n-${i}`, { tags: [`tag-${String(i).padStart(2, "0")}`] });
219
+ }
220
+ const stats = store.getVaultStats({ topTagsLimit: 20 });
221
+ expect(stats.topTags).toHaveLength(20);
222
+ expect(stats.tagCount).toBe(25);
223
+ });
224
+
225
+ it("response shape is complete", () => {
226
+ store.createNote("hello", { tags: ["a"] });
227
+ const stats = store.getVaultStats();
228
+ expect(stats).toHaveProperty("totalNotes");
229
+ expect(stats).toHaveProperty("earliestNote");
230
+ expect(stats).toHaveProperty("latestNote");
231
+ expect(stats).toHaveProperty("notesByMonth");
232
+ expect(stats).toHaveProperty("topTags");
233
+ expect(stats).toHaveProperty("tagCount");
234
+ });
235
+
236
+ it("getVaultStats returns correct stats", () => {
237
+ store.createNote("one", { tags: ["x"], created_at: "2025-05-01T00:00:00.000Z" });
238
+ store.createNote("two", { tags: ["x", "y"], created_at: "2025-06-01T00:00:00.000Z" });
239
+
240
+ const result = store.getVaultStats();
241
+ expect(result.totalNotes).toBe(2);
242
+ expect(result.tagCount).toBe(2);
243
+ expect(result.topTags[0].tag).toBe("x");
244
+ expect(result.topTags[0].count).toBe(2);
245
+ expect(result.notesByMonth).toHaveLength(2);
246
+ expect(result.earliestNote!.createdAt).toBe("2025-05-01T00:00:00.000Z");
247
+ expect(result.latestNote!.createdAt).toBe("2025-06-01T00:00:00.000Z");
248
+ });
249
+ });
250
+
251
+ // ---- Query ----
252
+
253
+ describe("queryNotes", () => {
254
+ it("queries by tag", () => {
255
+ store.createNote("Daily 1", { tags: ["daily"] });
256
+ store.createNote("Doc 1", { tags: ["doc"] });
257
+
258
+ const results = store.queryNotes({ tags: ["daily"] });
259
+ expect(results).toHaveLength(1);
260
+ expect(results[0].content).toBe("Daily 1");
261
+ });
262
+
263
+ it("queries by multiple tags (AND)", () => {
264
+ store.createNote("Voice daily", { tags: ["daily", "voice"] });
265
+ store.createNote("Text daily", { tags: ["daily"] });
266
+
267
+ const results = store.queryNotes({ tags: ["daily", "voice"] });
268
+ expect(results).toHaveLength(1);
269
+ expect(results[0].content).toBe("Voice daily");
270
+ });
271
+
272
+ it("queries by multiple tags (OR)", () => {
273
+ store.createNote("Voice daily", { tags: ["daily", "voice"] });
274
+ store.createNote("Text daily", { tags: ["daily"] });
275
+ store.createNote("A doc", { tags: ["doc"] });
276
+
277
+ const results = store.queryNotes({ tags: ["voice", "doc"], tagMatch: "any" });
278
+ expect(results).toHaveLength(2);
279
+ const contents = results.map((n) => n.content).sort();
280
+ expect(contents).toEqual(["A doc", "Voice daily"]);
281
+ });
282
+
283
+ it("excludes tags", () => {
284
+ store.createNote("Active", { tags: ["digest"] });
285
+ store.createNote("Archived", { tags: ["digest", "archived"] });
286
+
287
+ const results = store.queryNotes({ tags: ["digest"], excludeTags: ["archived"] });
288
+ expect(results).toHaveLength(1);
289
+ expect(results[0].content).toBe("Active");
290
+ });
291
+
292
+ it("filters by date range", () => {
293
+ store.createNote("Test");
294
+ const results = store.queryNotes({
295
+ dateFrom: new Date(Date.now() - 60000).toISOString(),
296
+ dateTo: new Date(Date.now() + 60000).toISOString(),
297
+ });
298
+ expect(results.length).toBeGreaterThan(0);
299
+ });
300
+
301
+ it("sorts ascending and descending", () => {
302
+ store.createNote("First", { id: "first" });
303
+ store.createNote("Second", { id: "second" });
304
+
305
+ const asc = store.queryNotes({ sort: "asc" });
306
+ expect(asc[0].content).toBe("First");
307
+
308
+ const desc = store.queryNotes({ sort: "desc" });
309
+ expect(desc[0].content).toBe("Second");
310
+ });
311
+
312
+ it("limits results", () => {
313
+ for (let i = 0; i < 5; i++) store.createNote(`Note ${i}`);
314
+ const results = store.queryNotes({ limit: 3 });
315
+ expect(results).toHaveLength(3);
316
+ });
317
+ });
318
+
319
+ // ---- Search ----
320
+
321
+ describe("searchNotes", () => {
322
+ it("finds notes by content", () => {
323
+ store.createNote("Walked up Flagstaff trail");
324
+ store.createNote("Meeting about Horizon");
325
+
326
+ const results = store.searchNotes("Flagstaff");
327
+ expect(results).toHaveLength(1);
328
+ expect(results[0].content).toContain("Flagstaff");
329
+ });
330
+
331
+ it("filters search by tag", () => {
332
+ store.createNote("Daily Flagstaff", { tags: ["daily"] });
333
+ store.createNote("Doc Flagstaff", { tags: ["doc"] });
334
+
335
+ const results = store.searchNotes("Flagstaff", { tags: ["daily"] });
336
+ expect(results).toHaveLength(1);
337
+ expect(results[0].tags).toContain("daily");
338
+ });
339
+
340
+ it("returns empty for no match", () => {
341
+ store.createNote("Hello world");
342
+ const results = store.searchNotes("nonexistent");
343
+ expect(results).toHaveLength(0);
344
+ });
345
+ });
346
+
347
+ // ---- Links ----
348
+
349
+ describe("links", () => {
350
+ it("creates a link", () => {
351
+ store.createNote("A", { id: "a" });
352
+ store.createNote("B", { id: "b" });
353
+
354
+ const link = store.createLink("a", "b", "mentions");
355
+ expect(link.sourceId).toBe("a");
356
+ expect(link.targetId).toBe("b");
357
+ expect(link.relationship).toBe("mentions");
358
+ });
359
+
360
+ it("deletes a link", () => {
361
+ store.createNote("A", { id: "a" });
362
+ store.createNote("B", { id: "b" });
363
+ store.createLink("a", "b", "mentions");
364
+ store.deleteLink("a", "b", "mentions");
365
+
366
+ const links = store.getLinks("a");
367
+ expect(links).toHaveLength(0);
368
+ });
369
+
370
+ it("gets outbound links", () => {
371
+ store.createNote("A", { id: "a" });
372
+ store.createNote("B", { id: "b" });
373
+ store.createNote("C", { id: "c" });
374
+ store.createLink("a", "b", "mentions");
375
+ store.createLink("c", "a", "quotes");
376
+
377
+ const outbound = store.getLinks("a", { direction: "outbound" });
378
+ expect(outbound).toHaveLength(1);
379
+ expect(outbound[0].targetId).toBe("b");
380
+ });
381
+
382
+ it("gets inbound links", () => {
383
+ store.createNote("A", { id: "a" });
384
+ store.createNote("B", { id: "b" });
385
+ store.createLink("a", "b", "mentions");
386
+
387
+ const inbound = store.getLinks("b", { direction: "inbound" });
388
+ expect(inbound).toHaveLength(1);
389
+ expect(inbound[0].sourceId).toBe("a");
390
+ });
391
+
392
+ it("gets all links (both directions)", () => {
393
+ store.createNote("A", { id: "a" });
394
+ store.createNote("B", { id: "b" });
395
+ store.createNote("C", { id: "c" });
396
+ store.createLink("a", "b", "mentions");
397
+ store.createLink("c", "a", "quotes");
398
+
399
+ const all = store.getLinks("a", { direction: "both" });
400
+ expect(all).toHaveLength(2);
401
+ });
402
+
403
+ it("link creation is idempotent", () => {
404
+ store.createNote("A", { id: "a" });
405
+ store.createNote("B", { id: "b" });
406
+ store.createLink("a", "b", "mentions");
407
+ store.createLink("a", "b", "mentions"); // duplicate
408
+ const links = store.getLinks("a");
409
+ expect(links.filter((l) => l.relationship === "mentions")).toHaveLength(1);
410
+ });
411
+ });
412
+
413
+ // ---- Attachments ----
414
+
415
+ describe("attachments", () => {
416
+ it("adds and retrieves attachments", () => {
417
+ const note = store.createNote("Voice memo", { tags: ["daily", "voice"] });
418
+ const attachment = store.addAttachment(note.id, "2026-03-31/audio.wav", "audio/wav");
419
+
420
+ expect(attachment.noteId).toBe(note.id);
421
+ expect(attachment.mimeType).toBe("audio/wav");
422
+
423
+ const attachments = store.getAttachments(note.id);
424
+ expect(attachments).toHaveLength(1);
425
+ expect(attachments[0].path).toBe("2026-03-31/audio.wav");
426
+ });
427
+
428
+ it("cascade deletes attachments with note", () => {
429
+ const note = store.createNote("Test");
430
+ store.addAttachment(note.id, "file.png", "image/png");
431
+ store.deleteNote(note.id);
432
+
433
+ const attachments = store.getAttachments(note.id);
434
+ expect(attachments).toHaveLength(0);
435
+ });
436
+ });
437
+
438
+ // ---- MCP Tools ----
439
+
440
+ describe("MCP tools", () => {
441
+ it("generates all 9 consolidated tools", () => {
442
+ const tools = generateMcpTools(store);
443
+ const names = tools.map((t) => t.name);
444
+
445
+ expect(names).toContain("query-notes");
446
+ expect(names).toContain("create-note");
447
+ expect(names).toContain("update-note");
448
+ expect(names).toContain("delete-note");
449
+ expect(names).toContain("list-tags");
450
+ expect(names).toContain("update-tag");
451
+ expect(names).toContain("delete-tag");
452
+ expect(names).toContain("find-path");
453
+ expect(names).toContain("vault-info");
454
+ expect(tools).toHaveLength(9);
455
+ });
456
+
457
+ it("create-note tool works", () => {
458
+ const tools = generateMcpTools(store);
459
+ const createNote = tools.find((t) => t.name === "create-note")!;
460
+ const result = createNote.execute({ content: "Hello", tags: ["daily"] }) as any;
461
+ expect(result.content).toBe("Hello");
462
+ expect(result.tags).toContain("daily");
463
+ });
464
+
465
+ it("create-note batch mode works", () => {
466
+ const tools = generateMcpTools(store);
467
+ const createNote = tools.find((t) => t.name === "create-note")!;
468
+ const result = createNote.execute({
469
+ notes: [
470
+ { content: "A", tags: ["daily"] },
471
+ { content: "B", tags: ["doc"] },
472
+ ],
473
+ }) as any[];
474
+ expect(result).toHaveLength(2);
475
+ expect(result[0].tags).toContain("daily");
476
+ expect(result[1].tags).toContain("doc");
477
+ });
478
+
479
+ it("create-note with links resolves targets by path", () => {
480
+ const tools = generateMcpTools(store);
481
+ const createNote = tools.find((t) => t.name === "create-note")!;
482
+ store.createNote("Target", { path: "People/Alice" });
483
+ const result = createNote.execute({
484
+ content: "Links to Alice",
485
+ links: [{ target: "People/Alice", relationship: "mentions" }],
486
+ }) as any;
487
+ const links = store.getLinks(result.id, { direction: "outbound" });
488
+ expect(links.some((l) => l.relationship === "mentions")).toBe(true);
489
+ });
490
+
491
+ it("update-note tool updates created_at", () => {
492
+ const note = store.createNote("Test");
493
+ const tools = generateMcpTools(store);
494
+ const updateNote = tools.find((t) => t.name === "update-note")!;
495
+ const newDate = "2025-03-01T00:00:00.000Z";
496
+ const result = updateNote.execute({ id: note.id, created_at: newDate }) as any;
497
+ expect(result.createdAt).toBe(newDate);
498
+ expect(result.content).toBe("Test");
499
+ });
500
+
501
+ it("update-note tool merges metadata", () => {
502
+ const note = store.createNote("Test", { metadata: { existing: "value" } });
503
+ const tools = generateMcpTools(store);
504
+ const updateNote = tools.find((t) => t.name === "update-note")!;
505
+ const result = updateNote.execute({ id: note.id, metadata: { importance: "high" } }) as any;
506
+ expect(result.metadata).toEqual({ existing: "value", importance: "high" });
507
+ });
508
+
509
+ it("update-note tags add/remove works", () => {
510
+ const note = store.createNote("Test");
511
+ const tools = generateMcpTools(store);
512
+ const updateNote = tools.find((t) => t.name === "update-note")!;
513
+
514
+ // Add tags
515
+ updateNote.execute({ id: note.id, tags: { add: ["pinned", "daily"] } });
516
+ expect(store.getNote(note.id)!.tags).toContain("pinned");
517
+ expect(store.getNote(note.id)!.tags).toContain("daily");
518
+
519
+ // Remove tags
520
+ updateNote.execute({ id: note.id, tags: { remove: ["pinned"] } });
521
+ expect(store.getNote(note.id)!.tags).not.toContain("pinned");
522
+ expect(store.getNote(note.id)!.tags).toContain("daily");
523
+ });
524
+
525
+ it("update-note links add/remove works", () => {
526
+ store.createNote("A", { id: "a" });
527
+ store.createNote("B", { id: "b" });
528
+ const tools = generateMcpTools(store);
529
+ const updateNote = tools.find((t) => t.name === "update-note")!;
530
+
531
+ // Add link
532
+ updateNote.execute({ id: "a", links: { add: [{ target: "b", relationship: "mentions" }] } });
533
+ expect(store.getLinks("a", { direction: "outbound" })).toHaveLength(1);
534
+
535
+ // Remove link
536
+ updateNote.execute({ id: "a", links: { remove: [{ target: "b", relationship: "mentions" }] } });
537
+ expect(store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
538
+ });
539
+
540
+ it("update-note removes wikilink brackets when removing wikilink-type link", () => {
541
+ store.createNote("Target", { id: "target", path: "People/Alice" });
542
+ const source = store.createNote("See [[People/Alice]] for details", { id: "source" });
543
+ store.createLink("source", "target", "wikilink");
544
+
545
+ const tools = generateMcpTools(store);
546
+ const updateNote = tools.find((t) => t.name === "update-note")!;
547
+ const result = updateNote.execute({
548
+ id: "source",
549
+ links: { remove: [{ target: "target", relationship: "wikilink" }] },
550
+ }) as any;
551
+ expect(result.content).toBe("See People/Alice for details");
552
+ });
553
+
554
+ it("update-note batch mode works", () => {
555
+ const a = store.createNote("A", { id: "a" });
556
+ const b = store.createNote("B", { id: "b" });
557
+ const tools = generateMcpTools(store);
558
+ const updateNote = tools.find((t) => t.name === "update-note")!;
559
+ const result = updateNote.execute({
560
+ notes: [
561
+ { id: "a", content: "A updated" },
562
+ { id: "b", tags: { add: ["pinned"] } },
563
+ ],
564
+ }) as any[];
565
+ expect(result).toHaveLength(2);
566
+ expect(result[0].content).toBe("A updated");
567
+ expect(store.getNote("b")!.tags).toContain("pinned");
568
+ });
569
+
570
+ it("update-note resolves note by path", () => {
571
+ store.createNote("Test", { path: "Projects/README" });
572
+ const tools = generateMcpTools(store);
573
+ const updateNote = tools.find((t) => t.name === "update-note")!;
574
+ const result = updateNote.execute({ id: "Projects/README", content: "Updated" }) as any;
575
+ expect(result.content).toBe("Updated");
576
+ });
577
+
578
+ it("query-notes single note by id", () => {
579
+ const note = store.createNote("Hello", { path: "test/note" });
580
+ const tools = generateMcpTools(store);
581
+ const query = tools.find((t) => t.name === "query-notes")!;
582
+ const result = query.execute({ id: note.id }) as any;
583
+ expect(result.content).toBe("Hello");
584
+ expect(result.path).toBe("test/note");
585
+ });
586
+
587
+ it("query-notes single note by path", () => {
588
+ store.createNote("By Path", { path: "Projects/README" });
589
+ const tools = generateMcpTools(store);
590
+ const query = tools.find((t) => t.name === "query-notes")!;
591
+ const result = query.execute({ id: "Projects/README" }) as any;
592
+ expect(result.content).toBe("By Path");
593
+ });
594
+
595
+ it("query-notes by tag", () => {
596
+ store.createNote("Test", { tags: ["daily"] });
597
+ const tools = generateMcpTools(store);
598
+ const query = tools.find((t) => t.name === "query-notes")!;
599
+ const result = query.execute({ tag: ["daily"] }) as any[];
600
+ expect(result).toHaveLength(1);
601
+ });
602
+
603
+ it("query-notes list defaults to no content (index mode)", () => {
604
+ const content = "This is the note body.";
605
+ store.createNote(content, { tags: ["daily"], path: "Notes/test", metadata: { status: "draft" } });
606
+ const tools = generateMcpTools(store);
607
+ const query = tools.find((t) => t.name === "query-notes")!;
608
+ const result = query.execute({ tag: ["daily"] }) as any[];
609
+ expect(result).toHaveLength(1);
610
+ const entry = result[0];
611
+ expect(entry.content).toBeUndefined();
612
+ expect(entry.id).toBeTruthy();
613
+ expect(entry.path).toBe("Notes/test");
614
+ expect(entry.byteSize).toBe(Buffer.byteLength(content, "utf8"));
615
+ });
616
+
617
+ it("query-notes list with include_content: true returns full content", () => {
618
+ store.createNote("Full body", { tags: ["daily"] });
619
+ const tools = generateMcpTools(store);
620
+ const query = tools.find((t) => t.name === "query-notes")!;
621
+ const result = query.execute({ tag: ["daily"], include_content: true }) as any[];
622
+ expect(result).toHaveLength(1);
623
+ expect(result[0].content).toBe("Full body");
624
+ });
625
+
626
+ it("query-notes index mode truncates preview and counts utf-8 bytes", () => {
627
+ const longContent = "line one\nline two has\tlots of whitespace\n" + "x".repeat(300) + " ✨✨✨";
628
+ store.createNote(longContent, { tags: ["long"] });
629
+ const tools = generateMcpTools(store);
630
+ const query = tools.find((t) => t.name === "query-notes")!;
631
+ const result = query.execute({ tag: ["long"] }) as any[];
632
+ expect(result).toHaveLength(1);
633
+ const entry = result[0];
634
+ expect(entry.byteSize).toBe(Buffer.byteLength(longContent, "utf8"));
635
+ expect(entry.byteSize).toBeGreaterThan(longContent.length);
636
+ expect(entry.preview.length).toBeLessThanOrEqual(120);
637
+ expect(entry.preview.includes("\n")).toBe(false);
638
+ });
639
+
640
+ it("query-notes index mode does not split astral-plane surrogate pairs", () => {
641
+ const emoji = "😀";
642
+ const longContent = emoji.repeat(130);
643
+ store.createNote(longContent, { tags: ["astral"] });
644
+ const tools = generateMcpTools(store);
645
+ const query = tools.find((t) => t.name === "query-notes")!;
646
+ const result = query.execute({ tag: ["astral"] }) as any[];
647
+ expect(result).toHaveLength(1);
648
+ const preview = result[0].preview as string;
649
+ const codePoints = Array.from(preview);
650
+ expect(codePoints.length).toBeLessThanOrEqual(120);
651
+ for (const cp of codePoints) {
652
+ expect(cp).toBe(emoji);
653
+ }
654
+ });
655
+
656
+ it("query-notes honors filters (date range, path_prefix, limit, offset)", () => {
657
+ store.createNote("A", { tags: ["keep"], path: "Projects/a", created_at: "2025-03-05T00:00:00.000Z" });
658
+ store.createNote("B", { tags: ["keep"], path: "Projects/b", created_at: "2025-03-10T00:00:00.000Z" });
659
+ store.createNote("C", { tags: ["keep"], path: "Other/c", created_at: "2025-03-15T00:00:00.000Z" });
660
+ store.createNote("D", { tags: ["keep"], path: "Projects/d", created_at: "2025-04-02T00:00:00.000Z" });
661
+
662
+ const tools = generateMcpTools(store);
663
+ const query = tools.find((t) => t.name === "query-notes")!;
664
+
665
+ // date range filter
666
+ const inMarch = query.execute({
667
+ date_from: "2025-03-01",
668
+ date_to: "2025-04-01",
669
+ sort: "asc",
670
+ }) as any[];
671
+ expect(inMarch).toHaveLength(3);
672
+ expect(inMarch.every((n) => n.content === undefined)).toBe(true);
673
+
674
+ // path_prefix filter
675
+ const projects = query.execute({ path_prefix: "Projects" }) as any[];
676
+ expect(projects).toHaveLength(3);
677
+ expect(projects.every((n) => n.path!.startsWith("Projects"))).toBe(true);
678
+
679
+ // limit + offset
680
+ const page = query.execute({
681
+ path_prefix: "Projects",
682
+ sort: "asc",
683
+ limit: 2,
684
+ offset: 1,
685
+ }) as any[];
686
+ expect(page).toHaveLength(2);
687
+ });
688
+
689
+ it("query-notes full-text search works", () => {
690
+ store.createNote("Flagstaff trail");
691
+ const tools = generateMcpTools(store);
692
+ const query = tools.find((t) => t.name === "query-notes")!;
693
+ const result = query.execute({ search: "Flagstaff" }) as any[];
694
+ expect(result).toHaveLength(1);
695
+ });
696
+
697
+ it("query-notes with include_links enriches results", () => {
698
+ store.createNote("A", { id: "a", path: "alpha" });
699
+ store.createNote("B", { id: "b", path: "beta" });
700
+ store.createLink("a", "b", "mentions");
701
+ const tools = generateMcpTools(store);
702
+ const query = tools.find((t) => t.name === "query-notes")!;
703
+ const result = query.execute({ id: "a", include_links: true }) as any;
704
+ expect(result.links).toBeDefined();
705
+ expect(result.links).toHaveLength(1);
706
+ });
707
+
708
+ it("query-notes include_metadata: true returns all metadata (single)", () => {
709
+ store.createNote("Body", { metadata: { summary: "short", status: "draft", priority: 1 } });
710
+ const tools = generateMcpTools(store);
711
+ const query = tools.find((t) => t.name === "query-notes")!;
712
+ const result = query.execute({ id: store.queryNotes({})[0].id, include_metadata: true }) as any;
713
+ expect(result.metadata).toEqual({ summary: "short", status: "draft", priority: 1 });
714
+ });
715
+
716
+ it("query-notes include_metadata: false strips metadata (single)", () => {
717
+ store.createNote("Body", { metadata: { summary: "short", status: "draft" } });
718
+ const tools = generateMcpTools(store);
719
+ const query = tools.find((t) => t.name === "query-notes")!;
720
+ const result = query.execute({ id: store.queryNotes({})[0].id, include_metadata: false }) as any;
721
+ expect(result.metadata).toBeUndefined();
722
+ expect(result.content).toBe("Body"); // other fields unaffected
723
+ });
724
+
725
+ it("query-notes include_metadata: string[] returns only specified fields (single)", () => {
726
+ store.createNote("Body", { metadata: { summary: "short", status: "draft", priority: 1 } });
727
+ const tools = generateMcpTools(store);
728
+ const query = tools.find((t) => t.name === "query-notes")!;
729
+ const result = query.execute({ id: store.queryNotes({})[0].id, include_metadata: ["summary"] }) as any;
730
+ expect(result.metadata).toEqual({ summary: "short" });
731
+ });
732
+
733
+ it("query-notes include_metadata: false strips metadata (list)", () => {
734
+ store.createNote("A", { tags: ["meta-test"], metadata: { summary: "a" } });
735
+ store.createNote("B", { tags: ["meta-test"], metadata: { summary: "b" } });
736
+ const tools = generateMcpTools(store);
737
+ const query = tools.find((t) => t.name === "query-notes")!;
738
+ const result = query.execute({ tag: "meta-test", include_metadata: false }) as any[];
739
+ expect(result).toHaveLength(2);
740
+ for (const n of result) {
741
+ expect(n.metadata).toBeUndefined();
742
+ }
743
+ });
744
+
745
+ it("query-notes include_metadata: string[] filters fields (list)", () => {
746
+ store.createNote("A", { tags: ["meta-filter"], metadata: { summary: "a", status: "ok", extra: true } });
747
+ store.createNote("B", { tags: ["meta-filter"], metadata: { summary: "b", extra: false } });
748
+ const tools = generateMcpTools(store);
749
+ const query = tools.find((t) => t.name === "query-notes")!;
750
+ const result = query.execute({ tag: "meta-filter", include_metadata: ["summary", "status"] }) as any[];
751
+ expect(result).toHaveLength(2);
752
+ const a = result.find((n: any) => n.metadata?.summary === "a");
753
+ const b = result.find((n: any) => n.metadata?.summary === "b");
754
+ expect(a.metadata).toEqual({ summary: "a", status: "ok" });
755
+ expect(b.metadata).toEqual({ summary: "b" }); // status absent → omitted
756
+ });
757
+
758
+ it("query-notes include_metadata: string[] with no matching fields returns undefined metadata", () => {
759
+ store.createNote("A", { tags: ["no-match-meta"], metadata: { summary: "a" } });
760
+ const tools = generateMcpTools(store);
761
+ const query = tools.find((t) => t.name === "query-notes")!;
762
+ const result = query.execute({ tag: "no-match-meta", include_metadata: ["nonexistent"] }) as any[];
763
+ expect(result).toHaveLength(1);
764
+ expect(result[0].metadata).toBeUndefined();
765
+ });
766
+
767
+ it("query-notes near param scopes results to graph neighborhood", () => {
768
+ store.createNote("Center", { id: "center" });
769
+ store.createNote("Near", { id: "near", tags: ["t"] });
770
+ store.createNote("Far", { id: "far", tags: ["t"] });
771
+ store.createLink("center", "near", "mentions");
772
+ // "far" is not linked to "center"
773
+
774
+ const tools = generateMcpTools(store);
775
+ const query = tools.find((t) => t.name === "query-notes")!;
776
+ const result = query.execute({ tag: "t", near: { note_id: "center", depth: 1 } }) as any[];
777
+ expect(result).toHaveLength(1);
778
+ expect(result[0].id).toBe("near");
779
+ });
780
+
781
+ it("delete-note accepts path", () => {
782
+ store.createNote("To delete", { path: "Temp/note" });
783
+ const tools = generateMcpTools(store);
784
+ const deleteTool = tools.find((t) => t.name === "delete-note")!;
785
+ const result = deleteTool.execute({ id: "Temp/note" }) as any;
786
+ expect(result.deleted).toBe(true);
787
+ expect(store.getNoteByPath("Temp/note")).toBeNull();
788
+ });
789
+
790
+ it("delete-tag with zero notes removes tag from list", () => {
791
+ store.createNote("Test", { tags: ["ephemeral"] });
792
+ store.untagNote(store.queryNotes({}).find((n) => n.tags?.includes("ephemeral"))!.id, ["ephemeral"]);
793
+ const before = store.listTags();
794
+ expect(before.some((t) => t.name === "ephemeral")).toBe(true);
795
+
796
+ const result = store.deleteTag("ephemeral");
797
+ expect(result).toEqual({ deleted: true, notes_untagged: 0 });
798
+
799
+ const after = store.listTags();
800
+ expect(after.some((t) => t.name === "ephemeral")).toBe(false);
801
+ });
802
+
803
+ it("delete-tag with N notes untags all but preserves notes", () => {
804
+ const n1 = store.createNote("A", { tags: ["doomed"] });
805
+ const n2 = store.createNote("B", { tags: ["doomed", "keeper"] });
806
+
807
+ const result = store.deleteTag("doomed");
808
+ expect(result).toEqual({ deleted: true, notes_untagged: 2 });
809
+
810
+ expect(store.getNote(n1.id)).not.toBeNull();
811
+ expect(store.getNote(n2.id)).not.toBeNull();
812
+ expect(store.getNote(n1.id)!.tags).not.toContain("doomed");
813
+ expect(store.getNote(n2.id)!.tags).not.toContain("doomed");
814
+ expect(store.getNote(n2.id)!.tags).toContain("keeper");
815
+ expect(store.listTags().some((t) => t.name === "doomed")).toBe(false);
816
+ });
817
+
818
+ it("delete-tag nonexistent returns deleted: false", () => {
819
+ const result = store.deleteTag("never-existed");
820
+ expect(result).toEqual({ deleted: false, notes_untagged: 0 });
821
+ });
822
+
823
+ it("delete-tag MCP tool works", () => {
824
+ const tools = generateMcpTools(store);
825
+ const createNote = tools.find((t) => t.name === "create-note")!;
826
+ createNote.execute({ content: "Test", tags: ["mcp-tag"] });
827
+
828
+ const deleteTool = tools.find((t) => t.name === "delete-tag")!;
829
+ const result = deleteTool.execute({ tag: "mcp-tag" }) as any;
830
+ expect(result.deleted).toBe(true);
831
+ expect(result.notes_untagged).toBe(1);
832
+
833
+ const listTool = tools.find((t) => t.name === "list-tags")!;
834
+ const tags = listTool.execute({}) as any[];
835
+ expect(tags.some((t: any) => t.name === "mcp-tag")).toBe(false);
836
+ });
837
+
838
+ it("list-tags single tag detail with schema", () => {
839
+ store.createNote("Test", { tags: ["person"] });
840
+ store.upsertTagSchema("person", {
841
+ description: "A person",
842
+ fields: { name: { type: "string" } },
843
+ });
844
+ const tools = generateMcpTools(store);
845
+ const listTags = tools.find((t) => t.name === "list-tags")!;
846
+ const result = listTags.execute({ tag: "person" }) as any;
847
+ expect(result.name).toBe("person");
848
+ expect(result.count).toBe(1);
849
+ expect(result.description).toBe("A person");
850
+ expect(result.fields.name.type).toBe("string");
851
+ });
852
+
853
+ it("list-tags include_schema returns schemas for all tags", () => {
854
+ store.createNote("A", { tags: ["person"] });
855
+ store.createNote("B", { tags: ["project"] });
856
+ store.upsertTagSchema("person", { description: "A person" });
857
+ const tools = generateMcpTools(store);
858
+ const listTags = tools.find((t) => t.name === "list-tags")!;
859
+ const result = listTags.execute({ include_schema: true }) as any[];
860
+ const person = result.find((t: any) => t.name === "person");
861
+ expect(person.description).toBe("A person");
862
+ const project = result.find((t: any) => t.name === "project");
863
+ expect(project.description).toBeNull();
864
+ });
865
+
866
+ it("update-tag creates schema if not exists", () => {
867
+ const tools = generateMcpTools(store);
868
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
869
+ const result = updateTag.execute({
870
+ tag: "person",
871
+ description: "A person",
872
+ fields: { name: { type: "string" } },
873
+ }) as any;
874
+ expect(result.tag).toBe("person");
875
+ expect(result.description).toBe("A person");
876
+ });
877
+
878
+ it("update-tag merges fields with existing", () => {
879
+ store.upsertTagSchema("person", {
880
+ description: "A person",
881
+ fields: { name: { type: "string" } },
882
+ });
883
+ const tools = generateMcpTools(store);
884
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
885
+ const result = updateTag.execute({
886
+ tag: "person",
887
+ fields: { age: { type: "integer" } },
888
+ }) as any;
889
+ expect(result.fields.name.type).toBe("string");
890
+ expect(result.fields.age.type).toBe("integer");
891
+ });
892
+
893
+ it("find-path works with ID/path resolution", () => {
894
+ store.createNote("A", { id: "a", path: "People/Alice" });
895
+ store.createNote("B", { id: "b" });
896
+ store.createNote("C", { id: "c", path: "Projects/X" });
897
+ store.createLink("a", "b", "mentions");
898
+ store.createLink("b", "c", "related-to");
899
+
900
+ const tools = generateMcpTools(store);
901
+ const findPath = tools.find((t) => t.name === "find-path")!;
902
+ const result = findPath.execute({ source: "People/Alice", target: "Projects/X" }) as any;
903
+ expect(result).not.toBeNull();
904
+ expect(result.path).toEqual(["a", "b", "c"]);
905
+ expect(result.relationships).toEqual(["mentions", "related-to"]);
906
+ });
907
+
908
+ it("create-note via store triggers wikilink sync", () => {
909
+ const tools = generateMcpTools(store);
910
+ const createNote = tools.find((t) => t.name === "create-note")!;
911
+
912
+ store.createNote("Target", { path: "Target Note" });
913
+ const source = createNote.execute({ content: "See [[Target Note]]" }) as any;
914
+
915
+ const links = store.getLinks(source.id, { direction: "outbound" });
916
+ expect(links.some((l) => l.relationship === "wikilink")).toBe(true);
917
+ });
918
+
919
+ it("create-note with schema tag auto-populates defaults", () => {
920
+ store.upsertTagSchema("person", {
921
+ description: "A person",
922
+ fields: {
923
+ first_appeared: { type: "string" },
924
+ active: { type: "boolean" },
925
+ priority: { type: "integer" },
926
+ status: { type: "string", enum: ["active", "archived"] },
927
+ },
928
+ });
929
+ const tools = generateMcpTools(store);
930
+ const createNote = tools.find((t) => t.name === "create-note")!;
931
+ const query = tools.find((t) => t.name === "query-notes")!;
932
+
933
+ const result = createNote.execute({ content: "Alice", tags: ["person"] }) as any;
934
+ const fresh = query.execute({ id: result.id }) as any;
935
+ expect(fresh.metadata.first_appeared).toBe("");
936
+ expect(fresh.metadata.active).toBe(false);
937
+ expect(fresh.metadata.priority).toBe(0);
938
+ expect(fresh.metadata.status).toBe("active");
939
+ });
940
+ });