@openparachute/vault 0.2.4 → 0.3.0-rc.1

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 (98) 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/indexed-fields.test.ts +285 -0
  7. package/core/src/indexed-fields.ts +238 -0
  8. package/core/src/mcp.ts +127 -6
  9. package/core/src/notes.ts +153 -11
  10. package/core/src/query-operators.ts +174 -0
  11. package/core/src/schema.ts +69 -2
  12. package/core/src/store.ts +92 -0
  13. package/core/src/tag-schemas.ts +5 -0
  14. package/core/src/types.ts +28 -1
  15. package/docs/HTTP_API.md +105 -1
  16. package/package/package.json +32 -0
  17. package/package.json +2 -2
  18. package/src/auth.test.ts +83 -114
  19. package/src/auth.ts +68 -6
  20. package/src/backup-launchd.ts +1 -1
  21. package/src/backup.test.ts +1 -1
  22. package/src/backup.ts +18 -17
  23. package/src/cli.ts +179 -121
  24. package/src/config-triggers.test.ts +49 -0
  25. package/src/config.test.ts +317 -2
  26. package/src/config.ts +420 -40
  27. package/src/context.test.ts +136 -0
  28. package/src/context.ts +115 -0
  29. package/src/daemon.ts +17 -16
  30. package/src/doctor.test.ts +9 -7
  31. package/src/launchd.test.ts +1 -1
  32. package/src/launchd.ts +6 -6
  33. package/src/mcp-http.ts +75 -21
  34. package/src/mcp-install.test.ts +125 -0
  35. package/src/mcp-install.ts +60 -0
  36. package/src/mcp-tools.ts +34 -96
  37. package/src/module-config.ts +109 -0
  38. package/src/oauth.test.ts +345 -57
  39. package/src/oauth.ts +155 -35
  40. package/src/published.test.ts +2 -2
  41. package/src/routes.ts +209 -33
  42. package/src/routing.test.ts +817 -300
  43. package/src/routing.ts +204 -202
  44. package/src/scopes.test.ts +136 -0
  45. package/src/scopes.ts +105 -0
  46. package/src/scribe-env.test.ts +49 -0
  47. package/src/scribe-env.ts +33 -0
  48. package/src/server.ts +57 -5
  49. package/src/services-manifest.test.ts +140 -0
  50. package/src/services-manifest.ts +99 -0
  51. package/src/systemd.ts +3 -3
  52. package/src/token-store.ts +42 -9
  53. package/src/transcription-worker.test.ts +583 -0
  54. package/src/transcription-worker.ts +346 -0
  55. package/src/triggers.test.ts +191 -1
  56. package/src/triggers.ts +17 -2
  57. package/src/vault.test.ts +693 -77
  58. package/src/version.test.ts +1 -1
  59. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  60. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  61. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  62. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  63. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  64. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  65. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  66. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  67. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  68. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  69. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  70. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  71. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  72. package/religions-abrahamic-filter.png +0 -0
  73. package/religions-buddhism-v2.png +0 -0
  74. package/religions-buddhism.png +0 -0
  75. package/religions-final.png +0 -0
  76. package/religions-v1.png +0 -0
  77. package/religions-v2.png +0 -0
  78. package/religions-zen.png +0 -0
  79. package/web/README.md +0 -73
  80. package/web/bun.lock +0 -827
  81. package/web/eslint.config.js +0 -23
  82. package/web/index.html +0 -15
  83. package/web/package.json +0 -36
  84. package/web/public/favicon.svg +0 -1
  85. package/web/public/icons.svg +0 -24
  86. package/web/src/App.tsx +0 -149
  87. package/web/src/Graph.tsx +0 -200
  88. package/web/src/NoteView.tsx +0 -155
  89. package/web/src/Sidebar.tsx +0 -186
  90. package/web/src/api.ts +0 -21
  91. package/web/src/index.css +0 -50
  92. package/web/src/main.tsx +0 -10
  93. package/web/src/types.ts +0 -37
  94. package/web/src/utils.ts +0 -107
  95. package/web/tsconfig.app.json +0 -25
  96. package/web/tsconfig.json +0 -7
  97. package/web/tsconfig.node.json +0 -24
  98. package/web/vite.config.ts +0 -16
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Background worker that drains pending transcription requests.
3
+ *
4
+ * ## How a request enters the queue
5
+ *
6
+ * The caller `POST /api/notes/:id/attachments` with `{transcribe: true}`.
7
+ * The route writes `attachment.metadata.transcribe_status = "pending"` and
8
+ * sets `note.metadata.transcribe_stub = true` as the opt-in to overwrite.
9
+ * The DB is the queue — a server restart resumes the scan without losing
10
+ * requests.
11
+ *
12
+ * ## What the worker does per pending attachment
13
+ *
14
+ * 1. Read the audio file from the vault's assets dir.
15
+ * 2. POST it as multipart/form-data to `SCRIBE_URL/v1/audio/transcriptions`
16
+ * (Whisper API shape). Response is `{ text: string }`.
17
+ * 3. On success:
18
+ * - If `note.metadata.transcribe_stub === true`, replace the
19
+ * `_Transcript pending._` placeholder with the transcript, or the
20
+ * whole note body if the placeholder is absent. Clear the stub marker.
21
+ * - Mark `attachment.metadata.transcribe_status = "done"` and record
22
+ * `transcript` + `transcribe_done_at`.
23
+ * - If the vault's `audio_retention` is `"until_transcribed"`, unlink
24
+ * the audio file on disk (the attachment row stays, so the transcript
25
+ * metadata is still addressable).
26
+ * 4. On failure:
27
+ * - Up to `maxAttempts` retries with exponential backoff encoded as
28
+ * `transcribe_backoff_until`. Status stays `"pending"`; we simply skip
29
+ * ones whose backoff hasn't expired.
30
+ * - After `maxAttempts`, flip status to `"failed"` with `transcribe_error`.
31
+ *
32
+ * ## Concurrency
33
+ *
34
+ * FIFO, one at a time, across all vaults. The poll-then-process loop is
35
+ * intentionally simple — transcription is already seconds-long and scribe
36
+ * is not designed for high concurrency. Scaling to multiple in-flight
37
+ * jobs can be added later without changing the wire contract.
38
+ */
39
+
40
+ import { join, normalize } from "path";
41
+ import { existsSync, readFileSync, unlinkSync } from "fs";
42
+ import type { Store, Attachment } from "../core/src/types.ts";
43
+ import { appendContextPart, fetchContextEntries, type ContextPayload } from "./context.ts";
44
+ import type { TriggerIncludeContext } from "./config.ts";
45
+
46
+ /** Placeholder pattern written by Lens's voice-memo stub. */
47
+ const TRANSCRIPT_PLACEHOLDER = /_Transcript pending\._/;
48
+
49
+ const DEFAULT_POLL_MS = 5_000;
50
+ const DEFAULT_MAX_ATTEMPTS = 3;
51
+ const DEFAULT_TIMEOUT_MS = 120_000;
52
+
53
+ export type AudioRetention = "keep" | "until_transcribed" | "never";
54
+
55
+ export interface TranscriptionWorkerOpts {
56
+ /** Vault names to scan each cycle. */
57
+ vaultList: () => string[];
58
+ /** Get a store for a vault name. */
59
+ getStore: (name: string) => Store;
60
+ /** Scribe base URL (no trailing slash). */
61
+ scribeUrl: string;
62
+ /** Optional bearer token for scribe. */
63
+ scribeToken?: string;
64
+ /** Resolve the assets root for a vault name. */
65
+ resolveAssetsDir: (vault: string) => string;
66
+ /** Per-vault audio retention. Default "keep". */
67
+ getAudioRetention?: (vault: string) => AudioRetention;
68
+ /**
69
+ * Per-vault context predicates for enriching the scribe POST. When present,
70
+ * the worker runs each predicate against the vault store and attaches the
71
+ * resulting entries as a `context` multipart part. Matches triggers'
72
+ * `action.include_context` so scribe sees the same shape via either path.
73
+ * Returning `undefined` or `[]` means no context is attached.
74
+ */
75
+ getContextPredicates?: (vault: string) => TriggerIncludeContext[] | undefined;
76
+ pollIntervalMs?: number;
77
+ maxAttempts?: number;
78
+ timeoutMs?: number;
79
+ fetchImpl?: typeof fetch;
80
+ logger?: { info?: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
81
+ }
82
+
83
+ export interface TranscriptionWorker {
84
+ /** Stop the loop and wait for in-flight work to finish. */
85
+ stop(): Promise<void>;
86
+ /** Run one poll cycle now. Returns number of attachments processed. */
87
+ tick(): Promise<number>;
88
+ }
89
+
90
+ interface PendingMeta {
91
+ transcribe_status?: string;
92
+ transcribe_attempts?: number;
93
+ transcribe_backoff_until?: string;
94
+ transcribe_requested_at?: string;
95
+ transcribe_error?: string;
96
+ transcript?: string;
97
+ transcribe_done_at?: string;
98
+ [k: string]: unknown;
99
+ }
100
+
101
+ /**
102
+ * Start the worker loop. Returns a handle with `stop()` + `tick()`.
103
+ * Tests should build the worker and call `tick()` directly; production
104
+ * calls `start()` implicitly by constructing the worker.
105
+ */
106
+ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): TranscriptionWorker {
107
+ const logger = opts.logger ?? console;
108
+ const fetchImpl = opts.fetchImpl ?? fetch;
109
+ const pollMs = opts.pollIntervalMs ?? DEFAULT_POLL_MS;
110
+ const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
111
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
112
+ const retentionFor = opts.getAudioRetention ?? (() => "keep" as const);
113
+
114
+ let stopped = false;
115
+ let inflight: Promise<void> = Promise.resolve();
116
+ let timer: ReturnType<typeof setTimeout> | null = null;
117
+
118
+ async function processOne(vault: string, attachment: Attachment): Promise<void> {
119
+ const store = opts.getStore(vault);
120
+ const meta: PendingMeta = { ...(attachment.metadata ?? {}) };
121
+ const attempts = (meta.transcribe_attempts as number | undefined) ?? 0;
122
+
123
+ // Honor backoff — we re-check here in case another tick queued this
124
+ // attachment between the listing and now.
125
+ if (meta.transcribe_backoff_until) {
126
+ const until = Date.parse(String(meta.transcribe_backoff_until));
127
+ if (Number.isFinite(until) && until > Date.now()) return;
128
+ }
129
+
130
+ const assetsRoot = opts.resolveAssetsDir(vault);
131
+ const filePath = normalize(join(assetsRoot, attachment.path));
132
+ if (!filePath.startsWith(normalize(assetsRoot)) || !existsSync(filePath)) {
133
+ // Audio gone — nothing to transcribe. Mark failed so we don't loop.
134
+ await store.setAttachmentMetadata(attachment.id, {
135
+ ...meta,
136
+ transcribe_status: "failed",
137
+ transcribe_error: "audio file not found",
138
+ });
139
+ return;
140
+ }
141
+
142
+ // Fetch context predicates for this vault. Errors are logged inside
143
+ // fetchContextEntries — we always have a payload (possibly empty) to
144
+ // pass through, so a bad predicate doesn't block transcription.
145
+ let context: ContextPayload | null = null;
146
+ const predicates = opts.getContextPredicates?.(vault);
147
+ if (predicates && predicates.length) {
148
+ context = await fetchContextEntries(store, predicates, logger);
149
+ }
150
+
151
+ let transcript: string;
152
+ try {
153
+ transcript = await callScribe({
154
+ url: opts.scribeUrl,
155
+ token: opts.scribeToken,
156
+ filePath,
157
+ filename: attachment.path.split("/").pop() ?? "audio",
158
+ mimeType: attachment.mimeType,
159
+ context,
160
+ timeoutMs,
161
+ fetchImpl,
162
+ });
163
+ } catch (err) {
164
+ const nextAttempts = attempts + 1;
165
+ const errMsg = err instanceof Error ? err.message : String(err);
166
+ if (nextAttempts >= maxAttempts) {
167
+ logger.error(`[transcribe] giving up on attachment ${attachment.id} after ${nextAttempts} attempts:`, errMsg);
168
+ await store.setAttachmentMetadata(attachment.id, {
169
+ ...meta,
170
+ transcribe_status: "failed",
171
+ transcribe_attempts: nextAttempts,
172
+ transcribe_error: errMsg,
173
+ });
174
+ // retention=never drops the audio on any terminal state, including
175
+ // failure. The user opted in to "I don't want the audio kept around
176
+ // regardless of outcome" — honor it.
177
+ if (retentionFor(vault) === "never") {
178
+ unlinkIfSafe(filePath, assetsRoot, logger);
179
+ }
180
+ return;
181
+ }
182
+ // Exponential backoff: 30s, 2m, 8m, ...
183
+ const backoffMs = 30_000 * Math.pow(4, nextAttempts - 1);
184
+ const backoffUntil = new Date(Date.now() + backoffMs).toISOString();
185
+ logger.error(`[transcribe] attachment ${attachment.id} attempt ${nextAttempts} failed; retrying at ${backoffUntil}:`, errMsg);
186
+ await store.setAttachmentMetadata(attachment.id, {
187
+ ...meta,
188
+ transcribe_status: "pending",
189
+ transcribe_attempts: nextAttempts,
190
+ transcribe_backoff_until: backoffUntil,
191
+ transcribe_error: errMsg,
192
+ });
193
+ return;
194
+ }
195
+
196
+ // Success. Apply to note if the caller still wants us to.
197
+ const note = await store.getNote(attachment.noteId);
198
+ if (note) {
199
+ const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
200
+ if (noteMeta.transcribe_stub === true) {
201
+ const body = TRANSCRIPT_PLACEHOLDER.test(note.content)
202
+ ? note.content.replace(TRANSCRIPT_PLACEHOLDER, transcript)
203
+ : transcript;
204
+ const { transcribe_stub: _drop, ...restMeta } = noteMeta;
205
+ try {
206
+ await store.updateNote(note.id, {
207
+ content: body,
208
+ metadata: restMeta,
209
+ skipUpdatedAt: true,
210
+ });
211
+ } catch (err) {
212
+ logger.error(`[transcribe] failed to apply transcript to note ${note.id}:`, err);
213
+ }
214
+ }
215
+ }
216
+
217
+ // Always record the transcript on the attachment, even if the note
218
+ // already moved on — the transcript is otherwise discarded.
219
+ const doneMeta: PendingMeta = {
220
+ ...meta,
221
+ transcribe_status: "done",
222
+ transcribe_attempts: attempts + 1,
223
+ transcribe_done_at: new Date().toISOString(),
224
+ transcript,
225
+ };
226
+ delete doneMeta.transcribe_backoff_until;
227
+ delete doneMeta.transcribe_error;
228
+ await store.setAttachmentMetadata(attachment.id, doneMeta);
229
+
230
+ // Retention: drop the file but keep the row so the transcript stays
231
+ // addressable. "until_transcribed" and "never" both unlink on success.
232
+ const retention = retentionFor(vault);
233
+ if (retention === "until_transcribed" || retention === "never") {
234
+ unlinkIfSafe(filePath, assetsRoot, logger);
235
+ }
236
+ }
237
+
238
+ function unlinkIfSafe(
239
+ filePath: string,
240
+ assetsRoot: string,
241
+ logger: { error: (...args: unknown[]) => void },
242
+ ): void {
243
+ try {
244
+ if (filePath.startsWith(normalize(assetsRoot)) && existsSync(filePath)) {
245
+ unlinkSync(filePath);
246
+ }
247
+ } catch (err) {
248
+ logger.error(`[transcribe] retention unlink failed for ${filePath}:`, err);
249
+ }
250
+ }
251
+
252
+ async function tick(): Promise<number> {
253
+ let processed = 0;
254
+ for (const vault of opts.vaultList()) {
255
+ const store = opts.getStore(vault);
256
+ let pending: Attachment[];
257
+ try {
258
+ pending = await store.listAttachmentsByTranscribeStatus("pending", 50);
259
+ } catch (err) {
260
+ logger.error(`[transcribe] list failed for vault "${vault}":`, err);
261
+ continue;
262
+ }
263
+
264
+ for (const attachment of pending) {
265
+ if (stopped) return processed;
266
+ // Backoff gate — skip without touching.
267
+ const meta = (attachment.metadata as PendingMeta | undefined) ?? {};
268
+ if (meta.transcribe_backoff_until) {
269
+ const until = Date.parse(String(meta.transcribe_backoff_until));
270
+ if (Number.isFinite(until) && until > Date.now()) continue;
271
+ }
272
+ try {
273
+ await processOne(vault, attachment);
274
+ processed++;
275
+ } catch (err) {
276
+ logger.error(`[transcribe] unexpected error on attachment ${attachment.id}:`, err);
277
+ }
278
+ }
279
+ }
280
+ return processed;
281
+ }
282
+
283
+ function schedule(): void {
284
+ if (stopped) return;
285
+ timer = setTimeout(() => {
286
+ inflight = tick().catch((err) => {
287
+ logger.error("[transcribe] tick error:", err);
288
+ }).then(() => {
289
+ schedule();
290
+ });
291
+ }, pollMs);
292
+ }
293
+
294
+ schedule();
295
+
296
+ return {
297
+ async stop() {
298
+ stopped = true;
299
+ if (timer) { clearTimeout(timer); timer = null; }
300
+ await inflight;
301
+ },
302
+ tick,
303
+ };
304
+ }
305
+
306
+ async function callScribe(args: {
307
+ url: string;
308
+ token?: string;
309
+ filePath: string;
310
+ filename: string;
311
+ mimeType: string;
312
+ context: ContextPayload | null;
313
+ timeoutMs: number;
314
+ fetchImpl: typeof fetch;
315
+ }): Promise<string> {
316
+ const controller = new AbortController();
317
+ const timer = setTimeout(() => controller.abort(), args.timeoutMs);
318
+ try {
319
+ const fileBuffer = readFileSync(args.filePath);
320
+ const file = new File([fileBuffer], args.filename, { type: args.mimeType });
321
+ const form = new FormData();
322
+ form.append("file", file);
323
+ if (args.context) appendContextPart(form, args.context);
324
+
325
+ const endpoint = `${args.url.replace(/\/$/, "")}/v1/audio/transcriptions`;
326
+ const headers: Record<string, string> = {};
327
+ if (args.token) headers["Authorization"] = `Bearer ${args.token}`;
328
+
329
+ const resp = await args.fetchImpl(endpoint, {
330
+ method: "POST",
331
+ headers,
332
+ body: form,
333
+ signal: controller.signal,
334
+ });
335
+ if (!resp.ok) {
336
+ throw new Error(`scribe returned ${resp.status}: ${await resp.text().catch(() => "")}`);
337
+ }
338
+ const result = await resp.json() as { text?: string };
339
+ if (typeof result.text !== "string") {
340
+ throw new Error("scribe response missing text field");
341
+ }
342
+ return result.text;
343
+ } finally {
344
+ clearTimeout(timer);
345
+ }
346
+ }
@@ -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;