@openparachute/vault 0.7.2 → 0.7.3-rc.10

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 (74) hide show
  1. package/README.md +5 -3
  2. package/core/src/attachment/policy.test.ts +66 -0
  3. package/core/src/attachment/policy.ts +131 -0
  4. package/core/src/attachment/tickets.test.ts +45 -0
  5. package/core/src/attachment/tickets.ts +117 -0
  6. package/core/src/attachment-tickets-tool.test.ts +286 -0
  7. package/core/src/conformance.ts +2 -1
  8. package/core/src/contract-typed-index.test.ts +4 -3
  9. package/core/src/core.test.ts +607 -11
  10. package/core/src/display-title.test.ts +190 -0
  11. package/core/src/embedding/chunker.test.ts +97 -0
  12. package/core/src/embedding/chunker.ts +180 -0
  13. package/core/src/embedding/provider.ts +108 -0
  14. package/core/src/embedding/staleness.test.ts +87 -0
  15. package/core/src/embedding/staleness.ts +83 -0
  16. package/core/src/embedding/vector-codec.test.ts +99 -0
  17. package/core/src/embedding/vector-codec.ts +60 -0
  18. package/core/src/embedding/vectors.test.ts +163 -0
  19. package/core/src/embedding/vectors.ts +135 -0
  20. package/core/src/expand.ts +11 -3
  21. package/core/src/indexed-fields.test.ts +9 -3
  22. package/core/src/indexed-fields.ts +9 -1
  23. package/core/src/lede.test.ts +96 -0
  24. package/core/src/mcp-semantic-search.test.ts +160 -0
  25. package/core/src/mcp.ts +405 -10
  26. package/core/src/notes.semantic-search.test.ts +131 -0
  27. package/core/src/notes.ts +319 -7
  28. package/core/src/query-warnings.ts +40 -0
  29. package/core/src/schema-defaults.ts +85 -1
  30. package/core/src/schema-v27-note-vectors.test.ts +178 -0
  31. package/core/src/schema.ts +81 -1
  32. package/core/src/search-fts-v25.test.ts +9 -1
  33. package/core/src/search-query.test.ts +42 -0
  34. package/core/src/search-query.ts +27 -0
  35. package/core/src/search-title-boost.test.ts +125 -0
  36. package/core/src/seed-packs.test.ts +117 -1
  37. package/core/src/seed-packs.ts +217 -1
  38. package/core/src/store.semantic-search.test.ts +236 -0
  39. package/core/src/store.ts +162 -5
  40. package/core/src/tag-schemas.ts +27 -12
  41. package/core/src/types.ts +63 -1
  42. package/core/src/vault-projection.ts +48 -0
  43. package/package.json +6 -1
  44. package/src/add-pack.test.ts +32 -0
  45. package/src/attachment-tickets.test.ts +350 -0
  46. package/src/attachment-tickets.ts +264 -0
  47. package/src/auth.ts +8 -2
  48. package/src/cli.ts +5 -1
  49. package/src/contract-errors.test.ts +2 -1
  50. package/src/contract-honest-queries.test.ts +88 -0
  51. package/src/contract-search.test.ts +41 -0
  52. package/src/embedding/capability.test.ts +33 -0
  53. package/src/embedding/capability.ts +34 -0
  54. package/src/embedding/external-api.test.ts +154 -0
  55. package/src/embedding/external-api.ts +144 -0
  56. package/src/embedding/onnx-transformers.test.ts +113 -0
  57. package/src/embedding/onnx-transformers.ts +141 -0
  58. package/src/embedding/select.test.ts +99 -0
  59. package/src/embedding/select.ts +92 -0
  60. package/src/embedding-worker.test.ts +300 -0
  61. package/src/embedding-worker.ts +226 -0
  62. package/src/mcp-http.ts +13 -1
  63. package/src/mcp-tools.ts +49 -14
  64. package/src/onboarding-seed.test.ts +64 -0
  65. package/src/routes.ts +173 -92
  66. package/src/routing.ts +17 -0
  67. package/src/scribe-discovery.test.ts +76 -1
  68. package/src/scribe-discovery.ts +28 -9
  69. package/src/semantic-search-routes.test.ts +161 -0
  70. package/src/server.ts +21 -2
  71. package/src/vault-embeddings-capability.test.ts +82 -0
  72. package/src/vault-store-embedding-wiring.test.ts +69 -0
  73. package/src/vault-store.ts +53 -2
  74. package/src/vault.test.ts +62 -13
