@openparachute/vault 0.4.7-rc.1 → 0.4.8-rc.4

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 (42) hide show
  1. package/README.md +44 -10
  2. package/core/src/connection-pragmas.test.ts +232 -0
  3. package/core/src/core.test.ts +257 -0
  4. package/core/src/cursor.test.ts +160 -0
  5. package/core/src/cursor.ts +272 -0
  6. package/core/src/mcp.ts +51 -7
  7. package/core/src/notes.ts +164 -2
  8. package/core/src/portable-md.test.ts +247 -0
  9. package/core/src/portable-md.ts +118 -1
  10. package/core/src/schema.ts +98 -2
  11. package/core/src/store.ts +11 -1
  12. package/core/src/types.ts +32 -0
  13. package/package.json +1 -1
  14. package/src/auth-status.ts +4 -0
  15. package/src/auto-transcribe.test.ts +116 -0
  16. package/src/auto-transcribe.ts +48 -0
  17. package/src/cli.ts +151 -50
  18. package/src/config.test.ts +26 -0
  19. package/src/config.ts +53 -1
  20. package/src/db.ts +15 -2
  21. package/src/export-watch.test.ts +99 -0
  22. package/src/mcp-install-interactive.test.ts +23 -2
  23. package/src/mcp-install-interactive.ts +21 -2
  24. package/src/mcp-install.test.ts +40 -0
  25. package/src/mcp-tools.ts +17 -1
  26. package/src/module-config.ts +70 -14
  27. package/src/module-manifest.test.ts +93 -0
  28. package/src/module-manifest.ts +94 -0
  29. package/src/routes.ts +267 -50
  30. package/src/scribe-discovery.test.ts +77 -0
  31. package/src/scribe-discovery.ts +91 -0
  32. package/src/scribe-env.test.ts +66 -1
  33. package/src/scribe-env.ts +42 -1
  34. package/src/self-register.test.ts +380 -0
  35. package/src/self-register.ts +234 -0
  36. package/src/server.ts +46 -11
  37. package/src/transcript-note.test.ts +171 -0
  38. package/src/transcript-note.ts +189 -0
  39. package/src/transcription-registry.ts +22 -0
  40. package/src/transcription-worker.test.ts +250 -0
  41. package/src/transcription-worker.ts +186 -27
  42. package/src/vault.test.ts +347 -0
package/src/server.ts CHANGED
@@ -24,12 +24,17 @@ import { defaultHookRegistry } from "../core/src/hooks.ts";
24
24
  import { registerTriggers } from "./triggers.ts";
25
25
  import { route } from "./routing.ts";
26
26
  import { startTranscriptionWorker, registerTranscriptionHook, type TranscriptionWorker } from "./transcription-worker.ts";
27
+ import { setTranscriptionWorker } from "./transcription-registry.ts";
27
28
  import { assetsDir } from "./routes.ts";
28
- import { resolveScribeAuthToken } from "./scribe-env.ts";
29
+ import { resolveScribeAuthToken, ensureScribeBearer } from "./scribe-env.ts";
30
+ import { getCachedScribeUrl } from "./scribe-discovery.ts";
31
+ import { readEnvFile, setEnvVar } from "./config.ts";
29
32
  import { resolveBindHostname } from "./bind.ts";
30
33
  import { MirrorManager } from "./mirror-manager.ts";
31
34
  import { setMirrorManager } from "./mirror-registry.ts";
32
35
  import { buildMirrorDeps, resolveMirrorVaultName } from "./mirror-deps.ts";
36
+ import { selfRegister } from "./self-register.ts";
37
+ import pkg from "../package.json" with { type: "json" };
33
38
 
34
39
  // Register webhook triggers from global config. Replaces the old hardcoded
35
40
  // tts-hook and transcription-hook with config-driven webhooks.
