@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.
- package/README.md +5 -3
- package/core/src/attachment/bytes-provider.ts +65 -0
- 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/content-range.test.ts +127 -0
- package/core/src/content-range.ts +100 -0
- package/core/src/contract-typed-index.test.ts +4 -3
- package/core/src/core.test.ts +521 -4
- 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 +712 -11
- package/core/src/notes.semantic-search.test.ts +131 -0
- package/core/src/notes.ts +313 -6
- package/core/src/query-warnings.ts +20 -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 +55 -1
- package/core/src/vault-projection.ts +55 -0
- package/package.json +6 -1
- package/src/add-pack.test.ts +32 -0
- package/src/attachment-bytes.ts +68 -0
- package/src/attachment-tickets.test.ts +475 -0
- package/src/attachment-tickets.ts +340 -0
- package/src/cli.ts +5 -1
- package/src/contract-errors.test.ts +2 -1
- 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 +33 -4
- package/src/mcp-tools.ts +61 -14
- package/src/onboarding-seed.test.ts +64 -0
- package/src/read-attachment.test.ts +436 -0
- package/src/routes.ts +226 -96
- package/src/routing.ts +17 -0
- package/src/semantic-search-routes.test.ts +161 -0
- package/src/server.ts +28 -2
- package/src/storage.test.ts +200 -1
- 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 +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,
|
|
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
|
|
4311
|
-
//
|
|
4312
|
-
//
|
|
4313
|
-
//
|
|
4314
|
-
//
|
|
4315
|
-
//
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
const
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
"
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
"
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
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.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;
|
|
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
|
|
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(
|
|
4695
|
+
return new Response(bunFile, {
|
|
4567
4696
|
headers: {
|
|
4568
4697
|
"Content-Type": contentType,
|
|
4569
|
-
"Content-Length": String(
|
|
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
|
|
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(),
|