@openparachute/vault 0.7.3-rc.9 → 0.7.4-rc.1

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 (48) 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/embeddings-routes.test.ts +188 -0
  24. package/src/embeddings-routes.ts +178 -0
  25. package/src/live-frame-parity.test.ts +21 -0
  26. package/src/mcp-http.ts +20 -3
  27. package/src/mcp-tools.ts +15 -3
  28. package/src/oauth-discovery.ts +31 -0
  29. package/src/read-attachment.test.ts +436 -0
  30. package/src/routes.ts +80 -4
  31. package/src/routing.test.ts +358 -4
  32. package/src/routing.ts +163 -23
  33. package/src/scopes.ts +22 -0
  34. package/src/server.ts +17 -8
  35. package/src/storage.test.ts +200 -1
  36. package/src/subscriptions.ts +13 -1
  37. package/src/transcription-worker.test.ts +151 -0
  38. package/src/transcription-worker.ts +113 -52
  39. package/src/vault-embeddings-capability.test.ts +28 -6
  40. package/src/vault-store-embedding-wiring.test.ts +25 -16
  41. package/src/vault-store.ts +47 -16
  42. package/src/vault.test.ts +26 -13
  43. package/src/ws-server.ts +9 -1
  44. package/src/ws-subscribe.test.ts +87 -0
  45. package/src/ws-subscribe.ts +25 -6
  46. package/web/ui/dist/assets/index-NvwxfZcu.js +61 -0
  47. package/web/ui/dist/index.html +1 -1
  48. package/web/ui/dist/assets/index-CJ6NtIwh.js +0 -61
