@openparachute/vault 0.2.4 → 0.3.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 (102) hide show
  1. package/.claude/settings.local.json +2 -25
  2. package/CHANGELOG.md +64 -0
  3. package/CLAUDE.md +17 -7
  4. package/README.md +169 -136
  5. package/core/src/core.test.ts +591 -19
  6. package/core/src/hooks.ts +111 -3
  7. package/core/src/indexed-fields.test.ts +285 -0
  8. package/core/src/indexed-fields.ts +238 -0
  9. package/core/src/mcp.ts +127 -6
  10. package/core/src/notes.ts +153 -11
  11. package/core/src/query-operators.ts +174 -0
  12. package/core/src/schema.ts +69 -2
  13. package/core/src/store.ts +95 -1
  14. package/core/src/tag-schemas.ts +5 -0
  15. package/core/src/types.ts +28 -1
  16. package/docs/HTTP_API.md +105 -1
  17. package/docs/auth-model.md +340 -0
  18. package/package/package.json +32 -0
  19. package/package.json +2 -2
  20. package/src/auth.test.ts +83 -114
  21. package/src/auth.ts +68 -6
  22. package/src/backup-launchd.ts +1 -1
  23. package/src/backup.test.ts +1 -1
  24. package/src/backup.ts +18 -17
  25. package/src/bind.test.ts +28 -0
  26. package/src/bind.ts +19 -0
  27. package/src/cli.ts +228 -133
  28. package/src/config-triggers.test.ts +49 -0
  29. package/src/config.test.ts +317 -2
  30. package/src/config.ts +420 -40
  31. package/src/context.test.ts +136 -0
  32. package/src/context.ts +115 -0
  33. package/src/daemon.ts +17 -16
  34. package/src/doctor.test.ts +9 -7
  35. package/src/launchd.test.ts +1 -1
  36. package/src/launchd.ts +6 -6
  37. package/src/mcp-http.ts +75 -21
  38. package/src/mcp-install.test.ts +125 -0
  39. package/src/mcp-install.ts +60 -0
  40. package/src/mcp-tools.ts +34 -96
  41. package/src/module-config.ts +109 -0
  42. package/src/oauth.test.ts +345 -57
  43. package/src/oauth.ts +155 -35
  44. package/src/published.test.ts +2 -2
  45. package/src/routes.ts +209 -33
  46. package/src/routing.test.ts +817 -300
  47. package/src/routing.ts +204 -202
  48. package/src/scopes.test.ts +294 -0
  49. package/src/scopes.ts +253 -0
  50. package/src/scribe-env.test.ts +49 -0
  51. package/src/scribe-env.ts +33 -0
  52. package/src/server.ts +73 -9
  53. package/src/services-manifest.test.ts +140 -0
  54. package/src/services-manifest.ts +99 -0
  55. package/src/systemd.ts +3 -3
  56. package/src/token-store.ts +42 -9
  57. package/src/transcription-worker.test.ts +864 -0
  58. package/src/transcription-worker.ts +501 -0
  59. package/src/triggers.test.ts +191 -1
  60. package/src/triggers.ts +17 -2
  61. package/src/vault.test.ts +693 -77
  62. package/src/version.test.ts +1 -1
  63. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  64. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  65. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  66. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  67. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  68. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  69. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  70. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  71. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  72. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  73. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  74. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  75. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  76. package/religions-abrahamic-filter.png +0 -0
  77. package/religions-buddhism-v2.png +0 -0
  78. package/religions-buddhism.png +0 -0
  79. package/religions-final.png +0 -0
  80. package/religions-v1.png +0 -0
  81. package/religions-v2.png +0 -0
  82. package/religions-zen.png +0 -0
  83. package/web/README.md +0 -73
  84. package/web/bun.lock +0 -827
  85. package/web/eslint.config.js +0 -23
  86. package/web/index.html +0 -15
  87. package/web/package.json +0 -36
  88. package/web/public/favicon.svg +0 -1
  89. package/web/public/icons.svg +0 -24
  90. package/web/src/App.tsx +0 -149
  91. package/web/src/Graph.tsx +0 -200
  92. package/web/src/NoteView.tsx +0 -155
  93. package/web/src/Sidebar.tsx +0 -186
  94. package/web/src/api.ts +0 -21
  95. package/web/src/index.css +0 -50
  96. package/web/src/main.tsx +0 -10
  97. package/web/src/types.ts +0 -37
  98. package/web/src/utils.ts +0 -107
  99. package/web/tsconfig.app.json +0 -25
  100. package/web/tsconfig.json +0 -7
  101. package/web/tsconfig.node.json +0 -24
  102. package/web/vite.config.ts +0 -16
