@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
package/src/config.test.ts
CHANGED
|
@@ -344,6 +344,22 @@ describe("config", () => {
|
|
|
344
344
|
expect(readGlobalConfig().autostart).toBe(false);
|
|
345
345
|
});
|
|
346
346
|
|
|
347
|
+
test("round-trips embeddings_enabled: true|false (semantic-search opt-in, 0.7.3)", () => {
|
|
348
|
+
// Absent means off — semantic search is opt-in as of 0.7.3. The key is
|
|
349
|
+
// only persisted when explicitly set (the self-host settings toggle).
|
|
350
|
+
writeGlobalConfig({ port: 1940 });
|
|
351
|
+
expect(readGlobalConfig().embeddings_enabled).toBeUndefined();
|
|
352
|
+
|
|
353
|
+
// Explicit true — the operator turned semantic search on without editing
|
|
354
|
+
// the env file; getSharedEmbeddingProvider threads this in as the default.
|
|
355
|
+
writeGlobalConfig({ port: 1940, embeddings_enabled: true });
|
|
356
|
+
expect(readGlobalConfig().embeddings_enabled).toBe(true);
|
|
357
|
+
|
|
358
|
+
// Explicit false — persisted opt-out (distinct from absent for clarity).
|
|
359
|
+
writeGlobalConfig({ port: 1940, embeddings_enabled: false });
|
|
360
|
+
expect(readGlobalConfig().embeddings_enabled).toBe(false);
|
|
361
|
+
});
|
|
362
|
+
|
|
347
363
|
test("round-trips default_mirror: internal|off", () => {
|
|
348
364
|
// Absent: createVault falls back to the in-code default ("internal" —
|
|
349
365
|
// backup-on-by-default). The knob is only persisted when explicitly set.
|
package/src/config.ts
CHANGED
|
@@ -387,6 +387,18 @@ export interface GlobalConfig {
|
|
|
387
387
|
/** Master toggle. Default false; the worker is a no-op when unset. */
|
|
388
388
|
enabled?: boolean;
|
|
389
389
|
};
|
|
390
|
+
/**
|
|
391
|
+
* Semantic-search opt-in (0.7.3, Aaron-ratified). Semantic search is OFF
|
|
392
|
+
* by default; set this to `true` to build the embedding provider and turn
|
|
393
|
+
* on embed-on-write + the backfill sweep. This is the persisted,
|
|
394
|
+
* settings-surface-friendly toggle a self-host operator flips without
|
|
395
|
+
* editing the env file. The `EMBEDDINGS_ENABLED` env var is the low-level
|
|
396
|
+
* OVERRIDE (`true`/`1` forces on, `false`/`0` forces off, anything else
|
|
397
|
+
* defers here) — see `src/embedding/select.ts`'s `resolveEmbeddingsEnabled`.
|
|
398
|
+
* Unset ⇒ off. Takes effect on server (re)start, since the process shares
|
|
399
|
+
* one embedding provider resolved at boot.
|
|
400
|
+
*/
|
|
401
|
+
embeddings_enabled?: boolean;
|
|
390
402
|
}
|
|
391
403
|
|
|
392
404
|
// ---------------------------------------------------------------------------
|
|
@@ -1308,6 +1320,7 @@ export function readGlobalConfig(): GlobalConfig {
|
|
|
1308
1320
|
const discoveryMatch = yaml.match(/^discovery:\s*(enabled|disabled)/m);
|
|
1309
1321
|
const autostartMatch = yaml.match(/^autostart:\s*(true|false)/m);
|
|
1310
1322
|
const autoCreateMatch = yaml.match(/^auto_create:\s*(true|false)/m);
|
|
1323
|
+
const embeddingsEnabledMatch = yaml.match(/^embeddings_enabled:\s*(true|false)/m);
|
|
1311
1324
|
const defaultMirrorMatch = yaml.match(/^default_mirror:\s*(internal|off)/m);
|
|
1312
1325
|
// auto_transcribe block — currently single boolean `enabled` (vault#353).
|
|
1313
1326
|
// Parsed as a nested 2-space-indent block so future fields can grow under
|
|
@@ -1340,6 +1353,9 @@ export function readGlobalConfig(): GlobalConfig {
|
|
|
1340
1353
|
if (autoCreateMatch) {
|
|
1341
1354
|
config.auto_create = autoCreateMatch[1]! === "true";
|
|
1342
1355
|
}
|
|
1356
|
+
if (embeddingsEnabledMatch) {
|
|
1357
|
+
config.embeddings_enabled = embeddingsEnabledMatch[1]! === "true";
|
|
1358
|
+
}
|
|
1343
1359
|
if (defaultMirrorMatch) {
|
|
1344
1360
|
config.default_mirror = defaultMirrorMatch[1]! as "internal" | "off";
|
|
1345
1361
|
}
|
|
@@ -1416,6 +1432,7 @@ export function writeGlobalConfig(config: GlobalConfig): void {
|
|
|
1416
1432
|
if (config.discovery) lines.push(`discovery: ${config.discovery}`);
|
|
1417
1433
|
if (config.autostart !== undefined) lines.push(`autostart: ${config.autostart}`);
|
|
1418
1434
|
if (config.auto_create !== undefined) lines.push(`auto_create: ${config.auto_create}`);
|
|
1435
|
+
if (config.embeddings_enabled !== undefined) lines.push(`embeddings_enabled: ${config.embeddings_enabled}`);
|
|
1419
1436
|
if (config.default_mirror) lines.push(`default_mirror: ${config.default_mirror}`);
|
|
1420
1437
|
if (config.owner_password_hash) {
|
|
1421
1438
|
lines.push(`owner_password_hash: "${config.owner_password_hash}"`);
|
|
@@ -2,7 +2,8 @@ import { describe, test, expect } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
resolveEmbeddingApiConfig,
|
|
4
4
|
buildEmbeddingProvider,
|
|
5
|
-
|
|
5
|
+
embeddingsEnabledEnvOverride,
|
|
6
|
+
resolveEmbeddingsEnabled,
|
|
6
7
|
DEFAULT_EXTERNAL_EMBEDDING_MODEL,
|
|
7
8
|
} from "./select.ts";
|
|
8
9
|
import { ExternalApiEmbeddingProvider } from "./external-api.ts";
|
|
@@ -29,71 +30,98 @@ describe("resolveEmbeddingApiConfig", () => {
|
|
|
29
30
|
});
|
|
30
31
|
});
|
|
31
32
|
|
|
32
|
-
describe("buildEmbeddingProvider — two-tier selection", () => {
|
|
33
|
+
describe("buildEmbeddingProvider — two-tier selection (once ENABLED)", () => {
|
|
34
|
+
// Semantic search is opt-in as of 0.7.3, so every case here enables it
|
|
35
|
+
// (via the env override) before asserting WHICH tier is selected.
|
|
33
36
|
test("zero-config: no EMBEDDING_API_URL -> the bundled floor (onnx-transformers)", () => {
|
|
34
|
-
const provider = buildEmbeddingProvider({});
|
|
37
|
+
const provider = buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "true" });
|
|
35
38
|
expect(provider).toBeInstanceOf(OnnxTransformersEmbeddingProvider);
|
|
36
39
|
});
|
|
37
40
|
|
|
38
41
|
test("EMBEDDING_API_URL set -> the config upgrade tier (external-api) wins", () => {
|
|
39
|
-
const provider = buildEmbeddingProvider({ EMBEDDING_API_URL: "http://localhost:11434/v1" });
|
|
42
|
+
const provider = buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "true", EMBEDDING_API_URL: "http://localhost:11434/v1" });
|
|
40
43
|
expect(provider).toBeInstanceOf(ExternalApiEmbeddingProvider);
|
|
41
|
-
expect(provider
|
|
44
|
+
expect(provider!.model).toBe(DEFAULT_EXTERNAL_EMBEDDING_MODEL);
|
|
42
45
|
});
|
|
43
46
|
|
|
44
47
|
test("EMBEDDING_MODEL alone (no URL) has no effect — still the bundled floor", () => {
|
|
45
|
-
const provider = buildEmbeddingProvider({ EMBEDDING_MODEL: "bge-m3" });
|
|
48
|
+
const provider = buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "true", EMBEDDING_MODEL: "bge-m3" });
|
|
46
49
|
expect(provider).toBeInstanceOf(OnnxTransformersEmbeddingProvider);
|
|
47
50
|
});
|
|
48
51
|
|
|
49
52
|
test("an explicit EMBEDDING_MODEL overrides the config-tier default", () => {
|
|
50
53
|
const provider = buildEmbeddingProvider({
|
|
54
|
+
EMBEDDINGS_ENABLED: "true",
|
|
51
55
|
EMBEDDING_API_URL: "http://x",
|
|
52
56
|
EMBEDDING_MODEL: "nomic-embed-text",
|
|
53
57
|
});
|
|
54
|
-
expect(provider
|
|
58
|
+
expect(provider!.model).toBe("nomic-embed-text");
|
|
55
59
|
});
|
|
56
60
|
});
|
|
57
61
|
|
|
58
|
-
describe("
|
|
59
|
-
test("
|
|
60
|
-
expect(
|
|
62
|
+
describe("embeddingsEnabledEnvOverride — the EMBEDDINGS_ENABLED tri-state override", () => {
|
|
63
|
+
test("\"true\"/\"1\" force ON (case-insensitive, trimmed)", () => {
|
|
64
|
+
expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: "true" })).toBe(true);
|
|
65
|
+
expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: "TRUE" })).toBe(true);
|
|
66
|
+
expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: " 1 " })).toBe(true);
|
|
61
67
|
});
|
|
62
68
|
|
|
63
|
-
test("case-insensitive
|
|
64
|
-
expect(
|
|
65
|
-
expect(
|
|
69
|
+
test("\"false\"/\"0\" force OFF (case-insensitive, trimmed)", () => {
|
|
70
|
+
expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: "false" })).toBe(false);
|
|
71
|
+
expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: "FALSE" })).toBe(false);
|
|
72
|
+
expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: " 0 " })).toBe(false);
|
|
66
73
|
});
|
|
67
74
|
|
|
68
|
-
test("unset
|
|
69
|
-
expect(
|
|
75
|
+
test("unset / blank / unrecognized → undefined (defer to the persisted setting)", () => {
|
|
76
|
+
expect(embeddingsEnabledEnvOverride({})).toBeUndefined();
|
|
77
|
+
expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: "" })).toBeUndefined();
|
|
78
|
+
expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: " " })).toBeUndefined();
|
|
79
|
+
expect(embeddingsEnabledEnvOverride({ EMBEDDINGS_ENABLED: "yes" })).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("resolveEmbeddingsEnabled — env override, else persisted, else OFF", () => {
|
|
84
|
+
test("defaults OFF when neither env nor persisted setting is present (opt-in)", () => {
|
|
85
|
+
expect(resolveEmbeddingsEnabled({})).toBe(false);
|
|
86
|
+
expect(resolveEmbeddingsEnabled({}, undefined)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("persisted setting decides when the env var is absent", () => {
|
|
90
|
+
expect(resolveEmbeddingsEnabled({}, true)).toBe(true);
|
|
91
|
+
expect(resolveEmbeddingsEnabled({}, false)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("env override wins over the persisted setting in BOTH directions", () => {
|
|
95
|
+
expect(resolveEmbeddingsEnabled({ EMBEDDINGS_ENABLED: "false" }, true)).toBe(false);
|
|
96
|
+
expect(resolveEmbeddingsEnabled({ EMBEDDINGS_ENABLED: "true" }, false)).toBe(true);
|
|
70
97
|
});
|
|
71
98
|
|
|
72
|
-
test("
|
|
73
|
-
expect(
|
|
74
|
-
expect(
|
|
75
|
-
expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: "no" })).toBe(false);
|
|
99
|
+
test("an unrecognized env value defers to the persisted setting (not a guess)", () => {
|
|
100
|
+
expect(resolveEmbeddingsEnabled({ EMBEDDINGS_ENABLED: "maybe" }, true)).toBe(true);
|
|
101
|
+
expect(resolveEmbeddingsEnabled({ EMBEDDINGS_ENABLED: "maybe" }, false)).toBe(false);
|
|
76
102
|
});
|
|
77
103
|
});
|
|
78
104
|
|
|
79
|
-
describe("buildEmbeddingProvider —
|
|
80
|
-
test("
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
EMBEDDING_API_URL: "http://localhost:11434/v1",
|
|
84
|
-
EMBEDDING_MODEL: "bge-m3",
|
|
85
|
-
});
|
|
86
|
-
expect(provider).toBeUndefined();
|
|
105
|
+
describe("buildEmbeddingProvider — opt-in gate (0.7.3)", () => {
|
|
106
|
+
test("DEFAULT off: no provider when nothing enables it (unset env, no persisted setting)", () => {
|
|
107
|
+
expect(buildEmbeddingProvider({})).toBeUndefined();
|
|
108
|
+
expect(buildEmbeddingProvider({ EMBEDDING_API_URL: "http://localhost:11434/v1", EMBEDDING_MODEL: "bge-m3" })).toBeUndefined();
|
|
87
109
|
});
|
|
88
110
|
|
|
89
|
-
test("
|
|
90
|
-
expect(buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "false" })).toBeUndefined();
|
|
111
|
+
test("EMBEDDINGS_ENABLED=false forces off even with the persisted setting on", () => {
|
|
112
|
+
expect(buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "false" }, { persistedEnabled: true })).toBeUndefined();
|
|
91
113
|
});
|
|
92
114
|
|
|
93
|
-
test("EMBEDDINGS_ENABLED=true (or
|
|
115
|
+
test("EMBEDDINGS_ENABLED=true (or =1) enables — both tiers then resolve normally", () => {
|
|
94
116
|
expect(buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "true" })).toBeInstanceOf(OnnxTransformersEmbeddingProvider);
|
|
117
|
+
expect(buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "1" })).toBeInstanceOf(OnnxTransformersEmbeddingProvider);
|
|
95
118
|
expect(
|
|
96
119
|
buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "true", EMBEDDING_API_URL: "http://x" }),
|
|
97
120
|
).toBeInstanceOf(ExternalApiEmbeddingProvider);
|
|
98
121
|
});
|
|
122
|
+
|
|
123
|
+
test("the persisted setting alone enables it (the self-host settings toggle, no env var)", () => {
|
|
124
|
+
expect(buildEmbeddingProvider({}, { persistedEnabled: true })).toBeInstanceOf(OnnxTransformersEmbeddingProvider);
|
|
125
|
+
expect(buildEmbeddingProvider({}, { persistedEnabled: false })).toBeUndefined();
|
|
126
|
+
});
|
|
99
127
|
});
|
package/src/embedding/select.ts
CHANGED
|
@@ -17,15 +17,28 @@
|
|
|
17
17
|
* in-process. This is what makes semantic search work on a fresh
|
|
18
18
|
* install with no operator action.
|
|
19
19
|
*
|
|
20
|
-
* **
|
|
21
|
-
* `buildEmbeddingProvider` returns
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* (see `
|
|
20
|
+
* **Opt-in gate (0.7.3, Aaron-ratified):** semantic search is OFF by
|
|
21
|
+
* default. `buildEmbeddingProvider` returns a provider ONLY when the
|
|
22
|
+
* feature is explicitly enabled — otherwise it returns `undefined` and
|
|
23
|
+
* BOTH tiers are short-circuited. The enable signal is resolved with a
|
|
24
|
+
* two-level precedence (see `resolveEmbeddingsEnabled`):
|
|
25
|
+
*
|
|
26
|
+
* 1. **`EMBEDDINGS_ENABLED` env var** — the low-level override. `true`/`1`
|
|
27
|
+
* forces ON, `false`/`0` forces OFF, anything else (incl. unset)
|
|
28
|
+
* defers to the persisted setting. Mirrors the cloud wrangler var.
|
|
29
|
+
* 2. **Persisted `embeddings_enabled`** (config.yaml, wired in by the
|
|
30
|
+
* caller — see `getSharedEmbeddingProvider`) — the self-host settings
|
|
31
|
+
* toggle, so an operator can turn semantic search on without editing
|
|
32
|
+
* the env file. Defaults OFF when unset.
|
|
33
|
+
*
|
|
34
|
+
* A caller wired against an `undefined` provider (see
|
|
35
|
+
* `Store.embeddingProvider`) reports the `embeddings` capability as
|
|
25
36
|
* disabled and `semanticSearch` throws `semantic_unavailable` — the exact
|
|
26
37
|
* same honest-failure path as "no provider configured" (never a silent
|
|
27
38
|
* keyword fallback). The embed-on-write drain simply has nothing to
|
|
28
|
-
* invoke, so it no-ops
|
|
39
|
+
* invoke, so it no-ops — no hook work, no backfill sweep, and (because the
|
|
40
|
+
* ~270MB `@huggingface/transformers` import is dynamic and only happens
|
|
41
|
+
* inside a real `embed()` call) no model download until a user opts in.
|
|
29
42
|
*
|
|
30
43
|
* `EMBEDDING_MODEL` alone (no `EMBEDDING_API_URL`) has no effect — it only
|
|
31
44
|
* shapes the config-upgrade tier. Resolved per-call (not cached here) so
|
|
@@ -58,28 +71,56 @@ export function resolveEmbeddingApiConfig(env: NodeJS.ProcessEnv = process.env):
|
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
/**
|
|
61
|
-
* `
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
74
|
+
* Parse `EMBEDDINGS_ENABLED` as an explicit tri-state OVERRIDE of the
|
|
75
|
+
* persisted config setting (all matches case-insensitive + trimmed):
|
|
76
|
+
*
|
|
77
|
+
* - `"true"` / `"1"` → `true` (force semantic search ON)
|
|
78
|
+
* - `"false"` / `"0"` → `false` (force it OFF)
|
|
79
|
+
* - unset / blank / anything unrecognized → `undefined` (no opinion —
|
|
80
|
+
* defer to the persisted `embeddings_enabled` setting)
|
|
81
|
+
*
|
|
82
|
+
* Returning `undefined` (rather than guessing) for an unrecognized value is
|
|
83
|
+
* what lets the env var be a true override: only an explicit true/false
|
|
84
|
+
* short-circuits the persisted setting.
|
|
85
|
+
*/
|
|
86
|
+
export function embeddingsEnabledEnvOverride(env: NodeJS.ProcessEnv = process.env): boolean | undefined {
|
|
87
|
+
const raw = env.EMBEDDINGS_ENABLED?.trim().toLowerCase();
|
|
88
|
+
if (!raw) return undefined;
|
|
89
|
+
if (raw === "true" || raw === "1") return true;
|
|
90
|
+
if (raw === "false" || raw === "0") return false;
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The effective enabled state for semantic search. Env override wins; else
|
|
96
|
+
* the persisted config setting; else OFF — semantic search is opt-in as of
|
|
97
|
+
* 0.7.3. Pure so both the provider factory and the "why is it off" hint
|
|
98
|
+
* derive from the same rule.
|
|
66
99
|
*/
|
|
67
|
-
export function
|
|
68
|
-
|
|
100
|
+
export function resolveEmbeddingsEnabled(
|
|
101
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
102
|
+
persistedEnabled?: boolean,
|
|
103
|
+
): boolean {
|
|
104
|
+
return embeddingsEnabledEnvOverride(env) ?? persistedEnabled ?? false;
|
|
69
105
|
}
|
|
70
106
|
|
|
71
107
|
/**
|
|
72
108
|
* Build the provider the current config selects, or `undefined` when
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
109
|
+
* semantic search is not enabled (see `resolveEmbeddingsEnabled` — env
|
|
110
|
+
* override, else the persisted `embeddings_enabled` setting, else OFF).
|
|
111
|
+
* Pure factory (no caching, no I/O beyond what the provider's own
|
|
112
|
+
* constructor does — which is none; both providers lazy-load/lazy-connect
|
|
113
|
+
* on first `embed()`), so it's cheap to call repeatedly in tests.
|
|
114
|
+
* Production callers should go through the shared singleton
|
|
78
115
|
* (`getSharedEmbeddingProvider`) instead of calling this directly, so the
|
|
79
|
-
* bundled ONNX model — when selected — loads at most once per process
|
|
116
|
+
* bundled ONNX model — when selected — loads at most once per process, and
|
|
117
|
+
* so the persisted setting is threaded in.
|
|
80
118
|
*/
|
|
81
|
-
export function buildEmbeddingProvider(
|
|
82
|
-
|
|
119
|
+
export function buildEmbeddingProvider(
|
|
120
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
121
|
+
opts?: { persistedEnabled?: boolean },
|
|
122
|
+
): EmbeddingProvider | undefined {
|
|
123
|
+
if (!resolveEmbeddingsEnabled(env, opts?.persistedEnabled)) return undefined;
|
|
83
124
|
const config = resolveEmbeddingApiConfig(env);
|
|
84
125
|
if (config.url) {
|
|
85
126
|
return new ExternalApiEmbeddingProvider({
|
|
@@ -98,6 +98,18 @@ describe("buildSnapshotFrames — chunking + done flag", () => {
|
|
|
98
98
|
for (const f of frames) expect(new TextEncoder().encode(f).byteLength).toBeLessThan(1_000_000);
|
|
99
99
|
expect(frames.flatMap((f) => JSON.parse(f).notes).length).toBe(8);
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
it("is shape-agnostic — frames a lean NoteIndex entry verbatim", () => {
|
|
103
|
+
// A lean subscription hands `toNoteIndex`-projected entries; the framer
|
|
104
|
+
// serializes them byte-for-byte, no content field re-added.
|
|
105
|
+
const lean = { id: "x", byteSize: 3, preview: "abc", displayTitle: "abc", tags: ["chat"], metadata: {} };
|
|
106
|
+
const frames = buildSnapshotFrames([lean as any]);
|
|
107
|
+
expect(frames.length).toBe(1);
|
|
108
|
+
const f = JSON.parse(frames[0]!);
|
|
109
|
+
expect(f.done).toBe(true);
|
|
110
|
+
expect(f.notes).toEqual([lean]);
|
|
111
|
+
expect(f.notes[0].content).toBeUndefined();
|
|
112
|
+
});
|
|
101
113
|
});
|
|
102
114
|
|
|
103
115
|
describe("parseClientMessage", () => {
|
|
@@ -171,6 +183,15 @@ describe("validateWsSubscribeQuery — same rejects as the SSE route (byte-ident
|
|
|
171
183
|
const v = validateWsSubscribeQuery(new URL("http://x/vault/v/api/subscribe?tag=chat&path_prefix=meetings/"));
|
|
172
184
|
expect("queryOpts" in v).toBe(true);
|
|
173
185
|
});
|
|
186
|
+
it("resolves include_content — default TRUE (full), `false`/`0` → lean, `true`/`1` → full", () => {
|
|
187
|
+
const at = (q: string) =>
|
|
188
|
+
validateWsSubscribeQuery(new URL(`http://x/vault/v/api/subscribe?tag=chat${q}`)) as { includeContent: boolean };
|
|
189
|
+
expect(at("").includeContent).toBe(true); // absent → full (byte-unchanged default)
|
|
190
|
+
expect(at("&include_content=false").includeContent).toBe(false); // opt into lean
|
|
191
|
+
expect(at("&include_content=0").includeContent).toBe(false);
|
|
192
|
+
expect(at("&include_content=true").includeContent).toBe(true);
|
|
193
|
+
expect(at("&include_content=1").includeContent).toBe(true);
|
|
194
|
+
});
|
|
174
195
|
it("shares the SSE route's queryOpts-level guard (cursor/has_links/date filters)", () => {
|
|
175
196
|
// The belt-and-suspenders layer both doors + the SSE route route through:
|
|
176
197
|
// a date filter isn't expressible as a flat URL param (removed 0.6.4), but
|
package/src/mcp-http.ts
CHANGED
|
@@ -180,9 +180,15 @@ export async function handleMcp(
|
|
|
180
180
|
}
|
|
181
181
|
try {
|
|
182
182
|
const result = await tool.execute((args ?? {}) as Record<string, unknown>);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
// The one wrapper change (attachments-for-agents design, Wave 2):
|
|
184
|
+
// `resultContent`, when a tool defines it, decides the MCP content
|
|
185
|
+
// blocks instead of the universal single-text-block default —
|
|
186
|
+
// `read-attachment`'s image branch is the only current user (needs a
|
|
187
|
+
// REAL {type:"image"} block alongside the row-JSON text block).
|
|
188
|
+
const content = tool.resultContent
|
|
189
|
+
? tool.resultContent(result)
|
|
190
|
+
: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }];
|
|
191
|
+
return { content };
|
|
186
192
|
} catch (err) {
|
|
187
193
|
// vault#555 fix 6 — never re-wrap an already-formed McpError. Passing
|
|
188
194
|
// the SAME instance straight through is strictly correct (no
|
|
@@ -231,6 +237,12 @@ export async function handleMcp(
|
|
|
231
237
|
referencing_tags?: unknown;
|
|
232
238
|
/** Attachment-tickets design (§2c "errors as JIT docs") — a short, imperative next-step, distinct from the older free-form `hint`. */
|
|
233
239
|
how_to?: string;
|
|
240
|
+
/** `read-attachment` (Wave 2) refusals — `image_too_large` / `unsupported_attachment_type` carry the attachment's actual byte size. */
|
|
241
|
+
size?: number;
|
|
242
|
+
/** `read-attachment` `image_too_large` — the 4 MiB cap it exceeded. */
|
|
243
|
+
max_bytes?: number;
|
|
244
|
+
/** `read-attachment` `unsupported_attachment_type` — the mime type that couldn't be read. */
|
|
245
|
+
mime_type?: string;
|
|
234
246
|
};
|
|
235
247
|
// Honest-queries validation errors (vault#550) — `limit`/`offset`/date
|
|
236
248
|
// values that are structurally invalid rather than merely "no
|
|
@@ -411,6 +423,11 @@ export async function handleMcp(
|
|
|
411
423
|
...(e.got !== undefined ? { got: e.got } : {}),
|
|
412
424
|
...(e.extension !== undefined ? { extension: e.extension } : {}),
|
|
413
425
|
...(e.how_to !== undefined ? { how_to: e.how_to } : {}),
|
|
426
|
+
// read-attachment (Wave 2) refusal fields — same forward-when-present
|
|
427
|
+
// discipline as the ticket fields just above.
|
|
428
|
+
...(e.size !== undefined ? { size: e.size } : {}),
|
|
429
|
+
...(e.max_bytes !== undefined ? { max_bytes: e.max_bytes } : {}),
|
|
430
|
+
...(e.mime_type !== undefined ? { mime_type: e.mime_type } : {}),
|
|
414
431
|
});
|
|
415
432
|
}
|
|
416
433
|
return {
|
package/src/mcp-tools.ts
CHANGED
|
@@ -45,6 +45,7 @@ import { looksLikeJwt } from "./hub-jwt.ts";
|
|
|
45
45
|
import { readGlobalConfig, DEFAULT_PORT } from "./config.ts";
|
|
46
46
|
import { getBaseUrl } from "./oauth-discovery.ts";
|
|
47
47
|
import { getSharedAttachmentTicketProvider } from "./attachment-tickets.ts";
|
|
48
|
+
import { createFsAttachmentBytesProvider } from "./attachment-bytes.ts";
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* Filter a vault projection to entries an in-scope tag contributes to.
|
|
@@ -112,10 +113,12 @@ export async function getServerInstruction(
|
|
|
112
113
|
description: config?.description ?? null,
|
|
113
114
|
projection,
|
|
114
115
|
coordinates: resolveVaultCoordinates(),
|
|
115
|
-
// Bun always wires an in-process AttachmentTicketProvider
|
|
116
|
+
// Bun always wires an in-process AttachmentTicketProvider AND a fresh
|
|
117
|
+
// fs-backed AttachmentBytesProvider per session (see
|
|
116
118
|
// `generateScopedMcpTools` below) — the connect-time brief can
|
|
117
|
-
// unconditionally teach the ticket tools
|
|
118
|
-
|
|
119
|
+
// unconditionally teach both the ticket tools and read-attachment on
|
|
120
|
+
// this door.
|
|
121
|
+
attachments: { ticketsEnabled: true, readEnabled: true },
|
|
119
122
|
});
|
|
120
123
|
}
|
|
121
124
|
|
|
@@ -273,6 +276,15 @@ export function generateScopedMcpTools(
|
|
|
273
276
|
urlBase: ticketUrlBase,
|
|
274
277
|
...(ticketNoteVisible ? { noteVisible: ticketNoteVisible } : {}),
|
|
275
278
|
},
|
|
279
|
+
// Attachment bytes (Wave 2 model lane): bun always wires a fresh fs
|
|
280
|
+
// provider per session — cheap (stateless), unlike the ticket
|
|
281
|
+
// provider's process-wide shared Map. Same `ticketNoteVisible`
|
|
282
|
+
// tag-scope predicate as the ticket seam above (identical contract:
|
|
283
|
+
// "is the owning note in scope").
|
|
284
|
+
attachmentBytes: {
|
|
285
|
+
provider: createFsAttachmentBytesProvider(vaultName),
|
|
286
|
+
...(ticketNoteVisible ? { noteVisible: ticketNoteVisible } : {}),
|
|
287
|
+
},
|
|
276
288
|
});
|
|
277
289
|
|
|
278
290
|
overrideVaultInfo(tools, vaultName, auth);
|
package/src/oauth-discovery.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { getHubOrigin } from "./hub-jwt.ts";
|
|
30
|
+
import { SCOPE_READ, SCOPE_WRITE } from "./scopes.ts";
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* OAuth scopes vault publishes through discovery, RESOURCE-NARROWED to the
|
|
@@ -87,6 +88,36 @@ export function handleProtectedResource(req: Request, vaultName: string): Respon
|
|
|
87
88
|
});
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Protected Resource Metadata (RFC 9728) for the CANONICAL ROOT `/mcp`
|
|
93
|
+
* endpoint (U1). Same document shape as `handleProtectedResource`, with two
|
|
94
|
+
* deliberate differences that follow from the root being vault-AGNOSTIC (the
|
|
95
|
+
* vault is derived from the token, not the URL):
|
|
96
|
+
*
|
|
97
|
+
* - `resource` is the origin-root `<base>/mcp`, not a `/vault/<name>/mcp`.
|
|
98
|
+
* - `scopes_supported` advertises the UN-NARROWED forms `vault:read` /
|
|
99
|
+
* `vault:write` (there's no vault name to narrow to here). A spec-following
|
|
100
|
+
* client reads these and requests the broad forms; the hub's consent picker
|
|
101
|
+
* narrows the grant to a chosen vault at authorization time (that path
|
|
102
|
+
* already exists hub-side), minting a token stamped with a
|
|
103
|
+
* `vault:<name>:<verb>` scope + `aud=vault.<name>` — exactly what the root
|
|
104
|
+
* endpoint derives the target vault from. `admin` is intentionally omitted:
|
|
105
|
+
* the interactive connect flow grants read/write; admin is an operator
|
|
106
|
+
* concern, not something the consent picker offers.
|
|
107
|
+
*
|
|
108
|
+
* Served at the RFC 9728 §3.1 path-insertion location for the root resource:
|
|
109
|
+
* `/.well-known/oauth-protected-resource/mcp`.
|
|
110
|
+
*/
|
|
111
|
+
export function handleRootProtectedResource(req: Request): Response {
|
|
112
|
+
const base = getBaseUrl(req);
|
|
113
|
+
return Response.json({
|
|
114
|
+
resource: `${base}/mcp`,
|
|
115
|
+
authorization_servers: [getHubOrigin()],
|
|
116
|
+
scopes_supported: [SCOPE_READ, SCOPE_WRITE],
|
|
117
|
+
bearer_methods_supported: ["header"],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
90
121
|
/**
|
|
91
122
|
* OAuth 2.0 Authorization Server Metadata (RFC 8414).
|
|
92
123
|
*
|