@openparachute/vault 0.7.5-rc.6 → 0.7.5
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/package.json +1 -1
- package/src/attachment-tickets.test.ts +66 -0
- package/src/attachment-tickets.ts +21 -2
- package/src/auto-transcribe.test.ts +112 -1
- package/src/auto-transcribe.ts +81 -3
- package/src/mirror-routes.ts +13 -6
- package/src/routes.ts +28 -3
- package/src/routing.ts +36 -0
- package/src/server.ts +14 -1
- package/src/transcription-routes.test.ts +195 -0
- package/src/transcription-routes.ts +327 -0
- package/src/vault.test.ts +24 -2
- package/web/ui/dist/assets/index-g3_KwmRE.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-cdAcASvO.js +0 -61
package/package.json
CHANGED
|
@@ -270,6 +270,72 @@ describe("attachment tickets — upload lifecycle", () => {
|
|
|
270
270
|
expect((updatedNote!.metadata as any)?.transcribe_stub).toBe(true);
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
+
// vault#643 — the fresh-install case. Auto-transcribe is ON by default and
|
|
274
|
+
// no provider is reachable (SCRIBE_URL unset, no scribe row), which is
|
|
275
|
+
// exactly what a new box looks like. The upload must not come back looking
|
|
276
|
+
// like an ordinary file: it carries a `failed` status and an actionable
|
|
277
|
+
// reason, so the state is visible in the API and the admin SPA.
|
|
278
|
+
test("audio + auto-transcribe on + NO provider → attachment records the failure, not silence", async () => {
|
|
279
|
+
const vaultName = freshVault("tickets-no-provider");
|
|
280
|
+
const store = getVaultStore(vaultName);
|
|
281
|
+
const note = await store.createNote("# Voice memo\n", { path: "memo-noprov" });
|
|
282
|
+
|
|
283
|
+
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
284
|
+
note: note.id,
|
|
285
|
+
filename: "memo.webm",
|
|
286
|
+
size_bytes: 4,
|
|
287
|
+
// NO `transcribe: true` — this is the AUTO path, the one that used to
|
|
288
|
+
// silently do nothing.
|
|
289
|
+
});
|
|
290
|
+
const res = await routeReq(
|
|
291
|
+
new Request(mint.url, {
|
|
292
|
+
method: "PUT",
|
|
293
|
+
headers: { "content-type": "audio/webm" },
|
|
294
|
+
body: new Uint8Array([1, 2, 3, 4]),
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
expect(res.status).toBe(201);
|
|
298
|
+
const attachment = (await res.json()) as any;
|
|
299
|
+
expect(attachment.metadata.transcribe_status).toBe("failed");
|
|
300
|
+
expect(attachment.metadata.transcribe_error).toMatch(/no transcription provider configured/i);
|
|
301
|
+
// The reason has to be actionable — naming both routes out of it.
|
|
302
|
+
expect(attachment.metadata.transcribe_error).toMatch(/TRANSCRIPTION_PROVIDER/);
|
|
303
|
+
expect(attachment.metadata.transcribe_error).toMatch(/SCRIBE_URL/);
|
|
304
|
+
expect(attachment.metadata.transcribe_origin).toBe("auto");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// The counter-case: turning auto-transcribe OFF must stay silent. The
|
|
308
|
+
// operator asked for nothing to happen, so nothing happening is correct and
|
|
309
|
+
// must not be dressed up as a failure.
|
|
310
|
+
test("audio + auto-transcribe explicitly OFF → no transcribe metadata at all", async () => {
|
|
311
|
+
const vaultName = freshVault("tickets-transcribe-off");
|
|
312
|
+
const store = getVaultStore(vaultName);
|
|
313
|
+
writeVaultConfig({
|
|
314
|
+
name: vaultName,
|
|
315
|
+
api_keys: [],
|
|
316
|
+
created_at: new Date().toISOString(),
|
|
317
|
+
auto_transcribe: { enabled: false },
|
|
318
|
+
} as never);
|
|
319
|
+
const note = await store.createNote("# Voice memo\n", { path: "memo-off" });
|
|
320
|
+
|
|
321
|
+
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
322
|
+
note: note.id,
|
|
323
|
+
filename: "memo.webm",
|
|
324
|
+
size_bytes: 4,
|
|
325
|
+
});
|
|
326
|
+
const res = await routeReq(
|
|
327
|
+
new Request(mint.url, {
|
|
328
|
+
method: "PUT",
|
|
329
|
+
headers: { "content-type": "audio/webm" },
|
|
330
|
+
body: new Uint8Array([1, 2, 3, 4]),
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
expect(res.status).toBe(201);
|
|
334
|
+
const attachment = (await res.json()) as any;
|
|
335
|
+
expect(attachment.metadata.transcribe_status).toBeUndefined();
|
|
336
|
+
expect(attachment.metadata.transcribe_error).toBeUndefined();
|
|
337
|
+
});
|
|
338
|
+
|
|
273
339
|
test("segment_index (voice W2): a valid integer >= 0 rides ticket mint through to the attachment row", async () => {
|
|
274
340
|
const vaultName = freshVault("tickets-segment");
|
|
275
341
|
const store = getVaultStore(vaultName);
|
|
@@ -22,7 +22,11 @@ import type { Store } from "../core/src/types.ts";
|
|
|
22
22
|
import type { AttachmentTicket, AttachmentTicketProvider } from "../core/src/attachment/tickets.ts";
|
|
23
23
|
import { sanitizeAttachmentExtension } from "../core/src/attachment/policy.ts";
|
|
24
24
|
import { assetsDir, readVaultConfig } from "./config.ts";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
NO_PROVIDER_ERROR,
|
|
27
|
+
classifyAutoTranscribe,
|
|
28
|
+
warnNoTranscriptionProvider,
|
|
29
|
+
} from "./auto-transcribe.ts";
|
|
26
30
|
import { invalidateUsageCache } from "./usage.ts";
|
|
27
31
|
|
|
28
32
|
function json(data: unknown, status = 200): Response {
|
|
@@ -265,7 +269,13 @@ async function handleUploadSpend(
|
|
|
265
269
|
// auto-transcribe toggle.
|
|
266
270
|
const explicitOptIn = ticket.transcribe === true;
|
|
267
271
|
const perVaultEnabled = readVaultConfig(vaultName)?.auto_transcribe?.enabled;
|
|
268
|
-
const
|
|
272
|
+
const autoDecision = explicitOptIn
|
|
273
|
+
? ({ kind: "transcribe" } as const)
|
|
274
|
+
: classifyAutoTranscribe(ticket.mimeType, { perVaultEnabled });
|
|
275
|
+
const autoOptIn = !explicitOptIn && autoDecision.kind === "transcribe";
|
|
276
|
+
// vault#643 — same honesty as the REST path: enabled-but-unconfigured is a
|
|
277
|
+
// misconfiguration, not a silent no-op.
|
|
278
|
+
const transcribeUnavailable = !explicitOptIn && autoDecision.kind === "unavailable";
|
|
269
279
|
const attMeta: Record<string, unknown> = {
|
|
270
280
|
original_name: ticket.filename,
|
|
271
281
|
size: buffer.length,
|
|
@@ -280,6 +290,15 @@ async function handleUploadSpend(
|
|
|
280
290
|
if (ticket.segmentIndex !== undefined) {
|
|
281
291
|
attMeta.segment_index = ticket.segmentIndex;
|
|
282
292
|
}
|
|
293
|
+
} else if (transcribeUnavailable) {
|
|
294
|
+
attMeta.transcribe_status = "failed";
|
|
295
|
+
attMeta.transcribe_error = NO_PROVIDER_ERROR;
|
|
296
|
+
attMeta.transcribe_requested_at = new Date().toISOString();
|
|
297
|
+
attMeta.transcribe_origin = "auto";
|
|
298
|
+
if (ticket.segmentIndex !== undefined) {
|
|
299
|
+
attMeta.segment_index = ticket.segmentIndex;
|
|
300
|
+
}
|
|
301
|
+
warnNoTranscriptionProvider();
|
|
283
302
|
}
|
|
284
303
|
|
|
285
304
|
const attachment = await store.addAttachment(ticket.noteId, relativePath, ticket.mimeType, attMeta);
|
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, test, expect } from "bun:test";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
_resetNoProviderWarnForTest,
|
|
11
|
+
classifyAutoTranscribe,
|
|
12
|
+
shouldAutoTranscribe,
|
|
13
|
+
warnNoTranscriptionProvider,
|
|
14
|
+
} from "./auto-transcribe.ts";
|
|
10
15
|
|
|
11
16
|
function readGlobalConfig(enabled: boolean | undefined) {
|
|
12
17
|
return () => ({
|
|
@@ -170,3 +175,109 @@ describe("shouldAutoTranscribe", () => {
|
|
|
170
175
|
});
|
|
171
176
|
});
|
|
172
177
|
});
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// vault#643 — "disabled" and "unavailable" are not the same answer.
|
|
181
|
+
//
|
|
182
|
+
// `shouldAutoTranscribe` collapsed both into `false`, so a box with
|
|
183
|
+
// auto-transcribe ON and no reachable provider accepted audio, transcribed
|
|
184
|
+
// nothing, wrote no marker, and logged nothing. The attachment was
|
|
185
|
+
// indistinguishable from a plain upload.
|
|
186
|
+
//
|
|
187
|
+
// That is the DEFAULT state of a fresh install: the provider resolves to
|
|
188
|
+
// `scribe-http`, nothing sets SCRIBE_URL, and voice memos silently never
|
|
189
|
+
// transcribe. The hosted door already marks a terminal state rather than
|
|
190
|
+
// skipping quietly (workers/vault/src/vault-do.ts — "voice not enabled for
|
|
191
|
+
// this plan" / "monthly voice limit reached"); this is the self-host twin.
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
describe("classifyAutoTranscribe (vault#643)", () => {
|
|
194
|
+
const audio = "audio/webm";
|
|
195
|
+
const withProvider = () => "http://127.0.0.1:1943";
|
|
196
|
+
const noProvider = () => undefined;
|
|
197
|
+
|
|
198
|
+
test("non-audio → not-audio, whatever else is true", () => {
|
|
199
|
+
expect(
|
|
200
|
+
classifyAutoTranscribe("image/png", { getCachedScribeUrlImpl: withProvider }).kind,
|
|
201
|
+
).toBe("not-audio");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("operator turned it off → disabled (silence is CORRECT here)", () => {
|
|
205
|
+
expect(
|
|
206
|
+
classifyAutoTranscribe(audio, {
|
|
207
|
+
perVaultEnabled: false,
|
|
208
|
+
getCachedScribeUrlImpl: withProvider,
|
|
209
|
+
}).kind,
|
|
210
|
+
).toBe("disabled");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("enabled + no provider → unavailable, NOT disabled — the whole point", () => {
|
|
214
|
+
expect(
|
|
215
|
+
classifyAutoTranscribe(audio, {
|
|
216
|
+
perVaultEnabled: true,
|
|
217
|
+
getCachedScribeUrlImpl: noProvider,
|
|
218
|
+
}).kind,
|
|
219
|
+
).toBe("unavailable");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a blank provider URL is unavailable, not a provider", () => {
|
|
223
|
+
expect(
|
|
224
|
+
classifyAutoTranscribe(audio, {
|
|
225
|
+
perVaultEnabled: true,
|
|
226
|
+
getCachedScribeUrlImpl: () => " ",
|
|
227
|
+
}).kind,
|
|
228
|
+
).toBe("unavailable");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("the default (no toggle set) with no provider is unavailable — the fresh-install case", () => {
|
|
232
|
+
expect(
|
|
233
|
+
classifyAutoTranscribe(audio, {
|
|
234
|
+
readGlobalConfigImpl: (() => ({})) as never,
|
|
235
|
+
getCachedScribeUrlImpl: noProvider,
|
|
236
|
+
}).kind,
|
|
237
|
+
).toBe("unavailable");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("enabled + provider → transcribe", () => {
|
|
241
|
+
expect(
|
|
242
|
+
classifyAutoTranscribe(audio, {
|
|
243
|
+
perVaultEnabled: true,
|
|
244
|
+
getCachedScribeUrlImpl: withProvider,
|
|
245
|
+
}).kind,
|
|
246
|
+
).toBe("transcribe");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("shouldAutoTranscribe stays a faithful boolean view of the classifier", () => {
|
|
250
|
+
for (const opts of [
|
|
251
|
+
{ perVaultEnabled: false, getCachedScribeUrlImpl: withProvider },
|
|
252
|
+
{ perVaultEnabled: true, getCachedScribeUrlImpl: noProvider },
|
|
253
|
+
{ perVaultEnabled: true, getCachedScribeUrlImpl: withProvider },
|
|
254
|
+
]) {
|
|
255
|
+
expect(shouldAutoTranscribe(audio, opts)).toBe(
|
|
256
|
+
classifyAutoTranscribe(audio, opts).kind === "transcribe",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("warnNoTranscriptionProvider throttle (vault#643)", () => {
|
|
263
|
+
test("warns once per window — a bulk import gets one line, not a hundred", () => {
|
|
264
|
+
_resetNoProviderWarnForTest();
|
|
265
|
+
const seen: string[] = [];
|
|
266
|
+
const orig = console.warn;
|
|
267
|
+
console.warn = (...args: unknown[]) => seen.push(String(args[0]));
|
|
268
|
+
try {
|
|
269
|
+
let now = 1_000_000;
|
|
270
|
+
const clock = () => now;
|
|
271
|
+
for (let i = 0; i < 50; i++) warnNoTranscriptionProvider(clock);
|
|
272
|
+
expect(seen.length).toBe(1);
|
|
273
|
+
expect(seen[0]).toMatch(/no transcription provider configured/i);
|
|
274
|
+
// Past the window → one more.
|
|
275
|
+
now += 61_000;
|
|
276
|
+
warnNoTranscriptionProvider(clock);
|
|
277
|
+
expect(seen.length).toBe(2);
|
|
278
|
+
} finally {
|
|
279
|
+
console.warn = orig;
|
|
280
|
+
_resetNoProviderWarnForTest();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|
package/src/auto-transcribe.ts
CHANGED
|
@@ -59,12 +59,90 @@ export function shouldAutoTranscribe(
|
|
|
59
59
|
if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
|
|
60
60
|
return false;
|
|
61
61
|
}
|
|
62
|
+
return classifyAutoTranscribe(mimeType, opts).kind === "transcribe";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Why an audio attachment is (or isn't) being transcribed. The three outcomes
|
|
67
|
+
* are NOT interchangeable, which is the whole point of this type:
|
|
68
|
+
*
|
|
69
|
+
* - `transcribe` — enqueue it.
|
|
70
|
+
* - `disabled` — the operator turned auto-transcribe off. Silence is
|
|
71
|
+
* correct: they asked for nothing to happen.
|
|
72
|
+
* - `unavailable` — auto-transcribe is ON, but no provider is reachable.
|
|
73
|
+
* This is a MISCONFIGURATION, and silence is wrong.
|
|
74
|
+
*
|
|
75
|
+
* vault#643: `shouldAutoTranscribe` collapsed the last two into `false`, so a
|
|
76
|
+
* box with transcription enabled and no reachable provider accepted audio,
|
|
77
|
+
* transcribed nothing, wrote no marker, logged nothing, and left an attachment
|
|
78
|
+
* indistinguishable from a plain upload. Observed on a fresh install: the
|
|
79
|
+
* provider resolves to `scribe-http` by default, nothing sets `SCRIBE_URL`, and
|
|
80
|
+
* voice memos silently never transcribe.
|
|
81
|
+
*
|
|
82
|
+
* The hosted door already gets this right — `workers/vault/src/vault-do.ts`
|
|
83
|
+
* marks a terminal state ("voice not enabled for this plan", "monthly voice
|
|
84
|
+
* limit reached") rather than skipping quietly, precisely so the operator never
|
|
85
|
+
* faces an eternal spinner. This brings self-host to the same posture.
|
|
86
|
+
*/
|
|
87
|
+
export type AutoTranscribeDecision =
|
|
88
|
+
| { kind: "transcribe" }
|
|
89
|
+
| { kind: "not-audio" }
|
|
90
|
+
| { kind: "disabled" }
|
|
91
|
+
| { kind: "unavailable" };
|
|
92
|
+
|
|
93
|
+
/** The full decision behind `shouldAutoTranscribe`. Same inputs, more answer. */
|
|
94
|
+
export function classifyAutoTranscribe(
|
|
95
|
+
mimeType: string,
|
|
96
|
+
opts: {
|
|
97
|
+
readGlobalConfigImpl?: typeof readGlobalConfig;
|
|
98
|
+
getCachedScribeUrlImpl?: () => string | undefined;
|
|
99
|
+
perVaultEnabled?: boolean;
|
|
100
|
+
enabledOverride?: boolean;
|
|
101
|
+
} = {},
|
|
102
|
+
): AutoTranscribeDecision {
|
|
103
|
+
if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
|
|
104
|
+
return { kind: "not-audio" };
|
|
105
|
+
}
|
|
62
106
|
const enabled = opts.enabledOverride
|
|
63
107
|
?? opts.perVaultEnabled
|
|
64
108
|
?? (opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled
|
|
65
109
|
?? true;
|
|
66
|
-
if (!enabled) return
|
|
110
|
+
if (!enabled) return { kind: "disabled" };
|
|
67
111
|
const url = (opts.getCachedScribeUrlImpl ?? getCachedScribeUrl)();
|
|
68
|
-
if (!url || !url.trim()) return
|
|
69
|
-
return
|
|
112
|
+
if (!url || !url.trim()) return { kind: "unavailable" };
|
|
113
|
+
return { kind: "transcribe" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The `transcribe_error` written when auto-transcribe is on but no provider is
|
|
118
|
+
* reachable. Deliberately actionable — the operator needs to know which of the
|
|
119
|
+
* two things to do, not just that something went wrong.
|
|
120
|
+
*/
|
|
121
|
+
export const NO_PROVIDER_ERROR =
|
|
122
|
+
"no transcription provider configured — set TRANSCRIPTION_PROVIDER to a local " +
|
|
123
|
+
"provider (see `parachute-vault transcription install`), or point SCRIBE_URL at " +
|
|
124
|
+
"a transcription service";
|
|
125
|
+
|
|
126
|
+
/** Throttle for {@link warnNoTranscriptionProvider}. */
|
|
127
|
+
const NO_PROVIDER_WARN_INTERVAL_MS = 60_000;
|
|
128
|
+
let lastNoProviderWarnAt = 0;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Warn (at most once a minute) that audio is arriving with nowhere to send it.
|
|
132
|
+
*
|
|
133
|
+
* Throttled because it fires per-upload: a bulk import of a hundred voice
|
|
134
|
+
* memos should produce one actionable line, not a hundred. Silence was the old
|
|
135
|
+
* behaviour and it is what made this invisible — a box can accept audio for
|
|
136
|
+
* months and never say that transcription isn't wired up.
|
|
137
|
+
*/
|
|
138
|
+
export function warnNoTranscriptionProvider(now: () => number = Date.now): void {
|
|
139
|
+
const t = now();
|
|
140
|
+
if (t - lastNoProviderWarnAt < NO_PROVIDER_WARN_INTERVAL_MS) return;
|
|
141
|
+
lastNoProviderWarnAt = t;
|
|
142
|
+
console.warn(`[transcribe] audio attachment accepted but ${NO_PROVIDER_ERROR}.`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Test seam: forget the throttle window. */
|
|
146
|
+
export function _resetNoProviderWarnForTest(): void {
|
|
147
|
+
lastNoProviderWarnAt = 0;
|
|
70
148
|
}
|
package/src/mirror-routes.ts
CHANGED
|
@@ -1655,7 +1655,7 @@ export async function applyCredentialsToMirror(
|
|
|
1655
1655
|
// "credentials": null
|
|
1656
1656
|
// | { "kind": "pat", "token": "ghp_..." }
|
|
1657
1657
|
// | { "kind": "none" },
|
|
1658
|
-
// "enable_sync":
|
|
1658
|
+
// "enable_sync": false // optional, DEFAULT FALSE (vault#641)
|
|
1659
1659
|
// }
|
|
1660
1660
|
//
|
|
1661
1661
|
// `credentials: null` means "use the stored mirror credentials." Passing
|
|
@@ -1663,11 +1663,18 @@ export async function applyCredentialsToMirror(
|
|
|
1663
1663
|
// for the CLONE, but IS persisted when sync is enabled (it's the push
|
|
1664
1664
|
// credential for the now-configured mirror).
|
|
1665
1665
|
//
|
|
1666
|
-
// `enable_sync`
|
|
1667
|
-
//
|
|
1668
|
-
// credentials
|
|
1669
|
-
// one
|
|
1670
|
-
//
|
|
1666
|
+
// `enable_sync` — DEFAULT **FALSE** when omitted (vault#641). When explicitly
|
|
1667
|
+
// enabled, a successful import also configures mirror push-back to the SAME
|
|
1668
|
+
// repo, reusing the import's credentials — "import a repo" and "back up to that
|
|
1669
|
+
// repo going forward" in one flow. The UI's checkbox is **unchecked** by default
|
|
1670
|
+
// to match.
|
|
1671
|
+
//
|
|
1672
|
+
// This shipped default-ON in vault#416 and was inverted in vault#641: import is
|
|
1673
|
+
// a READ, and defaulting to ON silently made the repo you were reading from into
|
|
1674
|
+
// this vault's push target. The full rationale lives at the handler below; keep
|
|
1675
|
+
// these two in agreement.
|
|
1676
|
+
//
|
|
1677
|
+
// Edge cases (handled in `enableSyncToImportedRepo`, never fail the
|
|
1671
1678
|
// whole import):
|
|
1672
1679
|
// - `auth: none` (public repo, no push creds) → skip + warn (can't push
|
|
1673
1680
|
// without a credential).
|
package/src/routes.ts
CHANGED
|
@@ -145,7 +145,12 @@ import {
|
|
|
145
145
|
import { join, extname, normalize } from "path";
|
|
146
146
|
import { existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
147
147
|
import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
|
|
148
|
-
import {
|
|
148
|
+
import {
|
|
149
|
+
NO_PROVIDER_ERROR,
|
|
150
|
+
classifyAutoTranscribe,
|
|
151
|
+
shouldAutoTranscribe,
|
|
152
|
+
warnNoTranscriptionProvider,
|
|
153
|
+
} from "./auto-transcribe.ts";
|
|
149
154
|
// usage.ts imports `assetsDir` from config.ts (neutral ground), so this import
|
|
150
155
|
// of invalidateUsageCache does NOT form a cycle — routes.ts → usage.ts only.
|
|
151
156
|
import { invalidateUsageCache } from "./usage.ts";
|
|
@@ -2369,7 +2374,18 @@ async function handleNotesInner(
|
|
|
2369
2374
|
const perVaultEnabled = vault
|
|
2370
2375
|
? readVaultConfig(vault)?.auto_transcribe?.enabled
|
|
2371
2376
|
: undefined;
|
|
2372
|
-
|
|
2377
|
+
// vault#643: classify rather than collapse to a boolean. "Operator
|
|
2378
|
+
// turned it off" and "nothing is configured to do it" both used to read
|
|
2379
|
+
// as `false`, so a misconfigured box silently accepted audio and
|
|
2380
|
+
// transcribed nothing — no marker, no status, no log.
|
|
2381
|
+
const autoDecision = explicitOptIn
|
|
2382
|
+
? ({ kind: "transcribe" } as const)
|
|
2383
|
+
: classifyAutoTranscribe(body.mimeType, { perVaultEnabled });
|
|
2384
|
+
const autoOptIn = !explicitOptIn && autoDecision.kind === "transcribe";
|
|
2385
|
+
// Enabled, audio, but no reachable provider. Record it on the attachment
|
|
2386
|
+
// so the state is visible in the API and the admin SPA instead of the
|
|
2387
|
+
// upload looking like an ordinary file.
|
|
2388
|
+
const transcribeUnavailable = !explicitOptIn && autoDecision.kind === "unavailable";
|
|
2373
2389
|
// Per-segment slots (voice W2, cloud twin: workers/vault/src/rest/notes.ts
|
|
2374
2390
|
// ~791-800): an optional `segment_index` (integer >= 0) lets one recording
|
|
2375
2391
|
// split across several attachments on ONE note, each resolving into its
|
|
@@ -2385,7 +2401,16 @@ async function handleNotesInner(
|
|
|
2385
2401
|
transcribe_origin: (explicitOptIn ? "legacy" : "auto") as "legacy" | "auto",
|
|
2386
2402
|
...(validSegment ? { segment_index: segIdx } : {}),
|
|
2387
2403
|
}
|
|
2388
|
-
:
|
|
2404
|
+
: transcribeUnavailable
|
|
2405
|
+
? {
|
|
2406
|
+
transcribe_status: "failed" as const,
|
|
2407
|
+
transcribe_error: NO_PROVIDER_ERROR,
|
|
2408
|
+
transcribe_requested_at: new Date().toISOString(),
|
|
2409
|
+
transcribe_origin: "auto" as const,
|
|
2410
|
+
...(validSegment ? { segment_index: segIdx } : {}),
|
|
2411
|
+
}
|
|
2412
|
+
: undefined;
|
|
2413
|
+
if (transcribeUnavailable) warnNoTranscriptionProvider();
|
|
2389
2414
|
|
|
2390
2415
|
const attachment = await store.addAttachment(note.id, body.path, body.mimeType, attMeta);
|
|
2391
2416
|
|
package/src/routing.ts
CHANGED
|
@@ -118,6 +118,10 @@ import {
|
|
|
118
118
|
handleMirrorRunNow,
|
|
119
119
|
} from "./mirror-routes.ts";
|
|
120
120
|
import { handleEmbeddingsGet, handleEmbeddingsPut } from "./embeddings-routes.ts";
|
|
121
|
+
import {
|
|
122
|
+
handleTranscriptionGet,
|
|
123
|
+
handleTranscriptionPut,
|
|
124
|
+
} from "./transcription-routes.ts";
|
|
121
125
|
import { getMirrorManager } from "./mirror-registry.ts";
|
|
122
126
|
import { buildUsageReport } from "./usage.ts";
|
|
123
127
|
import { handleTicketSpend } from "./attachment-tickets.ts";
|
|
@@ -716,6 +720,38 @@ export async function route(
|
|
|
716
720
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
717
721
|
}
|
|
718
722
|
|
|
723
|
+
// /.parachute/transcription — Admin-gated read+write of the transcription
|
|
724
|
+
// setup. Mirrors the embeddings toggle's shape (persist a preference, report
|
|
725
|
+
// `restart_required`) but answers a harder question: transcription can be
|
|
726
|
+
// "configured" and still not work, because it needs a binary and a model on
|
|
727
|
+
// disk that no config file can promise are there.
|
|
728
|
+
//
|
|
729
|
+
// So GET reports readiness with the missing PIECE named, the paths searched,
|
|
730
|
+
// and the exact command that fixes it. That's the gap this closes: a box
|
|
731
|
+
// could accept audio for weeks and transcribe nothing, with the only evidence
|
|
732
|
+
// a boot log line the operator scrolled past (vault#643).
|
|
733
|
+
//
|
|
734
|
+
// PUT persists provider/model only — installing downloads hundreds of MB and
|
|
735
|
+
// shells brew/tar, which belongs in the CLI with a progress bar, not in a
|
|
736
|
+
// request a browser tab can abandon. See transcription-routes.ts.
|
|
737
|
+
if (subpath === "/.parachute/transcription") {
|
|
738
|
+
if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
739
|
+
return Response.json(
|
|
740
|
+
{
|
|
741
|
+
error: "Forbidden",
|
|
742
|
+
error_type: "insufficient_scope",
|
|
743
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
744
|
+
required_scope: SCOPE_ADMIN,
|
|
745
|
+
granted_scopes: auth.scopes,
|
|
746
|
+
},
|
|
747
|
+
{ status: 403 },
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
if (req.method === "GET") return handleTranscriptionGet();
|
|
751
|
+
if (req.method === "PUT") return handleTranscriptionPut(req);
|
|
752
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
753
|
+
}
|
|
754
|
+
|
|
719
755
|
// The per-vault `/tokens` REST surface (pvt_* mint/list/revoke) was removed
|
|
720
756
|
// at 0.5.0 (vault#282 Stage 2 — vault is a pure hub resource-server). Hub
|
|
721
757
|
// JWTs are minted via hub's registry (`/api/auth/mint-token`); a `/tokens`
|
package/src/server.ts
CHANGED
|
@@ -279,7 +279,20 @@ if (providerName === "whisper-cpp") {
|
|
|
279
279
|
wireTranscriptionWorker(transcriptionWorker);
|
|
280
280
|
console.log(`[transcribe] worker started → ${scribeUrl}`);
|
|
281
281
|
} else {
|
|
282
|
-
console.log
|
|
282
|
+
// vault#643: was a console.log reading "worker disabled", which states a
|
|
283
|
+
// fact about the worker and says nothing about the CONSEQUENCE. On a fresh
|
|
284
|
+
// box the provider resolves to `scribe-http` by default and nothing sets
|
|
285
|
+
// SCRIBE_URL, so this is the DEFAULT state — and audio silently never
|
|
286
|
+
// transcribes. Name the consequence, and warn rather than log, because the
|
|
287
|
+
// box is misconfigured for a feature that is on by default.
|
|
288
|
+
console.warn(
|
|
289
|
+
"[transcribe] NO transcription provider is reachable — audio attachments " +
|
|
290
|
+
"will be accepted but never transcribed. " +
|
|
291
|
+
`Provider resolved to "${providerName}". Fix with either: ` +
|
|
292
|
+
"`parachute-vault transcription install` + TRANSCRIPTION_PROVIDER=<local provider>, " +
|
|
293
|
+
"or point SCRIBE_URL at a transcription service. " +
|
|
294
|
+
"Set `auto_transcribe.enabled: false` to silence this if you don't want transcription.",
|
|
295
|
+
);
|
|
283
296
|
}
|
|
284
297
|
}
|
|
285
298
|
|