@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.
- package/README.md +5 -3
- package/core/src/attachment/policy.test.ts +66 -0
- package/core/src/attachment/policy.ts +131 -0
- package/core/src/attachment/tickets.test.ts +45 -0
- package/core/src/attachment/tickets.ts +117 -0
- package/core/src/attachment-tickets-tool.test.ts +286 -0
- package/core/src/conformance.ts +2 -1
- package/core/src/contract-typed-index.test.ts +4 -3
- package/core/src/core.test.ts +607 -11
- package/core/src/display-title.test.ts +190 -0
- package/core/src/embedding/chunker.test.ts +97 -0
- package/core/src/embedding/chunker.ts +180 -0
- package/core/src/embedding/provider.ts +108 -0
- package/core/src/embedding/staleness.test.ts +87 -0
- package/core/src/embedding/staleness.ts +83 -0
- package/core/src/embedding/vector-codec.test.ts +99 -0
- package/core/src/embedding/vector-codec.ts +60 -0
- package/core/src/embedding/vectors.test.ts +163 -0
- package/core/src/embedding/vectors.ts +135 -0
- package/core/src/expand.ts +11 -3
- package/core/src/indexed-fields.test.ts +9 -3
- package/core/src/indexed-fields.ts +9 -1
- package/core/src/lede.test.ts +96 -0
- package/core/src/mcp-semantic-search.test.ts +160 -0
- package/core/src/mcp.ts +405 -10
- package/core/src/notes.semantic-search.test.ts +131 -0
- package/core/src/notes.ts +319 -7
- package/core/src/query-warnings.ts +40 -0
- package/core/src/schema-defaults.ts +85 -1
- package/core/src/schema-v27-note-vectors.test.ts +178 -0
- package/core/src/schema.ts +81 -1
- package/core/src/search-fts-v25.test.ts +9 -1
- package/core/src/search-query.test.ts +42 -0
- package/core/src/search-query.ts +27 -0
- package/core/src/search-title-boost.test.ts +125 -0
- package/core/src/seed-packs.test.ts +117 -1
- package/core/src/seed-packs.ts +217 -1
- package/core/src/store.semantic-search.test.ts +236 -0
- package/core/src/store.ts +162 -5
- package/core/src/tag-schemas.ts +27 -12
- package/core/src/types.ts +63 -1
- package/core/src/vault-projection.ts +48 -0
- package/package.json +6 -1
- package/src/add-pack.test.ts +32 -0
- package/src/attachment-tickets.test.ts +350 -0
- package/src/attachment-tickets.ts +264 -0
- package/src/auth.ts +8 -2
- package/src/cli.ts +5 -1
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-honest-queries.test.ts +88 -0
- package/src/contract-search.test.ts +41 -0
- package/src/embedding/capability.test.ts +33 -0
- package/src/embedding/capability.ts +34 -0
- package/src/embedding/external-api.test.ts +154 -0
- package/src/embedding/external-api.ts +144 -0
- package/src/embedding/onnx-transformers.test.ts +113 -0
- package/src/embedding/onnx-transformers.ts +141 -0
- package/src/embedding/select.test.ts +99 -0
- package/src/embedding/select.ts +92 -0
- package/src/embedding-worker.test.ts +300 -0
- package/src/embedding-worker.ts +226 -0
- package/src/mcp-http.ts +13 -1
- package/src/mcp-tools.ts +49 -14
- package/src/onboarding-seed.test.ts +64 -0
- package/src/routes.ts +173 -92
- package/src/routing.ts +17 -0
- package/src/scribe-discovery.test.ts +76 -1
- package/src/scribe-discovery.ts +28 -9
- package/src/semantic-search-routes.test.ts +161 -0
- package/src/server.ts +21 -2
- package/src/vault-embeddings-capability.test.ts +82 -0
- package/src/vault-store-embedding-wiring.test.ts +69 -0
- package/src/vault-store.ts +53 -2
- 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
|
|
4284
|
-
//
|
|
4285
|
-
//
|
|
4286
|
-
//
|
|
4287
|
-
//
|
|
4288
|
-
//
|
|
4289
|
-
|
|
4290
|
-
|
|
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.ts — the 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
|
+
});
|
package/src/scribe-discovery.ts
CHANGED
|
@@ -68,24 +68,43 @@ export function resolveScribeUrl(
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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(
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
});
|