@openparachute/vault 0.4.7-rc.2 → 0.4.8-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.parachute/module.json +0 -1
  2. package/README.md +44 -10
  3. package/core/src/connection-pragmas.test.ts +232 -0
  4. package/core/src/core.test.ts +257 -0
  5. package/core/src/cursor.test.ts +160 -0
  6. package/core/src/cursor.ts +272 -0
  7. package/core/src/mcp.ts +51 -7
  8. package/core/src/notes.ts +164 -2
  9. package/core/src/schema.ts +98 -2
  10. package/core/src/store.ts +11 -1
  11. package/core/src/types.ts +32 -0
  12. package/package.json +1 -1
  13. package/src/auth-status.ts +4 -0
  14. package/src/auto-transcribe.test.ts +116 -0
  15. package/src/auto-transcribe.ts +48 -0
  16. package/src/cli.ts +57 -48
  17. package/src/config.test.ts +26 -0
  18. package/src/config.ts +53 -1
  19. package/src/db.ts +15 -2
  20. package/src/mcp-install-interactive.test.ts +23 -2
  21. package/src/mcp-install-interactive.ts +21 -2
  22. package/src/mcp-install.test.ts +40 -0
  23. package/src/mcp-tools.ts +17 -1
  24. package/src/module-config.ts +70 -14
  25. package/src/module-manifest.test.ts +114 -0
  26. package/src/module-manifest.ts +104 -0
  27. package/src/routes.ts +268 -51
  28. package/src/routing.test.ts +4 -2
  29. package/src/routing.ts +4 -4
  30. package/src/scribe-discovery.test.ts +77 -0
  31. package/src/scribe-discovery.ts +91 -0
  32. package/src/scribe-env.test.ts +66 -1
  33. package/src/scribe-env.ts +42 -1
  34. package/src/self-register.test.ts +379 -0
  35. package/src/self-register.ts +234 -0
  36. package/src/server.ts +46 -11
  37. package/src/transcript-note.test.ts +171 -0
  38. package/src/transcript-note.ts +189 -0
  39. package/src/transcription-registry.ts +22 -0
  40. package/src/transcription-worker.test.ts +250 -0
  41. package/src/transcription-worker.ts +186 -27
  42. package/src/vault.test.ts +347 -0
