@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.
Files changed (43) hide show
  1. package/core/src/attachment/bytes-provider.ts +65 -0
  2. package/core/src/content-range-constants.ts +19 -0
  3. package/core/src/content-range.test.ts +127 -0
  4. package/core/src/content-range.ts +105 -8
  5. package/core/src/core.test.ts +66 -4
  6. package/core/src/expand.ts +11 -3
  7. package/core/src/lede.test.ts +96 -0
  8. package/core/src/mcp-manifest.test.ts +200 -0
  9. package/core/src/mcp-manifest.ts +736 -0
  10. package/core/src/mcp.ts +357 -607
  11. package/core/src/notes.ts +69 -10
  12. package/core/src/vault-projection.ts +17 -10
  13. package/package.json +1 -1
  14. package/src/attachment-bytes.ts +68 -0
  15. package/src/attachment-tickets.test.ts +126 -1
  16. package/src/attachment-tickets.ts +77 -1
  17. package/src/auth-hub-jwt.test.ts +118 -1
  18. package/src/auth.ts +64 -0
  19. package/src/config.test.ts +16 -0
  20. package/src/config.ts +17 -0
  21. package/src/embedding/select.test.ts +58 -30
  22. package/src/embedding/select.ts +62 -21
  23. package/src/live-frame-parity.test.ts +21 -0
  24. package/src/mcp-http.ts +20 -3
  25. package/src/mcp-tools.ts +15 -3
  26. package/src/oauth-discovery.ts +31 -0
  27. package/src/read-attachment.test.ts +436 -0
  28. package/src/routes.ts +80 -4
  29. package/src/routing.test.ts +229 -4
  30. package/src/routing.ts +135 -23
  31. package/src/scopes.ts +22 -0
  32. package/src/server.ts +17 -8
  33. package/src/storage.test.ts +200 -1
  34. package/src/subscriptions.ts +13 -1
  35. package/src/transcription-worker.test.ts +151 -0
  36. package/src/transcription-worker.ts +113 -52
  37. package/src/vault-embeddings-capability.test.ts +28 -6
  38. package/src/vault-store-embedding-wiring.test.ts +25 -16
  39. package/src/vault-store.ts +32 -16
  40. package/src/vault.test.ts +26 -13
  41. package/src/ws-server.ts +9 -1
  42. package/src/ws-subscribe.test.ts +87 -0
  43. package/src/ws-subscribe.ts +25 -6
@@ -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
- embeddingsExplicitlyDisabled,
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.model).toBe(DEFAULT_EXTERNAL_EMBEDDING_MODEL);
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.model).toBe("nomic-embed-text");
58
+ expect(provider!.model).toBe("nomic-embed-text");
55
59
  });
56
60
  });
57
61
 
58
- describe("embeddingsExplicitlyDisabled — the EMBEDDINGS_ENABLED off switch (M1)", () => {
59
- test("false only for the literal string \"false\"", () => {
60
- expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: "false" })).toBe(true);
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 and trims whitespace", () => {
64
- expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: "FALSE" })).toBe(true);
65
- expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: " false " })).toBe(true);
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 defaults to enabled (not disabled)", () => {
69
- expect(embeddingsExplicitlyDisabled({})).toBe(false);
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("\"true\" (and any other value) is enabled this var is an off switch, not an opt-in", () => {
73
- expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: "true" })).toBe(false);
74
- expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: "0" })).toBe(false);
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 — EMBEDDINGS_ENABLED=false (M1)", () => {
80
- test("returns undefined even when the config-upgrade tier is otherwise fully configured", () => {
81
- const provider = buildEmbeddingProvider({
82
- EMBEDDINGS_ENABLED: "false",
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("returns undefined for the zero-config (bundled-floor) case too", () => {
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 unset) is unaffected — both tiers still resolve normally", () => {
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
  });
@@ -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
- * **Off switch:** `EMBEDDINGS_ENABLED=false` short-circuits BOTH tiers
21
- * `buildEmbeddingProvider` returns `undefined` regardless of what else is
22
- * configured. Mirrors the `EMBEDDINGS_ENABLED` wrangler var C2 (cloud)
23
- * plans for the same gate. A caller wired against an `undefined` provider
24
- * (see `Store.embeddingProvider`) reports the `embeddings` capability as
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
- * `true` only when `EMBEDDINGS_ENABLED` is explicitly the literal string
62
- * `"false"` (case-insensitive, trimmed) — the off switch. Every other
63
- * value, INCLUDING unset, is "enabled": the feature defaults ON (the
64
- * bundled floor tier makes that safe — zero-config still works), and this
65
- * var exists to opt OUT, not to opt in.
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 embeddingsExplicitlyDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
68
- return env.EMBEDDINGS_ENABLED?.trim().toLowerCase() === "false";
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
- * `EMBEDDINGS_ENABLED=false` (see the off-switch doc above). Pure factory
74
- * (no caching, no I/O beyond what the provider's own constructor does
75
- * which is none; both providers lazy-load/lazy-connect on first
76
- * `embed()`), so it's cheap to call repeatedly in tests. Production
77
- * callers should go through the shared singleton
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(env: NodeJS.ProcessEnv = process.env): EmbeddingProvider | undefined {
82
- if (embeddingsExplicitlyDisabled(env)) return undefined;
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
- return {
184
- content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
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 (see
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 on this door.
118
- attachments: { ticketsEnabled: true },
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);
@@ -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
  *