@openparachute/vault 0.7.4-rc.1 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.4-rc.1",
3
+ "version": "0.7.4",
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);
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
 
package/src/vault.test.ts CHANGED
@@ -2913,6 +2913,173 @@ describe("HTTP /notes", async () => {
2913
2913
  });
2914
2914
  });
2915
2915
 
2916
+ describe("POST /notes/:id/attachments with segment_index (voice W2)", async () => {
2917
+ test("valid segment_index (integer >= 0) lands on the attachment's metadata", async () => {
2918
+ await store.createNote("# 🎙️ Voice memo\n\n_Transcript pending (part 1)._", { id: "seg1" });
2919
+ const res = await handleNotes(
2920
+ mkReq("POST", "/notes/seg1/attachments", {
2921
+ path: "memos/part-1.webm",
2922
+ mimeType: "audio/webm",
2923
+ transcribe: true,
2924
+ segment_index: 0,
2925
+ }),
2926
+ store,
2927
+ "/seg1/attachments",
2928
+ );
2929
+ expect(res.status).toBe(201);
2930
+ const att = await res.json() as any;
2931
+ expect(att.metadata?.segment_index).toBe(0);
2932
+ expect(att.metadata?.transcribe_status).toBe("pending");
2933
+ });
2934
+
2935
+ test.each([
2936
+ ["negative", -1],
2937
+ ["non-integer", 1.5],
2938
+ ["string", "1"],
2939
+ ])("invalid segment_index (%s) is dropped, not stored, same as cloud's fallback", async (_label, bad) => {
2940
+ await store.createNote("# 🎙️ Voice memo\n\n_Transcript pending._", { id: `seg-bad-${_label}` });
2941
+ const res = await handleNotes(
2942
+ mkReq("POST", `/notes/seg-bad-${_label}/attachments`, {
2943
+ path: "memos/bad.webm",
2944
+ mimeType: "audio/webm",
2945
+ transcribe: true,
2946
+ segment_index: bad,
2947
+ }),
2948
+ store,
2949
+ `/seg-bad-${_label}/attachments`,
2950
+ );
2951
+ // Malformed segment_index is NOT a request error — it silently falls
2952
+ // back to the un-segmented path (mirrors cloud's notes.ts validSegment
2953
+ // check), so linking still succeeds.
2954
+ expect(res.status).toBe(201);
2955
+ const att = await res.json() as any;
2956
+ expect(att.metadata?.segment_index).toBeUndefined();
2957
+ expect(att.metadata?.transcribe_status).toBe("pending");
2958
+ });
2959
+
2960
+ test("absent segment_index leaves the un-segmented path byte-unchanged", async () => {
2961
+ await store.createNote("# 🎙️ Voice memo\n\n_Transcript pending._", { id: "seg-absent" });
2962
+ const res = await handleNotes(
2963
+ mkReq("POST", "/notes/seg-absent/attachments", {
2964
+ path: "memos/bare.webm",
2965
+ mimeType: "audio/webm",
2966
+ transcribe: true,
2967
+ }),
2968
+ store,
2969
+ "/seg-absent/attachments",
2970
+ );
2971
+ expect(res.status).toBe(201);
2972
+ const att = await res.json() as any;
2973
+ expect(att.metadata?.segment_index).toBeUndefined();
2974
+ expect(att.metadata?.transcribe_status).toBe("pending");
2975
+ });
2976
+
2977
+ // ---- The join test, not just the door's half ------------------------
2978
+ //
2979
+ // The three tests above post `segment_index` at TOP LEVEL — the shape
2980
+ // both doors have now agreed on (cloud always read it there; self-host
2981
+ // didn't read it at all until this PR). That's a deliberate contract
2982
+ // choice, not a guess at what any client sends: the app was found
2983
+ // nesting it under `metadata` instead, which is the OTHER half of this
2984
+ // bug and is being fixed separately in sibling PR parachute-app#126
2985
+ // ("segment_index rides top-level on the wire"). Top-level is the one
2986
+ // true shape going forward; nested is a bug in the emitter, not a shape
2987
+ // either door should learn to accept.
2988
+ //
2989
+ // What this test proves: given a top-level `segment_index`, self-host
2990
+ // stores it AND the transcription worker resolves the correct per-part
2991
+ // marker from it — the full loop this door owns.
2992
+ // What it does NOT prove: that any real client actually sends this
2993
+ // shape today. app#126 pins the app's emission; this pins the door's
2994
+ // reception. Proving the two actually join — the app's real request
2995
+ // body landing on a running self-host vault and coming out right —
2996
+ // needs a cross-repo conformance test that doesn't exist yet (filed as
2997
+ // vault#629; that gap is exactly how this bug shipped in the first
2998
+ // place, since cloud's own conformance test was green against a shape
2999
+ // the app never sent).
3000
+ test("end-to-end: top-level segment_index on TWO real attachments resolves each part's marker independently", async () => {
3001
+ const assetsRoot = join(tmpDir, "assets");
3002
+ mkdirSync(join(assetsRoot, "memos"), { recursive: true });
3003
+ writeFileSync(join(assetsRoot, "memos/e2e-0.webm"), Buffer.from([1, 2, 3]));
3004
+ writeFileSync(join(assetsRoot, "memos/e2e-1.webm"), Buffer.from([4, 5, 6]));
3005
+ process.env.ASSETS_DIR = assetsRoot;
3006
+
3007
+ await store.createNote(
3008
+ "# 🎙️ Voice memo\n\n_Transcript pending (part 1)._\n\n_Transcript pending (part 2)._\n",
3009
+ { id: "seg-e2e", metadata: { transcribe_stub: true } },
3010
+ );
3011
+
3012
+ // Link both parts through the REAL REST endpoint, exactly as the
3013
+ // (now-fixed) app will call it: top-level segment_index, not nested.
3014
+ const res0 = await handleNotes(
3015
+ mkReq("POST", "/notes/seg-e2e/attachments", {
3016
+ path: "memos/e2e-0.webm",
3017
+ mimeType: "audio/webm",
3018
+ transcribe: true,
3019
+ segment_index: 0,
3020
+ }),
3021
+ store,
3022
+ "/seg-e2e/attachments",
3023
+ );
3024
+ const res1 = await handleNotes(
3025
+ mkReq("POST", "/notes/seg-e2e/attachments", {
3026
+ path: "memos/e2e-1.webm",
3027
+ mimeType: "audio/webm",
3028
+ transcribe: true,
3029
+ segment_index: 1,
3030
+ }),
3031
+ store,
3032
+ "/seg-e2e/attachments",
3033
+ );
3034
+ expect(res0.status).toBe(201);
3035
+ expect(res1.status).toBe(201);
3036
+ const att0 = await res0.json() as any;
3037
+ const att1 = await res1.json() as any;
3038
+
3039
+ const worker = startTranscriptionWorker({
3040
+ vaultList: () => ["default"],
3041
+ getStore: () => store as unknown as Store,
3042
+ scribeUrl: "http://scribe.test",
3043
+ resolveAssetsDir: () => process.env.ASSETS_DIR!,
3044
+ pollIntervalMs: 10_000_000,
3045
+ maxAttempts: 3,
3046
+ fetchImpl: (async () => new Response(
3047
+ JSON.stringify({ text: "part text" }),
3048
+ { status: 200, headers: { "content-type": "application/json" } },
3049
+ )) as typeof fetch,
3050
+ logger: { error: () => {}, info: () => {} },
3051
+ });
3052
+ try {
3053
+ // Complete part 2 first, then part 1 — out of order, same as the
3054
+ // original bug report.
3055
+ await worker.kick("default", att1);
3056
+ const midway = await store.getNote("seg-e2e");
3057
+ expect(midway!.content).toBe(
3058
+ "# 🎙️ Voice memo\n\n_Transcript pending (part 1)._\n\npart text\n",
3059
+ );
3060
+ // Shared stub SURVIVES — part 1 still needs the gate open. Before
3061
+ // this fix, an unstored segment_index made every part look
3062
+ // un-segmented, so completing ONE part cleared the stub and locked
3063
+ // the other out (the exact production bug).
3064
+ expect((midway!.metadata as any)?.transcribe_stub).toBe(true);
3065
+
3066
+ await worker.kick("default", att0);
3067
+ const final = await store.getNote("seg-e2e");
3068
+ expect(final!.content).toBe(
3069
+ "# 🎙️ Voice memo\n\npart text\n\npart text\n",
3070
+ );
3071
+ // Segmented notes never auto-clear the shared stub, even once every
3072
+ // part is done (pre-existing worker contract, pinned separately in
3073
+ // transcription-worker.test.ts) — out of scope here, asserted only
3074
+ // so this test doesn't silently rely on behavior this PR didn't add.
3075
+ expect((final!.metadata as any)?.transcribe_stub).toBe(true);
3076
+ } finally {
3077
+ await worker.stop();
3078
+ delete process.env.ASSETS_DIR;
3079
+ }
3080
+ });
3081
+ });
3082
+
2916
3083
  describe("DELETE /notes/:id/attachments/:attId", async () => {
2917
3084
  test("happy path: 204, DB row gone, storage file unlinked", async () => {
2918
3085
  const assetsRoot = join(tmpDir, "assets");