@openparachute/vault 0.7.3-rc.9 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/attachment/bytes-provider.ts +65 -0
- package/core/src/content-range-constants.ts +19 -0
- package/core/src/content-range.test.ts +127 -0
- package/core/src/content-range.ts +105 -8
- package/core/src/core.test.ts +66 -4
- package/core/src/expand.ts +11 -3
- package/core/src/lede.test.ts +96 -0
- package/core/src/mcp-manifest.test.ts +200 -0
- package/core/src/mcp-manifest.ts +736 -0
- package/core/src/mcp.ts +357 -607
- package/core/src/notes.ts +69 -10
- package/core/src/vault-projection.ts +17 -10
- package/package.json +1 -1
- package/src/attachment-bytes.ts +68 -0
- package/src/attachment-tickets.test.ts +126 -1
- package/src/attachment-tickets.ts +77 -1
- package/src/auth-hub-jwt.test.ts +118 -1
- package/src/auth.ts +64 -0
- package/src/config.test.ts +16 -0
- package/src/config.ts +17 -0
- package/src/embedding/select.test.ts +58 -30
- package/src/embedding/select.ts +62 -21
- package/src/live-frame-parity.test.ts +21 -0
- package/src/mcp-http.ts +20 -3
- package/src/mcp-tools.ts +15 -3
- package/src/oauth-discovery.ts +31 -0
- package/src/read-attachment.test.ts +436 -0
- package/src/routes.ts +80 -4
- package/src/routing.test.ts +229 -4
- package/src/routing.ts +135 -23
- package/src/scopes.ts +22 -0
- package/src/server.ts +17 -8
- package/src/storage.test.ts +200 -1
- package/src/subscriptions.ts +13 -1
- package/src/transcription-worker.test.ts +151 -0
- package/src/transcription-worker.ts +113 -52
- package/src/vault-embeddings-capability.test.ts +28 -6
- package/src/vault-store-embedding-wiring.test.ts +25 -16
- package/src/vault-store.ts +32 -16
- package/src/vault.test.ts +26 -13
- package/src/ws-server.ts +9 -1
- package/src/ws-subscribe.test.ts +87 -0
- package/src/ws-subscribe.ts +25 -6
|
@@ -64,41 +64,57 @@ import {
|
|
|
64
64
|
} from "../core/src/transcription/provider.ts";
|
|
65
65
|
import { ScribeHttpProvider } from "./transcription/providers/scribe-http.ts";
|
|
66
66
|
|
|
67
|
-
/** Placeholder pattern written by the voice-memo capture stub. */
|
|
68
|
-
const TRANSCRIPT_PLACEHOLDER = /_Transcript pending\._/;
|
|
69
|
-
|
|
70
67
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
68
|
+
* The in-body transcription markers.
|
|
69
|
+
*
|
|
70
|
+
* The BARE markers are the un-segmented default; voice W2 (segmented
|
|
71
|
+
* recordings) targets per-part variants built by `markersFor`. Both are a
|
|
72
|
+
* BYTE-EXACT cross-door + cross-repo contract — the cloud Workers-AI
|
|
73
|
+
* transcription path ships the identical strings, and the notes-ui status
|
|
74
|
+
* chip (parachute-surface TranscriptionStatus.tsx) keys off the failure
|
|
75
|
+
* marker's exact copy. Don't change any of this text without a coordinated
|
|
76
|
+
* change in both places. A friendlier "retry available" copy + chip
|
|
77
|
+
* affordance is a tracked parachute-surface follow-up.
|
|
76
78
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* tracked parachute-surface follow-up.
|
|
79
|
+
* Owning the failure marker here (it used to be written by Lens's now-removed
|
|
80
|
+
* scribe client) means a failed upload stops reading "Transcript pending"
|
|
81
|
+
* forever regardless of which client uploaded the audio.
|
|
81
82
|
*/
|
|
82
|
-
const
|
|
83
|
+
const BARE_PENDING = "_Transcript pending._";
|
|
84
|
+
const BARE_UNAVAILABLE = "_Transcription unavailable._";
|
|
85
|
+
|
|
86
|
+
/** Escape a literal string for safe embedding in a `RegExp`. */
|
|
87
|
+
function escapeRegExp(literal: string): string {
|
|
88
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
89
|
+
}
|
|
83
90
|
|
|
84
91
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* the `_Recorded …_` line, the header).
|
|
92
|
-
*
|
|
93
|
-
* Deliberately NO `/g` flag — `.replace` swaps only the FIRST match. A
|
|
94
|
-
* canonical capture body holds exactly one marker, so first-match is the
|
|
95
|
-
* correct target. `applyFailureMarker`'s includes-guard (no-op when the
|
|
96
|
-
* marker is already present) prevents markers accumulating across repeated
|
|
97
|
-
* terminal failures, so the body never carries two of the same marker. A
|
|
98
|
-
* hand-edited body that somehow contains both markers patches only the
|
|
99
|
-
* first — accepted (degenerate, operator-induced).
|
|
92
|
+
* The pending + terminal-failure markers for an attachment, honoring
|
|
93
|
+
* `segment_index` (voice W2 — segmented recordings). `undefined` yields the
|
|
94
|
+
* bare markers (fully backward compatible — un-segmented flows are byte-
|
|
95
|
+
* unchanged). An integer ≥ 0 yields this part's markers, with the human-
|
|
96
|
+
* facing part number N = segment_index + 1 (1-based, decimal):
|
|
97
|
+
* `_Transcript pending (part N)._` / `_Transcription unavailable (part N)._`
|
|
100
98
|
*/
|
|
101
|
-
|
|
99
|
+
function markersFor(segmentIndex: number | undefined): { pending: string; unavailable: string } {
|
|
100
|
+
if (segmentIndex === undefined) return { pending: BARE_PENDING, unavailable: BARE_UNAVAILABLE };
|
|
101
|
+
const n = segmentIndex + 1;
|
|
102
|
+
return {
|
|
103
|
+
pending: `_Transcript pending (part ${n})._`,
|
|
104
|
+
unavailable: `_Transcription unavailable (part ${n})._`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A valid segment index (integer ≥ 0) off attachment metadata, else
|
|
110
|
+
* `undefined` — the un-segmented path. Client-set at link time; anything that
|
|
111
|
+
* isn't a non-negative integer falls back to the bare markers rather than
|
|
112
|
+
* fabricating a `(part N)`.
|
|
113
|
+
*/
|
|
114
|
+
function segmentIndexOf(meta: { segment_index?: unknown }): number | undefined {
|
|
115
|
+
const raw = meta.segment_index;
|
|
116
|
+
return typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : undefined;
|
|
117
|
+
}
|
|
102
118
|
|
|
103
119
|
/**
|
|
104
120
|
* Default sweep cadence (ms). The sweep is the safety net for backoff-
|
|
@@ -195,6 +211,14 @@ interface PendingMeta {
|
|
|
195
211
|
* worker preserves the original stub-patching behavior (Lens flow).
|
|
196
212
|
*/
|
|
197
213
|
transcribe_origin?: "auto" | "legacy";
|
|
214
|
+
/**
|
|
215
|
+
* Voice W2 (segmented recordings): a client-set 0-based index marking this
|
|
216
|
+
* attachment as one segment of a longer recording sliced into ~10-min parts,
|
|
217
|
+
* all linked on ONE note. When present, the legacy in-body path targets this
|
|
218
|
+
* part's markers (`… (part N)._`, N = segment_index + 1) rather than the bare
|
|
219
|
+
* ones — making per-part ordering structurally guaranteed. See `markersFor`.
|
|
220
|
+
*/
|
|
221
|
+
segment_index?: number;
|
|
198
222
|
[k: string]: unknown;
|
|
199
223
|
}
|
|
200
224
|
|
|
@@ -357,17 +381,28 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
357
381
|
* attachment failure we're trying to record.
|
|
358
382
|
*
|
|
359
383
|
* Body policy (finding F — never destroy content):
|
|
360
|
-
* -
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
* -
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
384
|
+
* - Pending marker PRESENT → surgical replace of the pending marker with
|
|
385
|
+
* the failure marker. The `![[memo]]` embed + any surrounding text
|
|
386
|
+
* survive. For a segmented attachment (`segment_index` set) this is the
|
|
387
|
+
* per-part `_Transcript pending (part N)._`; otherwise the bare marker.
|
|
388
|
+
* - Failure marker ALREADY PRESENT → no-op (idempotent; a double-terminal-
|
|
389
|
+
* failure must not stack markers).
|
|
390
|
+
* - Otherwise (pending marker absent — the user edited the note while it
|
|
391
|
+
* was pending) → APPEND `\n\n` + failure marker to the existing content.
|
|
392
|
+
* The old code full-replaced the body here, destroying the embed AND the
|
|
393
|
+
* user's edits. We append instead so nothing is lost. If the content is
|
|
394
|
+
* empty, the marker alone becomes the body (avoids a leading blank line).
|
|
369
395
|
*/
|
|
370
|
-
async function applyFailureMarker(
|
|
396
|
+
async function applyFailureMarker(
|
|
397
|
+
store: Store,
|
|
398
|
+
noteId: string,
|
|
399
|
+
segmentIndex: number | undefined,
|
|
400
|
+
): Promise<void> {
|
|
401
|
+
// Bare markers by default; this segment's `(part N)` markers when the
|
|
402
|
+
// attachment carries a `segment_index` (voice W2). String-search replace
|
|
403
|
+
// targets the FIRST occurrence (a canonical body holds exactly one), and
|
|
404
|
+
// the includes-guard below keeps a repeated terminal failure from stacking.
|
|
405
|
+
const { pending, unavailable } = markersFor(segmentIndex);
|
|
371
406
|
// OC-guarded (vault#435): the read-transform-write below is re-run against
|
|
372
407
|
// fresh content on a conflict so a concurrent user edit isn't clobbered.
|
|
373
408
|
// The transform is pure w.r.t. the note it's handed; the stub-set and
|
|
@@ -381,17 +416,24 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
381
416
|
if (noteMeta.transcribe_stub !== true) return null;
|
|
382
417
|
|
|
383
418
|
let body: string;
|
|
384
|
-
if (
|
|
385
|
-
|
|
386
|
-
|
|
419
|
+
if (note.content.includes(pending)) {
|
|
420
|
+
// Function replacer so the search string is treated literally and the
|
|
421
|
+
// (fixed) failure marker is inserted verbatim.
|
|
422
|
+
body = note.content.replace(pending, () => unavailable);
|
|
423
|
+
} else if (note.content.includes(unavailable)) {
|
|
387
424
|
// Marker already present — nothing to do. Clear the stub and
|
|
388
425
|
// return without rewriting the body so we don't stack markers.
|
|
389
426
|
body = note.content;
|
|
390
427
|
} else {
|
|
391
428
|
body = note.content.length > 0
|
|
392
|
-
? `${note.content}\n\n${
|
|
393
|
-
:
|
|
429
|
+
? `${note.content}\n\n${unavailable}`
|
|
430
|
+
: unavailable;
|
|
394
431
|
}
|
|
432
|
+
// Segmented: the stub is SHARED across this note's parts — keep it set
|
|
433
|
+
// so sibling parts still resolve their own slots. Return content only
|
|
434
|
+
// (leave note metadata untouched). Un-segmented: clear the one-shot
|
|
435
|
+
// stub as before (byte-unchanged).
|
|
436
|
+
if (segmentIndex !== undefined) return { content: body };
|
|
395
437
|
const { transcribe_stub: _drop, ...restMeta } = noteMeta;
|
|
396
438
|
return { content: body, metadata: restMeta };
|
|
397
439
|
},
|
|
@@ -449,6 +491,12 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
449
491
|
// vs. the legacy stub-patching path (Lens flow). Auto-write notes also
|
|
450
492
|
// surface failures so the user can retry from the transcript note.
|
|
451
493
|
const isAutoOrigin = meta.transcribe_origin === "auto";
|
|
494
|
+
// Voice W2: when this attachment is one segment of a longer recording
|
|
495
|
+
// (client-set `segment_index`), the legacy in-body path targets this
|
|
496
|
+
// part's markers instead of the bare ones. Undefined for un-segmented
|
|
497
|
+
// attachments — byte-unchanged behavior. Only the legacy path consults it;
|
|
498
|
+
// the auto/transcript-note path is untouched (segments are a memo concern).
|
|
499
|
+
const segmentIndex = segmentIndexOf(meta);
|
|
452
500
|
|
|
453
501
|
// Honor backoff — we re-check here in case another tick queued this
|
|
454
502
|
// attachment between the listing and now.
|
|
@@ -469,7 +517,7 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
469
517
|
if (isAutoOrigin) {
|
|
470
518
|
await writeFailureTranscriptNote(store, attachment, "audio file not found", undefined, undefined);
|
|
471
519
|
} else {
|
|
472
|
-
await applyFailureMarker(store, attachment.noteId);
|
|
520
|
+
await applyFailureMarker(store, attachment.noteId, segmentIndex);
|
|
473
521
|
}
|
|
474
522
|
return;
|
|
475
523
|
}
|
|
@@ -527,7 +575,7 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
527
575
|
if (isAutoOrigin) {
|
|
528
576
|
await writeFailureTranscriptNote(store, attachment, errMsg, apiErr?.code, undefined);
|
|
529
577
|
} else {
|
|
530
|
-
await applyFailureMarker(store, attachment.noteId);
|
|
578
|
+
await applyFailureMarker(store, attachment.noteId, segmentIndex);
|
|
531
579
|
}
|
|
532
580
|
// retention=never drops the audio on any terminal state, including
|
|
533
581
|
// failure. The user opted in to "I don't want the audio kept around
|
|
@@ -578,6 +626,16 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
578
626
|
// before the transcript arrives opts out of the overwrite. OC-guarded
|
|
579
627
|
// (vault#435): re-applied against fresh content on a conflict so a
|
|
580
628
|
// concurrent user edit isn't clobbered.
|
|
629
|
+
//
|
|
630
|
+
// Success replaces whichever of THIS part's markers is present (bare, or
|
|
631
|
+
// `(part N)` when segmented). Built with no `/g` flag so `.replace`
|
|
632
|
+
// swaps only the FIRST match — a canonical capture body holds exactly
|
|
633
|
+
// one marker per part; alternation preserves positional-first semantics
|
|
634
|
+
// (a retried success replaces the failure marker where a first-try
|
|
635
|
+
// success replaced the pending one) so byte-for-byte matching today's
|
|
636
|
+
// un-segmented behavior.
|
|
637
|
+
const { pending, unavailable } = markersFor(segmentIndex);
|
|
638
|
+
const successTarget = new RegExp(`${escapeRegExp(pending)}|${escapeRegExp(unavailable)}`);
|
|
581
639
|
await applyNoteTransformWithOC(
|
|
582
640
|
store,
|
|
583
641
|
attachment.noteId,
|
|
@@ -586,28 +644,31 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
586
644
|
const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
|
|
587
645
|
if (noteMeta.transcribe_stub !== true) return null;
|
|
588
646
|
// Body policy (finding F — never destroy content):
|
|
589
|
-
// -
|
|
590
|
-
//
|
|
591
|
-
// unavailable._` marker, landing exactly where a first-try
|
|
592
|
-
// success would). The embed + surrounding capture body survive.
|
|
647
|
+
// - pending OR failure marker present → surgical replace in place.
|
|
648
|
+
// The embed + surrounding capture body survive.
|
|
593
649
|
// - neither present (user edited the note while pending) → APPEND
|
|
594
650
|
// the transcript instead of full-replacing the body, so the
|
|
595
651
|
// user's edits + the `![[memo]]` embed are preserved. The old
|
|
596
652
|
// code full-replaced here, which destroyed both.
|
|
597
653
|
let body: string;
|
|
598
|
-
if (
|
|
654
|
+
if (successTarget.test(note.content)) {
|
|
599
655
|
// Function replacer, NOT a string — speech-to-text is arbitrary
|
|
600
656
|
// user content, and String.replace treats `$&`, `$\``, `$'`,
|
|
601
657
|
// `$1`-`$9` as special patterns in a string replacement. A
|
|
602
658
|
// transcript containing `$&` would otherwise inject the matched
|
|
603
659
|
// marker text into the body. `() => transcript` returns the text
|
|
604
660
|
// verbatim.
|
|
605
|
-
body = note.content.replace(
|
|
661
|
+
body = note.content.replace(successTarget, () => transcript);
|
|
606
662
|
} else {
|
|
607
663
|
body = note.content.length > 0
|
|
608
664
|
? `${note.content}\n\n${transcript}`
|
|
609
665
|
: transcript;
|
|
610
666
|
}
|
|
667
|
+
// Segmented: the stub is SHARED across this note's parts — keep it
|
|
668
|
+
// set so sibling parts still resolve their own slots. Return content
|
|
669
|
+
// only (leave note metadata untouched). Un-segmented: clear the
|
|
670
|
+
// one-shot stub as before (byte-unchanged).
|
|
671
|
+
if (segmentIndex !== undefined) return { content: body };
|
|
611
672
|
const { transcribe_stub: _drop, ...restMeta } = noteMeta;
|
|
612
673
|
return { content: body, metadata: restMeta };
|
|
613
674
|
},
|
|
@@ -6,20 +6,28 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
8
8
|
import { Database } from "bun:sqlite";
|
|
9
|
-
import { BunStore } from "./vault-store.ts";
|
|
9
|
+
import { BunStore, resetSharedEmbeddingProviderForTests } from "./vault-store.ts";
|
|
10
10
|
import { handleVault } from "./routes.ts";
|
|
11
11
|
import type { EmbeddingCapability } from "./embedding/capability.ts";
|
|
12
12
|
|
|
13
13
|
let db: Database;
|
|
14
14
|
let store: BunStore;
|
|
15
|
+
let prevEnabled: string | undefined;
|
|
15
16
|
|
|
16
17
|
beforeEach(() => {
|
|
17
18
|
db = new Database(":memory:");
|
|
18
19
|
store = new BunStore(db);
|
|
20
|
+
// The production-default tests below exercise the memoized shared provider;
|
|
21
|
+
// reset it + control the opt-in env so the default resolves deterministically.
|
|
22
|
+
prevEnabled = process.env.EMBEDDINGS_ENABLED;
|
|
23
|
+
resetSharedEmbeddingProviderForTests();
|
|
19
24
|
});
|
|
20
25
|
|
|
21
26
|
afterEach(() => {
|
|
22
27
|
db.close();
|
|
28
|
+
if (prevEnabled === undefined) delete process.env.EMBEDDINGS_ENABLED;
|
|
29
|
+
else process.env.EMBEDDINGS_ENABLED = prevEnabled;
|
|
30
|
+
resetSharedEmbeddingProviderForTests();
|
|
23
31
|
});
|
|
24
32
|
|
|
25
33
|
const BASE = "http://localhost/api";
|
|
@@ -66,13 +74,27 @@ describe("GET /api/vault embeddings capability", () => {
|
|
|
66
74
|
expect(body.embeddings).toEqual({ enabled: false });
|
|
67
75
|
});
|
|
68
76
|
|
|
69
|
-
test("the production
|
|
77
|
+
test("the production DEFAULT is off (opt-in, 0.7.3) — no provider, enabled:false", async () => {
|
|
78
|
+
delete process.env.EMBEDDINGS_ENABLED;
|
|
79
|
+
resetSharedEmbeddingProviderForTests();
|
|
70
80
|
const cfg = { name: "default" };
|
|
71
81
|
// No resolver override — exercises the real default wiring
|
|
72
|
-
// (getSharedEmbeddingProvider() -> resolveEmbeddingCapability()).
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
82
|
+
// (getSharedEmbeddingProvider() -> resolveEmbeddingCapability()). Semantic
|
|
83
|
+
// search is opt-in as of 0.7.3, so with nothing enabling it the shared
|
|
84
|
+
// provider is undefined and the capability advertises disabled.
|
|
85
|
+
const res = await handleVault(new Request(`${BASE}/vault`, { method: "GET" }), store, cfg as any);
|
|
86
|
+
expect(res.status).toBe(200);
|
|
87
|
+
const body = (await res.json()) as any;
|
|
88
|
+
expect(body.embeddings).toEqual({ enabled: false });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("once ENABLED (EMBEDDINGS_ENABLED=true) the default wiring resolves the bundled floor, enabled:true", async () => {
|
|
92
|
+
process.env.EMBEDDINGS_ENABLED = "true";
|
|
93
|
+
resetSharedEmbeddingProviderForTests();
|
|
94
|
+
const cfg = { name: "default" };
|
|
95
|
+
// The bundled onnx-transformers floor is optimistically available() before
|
|
96
|
+
// any real model load — see src/embedding/onnx-transformers.ts's
|
|
97
|
+
// "lazy-fail, not crash" doc.
|
|
76
98
|
const res = await handleVault(new Request(`${BASE}/vault`, { method: "GET" }), store, cfg as any);
|
|
77
99
|
expect(res.status).toBe(200);
|
|
78
100
|
const body = (await res.json()) as any;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* End-to-end wiring test for the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* `semantic_unavailable`
|
|
6
|
-
* see `core/src/store.ts`'s
|
|
7
|
-
* core can't determine this on
|
|
8
|
-
* `Store` behavior lives in
|
|
9
|
-
*
|
|
2
|
+
* End-to-end wiring test for the semantic-search opt-in gate (0.7.3) at the
|
|
3
|
+
* REAL `src/vault-store.ts` call site. Semantic search is OFF by default;
|
|
4
|
+
* when off, `getVaultStore` builds NO provider and threads the opt-in hint
|
|
5
|
+
* through to `Store.semanticSearch`'s `semantic_unavailable` error — not the
|
|
6
|
+
* generic provider-setup message — see `core/src/store.ts`'s
|
|
7
|
+
* `embeddingDisabledReason` doc comment for why core can't determine this on
|
|
8
|
+
* its own. Unit coverage for the underlying `Store` behavior lives in
|
|
9
|
+
* `core/src/store.semantic-search.test.ts`.
|
|
10
10
|
*/
|
|
11
11
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
12
12
|
import { mkdirSync, rmSync } from "fs";
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
getVaultStore,
|
|
18
18
|
clearVaultStoreCache,
|
|
19
19
|
resetSharedEmbeddingProviderForTests,
|
|
20
|
+
EMBEDDINGS_DISABLED_REASON,
|
|
20
21
|
} from "./vault-store.ts";
|
|
21
22
|
|
|
22
23
|
let tmpHome: string;
|
|
@@ -43,11 +44,12 @@ afterEach(() => {
|
|
|
43
44
|
rmSync(tmpHome, { recursive: true, force: true });
|
|
44
45
|
});
|
|
45
46
|
|
|
46
|
-
describe("getVaultStore —
|
|
47
|
-
test("
|
|
48
|
-
process.env.EMBEDDINGS_ENABLED
|
|
47
|
+
describe("getVaultStore — semantic-search opt-in gate wiring", () => {
|
|
48
|
+
test("DEFAULT off (env unset, no persisted setting): no provider, and semanticSearch's hint names the opt-in toggle", async () => {
|
|
49
|
+
delete process.env.EMBEDDINGS_ENABLED;
|
|
49
50
|
const store = getVaultStore("test");
|
|
50
|
-
expect(store.
|
|
51
|
+
expect(store.embeddingProvider).toBeUndefined();
|
|
52
|
+
expect(store.embeddingDisabledReason).toBe(EMBEDDINGS_DISABLED_REASON);
|
|
51
53
|
|
|
52
54
|
let caught: unknown;
|
|
53
55
|
try {
|
|
@@ -57,13 +59,20 @@ describe("getVaultStore — EMBEDDINGS_ENABLED=false hint wiring", () => {
|
|
|
57
59
|
}
|
|
58
60
|
expect(caught).toBeInstanceOf(QueryError);
|
|
59
61
|
expect((caught as QueryError).error_type).toBe("semantic_unavailable");
|
|
60
|
-
expect((caught as QueryError).hint).toBe(
|
|
62
|
+
expect((caught as QueryError).hint).toBe(EMBEDDINGS_DISABLED_REASON);
|
|
61
63
|
});
|
|
62
64
|
|
|
63
|
-
test("
|
|
64
|
-
|
|
65
|
+
test("EMBEDDINGS_ENABLED=false is the same off path — no provider, opt-in hint", () => {
|
|
66
|
+
process.env.EMBEDDINGS_ENABLED = "false";
|
|
67
|
+
const store = getVaultStore("test");
|
|
68
|
+
expect(store.embeddingProvider).toBeUndefined();
|
|
69
|
+
expect(store.embeddingDisabledReason).toBe(EMBEDDINGS_DISABLED_REASON);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("EMBEDDINGS_ENABLED=true enables — the store gets a (bundled-floor) provider and no disabled reason", () => {
|
|
73
|
+
process.env.EMBEDDINGS_ENABLED = "true";
|
|
65
74
|
const store = getVaultStore("test");
|
|
66
|
-
expect(store.embeddingDisabledReason).toBeUndefined();
|
|
67
75
|
expect(store.embeddingProvider).toBeDefined();
|
|
76
|
+
expect(store.embeddingDisabledReason).toBeUndefined();
|
|
68
77
|
});
|
|
69
78
|
});
|
package/src/vault-store.ts
CHANGED
|
@@ -9,9 +9,18 @@ import { SqliteStore } from "../core/src/store.ts";
|
|
|
9
9
|
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
10
10
|
import type { Store } from "../core/src/types.ts";
|
|
11
11
|
import type { EmbeddingProvider } from "../core/src/embedding/provider.ts";
|
|
12
|
-
import { buildEmbeddingProvider
|
|
12
|
+
import { buildEmbeddingProvider } from "./embedding/select.ts";
|
|
13
|
+
import { readGlobalConfig } from "./config.ts";
|
|
13
14
|
import { openVaultDb } from "./db.ts";
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The honest hint `semanticSearch` surfaces when semantic search is OFF
|
|
18
|
+
* (the 0.7.3 opt-in default). Points the operator at both the env override
|
|
19
|
+
* and the persisted settings toggle — see `src/embedding/select.ts`.
|
|
20
|
+
*/
|
|
21
|
+
export const EMBEDDINGS_DISABLED_REASON =
|
|
22
|
+
"semantic search is off — it is opt-in as of 0.7.3; enable it with EMBEDDINGS_ENABLED=true, or set embeddings_enabled: true in ~/.parachute/vault/config.yaml, then restart the vault";
|
|
23
|
+
|
|
15
24
|
export { SqliteStore as BunStore };
|
|
16
25
|
export { defaultHookRegistry };
|
|
17
26
|
|
|
@@ -34,19 +43,26 @@ const storeToVault = new WeakMap<SqliteStore, string>();
|
|
|
34
43
|
* `loadEnvFile()` in `server.ts` is visible) and reused for every
|
|
35
44
|
* subsequent vault opened this process.
|
|
36
45
|
*
|
|
37
|
-
* `undefined` is a legitimate, cacheable resolution —
|
|
38
|
-
*
|
|
39
|
-
* "not resolved yet." A separate `resolved` flag (rather than null-
|
|
40
|
-
* checking the provider itself) distinguishes the two, so
|
|
41
|
-
*
|
|
46
|
+
* `undefined` is a legitimate, cacheable resolution — semantic search is
|
|
47
|
+
* OFF by default (opt-in as of 0.7.3), so the common case is "no provider,"
|
|
48
|
+
* not "not resolved yet." A separate `resolved` flag (rather than null-
|
|
49
|
+
* checking the provider itself) distinguishes the two, so a disabled vault
|
|
50
|
+
* doesn't re-run `buildEmbeddingProvider()` on every call.
|
|
51
|
+
*
|
|
52
|
+
* Resolution threads the persisted `embeddings_enabled` config.yaml setting
|
|
53
|
+
* in as the default, with the `EMBEDDINGS_ENABLED` env var as the override
|
|
54
|
+
* (see `select.ts`'s `resolveEmbeddingsEnabled`). Because it's memoized at
|
|
55
|
+
* first access, flipping the setting takes effect on the next server start.
|
|
42
56
|
*/
|
|
43
57
|
let sharedEmbeddingProvider: EmbeddingProvider | undefined;
|
|
44
58
|
let sharedEmbeddingProviderResolved = false;
|
|
45
59
|
|
|
46
|
-
/** The shared embedding provider (see `sharedEmbeddingProvider`'s doc comment). `undefined` when
|
|
60
|
+
/** The shared embedding provider (see `sharedEmbeddingProvider`'s doc comment). `undefined` when semantic search is off (the opt-in default). */
|
|
47
61
|
export function getSharedEmbeddingProvider(): EmbeddingProvider | undefined {
|
|
48
62
|
if (!sharedEmbeddingProviderResolved) {
|
|
49
|
-
sharedEmbeddingProvider = buildEmbeddingProvider(
|
|
63
|
+
sharedEmbeddingProvider = buildEmbeddingProvider(process.env, {
|
|
64
|
+
persistedEnabled: readGlobalConfig().embeddings_enabled,
|
|
65
|
+
});
|
|
50
66
|
sharedEmbeddingProviderResolved = true;
|
|
51
67
|
}
|
|
52
68
|
return sharedEmbeddingProvider;
|
|
@@ -68,16 +84,16 @@ export function getVaultStore(name: string): SqliteStore {
|
|
|
68
84
|
// for the embedding provider (see `getSharedEmbeddingProvider`'s doc
|
|
69
85
|
// comment) — every vault's semantic search resolves the SAME provider
|
|
70
86
|
// instance. `embeddingDisabledReason` is only ever set here (core can't
|
|
71
|
-
// read env
|
|
72
|
-
// comment), so `semanticSearch`'s no-provider hint can
|
|
73
|
-
//
|
|
74
|
-
//
|
|
87
|
+
// read env/config itself — see `Store.embeddingDisabledReason`'s doc
|
|
88
|
+
// comment), so `semanticSearch`'s no-provider hint can name the opt-in
|
|
89
|
+
// toggle instead of generic provider-setup instructions. Derived from
|
|
90
|
+
// provider-absence directly, so the hint is present exactly when (and
|
|
91
|
+
// because) semantic search is off — no second read of the resolution.
|
|
92
|
+
const embeddingProvider = getSharedEmbeddingProvider();
|
|
75
93
|
store = new SqliteStore(db, {
|
|
76
94
|
hooks: defaultHookRegistry,
|
|
77
|
-
embeddingProvider
|
|
78
|
-
embeddingDisabledReason:
|
|
79
|
-
? "semantic search is disabled by EMBEDDINGS_ENABLED=false"
|
|
80
|
-
: undefined,
|
|
95
|
+
embeddingProvider,
|
|
96
|
+
embeddingDisabledReason: embeddingProvider ? undefined : EMBEDDINGS_DISABLED_REASON,
|
|
81
97
|
});
|
|
82
98
|
stores.set(name, store);
|
|
83
99
|
storeToVault.set(store, name);
|
package/src/vault.test.ts
CHANGED
|
@@ -6625,12 +6625,15 @@ describe("stateless MCP transport", async () => {
|
|
|
6625
6625
|
expect(toolNames).not.toContain("merge-tags");
|
|
6626
6626
|
// Admin tools (vault#376) are hidden too
|
|
6627
6627
|
expect(toolNames).not.toContain("manage-token");
|
|
6628
|
-
// request-attachment-download
|
|
6628
|
+
// request-attachment-download and read-attachment are read-tier
|
|
6629
|
+
// (upload is write-tier).
|
|
6629
6630
|
expect(toolNames).toContain("request-attachment-download");
|
|
6630
6631
|
expect(toolNames).not.toContain("request-attachment-upload");
|
|
6631
|
-
|
|
6632
|
-
//
|
|
6633
|
-
|
|
6632
|
+
expect(toolNames).toContain("read-attachment");
|
|
6633
|
+
// Read tier is exactly 7 tools (doctor added by the re-tier;
|
|
6634
|
+
// request-attachment-download by the attachment-tickets design;
|
|
6635
|
+
// read-attachment by Wave 2).
|
|
6636
|
+
expect(toolNames.length).toBe(7);
|
|
6634
6637
|
|
|
6635
6638
|
closeAllStores();
|
|
6636
6639
|
});
|
|
@@ -6911,15 +6914,23 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6911
6914
|
return names;
|
|
6912
6915
|
}
|
|
6913
6916
|
|
|
6914
|
-
test("vault:read sees exactly the
|
|
6917
|
+
test("vault:read sees exactly the 7 read tools (doctor moved admin → read; read-attachment and request-attachment-download are read-tier)", async () => {
|
|
6915
6918
|
const names = await listToolNames(["vault:read"]);
|
|
6916
6919
|
expect(new Set(names)).toEqual(
|
|
6917
|
-
new Set([
|
|
6920
|
+
new Set([
|
|
6921
|
+
"query-notes",
|
|
6922
|
+
"list-tags",
|
|
6923
|
+
"find-path",
|
|
6924
|
+
"vault-info",
|
|
6925
|
+
"doctor",
|
|
6926
|
+
"request-attachment-download",
|
|
6927
|
+
"read-attachment",
|
|
6928
|
+
]),
|
|
6918
6929
|
);
|
|
6919
|
-
expect(names.length).toBe(
|
|
6930
|
+
expect(names.length).toBe(7);
|
|
6920
6931
|
});
|
|
6921
6932
|
|
|
6922
|
-
test("vault:read + vault:write sees the
|
|
6933
|
+
test("vault:read + vault:write sees the 11 read+write tools (tag-schema tools moved write → admin; request-attachment-upload is write-tier)", async () => {
|
|
6923
6934
|
const names = await listToolNames(["vault:read", "vault:write"]);
|
|
6924
6935
|
expect(new Set(names)).toEqual(
|
|
6925
6936
|
new Set([
|
|
@@ -6933,9 +6944,10 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6933
6944
|
"delete-note",
|
|
6934
6945
|
"request-attachment-upload",
|
|
6935
6946
|
"request-attachment-download",
|
|
6947
|
+
"read-attachment",
|
|
6936
6948
|
]),
|
|
6937
6949
|
);
|
|
6938
|
-
expect(names.length).toBe(
|
|
6950
|
+
expect(names.length).toBe(11);
|
|
6939
6951
|
expect(names).not.toContain("manage-token");
|
|
6940
6952
|
// Re-tier (this PR): update-tag/delete-tag/rename-tag/merge-tags are now
|
|
6941
6953
|
// admin-tier — structure/taxonomy curation, not content authorship.
|
|
@@ -6947,7 +6959,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6947
6959
|
expect(names).toContain("delete-note");
|
|
6948
6960
|
});
|
|
6949
6961
|
|
|
6950
|
-
test("vault:admin sees all
|
|
6962
|
+
test("vault:admin sees all 17 tools including manage-token + prune-schema + the tag-schema tools + all three attachment tools", async () => {
|
|
6951
6963
|
const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
|
|
6952
6964
|
expect(names).toContain("manage-token");
|
|
6953
6965
|
expect(names).toContain("prune-schema");
|
|
@@ -6958,10 +6970,11 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6958
6970
|
expect(names).toContain("merge-tags");
|
|
6959
6971
|
expect(names).toContain("request-attachment-upload");
|
|
6960
6972
|
expect(names).toContain("request-attachment-download");
|
|
6961
|
-
expect(names
|
|
6973
|
+
expect(names).toContain("read-attachment");
|
|
6974
|
+
expect(names.length).toBe(17);
|
|
6962
6975
|
});
|
|
6963
6976
|
|
|
6964
|
-
test("legacy-derived full token sees all
|
|
6977
|
+
test("legacy-derived full token sees all 17 tools (back-compat)", async () => {
|
|
6965
6978
|
const { handleScopedMcp } = await import("./mcp-http.ts");
|
|
6966
6979
|
const { writeVaultConfig } = await import("./config.ts");
|
|
6967
6980
|
const { closeAllStores } = await import("./vault-store.ts");
|
|
@@ -6994,7 +7007,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6994
7007
|
} as any);
|
|
6995
7008
|
const body = await res.json() as any;
|
|
6996
7009
|
const names: string[] = body.result.tools.map((t: any) => t.name);
|
|
6997
|
-
expect(names.length).toBe(
|
|
7010
|
+
expect(names.length).toBe(17);
|
|
6998
7011
|
expect(names).toContain("manage-token");
|
|
6999
7012
|
expect(names).toContain("prune-schema");
|
|
7000
7013
|
expect(names).toContain("doctor");
|
package/src/ws-server.ts
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
|
|
33
33
|
import type { Store } from "../core/src/types.ts";
|
|
34
|
+
import { toNoteIndex } from "../core/src/notes.ts";
|
|
34
35
|
import type { VaultConfig } from "./config.ts";
|
|
35
36
|
import { readVaultConfig } from "./config.ts";
|
|
36
37
|
import { getVaultStore } from "./vault-store.ts";
|
|
@@ -229,6 +230,9 @@ export function createSubscribeWsBinding(deps: SubscribeWsDeps = {}): {
|
|
|
229
230
|
closeWs(ws, WS_CLOSE.PROTOCOL, "invalid subscription query");
|
|
230
231
|
return;
|
|
231
232
|
}
|
|
233
|
+
// Lean list subscriptions (`include_content=false`) ship the `NoteIndex`
|
|
234
|
+
// projection — snapshot + live upserts — instead of full note bodies.
|
|
235
|
+
const lean = !validated.includeContent;
|
|
232
236
|
|
|
233
237
|
let tagScopeAllowed: Set<string> | null;
|
|
234
238
|
let matcher;
|
|
@@ -247,7 +251,10 @@ export function createSubscribeWsBinding(deps: SubscribeWsDeps = {}): {
|
|
|
247
251
|
closeWs(ws, WS_CLOSE.PROTOCOL, "snapshot query failed");
|
|
248
252
|
return;
|
|
249
253
|
}
|
|
250
|
-
|
|
254
|
+
// Project AFTER the tag-scope filter (which reads note.tags). `toNoteIndex`
|
|
255
|
+
// is the SAME lean shape the REST list route returns, so a client that
|
|
256
|
+
// renders REST lists renders these snapshot frames unchanged.
|
|
257
|
+
const frames = buildSnapshotFrames(lean ? snapshotNotes.map(toNoteIndex) : snapshotNotes);
|
|
251
258
|
|
|
252
259
|
// --- SYNCHRONOUS from here (no await) so no write interleaves between
|
|
253
260
|
// register and the snapshot flush.
|
|
@@ -261,6 +268,7 @@ export function createSubscribeWsBinding(deps: SubscribeWsDeps = {}): {
|
|
|
261
268
|
tagScopeAllowed,
|
|
262
269
|
tagScopeRaw: auth.scoped_tags,
|
|
263
270
|
sink: new WsSink(ws),
|
|
271
|
+
lean,
|
|
264
272
|
tracksFlush: false,
|
|
265
273
|
countsTowardCap: false,
|
|
266
274
|
});
|