@@ -47,8 +52,9 @@ function registerConfiguredTriggers(): void {
47
52
  // both will process the same attachments. The trigger's `missing_metadata`
48
53
  // guard keeps it idempotent once the worker marks `transcript` on the
49
54
  // attachment, but the noise is worth flagging.
50
- if (process.env.SCRIBE_URL) {
51
- const scribeHost = safeHost(process.env.SCRIBE_URL);
55
+ const probedScribeUrl = getCachedScribeUrl();
56
+ if (probedScribeUrl) {
57
+ const scribeHost = safeHost(probedScribeUrl);
52
58
  for (const t of config.triggers) {
53
59
  if (t.action.send !== "attachment") continue;
54
60
  if (scribeHost && safeHost(t.action.webhook) === scribeHost) {
@@ -74,17 +80,35 @@ loadEnvFile();
74
80
  registerConfiguredTriggers();
75
81
 
76
82
  /**
77
- * Start the transcription worker if SCRIBE_URL is configured. The worker
78
- * polls every vault for attachments with `metadata.transcribe_status = "pending"`
79
- * and sends the audio to scribe. Absent SCRIBE_URL, the worker stays off
80
- * — `{transcribe: true}` uploads still enqueue, they just wait.
83
+ * Start the transcription worker if scribe is discoverable. Scribe URL
84
+ * resolution order (per `scribe-discovery.ts`): `SCRIBE_URL` env var, then
85
+ * `~/.parachute/services.json` `parachute-scribe` entry, then nothing.
86
+ *
87
+ * Bearer generation (vault#353): if neither `SCRIBE_AUTH_TOKEN` nor the
88
+ * legacy `SCRIBE_TOKEN` is set, generate a fresh 32-byte base64url bearer
89
+ * and persist it to vault's `.env` so subsequent restarts use the same
90
+ * value. Idempotent — calls after the first see the existing token. The
91
+ * operator (or hub install) is expected to mirror this bearer to scribe's
92
+ * `SCRIBE_AUTH_TOKEN`; without that mirror, scribe will reject the
93
+ * Authorization header on a 401 and transcription fails with a friendly
94
+ * error captured on the transcript note.
81
95
  */
96
+ const scribeUrl = getCachedScribeUrl();
82
97
  let transcriptionWorker: TranscriptionWorker | null = null;
83
- if (process.env.SCRIBE_URL) {
98
+ if (scribeUrl) {
99
+ // Generate + persist the shared bearer on first boot. Subsequent boots
100
+ // pick up the existing value and don't rotate. Loading the .env back
101
+ // into process.env happens above (`loadEnvFile()`); we re-load here to
102
+ // pick up the just-written value without restart.
103
+ const { created, token } = ensureScribeBearer(readEnvFile, setEnvVar);
104
+ if (created) {
105
+ process.env.SCRIBE_AUTH_TOKEN = token;
106
+ console.log("[transcribe] generated SCRIBE_AUTH_TOKEN (32 bytes, base64url) — mirror this value into scribe's config");
107
+ }
84
108
  transcriptionWorker = startTranscriptionWorker({
85
109
  vaultList: () => listVaults(),
86
110
  getStore: (name) => getVaultStore(name),
87
- scribeUrl: process.env.SCRIBE_URL,
111
+ scribeUrl,
88
112
  scribeToken: resolveScribeAuthToken(),
89
113
  resolveAssetsDir: (vault) => assetsDir(vault),
90
114
  getAudioRetention: (vault) => readVaultConfig(vault)?.audio_retention ?? "keep",
@@ -97,9 +121,12 @@ if (process.env.SCRIBE_URL) {
97
121
  transcriptionWorker,
98
122
  (store) => getVaultNameForStore(store as never),
99
123
  );
100
- console.log(`[transcribe] worker started ${process.env.SCRIBE_URL}`);
124
+ // Expose the worker to the REST retry endpoint so retries kick immediately
125
+ // instead of waiting for the sweep. Idempotent on second boot.
126
+ setTranscriptionWorker(transcriptionWorker);
127
+ console.log(`[transcribe] worker started → ${scribeUrl}`);
101
128
  } else {
102
- console.log("[transcribe] worker disabled (set SCRIBE_URL to enable)");
129
+ console.log("[transcribe] worker disabled (no scribe in services.json and SCRIBE_URL unset)");
103
130
  }
104
131
 
105
132
  if (process.env.VAULT_AUTH_TOKEN?.trim()) {
@@ -151,6 +178,14 @@ if (listVaults().length === 0) {
151
178
  }
152
179
  }
153
180
 
181
+ // vault#266 — self-register manifest + installDir into services.json so
182
+ // hub's discovery / admin SPA can find vault without a `parachute install
183
+ // vault` round-trip. Idempotent (re-runs produce the same row when nothing
184
+ // changed); never throws (boot must not fail on bookkeeping). The merge-
185
+ // preserving `upsertService` ensures any hub-stamped fields on the row
186
+ // survive — see self-register.ts header for the v0.6 vs v0.7 design note.
187
+ selfRegister({ version: pkg.version });
188
+
154
189
  // Migrate tag schemas from vault.yaml → DB for each vault.
155
190
  // Only inserts schemas that don't already exist in the DB (safe across restarts).
156
191
  for (const vaultName of listVaults()) {
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Tests for vault transcript-note materialization (vault#353).
3
+ *
4
+ * Covers two surfaces:
5
+ * - `buildTranscriptNote` — pure, frontmatter shape per design Q3.
6
+ * - `upsertTranscriptNote` — DB-backed, create + retry-update flow.
7
+ */
8
+
9
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
10
+ import { Database } from "bun:sqlite";
11
+ import { mkdirSync, rmSync } from "fs";
12
+ import { join } from "path";
13
+ import { tmpdir } from "os";
14
+ import { BunStore } from "./vault-store.ts";
15
+ import { buildTranscriptNote, transcriptPathFor, upsertTranscriptNote } from "./transcript-note.ts";
16
+
17
+ let db: Database;
18
+ let store: BunStore;
19
+ let tmpDir: string;
20
+
21
+ beforeEach(() => {
22
+ tmpDir = join(tmpdir(), `transcript-note-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
23
+ mkdirSync(tmpDir, { recursive: true });
24
+ db = new Database(join(tmpDir, "test.db"));
25
+ store = new BunStore(db);
26
+ });
27
+
28
+ afterEach(() => {
29
+ db.close();
30
+ rmSync(tmpDir, { recursive: true, force: true });
31
+ });
32
+
33
+ describe("transcriptPathFor", () => {
34
+ test("appends `.transcript` to the audio path", () => {
35
+ expect(transcriptPathFor("inbox/2026-05-21.m4a")).toBe("inbox/2026-05-21.m4a.transcript");
36
+ });
37
+
38
+ test("handles nested paths", () => {
39
+ expect(transcriptPathFor("a/b/c/voice.webm")).toBe("a/b/c/voice.webm.transcript");
40
+ });
41
+ });
42
+
43
+ describe("buildTranscriptNote — success shape", () => {
44
+ test("frontmatter includes transcript_of, status, attachment id, duration, and provider", () => {
45
+ const result = buildTranscriptNote({
46
+ attachmentPath: "inbox/voice.webm",
47
+ attachmentId: "att-1",
48
+ attachmentNoteId: "note-1",
49
+ status: "complete",
50
+ text: "hello world",
51
+ provider: "groq",
52
+ durationMs: 1234,
53
+ createdAt: new Date("2026-05-21T09:13:42Z"),
54
+ });
55
+ expect(result.path).toBe("inbox/voice.webm.transcript");
56
+ expect(result.content).toBe("hello world");
57
+ expect(result.tags).toEqual(["transcript", "capture"]);
58
+ expect(result.metadata.transcript_of).toBe("inbox/voice.webm");
59
+ expect(result.metadata.transcript_attachment_id).toBe("att-1");
60
+ expect(result.metadata.transcript_status).toBe("complete");
61
+ expect(result.metadata.transcript_provider).toBe("groq");
62
+ expect(result.metadata.transcript_duration_ms).toBe(1234);
63
+ expect(result.metadata.title).toBe("Transcript of voice.webm");
64
+ expect(result.createdAt).toBe("2026-05-21T09:13:42.000Z");
65
+ expect(result.metadata.transcript_error).toBeUndefined();
66
+ });
67
+
68
+ test("provider/duration omitted when not supplied", () => {
69
+ const result = buildTranscriptNote({
70
+ attachmentPath: "voice.m4a",
71
+ attachmentId: "att-x",
72
+ attachmentNoteId: "note-x",
73
+ status: "complete",
74
+ text: "no provider info",
75
+ });
76
+ expect(result.metadata.transcript_provider).toBeUndefined();
77
+ expect(result.metadata.transcript_duration_ms).toBeUndefined();
78
+ });
79
+ });
80
+
81
+ describe("buildTranscriptNote — failure shape", () => {
82
+ test("body is empty + transcript_error captured", () => {
83
+ const result = buildTranscriptNote({
84
+ attachmentPath: "inbox/voice.webm",
85
+ attachmentId: "att-1",
86
+ attachmentNoteId: "note-1",
87
+ status: "failed",
88
+ error: "no transcription provider configured",
89
+ });
90
+ expect(result.content).toBe("");
91
+ expect(result.metadata.transcript_status).toBe("failed");
92
+ expect(result.metadata.transcript_error).toBe("no transcription provider configured");
93
+ });
94
+
95
+ test("falls back to 'unknown error' when no error string is supplied", () => {
96
+ const result = buildTranscriptNote({
97
+ attachmentPath: "voice.m4a",
98
+ attachmentId: "att-1",
99
+ attachmentNoteId: "note-1",
100
+ status: "failed",
101
+ });
102
+ expect(result.metadata.transcript_error).toBe("unknown error");
103
+ });
104
+ });
105
+
106
+ describe("upsertTranscriptNote", () => {
107
+ test("creates a new note + link on first call", async () => {
108
+ const audioOwner = await store.createNote("# Voice memo\n", { id: "owner" });
109
+ await store.addAttachment(audioOwner.id, "memos/a.webm", "audio/webm");
110
+
111
+ const note = await upsertTranscriptNote(store, {
112
+ attachmentPath: "memos/a.webm",
113
+ attachmentId: "att-1",
114
+ attachmentNoteId: audioOwner.id,
115
+ status: "complete",
116
+ text: "spoken words",
117
+ provider: "groq",
118
+ durationMs: 999,
119
+ });
120
+ expect(note.content).toBe("spoken words");
121
+ expect(note.path).toBe("memos/a.webm.transcript");
122
+
123
+ const fetched = await store.getNoteByPath("memos/a.webm.transcript");
124
+ expect(fetched?.id).toBe(note.id);
125
+ expect((fetched?.metadata as any)?.transcript_status).toBe("complete");
126
+ expect(fetched?.tags).toContain("transcript");
127
+ expect(fetched?.tags).toContain("capture");
128
+ });
129
+
130
+ test("overwrites existing transcript note in place on retry (id preserved)", async () => {
131
+ const owner = await store.createNote("# Voice memo\n", { id: "owner-2" });
132
+ await store.addAttachment(owner.id, "memos/b.webm", "audio/webm");
133
+
134
+ const first = await upsertTranscriptNote(store, {
135
+ attachmentPath: "memos/b.webm",
136
+ attachmentId: "att-2",
137
+ attachmentNoteId: owner.id,
138
+ status: "failed",
139
+ error: "no transcription provider configured",
140
+ });
141
+ expect(first.content).toBe("");
142
+
143
+ const retried = await upsertTranscriptNote(store, {
144
+ attachmentPath: "memos/b.webm",
145
+ attachmentId: "att-2",
146
+ attachmentNoteId: owner.id,
147
+ status: "complete",
148
+ text: "transcript that finally landed",
149
+ durationMs: 500,
150
+ });
151
+ expect(retried.id).toBe(first.id);
152
+ expect(retried.content).toBe("transcript that finally landed");
153
+ expect((retried.metadata as any)?.transcript_status).toBe("complete");
154
+ expect((retried.metadata as any)?.transcript_error).toBeUndefined();
155
+ });
156
+
157
+ test("attempting to upsert when the attachmentNoteId is missing still creates the note (link skipped)", async () => {
158
+ // No owner note created — the link create will throw or silently skip;
159
+ // either way the transcript note must still land so the failure has
160
+ // a visible record. (Defensive: a deleted-attachment race.)
161
+ const note = await upsertTranscriptNote(store, {
162
+ attachmentPath: "missing/a.webm",
163
+ attachmentId: "att-x",
164
+ attachmentNoteId: "nonexistent-note",
165
+ status: "failed",
166
+ error: "audio file not found",
167
+ });
168
+ expect(note.path).toBe("missing/a.webm.transcript");
169
+ expect((note.metadata as any)?.transcript_status).toBe("failed");
170
+ });
171
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Transcript-note materialization for the vault↔scribe auto-transcribe path
3
+ * (vault#353, design 2026-05-21 Part 2, design question 3).
4
+ *
5
+ * When the transcription worker resolves an audio attachment, it asks this
6
+ * module to create (or update) a sibling `<attachment-path>.transcript.md`
7
+ * note. The note's frontmatter links back to the original audio attachment
8
+ * via `transcript_of`, lets vault graph queries surface the relation, and
9
+ * captures success / failure state in a single uniform shape.
10
+ *
11
+ * Design Q3's exact shape:
12
+ *
13
+ * ---
14
+ * title: Transcript of <attachment-name>
15
+ * tags: [transcript, capture]
16
+ * created_at: <iso>
17
+ * transcript_of: <attachment-path>
18
+ * transcript_status: complete | failed
19
+ * transcript_provider: <name or unset>
20
+ * transcript_duration_ms: <wall-clock ms>
21
+ * transcript_error: <error-message — failed only>
22
+ * ---
23
+ *
24
+ * <transcript text — empty body when failed>
25
+ *
26
+ * The `transcript_status` values are uniform: `complete` for success and
27
+ * `failed` for any terminal failure (provider missing, scribe 5xx, timeout,
28
+ * etc.). The cause is in `transcript_error`. This matches the design's
29
+ * "same failure substrate" stance — no first-boot-specific branch, just
30
+ * different error strings.
31
+ *
32
+ * ## Retry semantics
33
+ *
34
+ * When the retry endpoint re-runs transcription on an already-failed
35
+ * transcript note, the same note is updated in place — `transcript_of`,
36
+ * `path`, and `id` all stay constant. Callers identify the transcript by
37
+ * note path (`<attachment-path>.transcript.md`) and call `upsertTranscriptNote`
38
+ * to overwrite. The transcript-of relation is materialized as both a
39
+ * frontmatter scalar AND a vault link (via `links.add` on create) so graph
40
+ * queries can find it without parsing frontmatter.
41
+ */
42
+
43
+ import type { Store, Note } from "../core/src/types.ts";
44
+
45
+ export type TranscriptStatus = "complete" | "failed";
46
+
47
+ export interface TranscriptNoteInput {
48
+ /**
49
+ * Path of the source audio attachment (relative to the vault assets dir).
50
+ * Used to derive the transcript note's path and the `transcript_of`
51
+ * frontmatter scalar.
52
+ */
53
+ attachmentPath: string;
54
+ /**
55
+ * Attachment row id of the source audio. Stamped onto the transcript note
56
+ * frontmatter (`transcript_attachment_id`) so the retry endpoint can find
57
+ * the original audio in one DB lookup without walking links or paths.
58
+ */
59
+ attachmentId: string;
60
+ /**
61
+ * The note that owns the audio attachment. The transcript-of relation
62
+ * (vault link) is established to this note so graph queries can find the
63
+ * transcript from either side.
64
+ */
65
+ attachmentNoteId: string;
66
+ /** Outcome — `complete` (transcript text available) or `failed`. */
67
+ status: TranscriptStatus;
68
+ /**
69
+ * Transcript body. Required when `status === "complete"`; ignored otherwise.
70
+ * Failure transcript notes have an empty body — the error string is in
71
+ * frontmatter, not in the note body, so failures don't read like content.
72
+ */
73
+ text?: string;
74
+ /** Error cause for `status === "failed"`. Required for failed status. */
75
+ error?: string;
76
+ /** Scribe-reported provider (e.g. "groq", "whisper"). Optional. */
77
+ provider?: string;
78
+ /** Wall-clock duration of the scribe call. Optional; 0 if unknown. */
79
+ durationMs?: number;
80
+ /** Created-at override (defaults to `new Date()`). */
81
+ createdAt?: Date;
82
+ }
83
+
84
+ /**
85
+ * Compute the canonical transcript-note path for an audio attachment.
86
+ *
87
+ * Example: `inbox/Voice 2026-05-21 09-13.m4a` → `inbox/Voice 2026-05-21 09-13.m4a.transcript`
88
+ *
89
+ * The `.md` extension is implicit in the vault path normalization (paths are
90
+ * stored without `.md` per vault convention; on export to portable markdown,
91
+ * `.md` is appended).
92
+ */
93
+ export function transcriptPathFor(attachmentPath: string): string {
94
+ return `${attachmentPath}.transcript`;
95
+ }
96
+
97
+ /**
98
+ * Build the body+metadata for a transcript note from the worker's input.
99
+ * Pure — no Store calls — so unit tests can assert shape without a DB.
100
+ * Exposed for tests and for any future caller that wants to materialize a
101
+ * transcript note independently of the worker.
102
+ */
103
+ export function buildTranscriptNote(input: TranscriptNoteInput): {
104
+ path: string;
105
+ content: string;
106
+ metadata: Record<string, unknown>;
107
+ tags: string[];
108
+ createdAt: string;
109
+ } {
110
+ const created = (input.createdAt ?? new Date()).toISOString();
111
+ const filename = input.attachmentPath.split("/").pop() ?? input.attachmentPath;
112
+ const metadata: Record<string, unknown> = {
113
+ title: `Transcript of ${filename}`,
114
+ transcript_of: input.attachmentPath,
115
+ transcript_attachment_id: input.attachmentId,
116
+ transcript_status: input.status,
117
+ };
118
+ if (input.provider) metadata.transcript_provider = input.provider;
119
+ if (typeof input.durationMs === "number") {
120
+ metadata.transcript_duration_ms = input.durationMs;
121
+ }
122
+ if (input.status === "failed") {
123
+ metadata.transcript_error = input.error ?? "unknown error";
124
+ }
125
+ const body = input.status === "complete" ? (input.text ?? "") : "";
126
+ return {
127
+ path: transcriptPathFor(input.attachmentPath),
128
+ content: body,
129
+ metadata,
130
+ tags: ["transcript", "capture"],
131
+ createdAt: created,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Create or update the transcript note at `<attachmentPath>.transcript.md`.
137
+ *
138
+ * - If no note exists at that path, creates one with the standard shape,
139
+ * establishes a `transcript_of` link to the attachment-owning note, and
140
+ * returns the new note.
141
+ * - If a transcript note already exists (retry path), overwrites its content
142
+ * + metadata in place, preserves the existing note id, and returns the
143
+ * updated note. Links are NOT re-created — the existing relation survives.
144
+ *
145
+ * Errors from the Store (path collision with a non-transcript note, etc.)
146
+ * bubble up; the worker catches them and logs.
147
+ */
148
+ export async function upsertTranscriptNote(
149
+ store: Store,
150
+ input: TranscriptNoteInput,
151
+ ): Promise<Note> {
152
+ const built = buildTranscriptNote(input);
153
+ const existing = await store.getNoteByPath(built.path);
154
+ if (existing) {
155
+ await store.updateNote(existing.id, {
156
+ content: built.content,
157
+ metadata: built.metadata,
158
+ // Preserve the original created_at; the retry doesn't reset it.
159
+ skipUpdatedAt: false,
160
+ });
161
+ // Re-apply tags via tagNote — updateNote() doesn't accept a tags field
162
+ // (the engine treats tag mutation as a separate op). tagNote upserts
163
+ // existing tags (no duplicates), so it's safe to call even when tags
164
+ // are unchanged from the prior write.
165
+ await store.tagNote(existing.id, built.tags);
166
+ const updated = await store.getNote(existing.id);
167
+ return updated ?? existing;
168
+ }
169
+ const created = await store.createNote(built.content, {
170
+ path: built.path,
171
+ tags: built.tags,
172
+ metadata: built.metadata,
173
+ created_at: built.createdAt,
174
+ });
175
+ // Materialize the transcript-of relation as a typed link too, so graph
176
+ // queries (find-path, neighborhood) surface the transcript without
177
+ // walking frontmatter. The relation is named "transcript_of" so it
178
+ // matches the frontmatter scalar; if the target note has been deleted
179
+ // by the time we get here (race during retry), skip silently — the
180
+ // frontmatter scalar still carries the relation for query-by-path.
181
+ try {
182
+ await store.createLink(created.id, input.attachmentNoteId, "transcript_of");
183
+ } catch {
184
+ // Target may be missing or the link constraint may reject — failure
185
+ // here doesn't invalidate the transcript itself, which is still
186
+ // queryable via the `transcript_of` frontmatter scalar + path.
187
+ }
188
+ return created;
189
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Process-singleton holder for the active TranscriptionWorker (vault#353).
3
+ *
4
+ * Mirrors `mirror-registry.ts`: `server.ts` constructs the worker on boot
5
+ * (when scribe is discoverable), and the REST retry endpoint
6
+ * (`/api/notes/:id/retry-transcription`) picks it up here to call `kick()`
7
+ * for an event-driven re-run. Absent the worker (no scribe), the retry
8
+ * endpoint still flips the attachment back to `pending` so the sweep would
9
+ * pick it up — but it'll just sit there until scribe shows up.
10
+ */
11
+
12
+ import type { TranscriptionWorker } from "./transcription-worker.ts";
13
+
14
+ let activeWorker: TranscriptionWorker | null = null;
15
+
16
+ export function setTranscriptionWorker(worker: TranscriptionWorker | null): void {
17
+ activeWorker = worker;
18
+ }
19
+
20
+ export function getTranscriptionWorker(): TranscriptionWorker | null {
21
+ return activeWorker;
22
+ }