@@ -129,7 +129,11 @@ describe("registerTriggers — dispatch modes", async () => {
129
129
  webhookServer?.stop(true);
130
130
  });
131
131
 
132
- function makeMockStore(note: Note, attachments: Attachment[] = []): Store {
132
+ function makeMockStore(
133
+ note: Note,
134
+ attachments: Attachment[] = [],
135
+ contextNotesByTag: Record<string, Note[]> = {},
136
+ ): Store {
133
137
  const notes = new Map<string, Note>();
134
138
  notes.set(note.id, { ...note });
135
139
  const attachmentStore = new Map<string, Attachment[]>();
@@ -153,6 +157,14 @@ describe("registerTriggers — dispatch modes", async () => {
153
157
  attachmentStore.set(noteId, existing);
154
158
  return att;
155
159
  },
160
+ queryNotes: async ({ tags, excludeTags }: { tags?: string[]; excludeTags?: string[] }) => {
161
+ const tag = tags?.[0];
162
+ if (!tag) return [];
163
+ const pool = contextNotesByTag[tag] ?? [];
164
+ if (!excludeTags?.length) return pool;
165
+ const excluded = new Set(excludeTags);
166
+ return pool.filter((n) => !(n.tags ?? []).some((t) => excluded.has(t)));
167
+ },
156
168
  } as unknown as Store;
157
169
  }
158
170
 
@@ -334,6 +346,184 @@ describe("registerTriggers — dispatch modes", async () => {
334
346
  const meta = updated?.metadata as Record<string, unknown>;
335
347
  expect(meta.empty_test_skipped_reason).toBe("note has no content to synthesize");
336
348
  });
349
+
350
+ it("send=attachment with include_context attaches context JSON part", async () => {
351
+ const hooks = new HookRegistry();
352
+ const note = makeNote({ id: "ctx1", content: "", tags: ["capture"] });
353
+
354
+ const tmpDir = `/tmp/trigger-ctx-att-${Date.now()}`;
355
+ const { mkdirSync, writeFileSync, rmSync } = await import("fs");
356
+ mkdirSync(`${tmpDir}/2026-04-11`, { recursive: true });
357
+ writeFileSync(`${tmpDir}/2026-04-11/recording.wav`, Buffer.from("fake-wav"));
358
+
359
+ const attachment: Attachment = {
360
+ id: "att-c1",
361
+ noteId: "ctx1",
362
+ path: "2026-04-11/recording.wav",
363
+ mimeType: "audio/wav",
364
+ createdAt: "2025-01-01T00:00:00Z",
365
+ };
366
+
367
+ const person = makeNote({
368
+ id: "p1",
369
+ path: "People/Aaron.md",
370
+ tags: ["person"],
371
+ metadata: { summary: "founder", aliases: ["AG"], secret: "nope" },
372
+ });
373
+ const project = makeNote({
374
+ id: "pj1",
375
+ path: "Projects/Lens.md",
376
+ tags: ["project"],
377
+ metadata: { summary: "note app" },
378
+ });
379
+ const store = makeMockStore(note, [attachment], { person: [person], project: [project] });
380
+
381
+ const originalAssetsDir = process.env.ASSETS_DIR;
382
+ process.env.ASSETS_DIR = tmpDir;
383
+
384
+ webhookHandler = () => Response.json({ text: "transcribed" });
385
+
386
+ registerTriggers(hooks, [{
387
+ name: "ctx_attachment_test",
388
+ when: { tags: ["capture"], has_content: false },
389
+ action: {
390
+ webhook: `http://127.0.0.1:${webhookPort}/transcribe`,
391
+ send: "attachment",
392
+ include_context: [
393
+ { tag: "person", include_metadata: ["summary", "aliases"] },
394
+ { tag: "project", include_metadata: ["summary"] },
395
+ ],
396
+ },
397
+ }], { error: () => {}, info: () => {} });
398
+
399
+ await hooks.dispatch("created", note, store);
400
+ await new Promise((r) => setTimeout(r, 50));
401
+
402
+ expect(lastRequest?.formData).toBeDefined();
403
+ const part = lastRequest!.formData!.get("context");
404
+ expect(part).toBeInstanceOf(Blob);
405
+ const body = JSON.parse(await (part as Blob).text());
406
+ expect(body.entries.length).toBe(2);
407
+ expect(body.entries[0]).toEqual({ name: "Aaron", summary: "founder", aliases: ["AG"] });
408
+ expect(body.entries[1]).toEqual({ name: "Lens", summary: "note app" });
409
+ // Non-whitelisted metadata must not leak.
410
+ expect(body.entries[0].secret).toBeUndefined();
411
+
412
+ if (originalAssetsDir) process.env.ASSETS_DIR = originalAssetsDir;
413
+ else delete process.env.ASSETS_DIR;
414
+ rmSync(tmpDir, { recursive: true, force: true });
415
+ });
416
+
417
+ it("send=attachment without include_context omits context part (no regression)", async () => {
418
+ const hooks = new HookRegistry();
419
+ const note = makeNote({ id: "nx", content: "", tags: ["capture"] });
420
+
421
+ const tmpDir = `/tmp/trigger-ctx-none-${Date.now()}`;
422
+ const { mkdirSync, writeFileSync, rmSync } = await import("fs");
423
+ mkdirSync(`${tmpDir}/2026-04-11`, { recursive: true });
424
+ writeFileSync(`${tmpDir}/2026-04-11/recording.wav`, Buffer.from("fake-wav"));
425
+ const attachment: Attachment = {
426
+ id: "att-x",
427
+ noteId: "nx",
428
+ path: "2026-04-11/recording.wav",
429
+ mimeType: "audio/wav",
430
+ createdAt: "2025-01-01T00:00:00Z",
431
+ };
432
+ const store = makeMockStore(note, [attachment]);
433
+ const originalAssetsDir = process.env.ASSETS_DIR;
434
+ process.env.ASSETS_DIR = tmpDir;
435
+
436
+ webhookHandler = () => Response.json({ text: "ok" });
437
+
438
+ registerTriggers(hooks, [{
439
+ name: "no_ctx",
440
+ when: { tags: ["capture"], has_content: false },
441
+ action: { webhook: `http://127.0.0.1:${webhookPort}/transcribe`, send: "attachment" },
442
+ }], { error: () => {}, info: () => {} });
443
+
444
+ await hooks.dispatch("created", note, store);
445
+ await new Promise((r) => setTimeout(r, 50));
446
+
447
+ expect(lastRequest?.formData).toBeDefined();
448
+ expect(lastRequest!.formData!.get("context")).toBeNull();
449
+
450
+ if (originalAssetsDir) process.env.ASSETS_DIR = originalAssetsDir;
451
+ else delete process.env.ASSETS_DIR;
452
+ rmSync(tmpDir, { recursive: true, force: true });
453
+ });
454
+
455
+ it("send=json with include_context inlines context field", async () => {
456
+ const hooks = new HookRegistry();
457
+ const note = makeNote({ id: "j1", content: "hello", tags: ["test"] });
458
+ const person = makeNote({
459
+ id: "p1",
460
+ path: "People/Aaron.md",
461
+ tags: ["person"],
462
+ metadata: { summary: "founder" },
463
+ });
464
+ const store = makeMockStore(note, [], { person: [person] });
465
+
466
+ webhookHandler = () => Response.json({});
467
+
468
+ registerTriggers(hooks, [{
469
+ name: "json_ctx",
470
+ when: { tags: ["test"] },
471
+ action: {
472
+ webhook: `http://127.0.0.1:${webhookPort}/hook`,
473
+ include_context: [{ tag: "person", include_metadata: ["summary"] }],
474
+ },
475
+ }], { error: () => {}, info: () => {} });
476
+
477
+ await hooks.dispatch("created", note, store);
478
+ await new Promise((r) => setTimeout(r, 50));
479
+
480
+ const body = lastRequest!.body as Record<string, unknown>;
481
+ expect(body.context).toBeDefined();
482
+ const ctx = body.context as { entries: Array<Record<string, unknown>> };
483
+ expect(ctx.entries).toEqual([{ name: "Aaron", summary: "founder" }]);
484
+ });
485
+
486
+ it("send=content ignores include_context (TTS-out has no use for it)", async () => {
487
+ const hooks = new HookRegistry();
488
+ const note = makeNote({ id: "c1", content: "speak", tags: ["reader"] });
489
+ const person = makeNote({
490
+ id: "p1",
491
+ path: "People/Aaron.md",
492
+ tags: ["person"],
493
+ metadata: { summary: "founder" },
494
+ });
495
+ const store = makeMockStore(note, [], { person: [person] });
496
+
497
+ const tmpDir = `/tmp/trigger-ctx-content-${Date.now()}`;
498
+ const { mkdirSync, rmSync } = await import("fs");
499
+ mkdirSync(tmpDir, { recursive: true });
500
+ const originalAssetsDir = process.env.ASSETS_DIR;
501
+ process.env.ASSETS_DIR = tmpDir;
502
+
503
+ webhookHandler = () =>
504
+ new Response(Buffer.from("audio"), { headers: { "Content-Type": "audio/ogg" } });
505
+
506
+ registerTriggers(hooks, [{
507
+ name: "content_ctx",
508
+ when: { tags: ["reader"], has_content: true },
509
+ action: {
510
+ webhook: `http://127.0.0.1:${webhookPort}/speech`,
511
+ send: "content",
512
+ include_context: [{ tag: "person", include_metadata: ["summary"] }],
513
+ },
514
+ }], { error: () => {}, info: () => {} });
515
+
516
+ await hooks.dispatch("created", note, store);
517
+ await new Promise((r) => setTimeout(r, 50));
518
+
519
+ const body = lastRequest!.body as Record<string, unknown>;
520
+ expect(body.context).toBeUndefined();
521
+ expect(body.input).toBe("speak");
522
+
523
+ if (originalAssetsDir) process.env.ASSETS_DIR = originalAssetsDir;
524
+ else delete process.env.ASSETS_DIR;
525
+ rmSync(tmpDir, { recursive: true, force: true });
526
+ });
337
527
  });
