@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.
@@ -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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.3",
3
+ "version": "0.7.4-rc.2",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -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 { path: string; mimeType: string; transcribe?: boolean };
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
 
@@ -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