@@ -0,0 +1,178 @@
1
+ /**
2
+ * HTTP surface for the semantic-search (embeddings) opt-in toggle.
3
+ *
4
+ * GET /vault/<name>/.parachute/embeddings — read the persisted setting +
5
+ * the running/effective/env state
6
+ * PUT /vault/<name>/.parachute/embeddings — flip the persisted setting
7
+ *
8
+ * The 0.7.3 fast-follow: 0.7.3 shipped the persisted `embeddings_enabled`
9
+ * config.yaml setting + `resolveEmbeddingsEnabled` (env override → persisted
10
+ * → off), but the ONLY way to flip it was hand-editing config.yaml or setting
11
+ * an env var. This module gives the vault admin SPA a real toggle over that
12
+ * setting.
13
+ *
14
+ * URL note: same reasoning as `mirror-routes.ts` — the admin SPA's static
15
+ * bundle owns `/vault/<name>/admin/*` (vault#252), so the API surface lives
16
+ * under the `.parachute/` module-protocol namespace (sibling to
17
+ * `.parachute/config`, `.parachute/mirror`, `.parachute/usage`).
18
+ *
19
+ * Auth: `vault:admin`, gated upstream in `routing.ts` (this module is the
20
+ * after-auth handler). `embeddings_enabled` is host-global config that affects
21
+ * every vault on the server, but it's reached through the same per-vault admin
22
+ * surface every other settings page uses; the UI copy names the host-wide
23
+ * scope.
24
+ *
25
+ * **Activation is restart-to-apply, not hot.** The embedding provider is
26
+ * resolved ONCE at boot (`getSharedEmbeddingProvider`) and captured into every
27
+ * open store (`Store.embeddingProvider` + `embeddingDisabledReason`, baked at
28
+ * construction) and into the embedding worker (`EmbeddingWorker.provider`, a
29
+ * private readonly field). Re-resolving the provider mid-run would mean
30
+ * mutating readonly fields across the worker AND every already-open store AND
31
+ * resetting a deliberately-once module memo — not clean. So this endpoint
32
+ * PERSISTS the setting and reports `restart_required` when the persisted
33
+ * change hasn't taken effect in the running process yet. Hot-reconfigure is a
34
+ * possible follow-up.
35
+ *
36
+ * **The `EMBEDDINGS_ENABLED` env var still wins.** It's the low-level override
37
+ * (`true`/`1` on, `false`/`0` off, else defer). When it's forcing a value the
38
+ * snapshot carries `env_override` so the UI can flag that the persisted toggle
39
+ * is advisory until the env var is removed — the toggle never lies about
40
+ * what's actually in force.
41
+ */
42
+
43
+ import { readGlobalConfig, writeGlobalConfig } from "./config.ts";
44
+ import { embeddingsEnabledEnvOverride, resolveEmbeddingsEnabled } from "./embedding/select.ts";
45
+ import { isSharedEmbeddingProviderActive } from "./vault-store.ts";
46
+
47
+ /**
48
+ * Approximate size of the bundled ONNX model (`bge-small-en-v1.5`, q8) that
49
+ * lazy-downloads on the FIRST embed after enabling. Surfaced so the UI can
50
+ * warn before the operator flips the switch. (The npm dependency ships ~270MB
51
+ * of runtime, but the model weights fetched on first use are ~34MB.)
52
+ */
53
+ export const EMBEDDING_MODEL_DOWNLOAD_MB = 34;
54
+
55
+ const CORS = { "Access-Control-Allow-Origin": "*" } as const;
56
+
57
+ /**
58
+ * The wire shape the admin SPA reads/writes. Both GET and PUT return this so
59
+ * the SPA reuses one decoder.
60
+ */
61
+ export interface EmbeddingsSettingsSnapshot {
62
+ /**
63
+ * The persisted `embeddings_enabled` in config.yaml — the value the toggle
64
+ * reflects. `false` when unset (semantic search is opt-in, default off).
65
+ */
66
+ enabled: boolean;
67
+ /**
68
+ * The `EMBEDDINGS_ENABLED` env override, tri-state: `true` (forced on),
69
+ * `false` (forced off), or `null` (no env var — defers to `enabled`). When
70
+ * non-null the env var wins over the persisted setting.
71
+ */
72
+ env_override: boolean | null;
73
+ /**
74
+ * Convenience flag: `true` exactly when `env_override` is non-null. The UI
75
+ * shows an advisory banner ("an env var is forcing this") when set.
76
+ */
77
+ env_forced: boolean;
78
+ /**
79
+ * What a fresh boot would resolve: env override, else persisted, else off.
80
+ * This is the state the running process will land in on next restart.
81
+ */
82
+ effective: boolean;
83
+ /**
84
+ * Whether semantic search is LIVE in the running process right now (the
85
+ * boot-resolved provider is present). Distinct from `effective`: a flip of
86
+ * the persisted setting changes `effective` immediately but not `active`.
87
+ */
88
+ active: boolean;
89
+ /**
90
+ * `true` when `active !== effective` — the operator's intended state is
91
+ * persisted but the running process hasn't picked it up. Restart to apply.
92
+ */
93
+ restart_required: boolean;
94
+ /** First-enable model-download size hint (MB) for the UI copy. */
95
+ model_download_mb: number;
96
+ }
97
+
98
+ /**
99
+ * Build the snapshot from the persisted setting + env + running state. Pure
100
+ * over its inputs (env + persisted + active) so it's unit-testable without a
101
+ * live server; the `active` reading is injected (defaults to the real
102
+ * process-shared provider state).
103
+ */
104
+ export function buildEmbeddingsSnapshot(opts?: {
105
+ env?: NodeJS.ProcessEnv;
106
+ persistedEnabled?: boolean;
107
+ active?: boolean;
108
+ }): EmbeddingsSettingsSnapshot {
109
+ const env = opts?.env ?? process.env;
110
+ const persisted = opts?.persistedEnabled ?? false;
111
+ const active = opts?.active ?? isSharedEmbeddingProviderActive();
112
+ const override = embeddingsEnabledEnvOverride(env);
113
+ const effective = resolveEmbeddingsEnabled(env, persisted);
114
+ return {
115
+ enabled: persisted,
116
+ env_override: override ?? null,
117
+ env_forced: override !== undefined,
118
+ effective,
119
+ active,
120
+ restart_required: active !== effective,
121
+ model_download_mb: EMBEDDING_MODEL_DOWNLOAD_MB,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * `GET /vault/<name>/.parachute/embeddings` — return the current settings
127
+ * snapshot. Always 200 (auth enforced upstream). `activeOverride` is a test
128
+ * seam; production passes nothing and the running provider state is read.
129
+ */
130
+ export function handleEmbeddingsGet(activeOverride?: boolean): Response {
131
+ const persisted = readGlobalConfig().embeddings_enabled;
132
+ const snapshot = buildEmbeddingsSnapshot({ persistedEnabled: persisted, active: activeOverride });
133
+ return Response.json(snapshot, { headers: CORS });
134
+ }
135
+
136
+ /**
137
+ * `PUT /vault/<name>/.parachute/embeddings` — persist a new
138
+ * `embeddings_enabled` value. Body: `{ "enabled": boolean }`.
139
+ *
140
+ * Reuses the config write path (`writeGlobalConfig` — never hand-rolls YAML),
141
+ * so the serialize logic that already knows how to persist `embeddings_enabled`
142
+ * is the single source of truth. Persists even when the env var is forcing a
143
+ * value (the operator's persisted intent is recorded; the env override still
144
+ * wins for `effective`/`active`, and the snapshot says so). Returns the same
145
+ * shape as GET, reflecting the new persisted value + the unchanged running
146
+ * state (so `restart_required` is honest right after the write).
147
+ */
148
+ export async function handleEmbeddingsPut(req: Request, activeOverride?: boolean): Promise<Response> {
149
+ let body: unknown;
150
+ try {
151
+ body = await req.json();
152
+ } catch (err) {
153
+ return Response.json(
154
+ { error: "Invalid JSON body", message: (err as Error).message ?? String(err) },
155
+ { status: 400 },
156
+ );
157
+ }
158
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
159
+ return Response.json(
160
+ { error: "Invalid body", field: "enabled", message: "Expected a JSON object { enabled: boolean }." },
161
+ { status: 400 },
162
+ );
163
+ }
164
+ const enabled = (body as { enabled?: unknown }).enabled;
165
+ if (typeof enabled !== "boolean") {
166
+ return Response.json(
167
+ { error: "Invalid body", field: "enabled", message: "`enabled` must be a boolean." },
168
+ { status: 400 },
169
+ );
170
+ }
171
+
172
+ const config = readGlobalConfig();
173
+ config.embeddings_enabled = enabled;
174
+ writeGlobalConfig(config);
175
+
176
+ const snapshot = buildEmbeddingsSnapshot({ persistedEnabled: enabled, active: activeOverride });
177
+ return Response.json(snapshot, { headers: CORS });
178
+ }
@@ -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
  *