@openparachute/vault 0.7.3 → 0.7.4-rc.2
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/core/src/attachment/tickets.ts +9 -0
- package/core/src/mcp-manifest.ts +5 -1
- package/core/src/mcp.ts +8 -0
- package/package.json +1 -1
- package/src/attachment-tickets.test.ts +46 -0
- package/src/attachment-tickets.ts +6 -0
- package/src/embeddings-routes.test.ts +188 -0
- package/src/embeddings-routes.ts +178 -0
- package/src/routes.ts +15 -1
- package/src/routing.test.ts +129 -0
- package/src/routing.ts +28 -0
- package/src/vault-store.ts +15 -0
- package/src/vault.test.ts +167 -0
- package/web/ui/dist/assets/index-NvwxfZcu.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-CJ6NtIwh.js +0 -61
|
@@ -37,6 +37,15 @@ export interface AttachmentTicket {
|
|
|
37
37
|
noteId?: string;
|
|
38
38
|
filename?: string;
|
|
39
39
|
transcribe?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Upload only, voice W2 parity with the REST path's `segment_index`
|
|
42
|
+
* (`src/routes.ts` POST `/notes/:id/attachments`) — an integer >= 0 lets
|
|
43
|
+
* one recording split across several ticket-minted attachments on ONE
|
|
44
|
+
* note, each resolving into its own `(part N)` marker. Only meaningful
|
|
45
|
+
* alongside `transcribe: true`; validated at mint (`generateMcpTools`'s
|
|
46
|
+
* `request-attachment-upload`), consumed at spend (`handleTicketSpend`).
|
|
47
|
+
*/
|
|
48
|
+
segmentIndex?: number;
|
|
40
49
|
/** Download only. */
|
|
41
50
|
attachmentId?: string;
|
|
42
51
|
}
|
package/core/src/mcp-manifest.ts
CHANGED
|
@@ -677,7 +677,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
677
677
|
name: "request-attachment-upload",
|
|
678
678
|
requiredVerb: "write",
|
|
679
679
|
description:
|
|
680
|
-
"Mint a short-lived, single-use upload URL for a note attachment. Bytes never pass through this tool — you get back a URL (+ a ready-to-run `curl_example`) your runtime's shell spends directly; no MCP session credential is needed to spend it. Provide the target `note` (id or path), the `filename`, and its exact `size_bytes` — declared here and enforced at spend (a mismatch, or exceeding the 100 MiB REST upload cap, fails the mint or the upload). `mime_type` is inferred from the filename's extension when omitted. Pass `transcribe: true` for an audio file to enqueue it exactly like the REST attach flow does. The ticket's `expires_at` scales with declared size (10 minutes base + 10s per MiB, capped at 30 minutes) and can be spent exactly once — a failed curl means re-minting, not retrying the same URL.",
|
|
680
|
+
"Mint a short-lived, single-use upload URL for a note attachment. Bytes never pass through this tool — you get back a URL (+ a ready-to-run `curl_example`) your runtime's shell spends directly; no MCP session credential is needed to spend it. Provide the target `note` (id or path), the `filename`, and its exact `size_bytes` — declared here and enforced at spend (a mismatch, or exceeding the 100 MiB REST upload cap, fails the mint or the upload). `mime_type` is inferred from the filename's extension when omitted. Pass `transcribe: true` for an audio file to enqueue it exactly like the REST attach flow does; `segment_index` additionally splits one recording across several attachments on the same note (voice W2 — see that field's description). The ticket's `expires_at` scales with declared size (10 minutes base + 10s per MiB, capped at 30 minutes) and can be spent exactly once — a failed curl means re-minting, not retrying the same URL.",
|
|
681
681
|
inputSchema: {
|
|
682
682
|
type: "object",
|
|
683
683
|
properties: {
|
|
@@ -689,6 +689,10 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
689
689
|
},
|
|
690
690
|
mime_type: { type: "string", description: "MIME type to store on the attachment row. Inferred from `filename`'s extension when omitted (`application/octet-stream` for an uncurated extension)." },
|
|
691
691
|
transcribe: { type: "boolean", description: "Opt into transcription for an audio attachment — mirrors the REST `POST /notes/:id/attachments` `transcribe` flag." },
|
|
692
|
+
segment_index: {
|
|
693
|
+
type: "number",
|
|
694
|
+
description: "Only meaningful alongside `transcribe: true`. An integer >= 0 that marks this attachment as one part of a multi-part recording linked to the same note — each part resolves into its own `_Transcript pending (part N)._` marker (N = segment_index + 1) instead of overwriting a shared bare marker. A malformed value (non-integer, negative, non-number) is silently ignored, falling back to the un-segmented bare marker.",
|
|
695
|
+
},
|
|
692
696
|
},
|
|
693
697
|
required: ["note", "filename", "size_bytes"],
|
|
694
698
|
},
|
package/core/src/mcp.ts
CHANGED
|
@@ -2558,6 +2558,13 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
2558
2558
|
? params.mime_type
|
|
2559
2559
|
: mimeForAttachmentExtension(ext);
|
|
2560
2560
|
|
|
2561
|
+
// Per-segment slots (voice W2), ticket-mint parity with the REST
|
|
2562
|
+
// path's own validation (`src/routes.ts` POST /notes/:id/attachments):
|
|
2563
|
+
// an integer >= 0, else silently dropped — a malformed value falls
|
|
2564
|
+
// back to the un-segmented bare markers rather than erroring the mint.
|
|
2565
|
+
const segIdx = params.segment_index;
|
|
2566
|
+
const validSegment = typeof segIdx === "number" && Number.isInteger(segIdx) && segIdx >= 0;
|
|
2567
|
+
|
|
2561
2568
|
const now = Date.now();
|
|
2562
2569
|
const expiresAt = now + computeTicketTtlMs(sizeBytes);
|
|
2563
2570
|
const id = generateTicketId();
|
|
@@ -2572,6 +2579,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
2572
2579
|
mimeType,
|
|
2573
2580
|
sizeBytes,
|
|
2574
2581
|
transcribe: params.transcribe === true,
|
|
2582
|
+
...(validSegment ? { segmentIndex: segIdx } : {}),
|
|
2575
2583
|
};
|
|
2576
2584
|
await ticketSeam.provider.put(ticket);
|
|
2577
2585
|
|
package/package.json
CHANGED
|
@@ -270,6 +270,52 @@ describe("attachment tickets — upload lifecycle", () => {
|
|
|
270
270
|
expect((updatedNote!.metadata as any)?.transcribe_stub).toBe(true);
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
+
test("segment_index (voice W2): a valid integer >= 0 rides ticket mint through to the attachment row", async () => {
|
|
274
|
+
const vaultName = freshVault("tickets-segment");
|
|
275
|
+
const store = getVaultStore(vaultName);
|
|
276
|
+
const note = await store.createNote("# Voice memo\n\n_Transcript pending (part 2)._", { path: "memo-seg" });
|
|
277
|
+
|
|
278
|
+
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
279
|
+
note: note.id,
|
|
280
|
+
filename: "part-2.wav",
|
|
281
|
+
size_bytes: 4,
|
|
282
|
+
transcribe: true,
|
|
283
|
+
segment_index: 1,
|
|
284
|
+
});
|
|
285
|
+
const res = await routeReq(
|
|
286
|
+
new Request(mint.url, { method: "PUT", headers: { "content-type": "audio/wav" }, body: new Uint8Array([1, 2, 3, 4]) }),
|
|
287
|
+
);
|
|
288
|
+
expect(res.status).toBe(201);
|
|
289
|
+
const attachment = (await res.json()) as any;
|
|
290
|
+
expect(attachment.metadata.segment_index).toBe(1);
|
|
291
|
+
expect(attachment.metadata.transcribe_status).toBe("pending");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test.each([
|
|
295
|
+
["negative", -1],
|
|
296
|
+
["non-integer", 1.5],
|
|
297
|
+
["string", "1"],
|
|
298
|
+
])("segment_index (voice W2): an invalid value (%s) is dropped at mint, not stored — same fallback as the REST path", async (_label, bad) => {
|
|
299
|
+
const vaultName = freshVault(`tickets-segment-bad-${_label}`);
|
|
300
|
+
const store = getVaultStore(vaultName);
|
|
301
|
+
const note = await store.createNote("# Voice memo\n\n_Transcript pending._", { path: "memo-seg-bad" });
|
|
302
|
+
|
|
303
|
+
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
304
|
+
note: note.id,
|
|
305
|
+
filename: "bad.wav",
|
|
306
|
+
size_bytes: 4,
|
|
307
|
+
transcribe: true,
|
|
308
|
+
segment_index: bad,
|
|
309
|
+
});
|
|
310
|
+
const res = await routeReq(
|
|
311
|
+
new Request(mint.url, { method: "PUT", headers: { "content-type": "audio/wav" }, body: new Uint8Array([1, 2, 3, 4]) }),
|
|
312
|
+
);
|
|
313
|
+
expect(res.status).toBe(201);
|
|
314
|
+
const attachment = (await res.json()) as any;
|
|
315
|
+
expect(attachment.metadata.segment_index).toBeUndefined();
|
|
316
|
+
expect(attachment.metadata.transcribe_status).toBe("pending");
|
|
317
|
+
});
|
|
318
|
+
|
|
273
319
|
test("blocked extension is refused at MINT — no ticket is ever created", async () => {
|
|
274
320
|
const vaultName = freshVault("tickets-blocked-ext");
|
|
275
321
|
const store = getVaultStore(vaultName);
|
|
@@ -274,6 +274,12 @@ async function handleUploadSpend(
|
|
|
274
274
|
attMeta.transcribe_status = "pending";
|
|
275
275
|
attMeta.transcribe_requested_at = new Date().toISOString();
|
|
276
276
|
attMeta.transcribe_origin = explicitOptIn ? "legacy" : "auto";
|
|
277
|
+
// Per-segment slots (voice W2) — already validated at mint (the ticket
|
|
278
|
+
// only carries `segmentIndex` when it passed the integer->=0 check), so
|
|
279
|
+
// just thread it through onto the attachment row's metadata.
|
|
280
|
+
if (ticket.segmentIndex !== undefined) {
|
|
281
|
+
attMeta.segment_index = ticket.segmentIndex;
|
|
282
|
+
}
|
|
277
283
|
}
|
|
278
284
|
|
|
279
285
|
const attachment = await store.addAttachment(ticket.noteId, relativePath, ticket.mimeType, attMeta);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit coverage for the semantic-search (embeddings) opt-in toggle surface
|
|
3
|
+
* (0.7.3 fast-follow). Fully sandboxed — a throwaway `PARACHUTE_HOME` so the
|
|
4
|
+
* config read/write path operates on a scratch `config.yaml`, and the running
|
|
5
|
+
* "active" state is injected (never touches the real process-shared provider
|
|
6
|
+
* memo or downloads a model).
|
|
7
|
+
*
|
|
8
|
+
* The snapshot builder is pure over (env, persisted, active) so most of the
|
|
9
|
+
* matrix (env override wins, restart-required gap, default-off) is exercised
|
|
10
|
+
* without any I/O; the handler tests verify the config write path + 400s.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
13
|
+
import { mkdirSync, rmSync } from "fs";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
import { tmpdir } from "os";
|
|
16
|
+
import { readGlobalConfig, writeGlobalConfig } from "./config.ts";
|
|
17
|
+
import {
|
|
18
|
+
buildEmbeddingsSnapshot,
|
|
19
|
+
handleEmbeddingsGet,
|
|
20
|
+
handleEmbeddingsPut,
|
|
21
|
+
EMBEDDING_MODEL_DOWNLOAD_MB,
|
|
22
|
+
type EmbeddingsSettingsSnapshot,
|
|
23
|
+
} from "./embeddings-routes.ts";
|
|
24
|
+
|
|
25
|
+
let tmpHome: string;
|
|
26
|
+
let prevHome: string | undefined;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
tmpHome = join(tmpdir(), `vault-embed-routes-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
30
|
+
mkdirSync(join(tmpHome, "vault"), { recursive: true });
|
|
31
|
+
prevHome = process.env.PARACHUTE_HOME;
|
|
32
|
+
process.env.PARACHUTE_HOME = tmpHome;
|
|
33
|
+
// Seed a config.yaml so readGlobalConfig has a file (port only — embeddings unset).
|
|
34
|
+
writeGlobalConfig({ port: 1940 });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
39
|
+
else process.env.PARACHUTE_HOME = prevHome;
|
|
40
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// An env with no EMBEDDINGS_ENABLED — the common self-host case.
|
|
44
|
+
const NO_ENV: NodeJS.ProcessEnv = {};
|
|
45
|
+
|
|
46
|
+
describe("buildEmbeddingsSnapshot", () => {
|
|
47
|
+
test("default: persisted unset, no env, not active → all off, no restart", () => {
|
|
48
|
+
const snap = buildEmbeddingsSnapshot({ env: NO_ENV, persistedEnabled: undefined, active: false });
|
|
49
|
+
expect(snap).toEqual({
|
|
50
|
+
enabled: false,
|
|
51
|
+
env_override: null,
|
|
52
|
+
env_forced: false,
|
|
53
|
+
effective: false,
|
|
54
|
+
active: false,
|
|
55
|
+
restart_required: false,
|
|
56
|
+
model_download_mb: EMBEDDING_MODEL_DOWNLOAD_MB,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("persisted on but not yet active → effective on, restart required", () => {
|
|
61
|
+
const snap = buildEmbeddingsSnapshot({ env: NO_ENV, persistedEnabled: true, active: false });
|
|
62
|
+
expect(snap.enabled).toBe(true);
|
|
63
|
+
expect(snap.effective).toBe(true);
|
|
64
|
+
expect(snap.active).toBe(false);
|
|
65
|
+
expect(snap.restart_required).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("persisted on and active → no restart required", () => {
|
|
69
|
+
const snap = buildEmbeddingsSnapshot({ env: NO_ENV, persistedEnabled: true, active: true });
|
|
70
|
+
expect(snap.restart_required).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("env override ON wins over persisted OFF", () => {
|
|
74
|
+
const snap = buildEmbeddingsSnapshot({
|
|
75
|
+
env: { EMBEDDINGS_ENABLED: "true" },
|
|
76
|
+
persistedEnabled: false,
|
|
77
|
+
active: true,
|
|
78
|
+
});
|
|
79
|
+
expect(snap.enabled).toBe(false); // toggle still reflects persisted
|
|
80
|
+
expect(snap.env_override).toBe(true);
|
|
81
|
+
expect(snap.env_forced).toBe(true);
|
|
82
|
+
expect(snap.effective).toBe(true); // env forces on
|
|
83
|
+
expect(snap.restart_required).toBe(false); // active matches env-forced effective
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("env override OFF wins over persisted ON (with a pending restart while still active)", () => {
|
|
87
|
+
const snap = buildEmbeddingsSnapshot({
|
|
88
|
+
env: { EMBEDDINGS_ENABLED: "false" },
|
|
89
|
+
persistedEnabled: true,
|
|
90
|
+
active: true,
|
|
91
|
+
});
|
|
92
|
+
expect(snap.enabled).toBe(true); // persisted intent preserved
|
|
93
|
+
expect(snap.env_override).toBe(false);
|
|
94
|
+
expect(snap.env_forced).toBe(true);
|
|
95
|
+
expect(snap.effective).toBe(false); // env forces off
|
|
96
|
+
expect(snap.active).toBe(true);
|
|
97
|
+
expect(snap.restart_required).toBe(true); // running still on, env wants off
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("unrecognized env value defers to persisted (no force)", () => {
|
|
101
|
+
const snap = buildEmbeddingsSnapshot({
|
|
102
|
+
env: { EMBEDDINGS_ENABLED: "maybe" },
|
|
103
|
+
persistedEnabled: true,
|
|
104
|
+
active: false,
|
|
105
|
+
});
|
|
106
|
+
expect(snap.env_override).toBeNull();
|
|
107
|
+
expect(snap.env_forced).toBe(false);
|
|
108
|
+
expect(snap.effective).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("handleEmbeddingsGet", () => {
|
|
113
|
+
test("reflects the persisted config.yaml value", async () => {
|
|
114
|
+
writeGlobalConfig({ port: 1940, embeddings_enabled: true });
|
|
115
|
+
const res = handleEmbeddingsGet(false);
|
|
116
|
+
expect(res.status).toBe(200);
|
|
117
|
+
const body = (await res.json()) as EmbeddingsSettingsSnapshot;
|
|
118
|
+
expect(body.enabled).toBe(true);
|
|
119
|
+
expect(body.effective).toBe(true);
|
|
120
|
+
expect(body.active).toBe(false);
|
|
121
|
+
expect(body.restart_required).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("unset persisted → enabled false", async () => {
|
|
125
|
+
const res = handleEmbeddingsGet(false);
|
|
126
|
+
const body = (await res.json()) as EmbeddingsSettingsSnapshot;
|
|
127
|
+
expect(body.enabled).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("handleEmbeddingsPut", () => {
|
|
132
|
+
function putReq(body: unknown): Request {
|
|
133
|
+
return new Request("http://x/vault/default/.parachute/embeddings", {
|
|
134
|
+
method: "PUT",
|
|
135
|
+
headers: { "content-type": "application/json" },
|
|
136
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
test("enabling persists embeddings_enabled: true via the config write path", async () => {
|
|
141
|
+
const res = await handleEmbeddingsPut(putReq({ enabled: true }), false);
|
|
142
|
+
expect(res.status).toBe(200);
|
|
143
|
+
const body = (await res.json()) as EmbeddingsSettingsSnapshot;
|
|
144
|
+
expect(body.enabled).toBe(true);
|
|
145
|
+
expect(body.restart_required).toBe(true); // persisted on, injected active=false
|
|
146
|
+
// The setting is actually on disk (config write path reused, not hand-rolled).
|
|
147
|
+
expect(readGlobalConfig().embeddings_enabled).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("disabling persists embeddings_enabled: false", async () => {
|
|
151
|
+
writeGlobalConfig({ port: 1940, embeddings_enabled: true });
|
|
152
|
+
const res = await handleEmbeddingsPut(putReq({ enabled: false }), true);
|
|
153
|
+
expect(res.status).toBe(200);
|
|
154
|
+
expect(readGlobalConfig().embeddings_enabled).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("preserves other config fields on write (round-trips through serialize)", async () => {
|
|
158
|
+
writeGlobalConfig({ port: 1940, discovery: "disabled", autostart: true });
|
|
159
|
+
await handleEmbeddingsPut(putReq({ enabled: true }), false);
|
|
160
|
+
const cfg = readGlobalConfig();
|
|
161
|
+
expect(cfg.embeddings_enabled).toBe(true);
|
|
162
|
+
expect(cfg.discovery).toBe("disabled");
|
|
163
|
+
expect(cfg.autostart).toBe(true);
|
|
164
|
+
expect(cfg.port).toBe(1940);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("non-boolean enabled → 400", async () => {
|
|
168
|
+
const res = await handleEmbeddingsPut(putReq({ enabled: "yes" }), false);
|
|
169
|
+
expect(res.status).toBe(400);
|
|
170
|
+
const body = (await res.json()) as { field?: string };
|
|
171
|
+
expect(body.field).toBe("enabled");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("missing enabled → 400", async () => {
|
|
175
|
+
const res = await handleEmbeddingsPut(putReq({}), false);
|
|
176
|
+
expect(res.status).toBe(400);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("non-object body → 400", async () => {
|
|
180
|
+
const res = await handleEmbeddingsPut(putReq("[]"), false);
|
|
181
|
+
expect(res.status).toBe(400);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("invalid JSON → 400", async () => {
|
|
185
|
+
const res = await handleEmbeddingsPut(putReq("{not json"), false);
|
|
186
|
+
expect(res.status).toBe(400);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP surface for the semantic-search (embeddings) opt-in toggle.
|
|
3
|
+
*
|
|
4
|
+
* GET /vault/<name>/.parachute/embeddings — read the persisted setting +
|
|
5
|
+
* the running/effective/env state
|
|
6
|
+
* PUT /vault/<name>/.parachute/embeddings — flip the persisted setting
|
|
7
|
+
*
|
|
8
|
+
* The 0.7.3 fast-follow: 0.7.3 shipped the persisted `embeddings_enabled`
|
|
9
|
+
* config.yaml setting + `resolveEmbeddingsEnabled` (env override → persisted
|
|
10
|
+
* → off), but the ONLY way to flip it was hand-editing config.yaml or setting
|
|
11
|
+
* an env var. This module gives the vault admin SPA a real toggle over that
|
|
12
|
+
* setting.
|
|
13
|
+
*
|
|
14
|
+
* URL note: same reasoning as `mirror-routes.ts` — the admin SPA's static
|
|
15
|
+
* bundle owns `/vault/<name>/admin/*` (vault#252), so the API surface lives
|
|
16
|
+
* under the `.parachute/` module-protocol namespace (sibling to
|
|
17
|
+
* `.parachute/config`, `.parachute/mirror`, `.parachute/usage`).
|
|
18
|
+
*
|
|
19
|
+
* Auth: `vault:admin`, gated upstream in `routing.ts` (this module is the
|
|
20
|
+
* after-auth handler). `embeddings_enabled` is host-global config that affects
|
|
21
|
+
* every vault on the server, but it's reached through the same per-vault admin
|
|
22
|
+
* surface every other settings page uses; the UI copy names the host-wide
|
|
23
|
+
* scope.
|
|
24
|
+
*
|
|
25
|
+
* **Activation is restart-to-apply, not hot.** The embedding provider is
|
|
26
|
+
* resolved ONCE at boot (`getSharedEmbeddingProvider`) and captured into every
|
|
27
|
+
* open store (`Store.embeddingProvider` + `embeddingDisabledReason`, baked at
|
|
28
|
+
* construction) and into the embedding worker (`EmbeddingWorker.provider`, a
|
|
29
|
+
* private readonly field). Re-resolving the provider mid-run would mean
|
|
30
|
+
* mutating readonly fields across the worker AND every already-open store AND
|
|
31
|
+
* resetting a deliberately-once module memo — not clean. So this endpoint
|
|
32
|
+
* PERSISTS the setting and reports `restart_required` when the persisted
|
|
33
|
+
* change hasn't taken effect in the running process yet. Hot-reconfigure is a
|
|
34
|
+
* possible follow-up.
|
|
35
|
+
*
|
|
36
|
+
* **The `EMBEDDINGS_ENABLED` env var still wins.** It's the low-level override
|
|
37
|
+
* (`true`/`1` on, `false`/`0` off, else defer). When it's forcing a value the
|
|
38
|
+
* snapshot carries `env_override` so the UI can flag that the persisted toggle
|
|
39
|
+
* is advisory until the env var is removed — the toggle never lies about
|
|
40
|
+
* what's actually in force.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { readGlobalConfig, writeGlobalConfig } from "./config.ts";
|
|
44
|
+
import { embeddingsEnabledEnvOverride, resolveEmbeddingsEnabled } from "./embedding/select.ts";
|
|
45
|
+
import { isSharedEmbeddingProviderActive } from "./vault-store.ts";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Approximate size of the bundled ONNX model (`bge-small-en-v1.5`, q8) that
|
|
49
|
+
* lazy-downloads on the FIRST embed after enabling. Surfaced so the UI can
|
|
50
|
+
* warn before the operator flips the switch. (The npm dependency ships ~270MB
|
|
51
|
+
* of runtime, but the model weights fetched on first use are ~34MB.)
|
|
52
|
+
*/
|
|
53
|
+
export const EMBEDDING_MODEL_DOWNLOAD_MB = 34;
|
|
54
|
+
|
|
55
|
+
const CORS = { "Access-Control-Allow-Origin": "*" } as const;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The wire shape the admin SPA reads/writes. Both GET and PUT return this so
|
|
59
|
+
* the SPA reuses one decoder.
|
|
60
|
+
*/
|
|
61
|
+
export interface EmbeddingsSettingsSnapshot {
|
|
62
|
+
/**
|
|
63
|
+
* The persisted `embeddings_enabled` in config.yaml — the value the toggle
|
|
64
|
+
* reflects. `false` when unset (semantic search is opt-in, default off).
|
|
65
|
+
*/
|
|
66
|
+
enabled: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* The `EMBEDDINGS_ENABLED` env override, tri-state: `true` (forced on),
|
|
69
|
+
* `false` (forced off), or `null` (no env var — defers to `enabled`). When
|
|
70
|
+
* non-null the env var wins over the persisted setting.
|
|
71
|
+
*/
|
|
72
|
+
env_override: boolean | null;
|
|
73
|
+
/**
|
|
74
|
+
* Convenience flag: `true` exactly when `env_override` is non-null. The UI
|
|
75
|
+
* shows an advisory banner ("an env var is forcing this") when set.
|
|
76
|
+
*/
|
|
77
|
+
env_forced: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* What a fresh boot would resolve: env override, else persisted, else off.
|
|
80
|
+
* This is the state the running process will land in on next restart.
|
|
81
|
+
*/
|
|
82
|
+
effective: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Whether semantic search is LIVE in the running process right now (the
|
|
85
|
+
* boot-resolved provider is present). Distinct from `effective`: a flip of
|
|
86
|
+
* the persisted setting changes `effective` immediately but not `active`.
|
|
87
|
+
*/
|
|
88
|
+
active: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* `true` when `active !== effective` — the operator's intended state is
|
|
91
|
+
* persisted but the running process hasn't picked it up. Restart to apply.
|
|
92
|
+
*/
|
|
93
|
+
restart_required: boolean;
|
|
94
|
+
/** First-enable model-download size hint (MB) for the UI copy. */
|
|
95
|
+
model_download_mb: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build the snapshot from the persisted setting + env + running state. Pure
|
|
100
|
+
* over its inputs (env + persisted + active) so it's unit-testable without a
|
|
101
|
+
* live server; the `active` reading is injected (defaults to the real
|
|
102
|
+
* process-shared provider state).
|
|
103
|
+
*/
|
|
104
|
+
export function buildEmbeddingsSnapshot(opts?: {
|
|
105
|
+
env?: NodeJS.ProcessEnv;
|
|
106
|
+
persistedEnabled?: boolean;
|
|
107
|
+
active?: boolean;
|
|
108
|
+
}): EmbeddingsSettingsSnapshot {
|
|
109
|
+
const env = opts?.env ?? process.env;
|
|
110
|
+
const persisted = opts?.persistedEnabled ?? false;
|
|
111
|
+
const active = opts?.active ?? isSharedEmbeddingProviderActive();
|
|
112
|
+
const override = embeddingsEnabledEnvOverride(env);
|
|
113
|
+
const effective = resolveEmbeddingsEnabled(env, persisted);
|
|
114
|
+
return {
|
|
115
|
+
enabled: persisted,
|
|
116
|
+
env_override: override ?? null,
|
|
117
|
+
env_forced: override !== undefined,
|
|
118
|
+
effective,
|
|
119
|
+
active,
|
|
120
|
+
restart_required: active !== effective,
|
|
121
|
+
model_download_mb: EMBEDDING_MODEL_DOWNLOAD_MB,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `GET /vault/<name>/.parachute/embeddings` — return the current settings
|
|
127
|
+
* snapshot. Always 200 (auth enforced upstream). `activeOverride` is a test
|
|
128
|
+
* seam; production passes nothing and the running provider state is read.
|
|
129
|
+
*/
|
|
130
|
+
export function handleEmbeddingsGet(activeOverride?: boolean): Response {
|
|
131
|
+
const persisted = readGlobalConfig().embeddings_enabled;
|
|
132
|
+
const snapshot = buildEmbeddingsSnapshot({ persistedEnabled: persisted, active: activeOverride });
|
|
133
|
+
return Response.json(snapshot, { headers: CORS });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `PUT /vault/<name>/.parachute/embeddings` — persist a new
|
|
138
|
+
* `embeddings_enabled` value. Body: `{ "enabled": boolean }`.
|
|
139
|
+
*
|
|
140
|
+
* Reuses the config write path (`writeGlobalConfig` — never hand-rolls YAML),
|
|
141
|
+
* so the serialize logic that already knows how to persist `embeddings_enabled`
|
|
142
|
+
* is the single source of truth. Persists even when the env var is forcing a
|
|
143
|
+
* value (the operator's persisted intent is recorded; the env override still
|
|
144
|
+
* wins for `effective`/`active`, and the snapshot says so). Returns the same
|
|
145
|
+
* shape as GET, reflecting the new persisted value + the unchanged running
|
|
146
|
+
* state (so `restart_required` is honest right after the write).
|
|
147
|
+
*/
|
|
148
|
+
export async function handleEmbeddingsPut(req: Request, activeOverride?: boolean): Promise<Response> {
|
|
149
|
+
let body: unknown;
|
|
150
|
+
try {
|
|
151
|
+
body = await req.json();
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return Response.json(
|
|
154
|
+
{ error: "Invalid JSON body", message: (err as Error).message ?? String(err) },
|
|
155
|
+
{ status: 400 },
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
159
|
+
return Response.json(
|
|
160
|
+
{ error: "Invalid body", field: "enabled", message: "Expected a JSON object { enabled: boolean }." },
|
|
161
|
+
{ status: 400 },
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const enabled = (body as { enabled?: unknown }).enabled;
|
|
165
|
+
if (typeof enabled !== "boolean") {
|
|
166
|
+
return Response.json(
|
|
167
|
+
{ error: "Invalid body", field: "enabled", message: "`enabled` must be a boolean." },
|
|
168
|
+
{ status: 400 },
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const config = readGlobalConfig();
|
|
173
|
+
config.embeddings_enabled = enabled;
|
|
174
|
+
writeGlobalConfig(config);
|
|
175
|
+
|
|
176
|
+
const snapshot = buildEmbeddingsSnapshot({ persistedEnabled: enabled, active: activeOverride });
|
|
177
|
+
return Response.json(snapshot, { headers: CORS });
|
|
178
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -2334,7 +2334,12 @@ async function handleNotesInner(
|
|
|
2334
2334
|
}
|
|
2335
2335
|
const parsedBody = await parseJsonBody(req);
|
|
2336
2336
|
if (!parsedBody.ok) return parsedBody.response;
|
|
2337
|
-
const body = parsedBody.body as {
|
|
2337
|
+
const body = parsedBody.body as {
|
|
2338
|
+
path: string;
|
|
2339
|
+
mimeType: string;
|
|
2340
|
+
transcribe?: boolean;
|
|
2341
|
+
segment_index?: unknown;
|
|
2342
|
+
};
|
|
2338
2343
|
if (!body.path || !body.mimeType) {
|
|
2339
2344
|
return json(
|
|
2340
2345
|
{ error: "path and mimeType are required", error_type: "missing_required_field", hint: "pass both `path` and `mimeType`" },
|
|
@@ -2365,11 +2370,20 @@ async function handleNotesInner(
|
|
|
2365
2370
|
? readVaultConfig(vault)?.auto_transcribe?.enabled
|
|
2366
2371
|
: undefined;
|
|
2367
2372
|
const autoOptIn = !explicitOptIn && shouldAutoTranscribe(body.mimeType, { perVaultEnabled });
|
|
2373
|
+
// Per-segment slots (voice W2, cloud twin: workers/vault/src/rest/notes.ts
|
|
2374
|
+
// ~791-800): an optional `segment_index` (integer >= 0) lets one recording
|
|
2375
|
+
// split across several attachments on ONE note, each resolving into its
|
|
2376
|
+
// own `(part N)` marker (N = segment_index + 1, see transcription-worker.ts
|
|
2377
|
+
// markersFor). A malformed value is silently ignored — the attachment
|
|
2378
|
+
// still links, it just falls back to the bare, un-segmented markers.
|
|
2379
|
+
const segIdx = body.segment_index;
|
|
2380
|
+
const validSegment = typeof segIdx === "number" && Number.isInteger(segIdx) && segIdx >= 0;
|
|
2368
2381
|
const attMeta = (explicitOptIn || autoOptIn)
|
|
2369
2382
|
? {
|
|
2370
2383
|
transcribe_status: "pending" as const,
|
|
2371
2384
|
transcribe_requested_at: new Date().toISOString(),
|
|
2372
2385
|
transcribe_origin: (explicitOptIn ? "legacy" : "auto") as "legacy" | "auto",
|
|
2386
|
+
...(validSegment ? { segment_index: segIdx } : {}),
|
|
2373
2387
|
}
|
|
2374
2388
|
: undefined;
|
|
2375
2389
|
|
package/src/routing.test.ts
CHANGED
|
@@ -2716,6 +2716,135 @@ describe("/vault/<name>/.parachute/mirror — auth + dispatch", () => {
|
|
|
2716
2716
|
});
|
|
2717
2717
|
});
|
|
2718
2718
|
|
|
2719
|
+
// ---------------------------------------------------------------------------
|
|
2720
|
+
// /vault/<name>/.parachute/embeddings — semantic-search (embeddings) opt-in
|
|
2721
|
+
// toggle admin surface (0.7.3 fast-follow, vault#624).
|
|
2722
|
+
//
|
|
2723
|
+
// Pins the auth gate + GET/PUT dispatch at the routing layer: the endpoint is
|
|
2724
|
+
// `vault:admin`-only (a host-global setting reached through the same per-vault
|
|
2725
|
+
// admin surface as the sibling `.parachute/mirror` / `.parachute/config`
|
|
2726
|
+
// pages). Snapshot shape + the env-override / restart-required semantics are
|
|
2727
|
+
// covered in embeddings-routes.test.ts; here we pin the *security* property —
|
|
2728
|
+
// unauth → 401, read/write → 403, admin → 200 + the flip persists — so it
|
|
2729
|
+
// can't silently regress below the rest of the admin surface. Mirrors the
|
|
2730
|
+
// `.parachute/mirror` auth+dispatch describe above.
|
|
2731
|
+
// ---------------------------------------------------------------------------
|
|
2732
|
+
|
|
2733
|
+
describe("/vault/<name>/.parachute/embeddings — auth + dispatch", () => {
|
|
2734
|
+
const P = "/vault/journal/.parachute/embeddings";
|
|
2735
|
+
|
|
2736
|
+
test("unauthenticated GET → 401", async () => {
|
|
2737
|
+
createVault("journal");
|
|
2738
|
+
const res = await route(new Request(`http://localhost:1940${P}`), P);
|
|
2739
|
+
expect(res.status).toBe(401);
|
|
2740
|
+
});
|
|
2741
|
+
|
|
2742
|
+
test("unauthenticated PUT → 401", async () => {
|
|
2743
|
+
createVault("journal");
|
|
2744
|
+
const res = await route(
|
|
2745
|
+
new Request(`http://localhost:1940${P}`, {
|
|
2746
|
+
method: "PUT",
|
|
2747
|
+
headers: { "content-type": "application/json" },
|
|
2748
|
+
body: JSON.stringify({ enabled: true }),
|
|
2749
|
+
}),
|
|
2750
|
+
P,
|
|
2751
|
+
);
|
|
2752
|
+
expect(res.status).toBe(401);
|
|
2753
|
+
});
|
|
2754
|
+
|
|
2755
|
+
test("vault:read token PUT → 403 insufficient_scope", async () => {
|
|
2756
|
+
createVault("journal");
|
|
2757
|
+
const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
|
|
2758
|
+
const res = await route(
|
|
2759
|
+
new Request(`http://localhost:1940${P}`, {
|
|
2760
|
+
method: "PUT",
|
|
2761
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2762
|
+
body: JSON.stringify({ enabled: true }),
|
|
2763
|
+
}),
|
|
2764
|
+
P,
|
|
2765
|
+
);
|
|
2766
|
+
expect(res.status).toBe(403);
|
|
2767
|
+
const body = (await res.json()) as { error_type?: string; required_scope?: string };
|
|
2768
|
+
expect(body.error_type).toBe("insufficient_scope");
|
|
2769
|
+
expect(body.required_scope).toBe("vault:admin");
|
|
2770
|
+
});
|
|
2771
|
+
|
|
2772
|
+
test("vault:write token PUT → 403 insufficient_scope (write ranks below admin)", async () => {
|
|
2773
|
+
createVault("journal");
|
|
2774
|
+
const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:write"] });
|
|
2775
|
+
const res = await route(
|
|
2776
|
+
new Request(`http://localhost:1940${P}`, {
|
|
2777
|
+
method: "PUT",
|
|
2778
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2779
|
+
body: JSON.stringify({ enabled: true }),
|
|
2780
|
+
}),
|
|
2781
|
+
P,
|
|
2782
|
+
);
|
|
2783
|
+
expect(res.status).toBe(403);
|
|
2784
|
+
const body = (await res.json()) as { error_type?: string; required_scope?: string };
|
|
2785
|
+
expect(body.error_type).toBe("insufficient_scope");
|
|
2786
|
+
expect(body.required_scope).toBe("vault:admin");
|
|
2787
|
+
});
|
|
2788
|
+
|
|
2789
|
+
test("cross-vault admin token → 403 (scope names a different vault)", async () => {
|
|
2790
|
+
createVault("journal");
|
|
2791
|
+
createVault("other");
|
|
2792
|
+
// Audience is `journal` (auth passes) but the admin scope names `other`,
|
|
2793
|
+
// so hasScopeForVault denies it against `journal`.
|
|
2794
|
+
const token = await mintJwt({ vaultName: "journal", scopes: ["vault:other:admin"] });
|
|
2795
|
+
const res = await route(
|
|
2796
|
+
new Request(`http://localhost:1940${P}`, {
|
|
2797
|
+
headers: { authorization: `Bearer ${token}` },
|
|
2798
|
+
}),
|
|
2799
|
+
P,
|
|
2800
|
+
);
|
|
2801
|
+
expect(res.status).toBe(403);
|
|
2802
|
+
const body = (await res.json()) as { error_type?: string };
|
|
2803
|
+
expect(body.error_type).toBe("insufficient_scope");
|
|
2804
|
+
});
|
|
2805
|
+
|
|
2806
|
+
test("vault:admin token GET → 200 (default off); admin PUT persists the flip", async () => {
|
|
2807
|
+
createVault("journal");
|
|
2808
|
+
const token = await createAdminToken("journal");
|
|
2809
|
+
|
|
2810
|
+
// GET reaches the handler (auth + admin scope satisfied) → 200 snapshot,
|
|
2811
|
+
// default-off (config seeded with `port` only).
|
|
2812
|
+
const getRes = await route(
|
|
2813
|
+
new Request(`http://localhost:1940${P}`, {
|
|
2814
|
+
headers: { authorization: `Bearer ${token}` },
|
|
2815
|
+
}),
|
|
2816
|
+
P,
|
|
2817
|
+
);
|
|
2818
|
+
expect(getRes.status).toBe(200);
|
|
2819
|
+
const getBody = (await getRes.json()) as { enabled?: boolean };
|
|
2820
|
+
expect(getBody.enabled).toBe(false);
|
|
2821
|
+
|
|
2822
|
+
// PUT flips the persisted setting → 200, echoing the new value.
|
|
2823
|
+
const putRes = await route(
|
|
2824
|
+
new Request(`http://localhost:1940${P}`, {
|
|
2825
|
+
method: "PUT",
|
|
2826
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2827
|
+
body: JSON.stringify({ enabled: true }),
|
|
2828
|
+
}),
|
|
2829
|
+
P,
|
|
2830
|
+
);
|
|
2831
|
+
expect(putRes.status).toBe(200);
|
|
2832
|
+
const putBody = (await putRes.json()) as { enabled?: boolean };
|
|
2833
|
+
expect(putBody.enabled).toBe(true);
|
|
2834
|
+
|
|
2835
|
+
// A follow-up GET reads the persisted value back → the write stuck.
|
|
2836
|
+
const reGet = await route(
|
|
2837
|
+
new Request(`http://localhost:1940${P}`, {
|
|
2838
|
+
headers: { authorization: `Bearer ${token}` },
|
|
2839
|
+
}),
|
|
2840
|
+
P,
|
|
2841
|
+
);
|
|
2842
|
+
expect(reGet.status).toBe(200);
|
|
2843
|
+
const reBody = (await reGet.json()) as { enabled?: boolean };
|
|
2844
|
+
expect(reBody.enabled).toBe(true);
|
|
2845
|
+
});
|
|
2846
|
+
});
|
|
2847
|
+
|
|
2719
2848
|
// ---------------------------------------------------------------------------
|
|
2720
2849
|
// /vault/<name>/.parachute/mirror/run-now — manual-trigger endpoint added
|
|
2721
2850
|
// alongside the SPA UI. Tests pin the auth gate matches the parent
|