@openparachute/vault 0.7.3-rc.1 → 0.7.3-rc.11

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 (75) hide show
  1. package/README.md +5 -3
  2. package/core/src/attachment/bytes-provider.ts +65 -0
  3. package/core/src/attachment/policy.test.ts +66 -0
  4. package/core/src/attachment/policy.ts +131 -0
  5. package/core/src/attachment/tickets.test.ts +45 -0
  6. package/core/src/attachment/tickets.ts +117 -0
  7. package/core/src/attachment-tickets-tool.test.ts +286 -0
  8. package/core/src/conformance.ts +2 -1
  9. package/core/src/content-range.test.ts +127 -0
  10. package/core/src/content-range.ts +100 -0
  11. package/core/src/contract-typed-index.test.ts +4 -3
  12. package/core/src/core.test.ts +521 -4
  13. package/core/src/display-title.test.ts +190 -0
  14. package/core/src/embedding/chunker.test.ts +97 -0
  15. package/core/src/embedding/chunker.ts +180 -0
  16. package/core/src/embedding/provider.ts +108 -0
  17. package/core/src/embedding/staleness.test.ts +87 -0
  18. package/core/src/embedding/staleness.ts +83 -0
  19. package/core/src/embedding/vector-codec.test.ts +99 -0
  20. package/core/src/embedding/vector-codec.ts +60 -0
  21. package/core/src/embedding/vectors.test.ts +163 -0
  22. package/core/src/embedding/vectors.ts +135 -0
  23. package/core/src/expand.ts +11 -3
  24. package/core/src/indexed-fields.test.ts +9 -3
  25. package/core/src/indexed-fields.ts +9 -1
  26. package/core/src/lede.test.ts +96 -0
  27. package/core/src/mcp-semantic-search.test.ts +160 -0
  28. package/core/src/mcp.ts +712 -11
  29. package/core/src/notes.semantic-search.test.ts +131 -0
  30. package/core/src/notes.ts +313 -6
  31. package/core/src/query-warnings.ts +20 -0
  32. package/core/src/schema-defaults.ts +85 -1
  33. package/core/src/schema-v27-note-vectors.test.ts +178 -0
  34. package/core/src/schema.ts +81 -1
  35. package/core/src/search-fts-v25.test.ts +9 -1
  36. package/core/src/search-query.test.ts +42 -0
  37. package/core/src/search-query.ts +27 -0
  38. package/core/src/search-title-boost.test.ts +125 -0
  39. package/core/src/seed-packs.test.ts +117 -1
  40. package/core/src/seed-packs.ts +217 -1
  41. package/core/src/store.semantic-search.test.ts +236 -0
  42. package/core/src/store.ts +162 -5
  43. package/core/src/tag-schemas.ts +27 -12
  44. package/core/src/types.ts +55 -1
  45. package/core/src/vault-projection.ts +55 -0
  46. package/package.json +6 -1
  47. package/src/add-pack.test.ts +32 -0
  48. package/src/attachment-bytes.ts +68 -0
  49. package/src/attachment-tickets.test.ts +475 -0
  50. package/src/attachment-tickets.ts +340 -0
  51. package/src/cli.ts +5 -1
  52. package/src/contract-errors.test.ts +2 -1
  53. package/src/embedding/capability.test.ts +33 -0
  54. package/src/embedding/capability.ts +34 -0
  55. package/src/embedding/external-api.test.ts +154 -0
  56. package/src/embedding/external-api.ts +144 -0
  57. package/src/embedding/onnx-transformers.test.ts +113 -0
  58. package/src/embedding/onnx-transformers.ts +141 -0
  59. package/src/embedding/select.test.ts +99 -0
  60. package/src/embedding/select.ts +92 -0
  61. package/src/embedding-worker.test.ts +300 -0
  62. package/src/embedding-worker.ts +226 -0
  63. package/src/mcp-http.ts +33 -4
  64. package/src/mcp-tools.ts +61 -14
  65. package/src/onboarding-seed.test.ts +64 -0
  66. package/src/read-attachment.test.ts +436 -0
  67. package/src/routes.ts +226 -96
  68. package/src/routing.ts +17 -0
  69. package/src/semantic-search-routes.test.ts +161 -0
  70. package/src/server.ts +28 -2
  71. package/src/storage.test.ts +200 -1
  72. package/src/vault-embeddings-capability.test.ts +82 -0
  73. package/src/vault-store-embedding-wiring.test.ts +69 -0
  74. package/src/vault-store.ts +53 -2
  75. package/src/vault.test.ts +48 -11
package/src/routes.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  truncatedResultsWarning,
21
21
  computeSearchDidYouMean,
22
22
  searchDidYouMeanWarning,
23
+ embeddingsPendingWarning,
23
24
  type QueryWarning,
24
25
  } from "../core/src/query-warnings.ts";
25
26
  import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "../core/src/search-query.ts";
@@ -41,6 +42,10 @@ import {
41
42
  type ContentRange,
42
43
  } from "../core/src/content-range.ts";