338
528
 
339
529
  describe("registerTriggers — validation", () => {
package/src/triggers.ts CHANGED
@@ -33,6 +33,7 @@ import type { HookRegistry, HookEvent } from "../core/src/hooks.ts";
33
33
  import type { TriggerConfig, TriggerWhen } from "./config.ts";
34
34
  import { getVaultNameForStore } from "./vault-store.ts";
35
35
  import { assetsDir } from "./routes.ts";
36
+ import { appendContextPart, fetchContextEntries, type ContextPayload } from "./context.ts";
36
37
 
37
38
  const DEFAULT_TIMEOUT = 60_000;
38
39
 
@@ -157,6 +158,7 @@ async function dispatchJson(
157
158
  attachments: Attachment[],
158
159
  existingMeta: Record<string, unknown>,
159
160
  hookEvent: HookEvent | undefined,
161
+ context: ContextPayload | null,
160
162
  signal: AbortSignal,
161
163
  ): Promise<DispatchResult> {
162
164
  const resp = await fetch(url, {
@@ -175,6 +177,10 @@ async function dispatchJson(
175
177
  createdAt: note.createdAt,
176
178
  updatedAt: note.updatedAt,
177
179
  },
180
+ // Inline when include_context is configured and matched anything; the
181
+ // receiver can key off a top-level `context` field without having to
182
+ // parse multipart.
183
+ ...(context && context.entries.length ? { context } : {}),
178
184
  }),
179
185
  signal,
180
186
  });
@@ -196,6 +202,7 @@ async function dispatchAttachment(
196
202
  note: Note,
197
203
  attachments: Attachment[],
198
204
  store: Store,
205
+ context: ContextPayload | null,
199
206
  signal: AbortSignal,
200
207
  ): Promise<DispatchResult> {
201
208
  const assetsRoot = resolveAssetsDir(store);
@@ -210,6 +217,7 @@ async function dispatchAttachment(
210
217
 
211
218
  const form = new FormData();
212
219
  form.append("file", file);
220
+ if (context) appendContextPart(form, context);
213
221
 
214
222
  const resp = await fetch(url, { method: "POST", body: form, signal });
215
223
  if (!resp.ok) {
@@ -325,20 +333,27 @@ export function registerTriggers(
325
333
  // Fire the webhook using the configured send mode
326
334
  let webhookResult: WebhookResponse;
327
335
  const attachments = await store.getAttachments(note.id);
336
+ // Pre-fetch context once per fire. Predicate errors are logged and
337
+ // the fire continues — context is additive, never blocking.
338
+ const context = trigger.action.include_context?.length
339
+ ? await fetchContextEntries(store, trigger.action.include_context, logger)
340
+ : null;
328
341
  const controller = new AbortController();
329
342
  const timer = setTimeout(() => controller.abort(), timeout);
330
343
  try {
331
344
  let result: DispatchResult;
332
345
  switch (sendMode) {
333
346
  case "attachment":
334
- result = await dispatchAttachment(trigger.action.webhook, note, attachments, store, controller.signal);
347
+ result = await dispatchAttachment(trigger.action.webhook, note, attachments, store, context, controller.signal);
335
348
  break;
336
349
  case "content":
350
+ // send=content is pure TTS (audio out); vault context makes no
351
+ // sense here and would confuse the server contract.
337
352
  result = await dispatchContent(trigger.action.webhook, note, store, controller.signal);
338
353
  break;
339
354
  case "json":
340
355
  default:
341
- result = await dispatchJson(trigger.action.webhook, trigger, note, attachments, existingMeta, hookEvent, controller.signal);
356
+ result = await dispatchJson(trigger.action.webhook, trigger, note, attachments, existingMeta, hookEvent, context, controller.signal);
342
357
  break;
343
358
  }
344
359
  webhookResult = result.webhookResult;