@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.
- package/.parachute/module.json +0 -1
- package/README.md +44 -10
- package/core/src/connection-pragmas.test.ts +232 -0
- package/core/src/core.test.ts +257 -0
- package/core/src/cursor.test.ts +160 -0
- package/core/src/cursor.ts +272 -0
- package/core/src/mcp.ts +51 -7
- package/core/src/notes.ts +164 -2
- package/core/src/schema.ts +98 -2
- package/core/src/store.ts +11 -1
- package/core/src/types.ts +32 -0
- package/package.json +1 -1
- package/src/auth-status.ts +4 -0
- package/src/auto-transcribe.test.ts +116 -0
- package/src/auto-transcribe.ts +48 -0
- package/src/cli.ts +57 -48
- package/src/config.test.ts +26 -0
- package/src/config.ts +53 -1
- package/src/db.ts +15 -2
- package/src/mcp-install-interactive.test.ts +23 -2
- package/src/mcp-install-interactive.ts +21 -2
- package/src/mcp-install.test.ts +40 -0
- package/src/mcp-tools.ts +17 -1
- package/src/module-config.ts +70 -14
- package/src/module-manifest.test.ts +114 -0
- package/src/module-manifest.ts +104 -0
- package/src/routes.ts +268 -51
- package/src/routing.test.ts +4 -2
- package/src/routing.ts +4 -4
- package/src/scribe-discovery.test.ts +77 -0
- package/src/scribe-discovery.ts +91 -0
- package/src/scribe-env.test.ts +66 -1
- package/src/scribe-env.ts +42 -1
- package/src/self-register.test.ts +379 -0
- package/src/self-register.ts +234 -0
- package/src/server.ts +46 -11
- package/src/transcript-note.test.ts +171 -0
- package/src/transcript-note.ts +189 -0
- package/src/transcription-registry.ts +22 -0
- package/src/transcription-worker.test.ts +250 -0
- package/src/transcription-worker.ts +186 -27
- 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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
//
|
|
822
|
-
|
|
823
|
-
|
|
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 (
|
|
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
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/routing.test.ts
CHANGED
|
@@ -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
|
});
|