43
44
  import { attachValidationStatus, enforceStrictWrite, applySchemaDefaults } from "../core/src/mcp.ts";
45
+ import {
46
+ BLOCKED_ATTACHMENT_EXTENSIONS,
47
+ ATTACHMENT_MIME_TYPES,
48
+ } from "../core/src/attachment/policy.ts";
44
49
  import type { ValidationWarning } from "../core/src/schema-defaults.ts";
45
50
  import { logStrictBypass } from "./scopes.ts";
46
51
  import * as linkOps from "../core/src/links.ts";
@@ -51,6 +56,11 @@ import {
51
56
  resolveTranscriptionCapability,
52
57
  type TranscriptionCapability,
53
58
  } from "./transcription/capability.ts";
59
+ import {
60
+ resolveEmbeddingCapability,
61
+ type EmbeddingCapability,
62
+ } from "./embedding/capability.ts";
63
+ import { getSharedEmbeddingProvider } from "./vault-store.ts";
54
64
  import { loadSchemaConfig } from "../core/src/schema-defaults.ts";
55
65
  import {
56
66
  buildExpandVisibility,
@@ -133,7 +143,7 @@ import {
133
143
  type ExpandMode,
134
144
  } from "../core/src/expand.ts";
135
145
  import { join, extname, normalize } from "path";
136
- import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
146
+ import { existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "fs";
137
147
  import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
138
148
  import { shouldAutoTranscribe } from "./auto-transcribe.ts";
139
149
  // usage.ts imports `assetsDir` from config.ts (neutral ground), so this import
@@ -1213,6 +1223,118 @@ async function handleNotesInner(
1213
1223
  return json(result);
1214
1224
  }
1215
1225
 
1226
+ // Semantic search (EXPERIMENTAL — semantic search MVP). Mirrors the
1227
+ // MCP `query-notes` tool's `near_text`/`semantic` params — see
1228
+ // `core/src/mcp.ts`'s `query-notes` handler for the identical
1229
+ // validation/shape this branch reproduces over REST. Checked BEFORE
1230
+ // the search/cursor exclusivity check below so `semantic=true`
1231
+ // combined with `search=` or `cursor=` gets its OWN error naming
1232
+ // `semantic`, not silently routed into the search branch.
1233
+ const nearText = parseQuery(url, "near_text");
1234
+ const semantic = parseBool(parseQuery(url, "semantic"), false);
1235
+ if (semantic) {
1236
+ if (search) {
1237
+ return json(
1238
+ {
1239
+ error: "semantic is incompatible with full-text search — pick one (semantic ranks by meaning via near_text; search ranks by keyword).",
1240
+ code: "INVALID_QUERY",
1241
+ error_type: "invalid_query",
1242
+ field: "semantic",
1243
+ hint: "drop `search` when using `semantic`, or drop `semantic`/`near_text` to use keyword search",
1244
+ },
1245
+ 400,
1246
+ );
1247
+ }
1248
+ if (parseQuery(url, "cursor") !== null) {
1249
+ return json(
1250
+ {
1251
+ error: "cursor is incompatible with semantic search — ranking is by similarity, not a stable row order to page through.",
1252
+ code: "INVALID_QUERY",
1253
+ error_type: "invalid_query",
1254
+ field: "semantic",
1255
+ hint: "drop `cursor` when using `semantic`",
1256
+ },
1257
+ 400,
1258
+ );
1259
+ }
1260
+ if (!nearText || !nearText.trim()) {
1261
+ return json(
1262
+ {
1263
+ error: "semantic=true requires `near_text` — the free text to rank notes by meaning.",
1264
+ code: "INVALID_QUERY",
1265
+ error_type: "invalid_query",
1266
+ field: "near_text",
1267
+ hint: "pass ?near_text=...&semantic=true",
1268
+ },
1269
+ 400,
1270
+ );
1271
+ }
1272
+ const parsed = parseNotesQueryOpts(url);
1273
+ if (parsed.error) return parsed.error;
1274
+ try {
1275
+ const semanticResult = await store.semanticSearch(nearText, { ...parsed.queryOpts, cursor: undefined });
1276
+ const semanticWarnings: QueryWarning[] = [];
1277
+ if (semanticResult.pendingCount > 0) {
1278
+ semanticWarnings.push(
1279
+ embeddingsPendingWarning(semanticResult.pendingCount, semanticResult.totalCandidates),
1280
+ );
1281
+ }
1282
+ const filtered = filterNotesByTagScope(semanticResult.notes, tagScope.allowed, tagScope.raw);
1283
+ const includeContent = parseBool(parseQuery(url, "include_content"), false);
1284
+ const contentRange = parseContentRangeQuery(url, includeContent);
1285
+ if (contentRange.error) return contentRange.error;
1286
+ const inclMeta = parseIncludeMetadata(url);
1287
+ let output: any[] = includeContent ? filtered.map((n) => ({ ...n })) : filtered.map(toNoteIndex);
1288
+ const expand = parseExpandParams(url, db, tagScope);
1289
+ if (expand && includeContent) {
1290
+ for (const n of output) expand.ctx.expanded.add(n.id);
1291
+ for (const n of output) {
1292
+ if (typeof n.content === "string") {
1293
+ n.content = expandContent(n.content, expand.ctx, expand.depth);
1294
+ }
1295
+ }
1296
+ }
1297
+ if (contentRange.range && includeContent) {
1298
+ for (const n of output) applyContentRange(n, contentRange.range);
1299
+ }
1300
+ if (inclMeta !== undefined && inclMeta !== true) {
1301
+ output = output.map((n: any) => filterMetadata(n, inclMeta));
1302
+ }
1303
+ if (parseBool(parseQuery(url, "include_link_count"), false)) {
1304
+ const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), parseLinkCountDirection(url));
1305
+ for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
1306
+ }
1307
+ return jsonWithWarnings(output, semanticWarnings);
1308
+ } catch (e: any) {
1309
+ if (e && e.name === "QueryError") {
1310
+ // `semantic_unavailable` (no/not-ready provider) is a capability
1311
+ // gap, not a malformed request — 503, distinct from every other
1312
+ // QueryError's 400. Never a silent fallback to keyword search.
1313
+ const status = e.error_type === "semantic_unavailable" ? 503 : 400;
1314
+ return json(
1315
+ {
1316
+ error: e.message,
1317
+ code: e.code ?? "INVALID_QUERY",
1318
+ error_type: e.error_type ?? "invalid_query",
1319
+ ...(e.field !== undefined ? { field: e.field } : {}),
1320
+ ...(e.got !== undefined ? { got: e.got } : {}),
1321
+ ...(e.hint !== undefined ? { hint: e.hint } : {}),
1322
+ },
1323
+ status,
1324
+ );
1325
+ }
1326
+ throw e;
1327
+ }
1328
+ }
1329
+ // `near_text` without `semantic: true` is inert — same ignored_param
1330
+ // convention as `search_mode` without `search` (below). Rather than a
1331
+ // whole extra branch, this warning rides whichever normal path (search
1332
+ // or structured query) actually runs — appended just before each
1333
+ // branch's own `jsonWithWarnings` call would otherwise return.
1334
+ const nearTextIgnored: QueryWarning[] = nearText !== null
1335
+ ? [ignoredParamWarning("near_text", "`semantic=true` is required to activate near_text — pass both together")]
1336
+ : [];
1337
+
1216
1338
  // Cursor + full-text search is mutually exclusive (vault#313 reviewer).