package/src/routes.ts CHANGED
@@ -17,8 +17,10 @@ import {
17
17
  collectUnknownTagWarnings,
18
18
  emptySearchWarning,
19
19
  ignoredParamWarning,
20
+ truncatedResultsWarning,
20
21
  computeSearchDidYouMean,
21
22
  searchDidYouMeanWarning,
23
+ embeddingsPendingWarning,
22
24
  type QueryWarning,
23
25
  } from "../core/src/query-warnings.ts";
24
26
  import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "../core/src/search-query.ts";
@@ -40,6 +42,10 @@ import {
40
42
  type ContentRange,
41
43
  } from "../core/src/content-range.ts";
42
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";
43
49
  import type { ValidationWarning } from "../core/src/schema-defaults.ts";
44
50
  import { logStrictBypass } from "./scopes.ts";
45
51
  import * as linkOps from "../core/src/links.ts";
@@ -50,6 +56,11 @@ import {
50
56
  resolveTranscriptionCapability,
51
57
  type TranscriptionCapability,
52
58
  } from "./transcription/capability.ts";
59
+ import {
60
+ resolveEmbeddingCapability,
61
+ type EmbeddingCapability,
62
+ } from "./embedding/capability.ts";
63
+ import { getSharedEmbeddingProvider } from "./vault-store.ts";
53
64
  import { loadSchemaConfig } from "../core/src/schema-defaults.ts";
