@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.
- package/.claude/settings.local.json +2 -25
- package/CHANGELOG.md +64 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +591 -19
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +153 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +92 -0
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +28 -1
- package/docs/HTTP_API.md +105 -1
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/cli.ts +179 -121
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +136 -0
- package/src/scopes.ts +105 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +57 -5
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +583 -0
- package/src/transcription-worker.ts +346 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- 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
|
+
}
|
package/src/triggers.test.ts
CHANGED
|
@@ -129,7 +129,11 @@ describe("registerTriggers — dispatch modes", async () => {
|
|
|
129
129
|
webhookServer?.stop(true);
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
-
function makeMockStore(
|
|
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;
|