1217
1339
  // FTS owns its own ordering (relevance, not updated_at), so a cursor
1218
1340
  // would skip rows. MCP rejects this combo at `core/src/mcp.ts`; REST
@@ -1254,7 +1376,7 @@ async function handleNotesInner(
1254
1376
  // (default, unchanged); explicit asc/desc switches to created_at.
1255
1377
  const sort = (parseQuery(url, "sort") as "asc" | "desc" | null) ?? undefined;
1256
1378
 
1257
- const searchWarnings: QueryWarning[] = [];
1379
+ const searchWarnings: QueryWarning[] = [...nearTextIgnored];
1258
1380
  // `offset` under full-text search (vault contracts-brief V1.2):
1259
1381
  // `searchNotes` has no offset parameter at all — FTS5 ranks by
1260
1382
  // relevance, not a stable row order, so paging by offset over it
@@ -1519,6 +1641,7 @@ async function handleNotesInner(
1519
1641
  // IS the structured-query path precisely because `search` was absent
1520
1642
  // or empty, so a `search_mode` param here is always a no-op.
1521
1643
  const queryWarnings: QueryWarning[] = [
1644
+ ...nearTextIgnored,
1522
1645
  ...collectRemovedParamWarnings(url),
1523
1646
  ...(parseQuery(url, "search_mode")
1524
1647
  ? [
@@ -3651,6 +3774,14 @@ export async function handleVault(
3651
3774
  * which pass this — is unaffected.
3652
3775
  */
3653
3776
  tagScope: TagScopeCtx = NO_TAG_SCOPE,
3777
+ /**
3778
+ * Injection seam (semantic search MVP, EXPERIMENTAL) for the embeddings
3779
+ * capability probe — same shape as `resolveCapability` above. Production
3780
+ * omits it and resolves the shared embedding provider's availability;
3781
+ * tests inject a deterministic resolver.
3782
+ */
3783
+ resolveEmbeddingCap: () => Promise<EmbeddingCapability> = () =>
3784
+ resolveEmbeddingCapability(getSharedEmbeddingProvider()),
3654
3785
  ): Promise<Response> {
3655
3786
  const url = new URL(req.url);
3656
3787
 
@@ -3660,6 +3791,12 @@ export async function handleVault(
3660
3791
  // and available. `minutes_remaining` is omitted (cloud/plan concern;
3661
3792
  // self-host is unmetered). This is the field Notes gates the mic on.
3662
3793
  result.transcription = await resolveCapability();
3794
+ // Embeddings capability flag (semantic search MVP, EXPERIMENTAL) — same
3795
+ // "configured AND available" contract as transcription above. This is
3796
+ // the honest advertisement `query-notes { semantic: true }` backs: a
3797
+ // vault reporting `enabled: false` gets `semantic_unavailable`, never a
3798
+ // silent keyword fallback.
3799
+ result.embeddings = await resolveEmbeddingCap();
3663
3800
  // Front-door structural map — ALWAYS included (unlike `stats`, which is
3664
3801
  // include_stats-gated): three cheap grouped-COUNT queries, so a fresh
3665
3802
  // reader orients in one call. Scope-aware: a tag-scoped token's map
@@ -4307,97 +4444,64 @@ export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
4307
4444
  */
4308
4445
  export const MAX_REQUEST_BODY_BYTES = MAX_UPLOAD_BYTES + 20 * 1024 * 1024; // 120MB
4309
4446
 
4310
- // Storage upload policy: DENY-LIST (vault#517). A knowledge vault stores
4311
- // arbitrary files ebooks, office docs, datasets, archives, binaries — so we
4312
- // accept ANY upload EXCEPT the handful of types a browser can execute as
4313
- // active content in our origin when served back from /storage/. (The prior
4314
- // allowlist rejected the long tail: .epub/.csv/.zip/… all came back "File type
4315
- // not allowed".)
4316
- //
4317
- // BLOCKED same-origin-XSS / active-content set:
4318
- // .html/.htm/.xhtml/.shtml/.xht HTML — embeds <script>
4319
- // .svg XML image — embeds <script>
4320
- // .xml can carry XSLT / be parsed as XHTML
4321
- // .js/.mjs/.cjs JavaScript
4322
- // .css style-injection / UI-redress vector
4323
- //
4324
- // Two independent guards keep every STORED file inert when served:
4325
- // 1. Only the curated MIME_TYPES below map to a real (always passive) type;
4326
- // every other extension serves as application/octet-stream a download,
4327
- // never rendered.
4328
- // 2. The GET byte-serve response pins `X-Content-Type-Options: nosniff`, so
4329
- // a browser can't sniff an octet-stream body into an executable type.
4330
- // The blocklist is belt-and-suspenders on top of those: even if a future MIME
4331
- // entry or an upstream proxy weakened (1) or (2), these extensions still never
4332
- // land on disk. If a future use case needs SVG, sanitize on read (strip
4333
- // <script>/<foreignObject>) and revisit.
4334
- const BLOCKED_EXTENSIONS = new Set([
4335
- ".html", ".htm", ".xhtml", ".shtml", ".xht",
4336
- ".svg",
4337
- ".xml",
4338
- ".js", ".mjs", ".cjs",
4339
- ".css",
4340
- ]);
4341
-
4342
- // Explicit MIME types for the commonly-previewed formats. Anything accepted
4343
- // but absent here serves as application/octet-stream a download, never
4344
- // rendered (e.g. .pages/.key/.numbers/.azw3/.exe/arbitrary binaries). None of
4345
- // these map to an active type (text/html, image/svg+xml), so a served asset
4346
- // can't execute script; `nosniff` on the GET response makes that ironclad.
4347
- //
4348
- // INVARIANT: never add an entry that maps to a browser-active type
4349
- // text/html, image/svg+xml, application/xhtml+xml, text/javascript,
4350
- // application/wasm, text/css. Doing so re-enables same-origin execution for
4351
- // that extension (and would mean it must also join BLOCKED_EXTENSIONS).
4352
- const MIME_TYPES: Record<string, string> = {
4353
- // Audio
4354
- ".wav": "audio/wav",
4355
- ".mp3": "audio/mpeg",
4356
- ".m4a": "audio/mp4",
4357
- ".ogg": "audio/ogg",
4358
- ".oga": "audio/ogg",
4359
- ".opus": "audio/opus",
4360
- ".aac": "audio/aac",
4361
- ".flac": "audio/flac",
4362
- ".webm": "audio/webm",
4363
- // Image
4364
- ".png": "image/png",
4365
- ".jpg": "image/jpeg",
4366
- ".jpeg": "image/jpeg",
4367
- ".gif": "image/gif",
4368
- ".webp": "image/webp",
4369
- ".bmp": "image/bmp",
4370
- ".tiff": "image/tiff",
4371
- ".tif": "image/tiff",
4372
- ".heic": "image/heic",
4373
- ".heif": "image/heif",
4374
- ".avif": "image/avif",
4375
- // Video
4376
- ".mp4": "video/mp4",
4377
- ".m4v": "video/x-m4v",
4378
- ".mov": "video/quicktime",
4379
- // Documents / ebooks / data
4380
- ".pdf": "application/pdf",
4381
- ".epub": "application/epub+zip",
4382
- ".mobi": "application/x-mobipocket-ebook",
4383
- ".txt": "text/plain; charset=utf-8",
4384
- ".md": "text/markdown; charset=utf-8",
4385
- ".markdown": "text/markdown; charset=utf-8",
4386
- ".rtf": "application/rtf",
4387
- ".csv": "text/csv; charset=utf-8",
4388
- ".tsv": "text/tab-separated-values; charset=utf-8",
4389
- ".json": "application/json; charset=utf-8",
4390
- ".doc": "application/msword",
4391
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
4392
- ".ppt": "application/vnd.ms-powerpoint",
4393
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
4394
- ".xls": "application/vnd.ms-excel",
4395
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
4396
- ".odt": "application/vnd.oasis.opendocument.text",
4397
- ".ods": "application/vnd.oasis.opendocument.spreadsheet",
4398
- ".odp": "application/vnd.oasis.opendocument.presentation",
4399
- ".zip": "application/zip",
4400
- };
4447
+ // Storage upload policy (extension blocklist + MIME lookup) now lives in
4448
+ // core/src/attachment/policy.tsthe canonical source shared with the
4449
+ // attachment-ticket mint/spend path (vault attachment-tickets design,
4450
+ // "shared BLOCKED_EXTENSIONS"), so a blocked extension can never diverge
4451
+ // between REST and tickets. See that module's doc comment for the full
4452
+ // same-origin-XSS rationale and the MIME curation invariant.
4453
+ const BLOCKED_EXTENSIONS = BLOCKED_ATTACHMENT_EXTENSIONS;
4454
+ const MIME_TYPES = ATTACHMENT_MIME_TYPES;
4455
+
4456
+ /**
4457
+ * Parse a single-range `Range: bytes=a-b` header (RFC 7233 §2.1 — single
4458
+ * range only, attachments-for-agents design D9, the REST twin of MCP's
4459
+ * `content_offset`). Returns `null` — this function's own contract is
4460
+ * "serve the full response, unranged" — for a missing header, a
4461
+ * MALFORMED value, an unrecognized unit, a multi-range list
4462
+ * (`bytes=0-10,20-30` ignored, not an error, per D9), or a range this
4463
+ * file can't satisfy (a `start` past EOF). `start`/`end` are both
4464
+ * INCLUSIVE byte offsets; `end` is clamped to `total - 1` when the
4465
+ * request left it open (`bytes=500-`) or asked past EOF.
4466
+ *
4467
+ * IMPORTANT this `null` contract is NOT the last word for an
4468
+ * unsatisfiable (syntactically-valid but out-of-bounds) range on a real
4469
+ * `Bun.serve()` deployment. Live-verified against an actual socket (not
4470
+ * the in-process `handleStorage()` call the test suite uses): when the
4471
+ * response body is a `Bun.file()` — which `handleStorage`'s full-response
4472
+ * branch below always hands back — Bun's OWN runtime transparently
4473
+ * reinterprets the incoming request's `Range` header a second time and,
4474
+ * for an out-of-bounds range, overrides our 200 with a native
4475
+ * **416 Range Not Satisfiable**, regardless of what this function or
4476
+ * `handleStorage` returned. That's RFC 7233-correct and is being KEPT,
4477
+ * not fought — so in practice, `null` from an out-of-bounds `start`
4478
+ * still results in a 200 from `handleStorage`'s own logic, but the byte
4479
+ * that actually reaches a real client for that specific case is a 416
4480
+ * courtesy of Bun itself. MALFORMED and multi-range headers are NOT
4481
+ * range-shaped at all, so Bun's native layer doesn't touch them — those
4482
+ * two cases genuinely serve the full 200, in-process harness and real
4483
+ * socket alike.
4484
+ */
4485
+ export function parseByteRangeHeader(header: string | null, total: number): { start: number; end: number } | null {
4486
+ if (!header || total <= 0) return null;
4487
+ const match = header.match(/^bytes=(\d*)-(\d*)$/);
4488
+ if (!match) return null; // malformed, unrecognized unit, or a multi-range list
4489
+ const [, startRaw, endRaw] = match;
4490
+ if (startRaw === "" && endRaw === "") return null;
4491
+
4492
+ if (startRaw === "") {
4493
+ // Suffix range: last N bytes (`bytes=-500`).
4494
+ const suffixLength = Number(endRaw);
4495
+ if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return null;
4496
+ return { start: Math.max(0, total - suffixLength), end: total - 1 };
4497
+ }
4498
+
4499
+ const start = Number(startRaw);
4500
+ if (!Number.isSafeInteger(start) || start < 0 || start >= total) return null;
4501
+ const end = endRaw === "" ? total - 1 : Math.min(Number(endRaw), total - 1);
4502
+ if (!Number.isSafeInteger(end) || end < start) return null;
4503
+ return { start, end };
4504
+ }
4401
4505
 
4402
4506
  export async function handleStorage(
4403
4507
  req: Request,
@@ -4561,12 +4665,38 @@ export async function handleStorage(
4561
4665
  const stat = statSync(filePath);
4562
4666
  const ext = extname(filePath).toLowerCase();
4563
4667
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
4564
- const fileBuffer = readFileSync(filePath);
4668
+ const total = stat.size;
4669
+
4670
+ // vault attachments-for-agents design (D9) — the REST twin of MCP's
4671
+ // `content_offset`. `Bun.file(filePath)` resolves lazily: `.slice()`
4672
+ // creates a bounded view and only the requested bytes are actually read
4673
+ // on `.arrayBuffer()`/streaming, replacing the prior whole-file
4674
+ // `readFileSync` (a standing memory smell on a large attachment, e.g. a
4675
+ // 90 MB video) on BOTH the ranged and full-file paths below.
4676
+ const bunFile = Bun.file(filePath);
4677
+ const range = parseByteRangeHeader(req.headers.get("range"), total);
4678
+
4679
+ if (range) {
4680
+ const { start, end } = range; // inclusive
4681
+ return new Response(bunFile.slice(start, end + 1), {
4682
+ status: 206,
4683
+ headers: {
4684
+ "Content-Type": contentType,
4685
+ "Content-Length": String(end - start + 1),
4686
+ "Content-Range": `bytes ${start}-${end}/${total}`,
4687
+ "Accept-Ranges": "bytes",
4688
+ // Defense-in-depth: never let a browser MIME-sniff a stored asset
4689
+ // into an active type — see the full-response branch below.
4690
+ "X-Content-Type-Options": "nosniff",
4691
+ },
4692
+ });
4693
+ }
4565
4694
 
4566
- return new Response(fileBuffer, {
4695
+ return new Response(bunFile, {
4567
4696
  headers: {
4568
4697
  "Content-Type": contentType,
4569
- "Content-Length": String(stat.size),
4698
+ "Content-Length": String(total),
4699
+ "Accept-Ranges": "bytes",
4570
4700
  // Defense-in-depth: never let a browser MIME-sniff a stored asset into
4571
4701
  // an active type (e.g. an octet-stream body sniffed as text/html).
4572
4702
  // Combined with the upload blocklist (no .svg/.html) this closes the
package/src/routing.ts CHANGED
@@ -103,6 +103,7 @@ import {
103
103
  } from "./mirror-routes.ts";
104
104
  import { getMirrorManager } from "./mirror-registry.ts";
105
105
  import { buildUsageReport } from "./usage.ts";
106
+ import { handleTicketSpend } from "./attachment-tickets.ts";
106
107
 
107
108
  /**
108
109
  * Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
@@ -490,6 +491,22 @@ export async function route(
490
491
  return handleAuthorizationServer(req, vaultName);
491
492
  }
492
493
 
494
+ // Attachment ticket spend (attachment-tickets design, Wave 1) — no auth
495
+ // beyond the ticket itself; the ticket IS the credential (single-use,
496
+ // short TTL, scoped to exactly one upload slot or one attachment's
497
+ // bytes). Deliberately placed BEFORE `authenticateVaultRequest` below —
498
+ // a bearer-less runtime (a bare `curl`) must be able to spend a ticket
499
+ // without ever holding this vault's API key. The path segment
500
+ // (`/tickets/<id>`) is outside the authed `/api` tree and outside
501
+ // `/mcp` on purpose — see `request-attachment-upload`/`-download`
502
+ // (core/src/mcp.ts) for where tickets are minted.
503
+ const vaultTicketMatch = subpath.match(/^\/tickets\/([^/]+)$/);
504
+ if (vaultTicketMatch) {
505
+ const ticketId = decodeURIComponent(vaultTicketMatch[1]!);
506
+ const store = getVaultStore(vaultName);
507
+ return handleTicketSpend(req, ticketId, vaultName, store);
508
+ }
509
+
493
510
  // ---------------------------------------------------------------------
494
511
  // Authenticated surface
495
512
  // ---------------------------------------------------------------------
@@ -0,0 +1,161 @@
1
+ /**
2
+ * REST face of semantic search (EXPERIMENTAL — semantic search MVP).
3
+ * Mirrors the MCP `query-notes` tool's `near_text`/`semantic` params —
4
+ * see `core/src/mcp-semantic-search.test.ts` for the MCP-side twin of
5
+ * this suite. Fully sandboxed — in-memory SQLite, mocked provider, no
6
+ * daemon, no real model.
7
+ */
8
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
9
+ import { Database } from "bun:sqlite";
10
+ import { BunStore } from "./vault-store.ts";
11
+ import { handleNotes } from "./routes.ts";
12
+ import type { EmbeddingProvider, EmbedInput, EmbedResult, ProviderAvailability } from "../core/src/embedding/provider.ts";
13
+ import { encodeVector, normalize } from "../core/src/embedding/vector-codec.ts";
14
+
15
+ const MODEL = "mock-model";
16
+ const DIMS = 4;
17
+
18
+ class MockEmbeddingProvider implements EmbeddingProvider {
19
+ readonly name = "mock";
20
+ readonly model = MODEL;
21
+ readonly dims = DIMS;
22
+ available_ = true;
23
+ reason_: string | undefined;
24
+
25
+ async embed(input: EmbedInput): Promise<EmbedResult> {
26
+ return { vectors: input.texts.map(() => new Float32Array([1, 0, 0, 0])), model: this.model, dims: this.dims };
27
+ }
28
+
29
+ async available(): Promise<ProviderAvailability> {
30
+ return this.available_ ? { ok: true } : { ok: false, reason: this.reason_ };
31
+ }
32
+ }
33
+
34
+ let db: Database;
35
+ let store: BunStore;
36
+
37
+ beforeEach(() => {
38
+ db = new Database(":memory:");
39
+ });
40
+
41
+ afterEach(() => {
42
+ db.close();
43
+ });
44
+
45
+ const BASE = "http://localhost/api";
46
+
47
+ function get(store: BunStore, path: string): Promise<Response> {
48
+ return handleNotes(new Request(`${BASE}/notes${path}`, { method: "GET" }), store, "");
49
+ }
50
+
51
+ function insertVector(noteId: string, vector: number[]): void {
52
+ const encoded = encodeVector(normalize(new Float32Array(vector)));
53
+ db.prepare(
54
+ `INSERT INTO note_vectors (note_id, chunk_ix, vector, dims, model, content_hash, embedded_at) VALUES (?, 0, ?, ?, ?, ?, ?)`,
55
+ ).run(noteId, encoded, DIMS, MODEL, "hash", new Date().toISOString());
56
+ }
57
+
58
+ /** Decode the `X-Parachute-Warnings` header (vault#550 — percent-encoded JSON; see `jsonWithWarnings` in routes.ts). */
59
+ function decodeWarningsHeader(res: Response): any[] | null {
60
+ const raw = res.headers.get("X-Parachute-Warnings");
61
+ if (!raw) return null;
62
+ return JSON.parse(decodeURIComponent(raw));
63
+ }
64
+
65
+ describe("GET /notes?near_text=&semantic= (REST, EXPERIMENTAL)", () => {
66
+ it("400s when semantic=true is passed without near_text", async () => {
67
+ store = new BunStore(db, { embeddingProvider: new MockEmbeddingProvider() });
68
+ const res = await get(store, "?semantic=true");
69
+ expect(res.status).toBe(400);
70
+ const body = await res.json();
71
+ expect(body.error_type).toBe("invalid_query");
72
+ expect(body.field).toBe("near_text");
73
+ });
74
+
75
+ it("400s when semantic=true is combined with search", async () => {
76
+ store = new BunStore(db, { embeddingProvider: new MockEmbeddingProvider() });
77
+ const res = await get(store, "?semantic=true&near_text=x&search=y");
78
+ expect(res.status).toBe(400);
79
+ const body = await res.json();
80
+ expect(body.field).toBe("semantic");
81
+ });
82
+
83
+ it("400s when semantic=true is combined with cursor", async () => {
84
+ store = new BunStore(db, { embeddingProvider: new MockEmbeddingProvider() });
85
+ const res = await get(store, "?semantic=true&near_text=x&cursor=");
86
+ expect(res.status).toBe(400);
87
+ });
88
+
89
+ it("503s with a structured semantic_unavailable error when no provider is configured", async () => {
90
+ store = new BunStore(db); // no embeddingProvider
91
+ const res = await get(store, "?semantic=true&near_text=anything");
92
+ expect(res.status).toBe(503);
93
+ const body = await res.json();
94
+ expect(body.error_type).toBe("semantic_unavailable");
95
+ });
96
+
97
+ it("M2: 503s (not a raw 500) when the provider's embed() call fails mid-flight, e.g. a lazy model load blowing up on the first real query", async () => {
98
+ const provider = new MockEmbeddingProvider();
99
+ provider.embed = async () => {
100
+ throw new Error("bundled ONNX embedding model failed to load: simulated ORT failure");
101
+ };
102
+ store = new BunStore(db, { embeddingProvider: provider });
103
+ const res = await get(store, "?semantic=true&near_text=anything");
104
+ expect(res.status).toBe(503);
105
+ const body = await res.json();
106
+ expect(body.error_type).toBe("semantic_unavailable");
107
+ });
108
+
109
+ it("near_text without semantic=true is inert and carries an ignored_param warning (via the header, bare-array body)", async () => {
110
+ store = new BunStore(db, { embeddingProvider: new MockEmbeddingProvider() });
111
+ await store.createNote("hello world");
112
+ const res = await get(store, "?near_text=hello");
113
+ expect(res.status).toBe(200);
114
+ const body = await res.json();
115
+ expect(Array.isArray(body)).toBe(true);
116
+ const warnings = decodeWarningsHeader(res);
117
+ expect(warnings?.some((w: any) => w.code === "ignored_param" && w.param === "near_text")).toBe(true);
118
+ });
119
+
120
+ it("returns notes ranked by meaning", async () => {
121
+ store = new BunStore(db, { embeddingProvider: new MockEmbeddingProvider() });
122
+ const close = await store.createNote("close note", { path: "close" });
123
+ const far = await store.createNote("far note", { path: "far" });
124
+ insertVector(close.id, [1, 0, 0, 0]);
125
+ insertVector(far.id, [0, 1, 0, 0]);
126
+
127
+ const res = await get(store, "?semantic=true&near_text=anything&include_content=true");
128
+ expect(res.status).toBe(200);
129
+ const body = (await res.json()) as any[];
130
+ expect(body.map((n) => n.id)).toEqual([close.id, far.id]);
131
+ expect(body[0].score).toBeCloseTo(1, 4);
132
+ });
133
+
134
+ it("composes with tag filters", async () => {
135
+ store = new BunStore(db, { embeddingProvider: new MockEmbeddingProvider() });
136
+ const tagged = await store.createNote("tagged", { path: "tagged", tags: ["project"] });
137
+ const untagged = await store.createNote("untagged", { path: "untagged" });
138
+ insertVector(tagged.id, [1, 0, 0, 0]);
139
+ insertVector(untagged.id, [1, 0, 0, 0]);
140
+
141
+ const res = await get(store, "?semantic=true&near_text=x&tag=project");
142
+ const body = (await res.json()) as any[];
143
+ expect(body.map((n) => n.id)).toEqual([tagged.id]);
144
+ });
145
+
146
+ it("attaches embeddings_pending warning (via the header) with counts while backfill is incomplete", async () => {
147
+ store = new BunStore(db, { embeddingProvider: new MockEmbeddingProvider() });
148
+ const embedded = await store.createNote("embedded", { path: "a" });
149
+ await store.createNote("pending", { path: "b" });
150
+ insertVector(embedded.id, [1, 0, 0, 0]);
151
+
152
+ const res = await get(store, "?semantic=true&near_text=x&include_content=true");
153
+ const body = (await res.json()) as any[];
154
+ expect(body.map((n: any) => n.id)).toEqual([embedded.id]);
155
+ const warnings = decodeWarningsHeader(res);
156
+ const w = warnings?.find((x: any) => x.code === "embeddings_pending");
157
+ expect(w).toBeDefined();
158
+ expect(w.pending).toBe(1);
159
+ expect(w.total_candidates).toBe(2);
160
+ });
161
+ });
package/src/server.ts CHANGED
@@ -19,7 +19,9 @@ import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig,
19
19
  import { existsSync, rmSync } from "fs";
20
20
  import { migrateVaultKeys } from "./token-store.ts";
21
21
  import { resolveFirstBootVaultName, reservedNameSquatWarnings } from "./vault-name.ts";
22
- import { getVaultStore, getVaultNameForStore } from "./vault-store.ts";
22
+ import { getVaultStore, getVaultNameForStore, getSharedEmbeddingProvider } from "./vault-store.ts";
23
+ import { EmbeddingWorker, registerEmbeddingHook } from "./embedding-worker.ts";
24
+ import { startAttachmentTicketSweep, stopAttachmentTicketSweep } from "./attachment-tickets.ts";
23
25
  import { seedOnboardingNotesBestEffort } from "./onboarding-seed.ts";
24
26
  import { defaultHookRegistry } from "../core/src/hooks.ts";
25
27
  import { registerTriggers } from "./triggers.ts";
@@ -220,6 +222,27 @@ if (providerName === "transcribe-cpp") {
220
222
  }
221
223
  }
222
224
 
225
+ // Embedding worker (semantic search MVP — EXPERIMENTAL). Unlike
226
+ // transcription (no-op without a discoverable scribe), a provider is
227
+ // ALWAYS resolved here — the bundled floor tier (`onnx-transformers.ts`)
228
+ // is the zero-config default, so a fresh install gets working semantic
229
+ // search with no operator action. The event hook embeds on every note
230
+ // create/update (a no-op edit makes zero provider calls — see
231
+ // `embedding-worker.ts`); the sweep drains the backfill for pre-existing
232
+ // notes and catches anything a dropped dispatch left behind.
233
+ const embeddingWorker = new EmbeddingWorker({
234
+ provider: getSharedEmbeddingProvider(),
235
+ vaultList: () => listVaults(),
236
+ getStore: (name: string) => getVaultStore(name),
237
+ });
238
+ registerEmbeddingHook(defaultHookRegistry, embeddingWorker, (store) => getVaultNameForStore(store as never));
239
+ embeddingWorker.start();
240
+
241
+ // Attachment-ticket sweep (vault#612) — drops expired-unspent tickets from
242
+ // the in-process store so an abandoned mint (an agent that never curls)
243
+ // doesn't sit in memory forever. See src/attachment-tickets.ts.
244
+ startAttachmentTicketSweep();
245
+
223
246
  if (process.env.VAULT_AUTH_TOKEN?.trim()) {
224
247
  console.log("[auth] VAULT_AUTH_TOKEN set — server-wide operator bearer active");
225
248
  }
@@ -599,7 +622,10 @@ async function shutdown(signal: string): Promise<void> {
599
622
  ),
600
623
  ),
601
624
  );
602
- // Then drain hooks + stop the transcription worker in parallel.
625
+ // Then drain hooks + stop the transcription/embedding workers in
626
+ // parallel.
627
+ embeddingWorker.stop();
628
+ stopAttachmentTicketSweep();
603
629
  await Promise.all([
604
630
  defaultHookRegistry.drain(),
605
631
  transcriptionWorker?.stop() ?? Promise.resolve(),