54
65
  import {
55
66
  buildExpandVisibility,
@@ -1212,6 +1223,118 @@ async function handleNotesInner(
1212
1223
  return json(result);
1213
1224
  }
1214
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
+
1215
1338
  // Cursor + full-text search is mutually exclusive (vault#313 reviewer).
1216
1339
  // FTS owns its own ordering (relevance, not updated_at), so a cursor
1217
1340
  // would skip rows. MCP rejects this combo at `core/src/mcp.ts`; REST
@@ -1253,7 +1376,24 @@ async function handleNotesInner(
1253
1376
  // (default, unchanged); explicit asc/desc switches to created_at.
1254
1377
  const sort = (parseQuery(url, "sort") as "asc" | "desc" | null) ?? undefined;
1255
1378
 
1256
- const searchWarnings: QueryWarning[] = [];
1379
+ const searchWarnings: QueryWarning[] = [...nearTextIgnored];
1380
+ // `offset` under full-text search (vault contracts-brief V1.2):
1381
+ // `searchNotes` has no offset parameter at all — FTS5 ranks by
1382
+ // relevance, not a stable row order, so paging by offset over it
1383
+ // is unsound. Rather than silently return page 1 with no signal
1384
+ // (the pre-existing behavior — `?search=x&offset=50` == `?search=x`),
1385
+ // flag it. Presence check (not truthiness) so `offset=0` — a caller
1386
+ // being explicit about "start" — still warns; it's equally
1387
+ // meaningless here. The structured-query path (below) has real
1388
+ // offset support and is unaffected.
1389
+ if (parseQuery(url, "offset") !== null) {
1390
+ searchWarnings.push(
1391
+ ignoredParamWarning(
1392
+ "offset",
1393
+ "full-text search has no stable row order to offset into — results are always page 1, ranked by relevance",
1394
+ ),
1395
+ );
1396
+ }
1257
1397
  let rawResults: Note[];
1258
1398
  // "Only whitespace/quotes" (vault#551 edge case) — short-circuit
1259
1399
  // BEFORE ever calling FTS5 rather than risk a syntax error (or a
@@ -1501,6 +1641,7 @@ async function handleNotesInner(
1501
1641
  // IS the structured-query path precisely because `search` was absent
1502
1642
  // or empty, so a `search_mode` param here is always a no-op.
1503
1643
  const queryWarnings: QueryWarning[] = [
1644
+ ...nearTextIgnored,
1504
1645
  ...collectRemovedParamWarnings(url),
1505
1646
  ...(parseQuery(url, "search_mode")
1506
1647
  ? [
@@ -1558,6 +1699,15 @@ async function handleNotesInner(
1558
1699
  }
1559
1700
  throw e;
1560
1701
  }
1702
+ // Truncation-honesty (vault contracts-brief V1.3): captured BEFORE
1703
+ // near/tag-scope filtering, which can only shrink the set further —
1704
+ // this reflects whether the underlying store query actually hit its
1705
+ // `limit` (i.e. there may be more rows the caller never saw). Cursor
1706
+ // mode is exempt — it already carries `next_cursor` as the honest
1707
+ // "more may follow" signal, so a second warning would be redundant.
1708
+ if (!cursorMode && results.length === queryOpts.limit) {
1709
+ queryWarnings.push(truncatedResultsWarning(queryOpts.limit));
1710
+ }
1561
1711
 
1562
1712
  // Near-scope filter (graph neighborhood)
1563
1713
  const nearNoteId = parseQuery(url, "near[note_id]");
@@ -3624,6 +3774,14 @@ export async function handleVault(
3624
3774
  * which pass this — is unaffected.
3625
3775
  */
3626
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()),
3627
3785
  ): Promise<Response> {
3628
3786
  const url = new URL(req.url);
3629
3787
 
@@ -3633,6 +3791,12 @@ export async function handleVault(
3633
3791
  // and available. `minutes_remaining` is omitted (cloud/plan concern;
3634
3792
  // self-host is unmetered). This is the field Notes gates the mic on.
3635
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();
3636
3800
  // Front-door structural map — ALWAYS included (unlike `stats`, which is
3637
3801
  // include_stats-gated): three cheap grouped-COUNT queries, so a fresh
3638
3802
  // reader orients in one call. Scope-aware: a tag-scoped token's map
@@ -4280,97 +4444,14 @@ export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
4280
4444
  */
4281
4445
  export const MAX_REQUEST_BODY_BYTES = MAX_UPLOAD_BYTES + 20 * 1024 * 1024; // 120MB
4282
4446
 
4283
- // Storage upload policy: DENY-LIST (vault#517). A knowledge vault stores
4284
- // arbitrary files ebooks, office docs, datasets, archives, binaries — so we
4285
- // accept ANY upload EXCEPT the handful of types a browser can execute as
4286
- // active content in our origin when served back from /storage/. (The prior
4287
- // allowlist rejected the long tail: .epub/.csv/.zip/… all came back "File type
4288
- // not allowed".)
4289
- //
4290
- // BLOCKED same-origin-XSS / active-content set:
4291
- // .html/.htm/.xhtml/.shtml/.xht HTML — embeds <script>
4292
- // .svg XML image — embeds <script>
4293
- // .xml can carry XSLT / be parsed as XHTML
4294
- // .js/.mjs/.cjs JavaScript
4295
- // .css style-injection / UI-redress vector
4296
- //
4297
- // Two independent guards keep every STORED file inert when served:
4298
- // 1. Only the curated MIME_TYPES below map to a real (always passive) type;
4299
- // every other extension serves as application/octet-stream — a download,
4300
- // never rendered.
4301
- // 2. The GET byte-serve response pins `X-Content-Type-Options: nosniff`, so
4302
- // a browser can't sniff an octet-stream body into an executable type.
4303
- // The blocklist is belt-and-suspenders on top of those: even if a future MIME
4304
- // entry or an upstream proxy weakened (1) or (2), these extensions still never
4305
- // land on disk. If a future use case needs SVG, sanitize on read (strip
4306
- // <script>/<foreignObject>) and revisit.
4307
- const BLOCKED_EXTENSIONS = new Set([
4308
- ".html", ".htm", ".xhtml", ".shtml", ".xht",
4309
- ".svg",
4310
- ".xml",
4311
- ".js", ".mjs", ".cjs",
4312
- ".css",
4313
- ]);
4314
-
4315
- // Explicit MIME types for the commonly-previewed formats. Anything accepted
4316
- // but absent here serves as application/octet-stream — a download, never
4317
- // rendered (e.g. .pages/.key/.numbers/.azw3/.exe/arbitrary binaries). None of
4318
- // these map to an active type (text/html, image/svg+xml), so a served asset
4319
- // can't execute script; `nosniff` on the GET response makes that ironclad.
4320
- //
4321
- // INVARIANT: never add an entry that maps to a browser-active type —
4322
- // text/html, image/svg+xml, application/xhtml+xml, text/javascript,
4323
- // application/wasm, text/css. Doing so re-enables same-origin execution for
4324
- // that extension (and would mean it must also join BLOCKED_EXTENSIONS).
4325
- const MIME_TYPES: Record<string, string> = {
4326
- // Audio
4327
- ".wav": "audio/wav",
4328
- ".mp3": "audio/mpeg",
4329
- ".m4a": "audio/mp4",
4330
- ".ogg": "audio/ogg",
4331
- ".oga": "audio/ogg",
4332
- ".opus": "audio/opus",
4333
- ".aac": "audio/aac",
4334
- ".flac": "audio/flac",
4335
- ".webm": "audio/webm",
4336
- // Image
4337
- ".png": "image/png",
4338
- ".jpg": "image/jpeg",
4339
- ".jpeg": "image/jpeg",
4340
- ".gif": "image/gif",
4341
- ".webp": "image/webp",
4342
- ".bmp": "image/bmp",
4343
- ".tiff": "image/tiff",
4344
- ".tif": "image/tiff",
4345
- ".heic": "image/heic",
4346
- ".heif": "image/heif",
4347
- ".avif": "image/avif",
4348
- // Video
4349
- ".mp4": "video/mp4",
4350
- ".m4v": "video/x-m4v",
4351
- ".mov": "video/quicktime",
4352
- // Documents / ebooks / data
4353
- ".pdf": "application/pdf",
4354
- ".epub": "application/epub+zip",
4355
- ".mobi": "application/x-mobipocket-ebook",
4356
- ".txt": "text/plain; charset=utf-8",
4357
- ".md": "text/markdown; charset=utf-8",
4358
- ".markdown": "text/markdown; charset=utf-8",
4359
- ".rtf": "application/rtf",
4360
- ".csv": "text/csv; charset=utf-8",
4361
- ".tsv": "text/tab-separated-values; charset=utf-8",
4362
- ".json": "application/json; charset=utf-8",
4363
- ".doc": "application/msword",
4364
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
4365
- ".ppt": "application/vnd.ms-powerpoint",
4366
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
4367
- ".xls": "application/vnd.ms-excel",
4368
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
4369
- ".odt": "application/vnd.oasis.opendocument.text",
4370
- ".ods": "application/vnd.oasis.opendocument.spreadsheet",
4371
- ".odp": "application/vnd.oasis.opendocument.presentation",
4372
- ".zip": "application/zip",
4373
- };
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;
4374
4455
 
4375
4456
  export async function handleStorage(
4376
4457
  req: Request,
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
  // ---------------------------------------------------------------------
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { describe, test, expect, beforeEach } from "bun:test";
10
- import { resolveScribeUrl, clearScribeUrlCache } from "./scribe-discovery.ts";
10
+ import { resolveScribeUrl, getCachedScribeUrl, clearScribeUrlCache } from "./scribe-discovery.ts";
11
11
 
12
12
  function mkManifest(services: Array<{ name: string; port: number; origin?: string }>): typeof import("./services-manifest.ts").readManifest {
13
13
  return () => ({
@@ -75,3 +75,78 @@ describe("resolveScribeUrl", () => {
75
75
  expect(resolveScribeUrl(env, manifest)).toBe("http://127.0.0.1:1943");
76
76
  });
77
77
  });
78
+
79
+ describe("getCachedScribeUrl (vault contracts-brief V1.5 — TTL, not process-lifetime)", () => {
80
+ // A manifest fn that counts calls, so tests can prove whether the cache
81
+ // actually skipped a re-read (rather than just asserting the RESULT,
82
+ // which would pass even if the cache silently didn't cache at all).
83
+ function mkCountingManifest(port: number): { impl: typeof import("./services-manifest.ts").readManifest; calls: () => number } {
84
+ let calls = 0;
85
+ const impl = (() => {
86
+ calls++;
87
+ return {
88
+ services: [
89
+ { name: "parachute-scribe", port, paths: ["/parachute-scribe"], health: "/health", version: "0.0.0-test" },
90
+ ],
91
+ };
92
+ }) as unknown as typeof import("./services-manifest.ts").readManifest;
93
+ return { impl, calls: () => calls };
94
+ }
95
+
96
+ test("within the TTL window, repeated calls reuse the cached value (manifest read once)", () => {
97
+ const env = {} as NodeJS.ProcessEnv;
98
+ const { impl, calls } = mkCountingManifest(1943);
99
+ let t = 1_000_000;
100
+ const now = () => t;
101
+
102
+ expect(getCachedScribeUrl(env, impl, console, now)).toBe("http://127.0.0.1:1943");
103
+ t += 1_000; // well inside the 30s TTL
104
+ expect(getCachedScribeUrl(env, impl, console, now)).toBe("http://127.0.0.1:1943");
105
+ expect(calls()).toBe(1);
106
+ });
107
+
108
+ test("after the TTL expires, the next call re-resolves — a scribe that appeared mid-process is picked up without a restart", () => {
109
+ const env = {} as NodeJS.ProcessEnv;
110
+ // First manifest: no scribe registered yet.
111
+ let manifestCalls = 0;
112
+ let portToReturn: number | null = null;
113
+ const impl = (() => {
114
+ manifestCalls++;
115
+ return {
116
+ services: portToReturn === null
117
+ ? []
118
+ : [{ name: "parachute-scribe", port: portToReturn, paths: ["/parachute-scribe"], health: "/health", version: "0.0.0-test" }],
119
+ };
120
+ }) as unknown as typeof import("./services-manifest.ts").readManifest;
121
+ let t = 1_000_000;
122
+ const now = () => t;
123
+
124
+ // Nothing registered yet — vault boots before scribe.
125
+ expect(getCachedScribeUrl(env, impl, console, now)).toBeUndefined();
126
+
127
+ // Scribe installs and registers itself mid-process — still within TTL,
128
+ // so the stale "undefined" is what a caller sees (this is the bound the
129
+ // TTL accepts, not the bug being fixed).
130
+ portToReturn = 1943;
131
+ t += 1_000;
132
+ expect(getCachedScribeUrl(env, impl, console, now)).toBeUndefined();
133
+ expect(manifestCalls).toBe(1); // still cached — didn't re-read
134
+
135
+ // TTL elapses — next probe re-reads and picks up the now-live scribe,
136
+ // with no vault restart in between.
137
+ t += 30_000;
138
+ expect(getCachedScribeUrl(env, impl, console, now)).toBe("http://127.0.0.1:1943");
139
+ expect(manifestCalls).toBe(2);
140
+ });
141
+
142
+ test("clearScribeUrlCache forces a fresh resolve even within the TTL window", () => {
143
+ const env = {} as NodeJS.ProcessEnv;
144
+ const { impl, calls } = mkCountingManifest(1943);
145
+ const now = () => 1_000_000;
146
+
147
+ expect(getCachedScribeUrl(env, impl, console, now)).toBe("http://127.0.0.1:1943");
148
+ clearScribeUrlCache();
149
+ expect(getCachedScribeUrl(env, impl, console, now)).toBe("http://127.0.0.1:1943");
150
+ expect(calls()).toBe(2);
151
+ });
152
+ });
@@ -68,24 +68,43 @@ export function resolveScribeUrl(
68
68
  }
69
69
 
70
70
  /**
71
- * Process-lifetime cache. Computed at first call (typically during server
72
- * boot), reused for every subsequent transcription request. Operators who
73
- * change the scribe URL via `services.json` (re-install of scribe with a
74
- * different port) need to restart vault; we deliberately don't watch the
75
- * file because the v0.6 deploy model has a single restart-on-change story.
71
+ * Short-TTL cache (vault contracts-brief V1.5 was process-lifetime).
72
+ * `resolveScribeUrl` is just an env read + a `services.json` stat/read, cheap
73
+ * enough to redo on this cadence but the transcription capability probe
74
+ * (`GET /api/vault`, `src/transcription/capability.ts`) can be hit often
75
+ * enough that re-reading the manifest on EVERY call is wasteful. A TTL
76
+ * splits the difference: scribe installing/moving/starting mid-process (the
77
+ * real deploy sequencing — hub can bring scribe up after vault) is picked up
78
+ * within `SCRIBE_URL_CACHE_TTL_MS` instead of requiring a vault restart,
79
+ * while a steady-state deployment still avoids a filesystem read per
80
+ * request. Previously this was a `let ... = null` sentinel computed once at
81
+ * first call and reused for the rest of the process — that's the staleness
82
+ * this replaces.
76
83
  *
77
84
  * Tests should pass an explicit `env` + `readManifestImpl` to `resolveScribeUrl`
78
- * directly to bypass the cache.
85
+ * directly to bypass the cache, or use the `now` injection seam here to
86
+ * simulate TTL expiry without real sleeps.
79
87
  */
88
+ const SCRIBE_URL_CACHE_TTL_MS = 30_000;
89
+
80
90
  let cachedScribeUrl: string | undefined | null = null;
91
+ let cachedAt = 0;
81
92
 
82
- export function getCachedScribeUrl(): string | undefined {
83
- if (cachedScribeUrl === null) {
84
- cachedScribeUrl = resolveScribeUrl();
93
+ export function getCachedScribeUrl(
94
+ env: NodeJS.ProcessEnv = process.env,
95
+ readManifestImpl: typeof readManifest = readManifest,
96
+ logger: { warn?: (...args: unknown[]) => void } = console,
97
+ now: () => number = Date.now,
98
+ ): string | undefined {
99
+ const t = now();
100
+ if (cachedScribeUrl === null || t - cachedAt >= SCRIBE_URL_CACHE_TTL_MS) {
101
+ cachedScribeUrl = resolveScribeUrl(env, readManifestImpl, logger);
102
+ cachedAt = t;
85
103
  }
86
104
  return cachedScribeUrl;
87
105
  }
88
106
 
89
107
  export function clearScribeUrlCache(): void {
90
108
  cachedScribeUrl = null;
109
+ cachedAt = 0;
91
110
  }
@@ -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
+ });