@@ -0,0 +1,114 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { readSelfManifest, resolvePackageRoot } from "./module-manifest.ts";
6
+
7
+ function withTempPackageRoot(
8
+ manifest: unknown | undefined,
9
+ fn: (root: string) => void,
10
+ ): void {
11
+ const root = mkdtempSync(join(tmpdir(), "pvault-manifest-"));
12
+ try {
13
+ if (manifest !== undefined) {
14
+ mkdirSync(join(root, ".parachute"), { recursive: true });
15
+ writeFileSync(
16
+ join(root, ".parachute", "module.json"),
17
+ typeof manifest === "string" ? manifest : JSON.stringify(manifest),
18
+ );
19
+ }
20
+ fn(root);
21
+ } finally {
22
+ rmSync(root, { recursive: true, force: true });
23
+ }
24
+ }
25
+
26
+ describe("module-manifest", () => {
27
+ test("resolvePackageRoot returns the directory containing package.json", () => {
28
+ // In the test env, this module lives at <repo>/src/module-manifest.test.ts —
29
+ // so the resolved root is the repo root. We don't pin the exact path
30
+ // (tests run from various cwds); we just sanity-check it's an absolute
31
+ // directory ending in the vault repo's name.
32
+ const root = resolvePackageRoot();
33
+ expect(root.startsWith("/")).toBe(true);
34
+ expect(root.endsWith("/src")).toBe(false);
35
+ });
36
+
37
+ test("readSelfManifest returns null when .parachute/module.json is missing", () => {
38
+ withTempPackageRoot(undefined, (root) => {
39
+ expect(readSelfManifest(root)).toBeNull();
40
+ });
41
+ });
42
+
43
+ test("readSelfManifest parses a valid manifest (no kind — hub#301 Phase B)", () => {
44
+ withTempPackageRoot(
45
+ {
46
+ name: "vault",
47
+ manifestName: "parachute-vault",
48
+ displayName: "Vault",
49
+ tagline: "Test tagline",
50
+ port: 1940,
51
+ paths: ["/vault/default"],
52
+ health: "/vault/default/health",
53
+ },
54
+ (root) => {
55
+ const m = readSelfManifest(root);
56
+ expect(m).not.toBeNull();
57
+ expect(m?.name).toBe("vault");
58
+ expect(m?.manifestName).toBe("parachute-vault");
59
+ expect(m?.displayName).toBe("Vault");
60
+ expect(m?.kind).toBeUndefined();
61
+ expect(m?.port).toBe(1940);
62
+ expect(m?.paths).toEqual(["/vault/default"]);
63
+ },
64
+ );
65
+ });
66
+
67
+ test("readSelfManifest tolerates a legacy manifest that still includes kind", () => {
68
+ // hub#301 Phase B retired the `kind` field, but legacy manifests on
69
+ // pinned installs may still include it. The reader accepts it without
70
+ // erroring; the field is never branched on.
71
+ withTempPackageRoot(
72
+ {
73
+ name: "vault",
74
+ manifestName: "parachute-vault",
75
+ kind: "api",
76
+ port: 1940,
77
+ paths: ["/vault/default"],
78
+ health: "/vault/default/health",
79
+ },
80
+ (root) => {
81
+ const m = readSelfManifest(root);
82
+ expect(m).not.toBeNull();
83
+ expect(m?.kind).toBe("api");
84
+ },
85
+ );
86
+ });
87
+
88
+ test("readSelfManifest throws on malformed JSON", () => {
89
+ withTempPackageRoot("{ not valid json", (root) => {
90
+ expect(() => readSelfManifest(root)).toThrow();
91
+ });
92
+ });
93
+
94
+ test("readSelfManifest throws when required field missing", () => {
95
+ withTempPackageRoot(
96
+ { name: "vault" /* missing manifestName / port / paths / health */ },
97
+ (root) => {
98
+ expect(() => readSelfManifest(root)).toThrow(/missing required/);
99
+ },
100
+ );
101
+ });
102
+
103
+ test("readSelfManifest reads the actual shipped manifest in the repo", () => {
104
+ // Smoke test the real shipped file — guards against ever shipping a
105
+ // malformed manifest. Uses the real resolvePackageRoot (which finds
106
+ // the repo root in tests). Post hub#301 Phase B, the shipped manifest
107
+ // no longer includes `kind`.
108
+ const m = readSelfManifest();
109
+ expect(m).not.toBeNull();
110
+ expect(m?.manifestName).toBe("parachute-vault");
111
+ expect(m?.kind).toBeUndefined();
112
+ expect(m?.port).toBe(1940);
113
+ });
114
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Reader for the package's own `.parachute/module.json`.
3
+ *
4
+ * Vault ships `module.json` alongside `package.json` at the package root.
5
+ * This module locates the file via `import.meta.url` (which works for both
6
+ * `bun src/cli.ts …` dev runs and the published-package `parachute-vault`
7
+ * binary — the file ships in `package.json` `files` next to `src/`).
8
+ *
9
+ * Used by `self-register.ts` on server boot: vault reads its own manifest
10
+ * + computes the package's `installDir` so the services.json row carries
11
+ * the same metadata that hub's `FIRST_PARTY_FALLBACKS[vault]` provides
12
+ * today. The endgame is that hub's vendored fallback retires once every
13
+ * first-party module self-registers reliably — this is the POC for the
14
+ * pattern.
15
+ *
16
+ * Shape mirrors `parachute-hub/src/module-manifest.ts`. Kept narrow: we
17
+ * only consume the fields vault stamps onto services.json
18
+ * (displayName, tagline, stripPrefix). The full manifest validator lives
19
+ * on the hub side; vault treats its own manifest as authored-by-us +
20
+ * trusts the shape.
21
+ */
22
+
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import { dirname, join, resolve } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ export type ModuleKind = "api" | "frontend" | "tool";
28
+
29
+ /**
30
+ * Subset of the full manifest schema (see `parachute-hub/src/module-manifest.ts`)
31
+ * — only the fields vault's self-registration consumes today. Adding more is
32
+ * a one-line edit when the surface widens.
33
+ */
34
+ export interface VaultModuleManifest {
35
+ readonly name: string;
36
+ readonly manifestName: string;
37
+ readonly displayName?: string;
38
+ readonly tagline?: string;
39
+ /**
40
+ * Deprecated as of hub#301 Phase B (kind retirement, 2026-05-23). Hub's
41
+ * validator dropped `kind` from required-fields in hub#327; vault no
42
+ * longer ships the field in `.parachute/module.json`. Kept here as
43
+ * optional only so an older shipped manifest (pinned legacy install)
44
+ * still parses without throwing — the field is never branched on.
45
+ */
46
+ readonly kind?: ModuleKind;
47
+ readonly port: number;
48
+ readonly paths: readonly string[];
49
+ readonly health: string;
50
+ readonly stripPrefix?: boolean;
51
+ }
52
+
53
+ /**
54
+ * Resolve the path to the package root — the directory containing both
55
+ * `package.json` and `.parachute/module.json`. Walks up from
56
+ * `import.meta.url` so the answer is correct under both:
57
+ *
58
+ * - dev: `bun src/cli.ts serve` → `src/module-manifest.ts` → parent = repo root
59
+ * - prod: published package → `src/module-manifest.ts` → parent = installed
60
+ * package dir (e.g. `~/.bun/install/global/node_modules/@openparachute/vault`)
61
+ *
62
+ * Exported for tests + the self-register flow that needs to stamp this as
63
+ * `installDir` on the services.json row.
64
+ */
65
+ export function resolvePackageRoot(): string {
66
+ const here = dirname(fileURLToPath(import.meta.url));
67
+ // `src/module-manifest.ts` lives one level under the package root.
68
+ return resolve(here, "..");
69
+ }
70
+
71
+ /**
72
+ * Read `<packageRoot>/.parachute/module.json` if present. Returns null when
73
+ * the file is missing (e.g. during local dev before the file was committed)
74
+ * — callers treat that as "self-registration unavailable, log + continue."
75
+ *
76
+ * Throws on malformed JSON: a corrupt manifest is a deploy bug we want to
77
+ * surface, not silently swallow. The self-register caller catches + logs
78
+ * so a bad manifest doesn't crash server boot.
79
+ */
80
+ export function readSelfManifest(
81
+ packageRoot: string = resolvePackageRoot(),
82
+ ): VaultModuleManifest | null {
83
+ const path = join(packageRoot, ".parachute", "module.json");
84
+ if (!existsSync(path)) return null;
85
+ const raw = readFileSync(path, "utf8");
86
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
87
+ // Minimal shape validation. Only the fields we actually consume — anything
88
+ // else passes through untouched. Strict full-shape validation is the hub's
89
+ // job (it'll fail an install on a malformed manifest); vault treats its
90
+ // own shipped file as authored-by-us.
91
+ if (typeof parsed.name !== "string" || typeof parsed.manifestName !== "string") {
92
+ throw new Error(`${path}: manifest missing required "name" / "manifestName"`);
93
+ }
94
+ if (typeof parsed.port !== "number" || !Array.isArray(parsed.paths)) {
95
+ throw new Error(`${path}: manifest missing required "port" / "paths"`);
96
+ }
97
+ if (typeof parsed.health !== "string") {
98
+ throw new Error(`${path}: manifest missing required "health"`);
99
+ }
100
+ // `kind` is retired as of hub#301 Phase B — hub#327 made it optional in
101
+ // the hub-side validator, and vault no longer ships it. If a legacy
102
+ // manifest still includes the field, accept it; just don't require it.
103
+ return parsed as unknown as VaultModuleManifest;
104
+ }
package/src/routes.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  import { join, extname, normalize } from "path";
46
46
  import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
47
47
  import { vaultDir } from "./config.ts";
48
+ import { shouldAutoTranscribe } from "./auto-transcribe.ts";
48
49
 
49
50
  // ---------------------------------------------------------------------------
50
51
  // Helpers
@@ -509,6 +510,21 @@ async function handleNotesInner(
509
510
  return json(result);
510
511
  }
511
512
 
513
+ // Cursor + full-text search is mutually exclusive (vault#313 reviewer).
514
+ // FTS owns its own ordering (relevance, not updated_at), so a cursor
515
+ // would skip rows. MCP rejects this combo at `core/src/mcp.ts`; REST
516
+ // would otherwise route into the `if (search)` branch below and
517
+ // silently drop the cursor. Reject here for surface parity.
518
+ if (search && parseQuery(url, "cursor")) {
519
+ return json(
520
+ {
521
+ error: "cursor is incompatible with full-text search — FTS has its own ordering. Use date_filter on updated_at for since-last-checked search.",
522
+ code: "INVALID_QUERY",
523
+ },
524
+ 400,
525
+ );
526
+ }
527
+
512
528
  // Full-text search
513
529
  if (search) {
514
530
  const searchTags = parseQueryList(url, "tag");
@@ -564,49 +580,74 @@ async function handleNotesInner(
564
580
  const tags = parseQueryList(url, "tag");
565
581
  const bracket = parseMetaBrackets(url);
566
582
  if (bracket.error) return bracket.error;
583
+ // Opaque cursor for "since last checked" agent loops (vault#313).
584
+ // When present, switches the response shape to {notes, next_cursor}
585
+ // and routes through queryNotesPaged for keyset pagination. Mutually
586
+ // exclusive with the `near` graph-neighborhood scope (rebuilding the
587
+ // neighborhood per page isn't stable) — rejected below.
588
+ const cursorParam = parseQuery(url, "cursor");
589
+ const nearNoteIdEarly = parseQuery(url, "near[note_id]");
590
+ if (cursorParam && nearNoteIdEarly) {
591
+ return json(
592
+ {
593
+ error: "cursor is incompatible with near (graph neighborhood). Resolve the neighborhood first, then iterate with cursor over the resulting note set.",
594
+ code: "INVALID_QUERY",
595
+ },
596
+ 400,
597
+ );
598
+ }
567
599
  let results: Note[];
600
+ let nextCursor: string | null = null;
601
+ const queryOpts = {
602
+ tags,
603
+ tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
604
+ excludeTags: parseQueryList(url, "exclude_tag"),
605
+ hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
606
+ hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
607
+ path: parseQuery(url, "path") ?? undefined,
608
+ pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
609
+ // Extension filter (vault#328). Accepts repeated `extension=`
610
+ // params for the array form: `?extension=csv&extension=yaml`.
611
+ // `parseQueryList` already returns undefined when no params
612
+ // are present, so the filter is silently skipped on a plain
613
+ // GET without the extension query.
614
+ extension: parseExtensionFilter(url),
615
+ metadata: bracket.metadata,
616
+ // Date-range precedence chain (highest to lowest):
617
+ // 1. Bracket-style `meta[created_at][gte]=…` (canonical).
618
+ // 2. Flat `date_field=…&date_from=…&date_to=…` (deprecated).
619
+ // 3. Legacy `date_from=…&date_to=…` (no date_field, deprecated)
620
+ // — filters on `n.created_at` by definition.
621
+ // The engine rejects combinations of `dateFilter` with the legacy
622
+ // `dateFrom`/`dateTo`, so we never set both shapes simultaneously.
623
+ ...(bracket.dateFilter
624
+ ? { dateFilter: bracket.dateFilter }
625
+ : parseQuery(url, "date_field")
626
+ ? {
627
+ dateFilter: {
628
+ field: parseQuery(url, "date_field")!,
629
+ from: parseQuery(url, "date_from") ?? undefined,
630
+ to: parseQuery(url, "date_to") ?? undefined,
631
+ },
632
+ }
633
+ : {
634
+ dateFrom: parseQuery(url, "date_from") ?? undefined,
635
+ dateTo: parseQuery(url, "date_to") ?? undefined,
636
+ }),
637
+ sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
638
+ orderBy: parseQuery(url, "order_by") ?? undefined,
639
+ limit: parseInt10(parseQuery(url, "limit")) ?? 50,
640
+ offset: parseInt10(parseQuery(url, "offset")),
641
+ cursor: cursorParam ?? undefined,
642
+ };
568
643
  try {
569
- results = await store.queryNotes({
570
- tags,
571
- tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
572
- excludeTags: parseQueryList(url, "exclude_tag"),
573
- hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
574
- hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
575
- path: parseQuery(url, "path") ?? undefined,
576
- pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
577
- // Extension filter (vault#328). Accepts repeated `extension=`
578
- // params for the array form: `?extension=csv&extension=yaml`.
579
- // `parseQueryList` already returns undefined when no params
580
- // are present, so the filter is silently skipped on a plain
581
- // GET without the extension query.
582
- extension: parseExtensionFilter(url),
583
- metadata: bracket.metadata,
584
- // Date-range precedence chain (highest to lowest):
585
- // 1. Bracket-style `meta[created_at][gte]=…` (canonical).
586
- // 2. Flat `date_field=…&date_from=…&date_to=…` (deprecated).
587
- // 3. Legacy `date_from=…&date_to=…` (no date_field, deprecated)
588
- // — filters on `n.created_at` by definition.
589
- // The engine rejects combinations of `dateFilter` with the legacy
590
- // `dateFrom`/`dateTo`, so we never set both shapes simultaneously.
591
- ...(bracket.dateFilter
592
- ? { dateFilter: bracket.dateFilter }
593
- : parseQuery(url, "date_field")
594
- ? {
595
- dateFilter: {
596
- field: parseQuery(url, "date_field")!,
597
- from: parseQuery(url, "date_from") ?? undefined,
598
- to: parseQuery(url, "date_to") ?? undefined,
599
- },
600
- }
601
- : {
602
- dateFrom: parseQuery(url, "date_from") ?? undefined,
603
- dateTo: parseQuery(url, "date_to") ?? undefined,
604
- }),
605
- sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
606
- orderBy: parseQuery(url, "order_by") ?? undefined,
607
- limit: parseInt10(parseQuery(url, "limit")) ?? 50,
608
- offset: parseInt10(parseQuery(url, "offset")),
609
- });
644
+ if (cursorParam) {
645
+ const page = await store.queryNotesPaged(queryOpts);
646
+ results = page.notes;
647
+ nextCursor = page.next_cursor;
648
+ } else {
649
+ results = await store.queryNotes(queryOpts);
650
+ }
610
651
  } catch (e: any) {
611
652
  // QueryError (non-indexed order_by, unknown operator, ...) surfaces
612
653
  // here. Duck-type on `name` + `code` — core is a separate module, so
@@ -614,6 +655,14 @@ async function handleNotesInner(
614
655
  if (e && e.name === "QueryError") {
615
656
  return json({ error: e.message, code: e.code ?? "INVALID_QUERY" }, 400);
616
657
  }
658
+ // CursorError carries a structured code (cursor_invalid /
659
+ // cursor_query_mismatch) so the agent loop can distinguish a
660
+ // malformed cursor from a hash-mismatch and react appropriately
661
+ // (the latter typically means the agent changed its filter and
662
+ // should drop the cursor + restart from scratch).
663
+ if (e && e.name === "CursorError") {
664
+ return json({ error: e.message, code: e.code ?? "cursor_invalid" }, 400);
665
+ }
617
666
  throw e;
618
667
  }
619
668
 
@@ -682,9 +731,14 @@ async function handleNotesInner(
682
731
  if (includeAttachments) enriched.attachments = await store.getAttachments(n.id);
683
732
  enrichedOut.push(enriched);
684
733
  }
734
+ // Cursor mode wraps the list in {notes, next_cursor} so an agent
735
+ // loop can chain calls without tracking a watermark client-side.
736
+ // Legacy callers (no `cursor` param) still get the flat array.
737
+ if (cursorParam) return json({ notes: enrichedOut, next_cursor: nextCursor });
685
738
  return json(enrichedOut);
686
739
  }
687
740
 
741
+ if (cursorParam) return json({ notes: output, next_cursor: nextCursor });
688
742
  return json(output);
689
743
  }
690
744
 
@@ -813,19 +867,33 @@ async function handleNotesInner(
813
867
  const body = await req.json() as { path: string; mimeType: string; transcribe?: boolean };
814
868
  if (!body.path || !body.mimeType) return json({ error: "path and mimeType are required" }, 400);
815
869
 
816
- // `transcribe: true` asks the transcription worker to read this audio
817
- // file and replace the note's content with the transcript. The caller
818
- // is declaring "overwrite my current content when the transcript lands"
819
- // we persist that as `transcribe_stub: true` on the note so a later
820
- // user edit (which clears the marker) can opt out before the worker
821
- // runs.
822
- const attMeta = body.transcribe
823
- ? { transcribe_status: "pending" as const, transcribe_requested_at: new Date().toISOString() }
870
+ // Decide whether to enqueue this attachment for transcription. Two paths:
871
+ //
872
+ // - **Explicit caller opt-in (legacy path, Lens flow):** `transcribe: true`
873
+ // on the POST. The note already has a `_Transcript pending._` stub the
874
+ // worker replaces on success `transcribe_origin: "legacy"` preserves
875
+ // the stub-patching behavior.
876
+ // - **Auto-transcribe (vault#353):** mime-type is `audio/*` AND the
877
+ // operator has flipped `auto_transcribe.enabled = true` AND scribe is
878
+ // reachable. The caller didn't opt in explicitly; we infer from the
879
+ // audio mime-type. `transcribe_origin: "auto"` tells the worker to
880
+ // materialize a `<attachment-path>.transcript.md` note on completion.
881
+ //
882
+ // Explicit `transcribe: true` wins — if the caller asked, we honor that
883
+ // regardless of the auto-transcribe toggle (back-compat).
884
+ const explicitOptIn = body.transcribe === true;
885
+ const autoOptIn = !explicitOptIn && shouldAutoTranscribe(body.mimeType);
886
+ const attMeta = (explicitOptIn || autoOptIn)
887
+ ? {
888
+ transcribe_status: "pending" as const,
889
+ transcribe_requested_at: new Date().toISOString(),
890
+ transcribe_origin: (explicitOptIn ? "legacy" : "auto") as "legacy" | "auto",
891
+ }
824
892
  : undefined;
825
893
 
826
894
  const attachment = await store.addAttachment(note.id, body.path, body.mimeType, attMeta);
827
895
 
828
- if (body.transcribe) {
896
+ if (explicitOptIn) {
829
897
  const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
830
898
  if (noteMeta.transcribe_stub !== true) {
831
899
  await store.updateNote(note.id, {
@@ -874,6 +942,33 @@ async function handleNotesInner(
874
942
  return json({ error: "Method not allowed" }, 405);
875
943
  }
876
944
 
945
+ // POST /notes/:idOrPath/retry-transcription — vault#353 design Q5.
946
+ //
947
+ // Re-runs the auto-transcribe pipeline against the original audio
948
+ // attachment recorded in the transcript note's `transcript_attachment_id`
949
+ // frontmatter. Only valid on transcript notes (the target idOrPath must
950
+ // be a transcript note with `transcript_status: "failed"`); calling on
951
+ // anything else returns 400 with a clear reason.
952
+ //
953
+ // Wire shape:
954
+ // POST .../notes/<idOrPath>/retry-transcription
955
+ // → 202 { attachment_id, transcript_path } when re-enqueued
956
+ // 400 invalid_target (not a transcript note)
957
+ // 400 not_failed (transcript already succeeded; nothing to retry)
958
+ // 404 attachment_missing (transcript_attachment_id row deleted)
959
+ // 404 audio_missing (audio file unlinked from disk)
960
+ // 503 scribe_unavailable (no worker configured this boot)
961
+ if (sub === "/retry-transcription") {
962
+ if (method !== "POST") return json({ error: "Method not allowed" }, 405);
963
+ if (!vault) return json({ error: "Vault context required" }, 400);
964
+ const note = await resolveNote(store, idOrPath);
965
+ if (!note) return json({ error: "Not found" }, 404);
966
+ if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
967
+ return json({ error: "Not found" }, 404);
968
+ }
969
+ return handleRetryTranscription(store, note, vault);
970
+ }
971
+
877
972
  if (sub !== "") return json({ error: "Not found" }, 404);
878
973
 
879
974
  // GET /notes/:idOrPath — single note
@@ -1213,7 +1308,7 @@ async function handleNotesInner(
1213
1308
  }
1214
1309
  }
1215
1310
 
1216
- // DELETE /notes/:idOrPath — admin only (enforced at server level)
1311
+ // DELETE /notes/:idOrPath — vault:write (no admin gate; consistent with verbForMethod)
1217
1312
  if (method === "DELETE") {
1218
1313
  const note = await resolveNote(store, idOrPath);
1219
1314
  if (!note) return json({ error: "Not found" }, 404);
@@ -1823,6 +1918,128 @@ ${rendered}
1823
1918
  });
1824
1919
  }
1825
1920
 
1921
+ // ---------------------------------------------------------------------------
1922
+ // Retry transcription (vault#353 design Q5)
1923
+ // ---------------------------------------------------------------------------
1924
+
1925
+ /**
1926
+ * Re-enqueue the original audio attachment for a `transcript_status: failed`
1927
+ * transcript note. Steps:
1928
+ *
1929
+ * 1. Validate target is a transcript note (`transcript_status` set in
1930
+ * metadata) AND that status is `failed`.
1931
+ * 2. Find the original audio attachment by id from
1932
+ * `transcript_attachment_id` frontmatter. 404 if the row's gone.
1933
+ * 3. Validate the audio file still exists on disk (retention=keep is
1934
+ * assumed by the retry contract; retention=until_transcribed unlinks
1935
+ * only on success, retention=never unlinks on failure — that last one
1936
+ * explicitly breaks retry, by design).
1937
+ * 4. Reset `transcribe_status = "pending"`, clear backoff + error fields.
1938
+ * The auto-origin marker is preserved so the worker writes a transcript
1939
+ * note (overwriting this one in place).
1940
+ * 5. Kick the worker if registered; otherwise the sweep picks it up.
1941
+ */
1942
+ async function handleRetryTranscription(
1943
+ store: Store,
1944
+ note: Note,
1945
+ vault: string,
1946
+ ): Promise<Response> {
1947
+ const meta = (note.metadata as Record<string, unknown> | undefined) ?? {};
1948
+ if (typeof meta.transcript_status !== "string") {
1949
+ return json(
1950
+ {
1951
+ error: "invalid_target",
1952
+ message: "Target note is not a transcript note (no transcript_status frontmatter).",
1953
+ },
1954
+ 400,
1955
+ );
1956
+ }
1957
+ if (meta.transcript_status !== "failed") {
1958
+ return json(
1959
+ {
1960
+ error: "not_failed",
1961
+ message: `Transcript note status is "${meta.transcript_status}" — only failed transcripts can be retried.`,
1962
+ transcript_status: meta.transcript_status,
1963
+ },
1964
+ 400,
1965
+ );
1966
+ }
1967
+ const attachmentId = typeof meta.transcript_attachment_id === "string"
1968
+ ? meta.transcript_attachment_id
1969
+ : undefined;
1970
+ if (!attachmentId) {
1971
+ return json(
1972
+ {
1973
+ error: "missing_attachment_id",
1974
+ message: "Transcript note has no `transcript_attachment_id` — can't locate the original audio.",
1975
+ },
1976
+ 400,
1977
+ );
1978
+ }
1979
+ const attachment = await store.getAttachment(attachmentId);
1980
+ if (!attachment) {
1981
+ return json(
1982
+ {
1983
+ error: "attachment_missing",
1984
+ message: `Original audio attachment ${attachmentId} no longer exists in the vault.`,
1985
+ },
1986
+ 404,
1987
+ );
1988
+ }
1989
+ // Audio file existence + safety: defense-in-depth against a bad attachment
1990
+ // row pointing outside the vault assets dir. Same guard as the worker.
1991
+ const assetsRoot = assetsDir(vault);
1992
+ const audioFilePath = normalize(join(assetsRoot, attachment.path));
1993
+ if (!audioFilePath.startsWith(normalize(assetsRoot)) || !existsSync(audioFilePath)) {
1994
+ return json(
1995
+ {
1996
+ error: "audio_missing",
1997
+ message: `Original audio file at "${attachment.path}" no longer exists on disk.`,
1998
+ },
1999
+ 404,
2000
+ );
2001
+ }
2002
+
2003
+ // Reset transcribe_status. Worker reads this row, sees "pending", processes
2004
+ // it. Preserve `transcribe_origin: "auto"` so the worker materializes the
2005
+ // transcript note (overwriting this failed note in place).
2006
+ const attMeta = { ...(attachment.metadata ?? {}) } as Record<string, unknown>;
2007
+ attMeta.transcribe_status = "pending";
2008
+ attMeta.transcribe_requested_at = new Date().toISOString();
2009
+ attMeta.transcribe_origin = "auto";
2010
+ delete attMeta.transcribe_backoff_until;
2011
+ delete attMeta.transcribe_error;
2012
+ delete attMeta.transcribe_error_code;
2013
+ delete attMeta.transcribe_attempts;
2014
+ await store.setAttachmentMetadata(attachment.id, attMeta);
2015
+
2016
+ // Kick the worker for an event-driven re-run (no 30s sweep wait). The
2017
+ // worker re-reads the row + processes immediately. If the worker isn't
2018
+ // registered (scribe not configured this boot), we still reset the row;
2019
+ // the next boot's sweep will pick it up. The 503 path is for callers that
2020
+ // want certainty — but for v0.6 the sweep guarantee is enough.
2021
+ const { getTranscriptionWorker } = await import("./transcription-registry.ts");
2022
+ const worker = getTranscriptionWorker();
2023
+ if (worker) {
2024
+ // Refresh the attachment after the metadata write so the worker's
2025
+ // in-process dedupe check sees pending.
2026
+ const fresh = await store.getAttachment(attachment.id) ?? attachment;
2027
+ // Fire-and-forget — the response shouldn't wait on transcription.
2028
+ void worker.kick(vault, fresh);
2029
+ }
2030
+
2031
+ return json(
2032
+ {
2033
+ status: "queued",
2034
+ attachment_id: attachment.id,
2035
+ attachment_path: attachment.path,
2036
+ transcript_note_id: note.id,
2037
+ worker: worker ? "kicked" : "sweep-only",
2038
+ },
2039
+ 202,
2040
+ );
2041
+ }
2042
+
1826
2043
  // ---------------------------------------------------------------------------
1827
2044
  // Storage (file upload/serve) — kept as-is, Daily needs it
1828
2045
  // ---------------------------------------------------------------------------
@@ -911,7 +911,6 @@ describe("/.parachute/info + /.parachute/icon.svg", () => {
911
911
  tagline: string;
912
912
  version: string;
913
913
  iconUrl: string;
914
- kind: string;
915
914
  };
916
915
  expect(body).toEqual({
917
916
  name: "parachute-vault",
@@ -919,8 +918,11 @@ describe("/.parachute/info + /.parachute/icon.svg", () => {
919
918
  tagline: expect.stringContaining("knowledge graph"),
920
919
  version: pkg.version,
921
920
  iconUrl: "/vault/journal/.parachute/icon.svg",
922
- kind: "api",
923
921
  });
922
+ // `kind` retired from the info-endpoint response per hub#330 (companion
923
+ // to vault#359's module.json drop). Pin its absence so regressions are
924
+ // surfaced — the shape is a locked contract with the hub.
925
+ expect(body).not.toHaveProperty("kind");
924
926
  });
925
927
 
926
928
  test("info iconUrl is vault-scoped and points at a live icon handler", async () => {
package/src/routing.ts CHANGED
@@ -132,11 +132,11 @@ function handleParachuteInfo(vaultName: string): Response {
132
132
  tagline: "Agent-native knowledge graph — notes, tags, links, attachments over REST + MCP",
133
133
  version: pkg.version,
134
134
  iconUrl: `/vault/${vaultName}/.parachute/icon.svg`,
135
- // Hub renders `kind: "api"` cards as an expandable detail panel (MCP URL,
136
- // OAuth link, version) rather than navigating to the API's root. Vault
137
- // has no browser UI, so navigating to it shows raw JSON — not useful.
138
- kind: "api",
139
135
  };
136
+ // `kind` was previously emitted here (and matched module.json) to let the
137
+ // hub branch its card rendering on api vs ui. Retired per hub#330 — the hub
138
+ // now infers presentation from the response shape itself. Companion to
139
+ // vault#359 (manifest drop); closes part of hub#340.
140
140
  return Response.json(body, {
141
141
  headers: { "Access-Control-Allow-Origin": "*" },
142
142
  });