@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/core/src/mcp.ts
CHANGED
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
collectUnknownTagWarnings,
|
|
11
11
|
emptySearchWarning,
|
|
12
12
|
ignoredParamWarning,
|
|
13
|
+
truncatedResultsWarning,
|
|
13
14
|
computeSearchDidYouMean,
|
|
14
15
|
searchDidYouMeanWarning,
|
|
16
|
+
embeddingsPendingWarning,
|
|
15
17
|
type QueryWarning,
|
|
16
18
|
} from "./query-warnings.js";
|
|
17
19
|
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "./search-query.js";
|
|
@@ -43,6 +45,18 @@ import {
|
|
|
43
45
|
contentRangeRequiresContent,
|
|
44
46
|
MIN_CONTENT_LENGTH,
|
|
45
47
|
} from "./content-range.js";
|
|
48
|
+
import {
|
|
49
|
+
BLOCKED_ATTACHMENT_EXTENSIONS,
|
|
50
|
+
sanitizeAttachmentExtension,
|
|
51
|
+
mimeForAttachmentExtension,
|
|
52
|
+
} from "./attachment/policy.js";
|
|
53
|
+
import {
|
|
54
|
+
computeTicketTtlMs,
|
|
55
|
+
generateTicketId,
|
|
56
|
+
MAX_TICKET_UPLOAD_BYTES,
|
|
57
|
+
type AttachmentTicket,
|
|
58
|
+
type AttachmentTicketProvider,
|
|
59
|
+
} from "./attachment/tickets.js";
|
|
46
60
|
|
|
47
61
|
export interface McpToolDef {
|
|
48
62
|
name: string;
|
|
@@ -78,7 +92,7 @@ export interface McpToolDef {
|
|
|
78
92
|
*/
|
|
79
93
|
function structuredError(
|
|
80
94
|
message: string,
|
|
81
|
-
fields: { error_type: string; field?: string; hint?: string },
|
|
95
|
+
fields: { error_type: string; field?: string; hint?: string } & Record<string, unknown>,
|
|
82
96
|
): Error {
|
|
83
97
|
return Object.assign(new Error(message), fields);
|
|
84
98
|
}
|
|
@@ -244,6 +258,39 @@ export interface GenerateMcpToolsOpts {
|
|
|
244
258
|
* path with no extra fetch.
|
|
245
259
|
*/
|
|
246
260
|
aggregateVisibility?: (note: Note) => boolean;
|
|
261
|
+
/**
|
|
262
|
+
* `AttachmentTicketProvider` seam (vault attachment-tickets design,
|
|
263
|
+
* Wave 1 — D10 "tools omitted when unwired"). When provided,
|
|
264
|
+
* `generateMcpTools` appends `request-attachment-upload` /
|
|
265
|
+
* `request-attachment-download` to the returned tool list. Omitted (a
|
|
266
|
+
* door that hasn't wired its ticket seam yet) → the two tools are
|
|
267
|
+
* ABSENT from the list entirely — not merely erroring on call — so an
|
|
268
|
+
* agent is never shown an affordance the runtime can't back.
|
|
269
|
+
*/
|
|
270
|
+
attachmentTickets?: {
|
|
271
|
+
provider: AttachmentTicketProvider;
|
|
272
|
+
/** This vault's own name — stamped onto every minted ticket so the spend route can cheaply reject a cross-vault replay. */
|
|
273
|
+
vaultName: string;
|
|
274
|
+
/**
|
|
275
|
+
* Absolute base URL for this vault's ticket spend path, no trailing
|
|
276
|
+
* slash — e.g. `https://host/vault/<name>`. A minted ticket's URL is
|
|
277
|
+
* `${urlBase}/tickets/<id>`. Resolved by the caller (server layer)
|
|
278
|
+
* from the incoming request's `X-Forwarded-Host`/proto — core stays
|
|
279
|
+
* request-unaware.
|
|
280
|
+
*/
|
|
281
|
+
urlBase: string;
|
|
282
|
+
/**
|
|
283
|
+
* OPTIONAL per-note visibility predicate (tag-scope confidentiality —
|
|
284
|
+
* same intent as `expandVisibility`/`ifExistsVisible` above, kept
|
|
285
|
+
* separate because it needs to be awaitable). Both ticket tools run
|
|
286
|
+
* fully async execute() paths (unlike `expandVisibility`/
|
|
287
|
+
* `ifExistsVisible`, which feed core's SYNCHRONOUS wikilink/if_exists
|
|
288
|
+
* code and so need the shared pre-resolved-allowlist-holder
|
|
289
|
+
* machinery), so this can just be awaited inline. Omitted (unscoped /
|
|
290
|
+
* internal callers) → every note/attachment is visible.
|
|
291
|
+
*/
|
|
292
|
+
noteVisible?: (note: Note) => boolean | Promise<boolean>;
|
|
293
|
+
};
|
|
247
294
|
}
|
|
248
295
|
|
|
249
296
|
/**
|
|
@@ -310,7 +357,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
310
357
|
});
|
|
311
358
|
};
|
|
312
359
|
|
|
313
|
-
|
|
360
|
+
const tools: McpToolDef[] = [
|
|
314
361
|
|
|
315
362
|
// =====================================================================
|
|
316
363
|
// 1. query-notes — the universal read tool
|
|
@@ -411,6 +458,16 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
411
458
|
description:
|
|
412
459
|
'How `search` text is turned into an FTS5 query (vault#551). "literal" (DEFAULT): escape + phrase-quote the text so punctuation is literal content, not FTS5 syntax — the fix for `search: "didn\'t"` / "eleven-day" / "18.6" silently returning `[]`. "advanced": pass the text through to FTS5 raw, for callers who want boolean (AND/OR/NOT), manual phrase quoting, or prefix (`*`) syntax — a malformed advanced query throws a structured error (`error_type: "invalid_search_syntax"`) instead of silently returning `[]`. Has no effect without `search` (an `ignored_param` warning fires if you pass it without `search`). Omit for the default ("literal").',
|
|
413
460
|
},
|
|
461
|
+
near_text: {
|
|
462
|
+
type: "string",
|
|
463
|
+
description:
|
|
464
|
+
'EXPERIMENTAL (semantic search MVP — may change or be removed while quality is validated). Free text to rank notes by MEANING rather than keyword — "that idea about music remixes as community building" finds the note even if it never uses those exact words. Requires `semantic: true`; mutually exclusive with `search`/`aggregate`/`cursor`. Composes with every other filter (`tag`, `metadata`, date range, ...) exactly like `search` does — those narrow the candidate set FIRST, then ranking runs over just that set. Long notes are chunked internally and ranked by their BEST-matching section, so a match buried in one part of a long note still surfaces the whole note. Results carry a `score` field — cosine similarity in `[-1, 1]` (typically 0.2–0.9), NOT the same scale as `search`\'s bm25 `score`; only meaningful as a relative ranking within one result set.',
|
|
465
|
+
},
|
|
466
|
+
semantic: {
|
|
467
|
+
type: "boolean",
|
|
468
|
+
description:
|
|
469
|
+
'EXPERIMENTAL. Opt into vector ranking via `near_text` (required when true). No embedding provider configured, or the vault hasn\'t finished indexing, is reported HONESTLY — never a silent fallback to keyword search: a provider-less vault throws a structured `semantic_unavailable` error; a mid-backfill vault returns real (possibly partial) results plus an `embeddings_pending` warning naming how many candidate notes aren\'t embedded yet.',
|
|
470
|
+
},
|
|
414
471
|
metadata: {
|
|
415
472
|
type: "object",
|
|
416
473
|
description: "Filter by metadata values. Each value is either a primitive (exact match, scans JSON) or an operator object: `{eq|ne|gt|gte|lt|lte|in|not_in|exists: value}`. Operator objects require the field to be declared `indexed: true` in a tag schema — they route through the backing B-tree index. Multiple operators on one field AND together (e.g. `{gt: 5, lt: 10}`). `in`/`not_in` take arrays; `exists` takes a boolean.",
|
|
@@ -498,7 +555,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
498
555
|
include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
|
|
499
556
|
expand_links: { type: "boolean", description: "Inline [[wikilinks]] in returned content (default: false). Has no effect if content is not included (e.g., default list mode with include_content=false); wikilinks inside fenced or inline code are not expanded." },
|
|
500
557
|
expand_depth: { type: "number", description: "Recursion depth for link expansion (default 1, max 3). Only meaningful in 'full' mode — 'summary' mode does not recurse." },
|
|
501
|
-
expand_mode: { type: "string", enum: ["full", "summary"], description: "Expansion rendering: 'full' inlines the linked note's content, 'summary' inlines
|
|
558
|
+
expand_mode: { type: "string", enum: ["full", "summary"], description: "Expansion rendering: 'full' inlines the linked note's content, 'summary' inlines metadata.summary — falling back to the note's opening paragraph when no metadata.summary exists. Default: 'full'." },
|
|
502
559
|
},
|
|
503
560
|
},
|
|
504
561
|
execute: async (params) => {
|
|
@@ -639,6 +696,12 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
639
696
|
"INVALID_QUERY",
|
|
640
697
|
);
|
|
641
698
|
}
|
|
699
|
+
if (cursorMode && params.semantic) {
|
|
700
|
+
throw new QueryError(
|
|
701
|
+
`cursor is incompatible with semantic search — ranking is by similarity, not a stable row order to page through.`,
|
|
702
|
+
"INVALID_QUERY",
|
|
703
|
+
);
|
|
704
|
+
}
|
|
642
705
|
// Tag-expansion axis (vault tag `expand` axis). Validate loudly so a
|
|
643
706
|
// typo'd value doesn't silently fall back to the default.
|
|
644
707
|
let expand: TagExpandMode | undefined;
|
|
@@ -678,6 +741,13 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
678
741
|
{ error_type: "invalid_query", field: "aggregate", hint: "drop `cursor` when using `aggregate`" },
|
|
679
742
|
);
|
|
680
743
|
}
|
|
744
|
+
if (params.semantic) {
|
|
745
|
+
throw new QueryError(
|
|
746
|
+
`aggregate is incompatible with semantic search — a rollup returns groups, not ranked notes.`,
|
|
747
|
+
"INVALID_QUERY",
|
|
748
|
+
{ error_type: "invalid_query", field: "aggregate", hint: "drop `semantic`/`near_text` when using `aggregate`" },
|
|
749
|
+
);
|
|
750
|
+
}
|
|
681
751
|
const aggRaw = params.aggregate as Record<string, unknown>;
|
|
682
752
|
if (typeof aggRaw !== "object" || aggRaw === null || Array.isArray(aggRaw)) {
|
|
683
753
|
throw new QueryError(
|
|
@@ -770,7 +840,109 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
770
840
|
// before the result reaches the caller, so an out-of-scope tag name
|
|
771
841
|
// never leaks via `did_you_mean`.
|
|
772
842
|
let queryWarnings: QueryWarning[] = [];
|
|
773
|
-
|
|
843
|
+
// `near_text` only does anything alongside `semantic: true` (mirrors
|
|
844
|
+
// the `search_mode`-without-`search` ignored_param case above).
|
|
845
|
+
if (params.near_text !== undefined && !params.semantic) {
|
|
846
|
+
queryWarnings.push(
|
|
847
|
+
ignoredParamWarning(
|
|
848
|
+
"near_text",
|
|
849
|
+
"`semantic: true` is required to activate near_text — pass both together",
|
|
850
|
+
),
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
// --- Semantic search (EXPERIMENTAL — semantic search MVP) ---
|
|
854
|
+
if (params.semantic) {
|
|
855
|
+
if (params.search) {
|
|
856
|
+
throw new QueryError(
|
|
857
|
+
`semantic is incompatible with full-text search — pick one (semantic ranks by meaning via near_text; search ranks by keyword).`,
|
|
858
|
+
"INVALID_QUERY",
|
|
859
|
+
{
|
|
860
|
+
error_type: "invalid_query",
|
|
861
|
+
field: "semantic",
|
|
862
|
+
hint: "drop `search` when using `semantic`, or drop `semantic`/`near_text` to use keyword search",
|
|
863
|
+
},
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
if (typeof params.near_text !== "string" || params.near_text.trim() === "") {
|
|
867
|
+
throw new QueryError(
|
|
868
|
+
`semantic: true requires \`near_text\` — the free text to rank notes by meaning.`,
|
|
869
|
+
"INVALID_QUERY",
|
|
870
|
+
{
|
|
871
|
+
error_type: "invalid_query",
|
|
872
|
+
field: "near_text",
|
|
873
|
+
hint: `pass near_text: "..." alongside semantic: true`,
|
|
874
|
+
},
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
// `search_mode` only shapes `search` text parsing — a stray value
|
|
878
|
+
// alongside `semantic` is almost certainly a leftover from a
|
|
879
|
+
// keyword query, so flag it (same policy as the structured-query
|
|
880
|
+
// branch below).
|
|
881
|
+
if (searchMode !== undefined) {
|
|
882
|
+
queryWarnings.push(
|
|
883
|
+
ignoredParamWarning(
|
|
884
|
+
"search_mode",
|
|
885
|
+
"no `search` was provided — search_mode only affects full-text search query parsing",
|
|
886
|
+
),
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
const tags = normalizeTags(params.tag);
|
|
890
|
+
const excludeTagsRaw = params.exclude_tags ?? params.excludeTags ?? params.exclude_tag;
|
|
891
|
+
const excludeTags = normalizeTags(excludeTagsRaw);
|
|
892
|
+
const semanticOpts: QueryOpts = {
|
|
893
|
+
tags,
|
|
894
|
+
tagMatch: (params.tag_match as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
|
|
895
|
+
expand,
|
|
896
|
+
excludeTags,
|
|
897
|
+
hasTags: params.has_tags as boolean | undefined,
|
|
898
|
+
hasLinks: params.has_links as boolean | undefined,
|
|
899
|
+
hasBrokenLinks: params.has_broken_links as boolean | undefined,
|
|
900
|
+
path: params.path as string | undefined,
|
|
901
|
+
pathPrefix: params.path_prefix as string | undefined,
|
|
902
|
+
extension: params.extension as string | string[] | undefined,
|
|
903
|
+
// Same `near[]` neighborhood push-down `search`/structured-query
|
|
904
|
+
// use — a semantic query can be scoped to a graph neighborhood too.
|
|
905
|
+
ids: nearScope ? [...nearScope] : undefined,
|
|
906
|
+
metadata: params.metadata as Record<string, unknown> | undefined,
|
|
907
|
+
createdBy: params.created_by as string | undefined,
|
|
908
|
+
lastUpdatedBy: params.last_updated_by as string | undefined,
|
|
909
|
+
createdVia: params.created_via as string | undefined,
|
|
910
|
+
lastUpdatedVia: params.last_updated_via as string | undefined,
|
|
911
|
+
dateFrom: params.date_from as string | undefined,
|
|
912
|
+
dateTo: params.date_to as string | undefined,
|
|
913
|
+
dateFilter: params.date_filter as
|
|
914
|
+
| { field?: string; from?: string; to?: string }
|
|
915
|
+
| undefined,
|
|
916
|
+
limit: (params.limit as number) ?? 50,
|
|
917
|
+
};
|
|
918
|
+
// Uncaught on purpose: `semantic_unavailable` (no/not-ready
|
|
919
|
+
// provider) propagates to src/mcp-http.ts's QueryError → JSON-RPC
|
|
920
|
+
// error mapping, same as `invalid_search_syntax` above — never a
|
|
921
|
+
// silent fallback to keyword search.
|
|
922
|
+
const semanticResult = await store.semanticSearch(params.near_text, semanticOpts);
|
|
923
|
+
results = semanticResult.notes;
|
|
924
|
+
if (semanticResult.pendingCount > 0) {
|
|
925
|
+
queryWarnings.push(
|
|
926
|
+
embeddingsPendingWarning(semanticResult.pendingCount, semanticResult.totalCandidates),
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
} else if (params.search) {
|
|
930
|
+
// `offset` under full-text search (vault contracts-brief V1.2):
|
|
931
|
+
// `searchNotes` has no offset parameter at all — FTS5 ranks by
|
|
932
|
+
// relevance, not a stable row order, so paging by offset over it
|
|
933
|
+
// is unsound. Rather than silently return page 1 with no signal,
|
|
934
|
+
// flag it. Presence check (`!== undefined`) so `offset: 0` — a
|
|
935
|
+
// caller being explicit about "start" — still warns. The
|
|
936
|
+
// structured-query branch (below) has real offset support and is
|
|
937
|
+
// unaffected.
|
|
938
|
+
if (params.offset !== undefined) {
|
|
939
|
+
queryWarnings.push(
|
|
940
|
+
ignoredParamWarning(
|
|
941
|
+
"offset",
|
|
942
|
+
"full-text search has no stable row order to offset into — results are always page 1, ranked by relevance",
|
|
943
|
+
),
|
|
944
|
+
);
|
|
945
|
+
}
|
|
774
946
|
// Normalize tag param
|
|
775
947
|
const tags = normalizeTags(params.tag);
|
|
776
948
|
const mode: SearchMode = searchMode ?? "literal";
|
|
@@ -887,6 +1059,13 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
887
1059
|
nextCursor = page.next_cursor;
|
|
888
1060
|
} else {
|
|
889
1061
|
results = await store.queryNotes(queryOpts);
|
|
1062
|
+
// Truncation-honesty (vault contracts-brief V1.3): no cursor was
|
|
1063
|
+
// requested, so `next_cursor` never surfaces as an honest
|
|
1064
|
+
// "more may follow" signal — this is that signal. Mirrors the
|
|
1065
|
+
// REST structured-query path in src/routes.ts.
|
|
1066
|
+
if (results.length === queryOpts.limit) {
|
|
1067
|
+
queryWarnings.push(truncatedResultsWarning(queryOpts.limit));
|
|
1068
|
+
}
|
|
890
1069
|
}
|
|
891
1070
|
}
|
|
892
1071
|
|
|
@@ -1221,6 +1400,12 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
1221
1400
|
...updates,
|
|
1222
1401
|
actor: writeActor,
|
|
1223
1402
|
via: writeVia,
|
|
1403
|
+
// `tagsForSchemaResolution` (vault#date-field-type review round
|
|
1404
|
+
// 2) — same bug/fix as the update-note handler: `store.tagNote`
|
|
1405
|
+
// below runs AFTER this UPDATE, so without the PROJECTED tag
|
|
1406
|
+
// set here, `date`-field normalization inside store.updateNote
|
|
1407
|
+
// would miss a field newly declared by an incoming tag.
|
|
1408
|
+
tagsForSchemaResolution: [...projectedTags],
|
|
1224
1409
|
});
|
|
1225
1410
|
}
|
|
1226
1411
|
|
|
@@ -1479,7 +1664,7 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
1479
1664
|
- For batch: pass a \`notes\` array, each with an \`id\` field.
|
|
1480
1665
|
- **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design). **Batch default (vault#554):** a top-level \`force\` and/or \`if_updated_at\` alongside a \`notes\` array applies as the DEFAULT for every item that doesn't set its own — e.g. \`{force: true, notes: [{id: "a", content: "..."}, {id: "b", content: "...", if_updated_at: "..."}]}\` forces item "a" but still enforces the precondition on item "b" (its own \`if_updated_at\` wins). Per-item values always take precedence over the top-level default.
|
|
1481
1666
|
- **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
|
|
1482
|
-
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.
|
|
1667
|
+
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`, \`displayTitle\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present. \`displayTitle\` is the note's first non-empty content line (heading markers stripped, ~120 chars max), \`null\` when content is empty — never stored, computed fresh from content already in hand.
|
|
1483
1668
|
|
|
1484
1669
|
Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\` (the principal + interface of the first write) and \`lastUpdatedBy\`/\`lastUpdatedVia\` (the most recent write). NULL on notes written before attribution existed. Filter on them with \`created_by\`/\`last_updated_by\`/\`created_via\`/\`last_updated_via\`.`,
|
|
1485
1670
|
inputSchema: {
|
|
@@ -1553,7 +1738,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1553
1738
|
},
|
|
1554
1739
|
include_content: {
|
|
1555
1740
|
type: "boolean",
|
|
1556
|
-
description: "Response shape opt-out. Default `true` (returns the full Note with content). Set `false` to receive the lean index shape (drops `content`, adds `byteSize
|
|
1741
|
+
description: "Response shape opt-out. Default `true` (returns the full Note with content). Set `false` to receive the lean index shape (drops `content`, adds `byteSize`, a whitespace-collapsed `preview`, and a computed `displayTitle`). `validation_status` is preserved on the lean shape when present. Applies uniformly to single and batch responses.",
|
|
1557
1742
|
},
|
|
1558
1743
|
include_links: {
|
|
1559
1744
|
type: "boolean",
|
|
@@ -1936,10 +2121,18 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1936
2121
|
// --- Strict-schema gate (vault#299 Part A) ---
|
|
1937
2122
|
// Validate the PROSPECTIVE shape (final tags + merged metadata,
|
|
1938
2123
|
// including a state_transition's `to`) before the write so a
|
|
1939
|
-
// rejection leaves the note untouched.
|
|
2124
|
+
// rejection leaves the note untouched. `projectedTags` is hoisted
|
|
2125
|
+
// out of this block (not just used here) — the `store.updateNote`
|
|
2126
|
+
// call below also needs it, as `tagsForSchemaResolution`, so that
|
|
2127
|
+
// `date`-field normalization sees a tag ADDED in this SAME call
|
|
2128
|
+
// (vault#date-field-type review round 2 — the actual `store.tagNote`
|
|
2129
|
+
// mutation happens AFTER the core UPDATE, so without this the
|
|
2130
|
+
// schema resolution inside `store.updateNote` would still be
|
|
2131
|
+
// looking at the note's stale pre-write tag set).
|
|
2132
|
+
let projectedTags: Set<string>;
|
|
1940
2133
|
{
|
|
1941
2134
|
const removeSet = new Set<string>((item.tags as any)?.remove ?? []);
|
|
1942
|
-
|
|
2135
|
+
projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
|
|
1943
2136
|
for (const t of ((item.tags as any)?.add as string[] | undefined) ?? []) projectedTags.add(t);
|
|
1944
2137
|
const baseMeta = updates.metadata ?? ((note.metadata as Record<string, unknown>) ?? {});
|
|
1945
2138
|
const projectedMeta = stItem !== undefined
|
|
@@ -1983,6 +2176,12 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1983
2176
|
// updated_at on a no-op).
|
|
1984
2177
|
updates.actor = writeActor;
|
|
1985
2178
|
updates.via = writeVia;
|
|
2179
|
+
// `tagsForSchemaResolution` (vault#date-field-type review round
|
|
2180
|
+
// 2) — the PROJECTED final tag set, so `date`-field
|
|
2181
|
+
// normalization inside store.updateNote sees a tag ADDED in
|
|
2182
|
+
// this same call, not just the note's pre-write tags. See the
|
|
2183
|
+
// comment on `projectedTags`'s declaration above.
|
|
2184
|
+
updates.tagsForSchemaResolution = [...projectedTags];
|
|
1986
2185
|
// store.updateNote routes through noteOps.updateNote, which runs
|
|
1987
2186
|
// the UPDATE (with optional `AND updated_at IS ?`) atomically and
|
|
1988
2187
|
// throws ConflictError on mismatch. No mutations have happened
|
|
@@ -2246,11 +2445,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2246
2445
|
additionalProperties: {
|
|
2247
2446
|
type: "object",
|
|
2248
2447
|
properties: {
|
|
2249
|
-
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object, reference — all
|
|
2448
|
+
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object, reference, date — all eight are accepted for storage + advisory validation; any OTHER value is rejected outright (error_type invalid_field_type, vault#555 — bundled with every other violation in the same call, see the `update-tag` tool description). Only string/integer/boolean/reference/date are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field). `reference` is a DUAL-WRITE type (typed-reference-field): the value is stored + validated exactly like `string` (pass a note id, path, or title), AND create-note/update-note additionally resolve that value to a note and maintain a graph `links` edge from this note to it, with `relationship` set to the field name — kept in sync on every write that changes the field (a new value re-points the link; clearing the field drops it). A target that doesn't resolve yet is queued and backfills automatically, same as a structured `links` entry — see `docs/design/typed-reference-field.md`. `date` stores/validates exactly like `string`, but the value must be an ISO-8601 date (`2026-07-09`) or full timestamp (`2026-07-09T00:00:00.000Z`) — an unparseable value is a type_mismatch (advisory) or a rejected write (`strict: true` / `indexed: true`), same treatment as any other type mismatch. A full timestamp carrying an explicit `±HH:MM` offset is normalized to canonical UTC (`Z`-suffixed) on write — the offset is accepted, but not persisted verbatim — so indexed `date` fields sort/filter correctly under the TEXT comparison `gt`/`gte`/`lt`/`lte`/`date_filter`/`order_by` all use; a bare date (no time component) is left as-is." },
|
|
2250
2449
|
description: { type: "string" },
|
|
2251
2450
|
enum: { type: "array", items: { type: "string" }, description: "Allowed values. Does NOT auto-backfill — a note that omits this field stays without it unless `default` is also set (vault#553; the pre-0.7.0 behavior of silently defaulting to the first enum value is retired). Set `default` explicitly if you want backfill." },
|
|
2252
2451
|
default: { description: "Explicit backfill value (vault#553) applied when a note gains this tag without setting the field. Must conform to this field's own `type` (and `enum`, if declared) — a non-conforming default is rejected (invalid_default / invalid_field_default) rather than silently stored. Omit entirely to leave the field ABSENT (not backfilled) on notes that don't set it — this is what makes `exists:false` a trustworthy \"never set\" query." },
|
|
2253
|
-
indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed. Only string/integer/boolean/reference are indexable. Indexed ⇒ a type-mismatched write is HARD-REJECTED (schema_validation), not just warned — vault#553." },
|
|
2452
|
+
indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed. Only string/integer/boolean/reference/date are indexable. Indexed ⇒ a type-mismatched write is HARD-REJECTED (schema_validation), not just warned — vault#553." },
|
|
2254
2453
|
strict: { type: "boolean", description: "vault#299. Default false (advisory). When true, ALL of this field's declared constraints (type + enum + required + cardinality) are ENFORCED — a violating write is rejected with a schema_validation error, not just warned. All-or-nothing per field; free-form fields on a strict tag simply leave strict off. Note: `indexed: true` fields enforce their TYPE constraint regardless of this flag (vault#553)." },
|
|
2255
2454
|
required: { type: "boolean", description: "vault#299. The field must be present + non-null on a note with this tag. Advisory unless `strict: true`." },
|
|
2256
2455
|
cardinality: { type: "string", enum: ["one", "many"], description: "vault#299. 'one' (scalar, default) or 'many' (array). Advisory unless `strict: true`." },
|
|
@@ -2625,6 +2824,202 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2625
2824
|
},
|
|
2626
2825
|
|
|
2627
2826
|
];
|
|
2827
|
+
|
|
2828
|
+
// =====================================================================
|
|
2829
|
+
// 12/13. request-attachment-upload / request-attachment-download —
|
|
2830
|
+
// runtime-lane attachment tickets (Wave 1). Present ONLY when the
|
|
2831
|
+
// server layer wires an AttachmentTicketProvider — see
|
|
2832
|
+
// `GenerateMcpToolsOpts.attachmentTickets`'s doc comment (D10, "tools
|
|
2833
|
+
// omitted when unwired"). Bytes never pass through either tool: the
|
|
2834
|
+
// model gets back a URL + a literal curl_example; a runtime with a
|
|
2835
|
+
// shell spends it directly, outside this MCP session entirely.
|
|
2836
|
+
// =====================================================================
|
|
2837
|
+
const ticketSeam = opts?.attachmentTickets;
|
|
2838
|
+
if (ticketSeam) {
|
|
2839
|
+
tools.push(
|
|
2840
|
+
{
|
|
2841
|
+
name: "request-attachment-upload",
|
|
2842
|
+
requiredVerb: "write",
|
|
2843
|
+
description:
|
|
2844
|
+
"Mint a short-lived, single-use upload URL for a note attachment. Bytes never pass through this tool — you get back a URL (+ a ready-to-run `curl_example`) your runtime's shell spends directly; no MCP session credential is needed to spend it. Provide the target `note` (id or path), the `filename`, and its exact `size_bytes` — declared here and enforced at spend (a mismatch, or exceeding the 100 MiB REST upload cap, fails the mint or the upload). `mime_type` is inferred from the filename's extension when omitted. Pass `transcribe: true` for an audio file to enqueue it exactly like the REST attach flow does. The ticket's `expires_at` scales with declared size (10 minutes base + 10s per MiB, capped at 30 minutes) and can be spent exactly once — a failed curl means re-minting, not retrying the same URL.",
|
|
2845
|
+
inputSchema: {
|
|
2846
|
+
type: "object",
|
|
2847
|
+
properties: {
|
|
2848
|
+
note: { type: "string", description: "Target note ID or path" },
|
|
2849
|
+
filename: { type: "string", description: "Original filename — sanitized for a blocked extension (active-content types: .html/.svg/.xml/.js/.css/…) and used to infer the MIME type when `mime_type` is omitted." },
|
|
2850
|
+
size_bytes: {
|
|
2851
|
+
type: "number",
|
|
2852
|
+
description: `Declared upload size in bytes. Must be > 0 and <= ${MAX_TICKET_UPLOAD_BYTES} (100 MiB — the same ceiling REST's own /storage/upload enforces). The spend endpoint rejects (413) any upload that exceeds this declared size.`,
|
|
2853
|
+
},
|
|
2854
|
+
mime_type: { type: "string", description: "MIME type to store on the attachment row. Inferred from `filename`'s extension when omitted (`application/octet-stream` for an uncurated extension)." },
|
|
2855
|
+
transcribe: { type: "boolean", description: "Opt into transcription for an audio attachment — mirrors the REST `POST /notes/:id/attachments` `transcribe` flag." },
|
|
2856
|
+
},
|
|
2857
|
+
required: ["note", "filename", "size_bytes"],
|
|
2858
|
+
},
|
|
2859
|
+
execute: async (params) => {
|
|
2860
|
+
const noteRef = params.note;
|
|
2861
|
+
if (typeof noteRef !== "string" || noteRef.trim() === "") {
|
|
2862
|
+
throw structuredError("`note` is required", {
|
|
2863
|
+
error_type: "missing_required_field",
|
|
2864
|
+
field: "note",
|
|
2865
|
+
how_to: "pass the target note's id or path",
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
const filename = params.filename;
|
|
2869
|
+
if (typeof filename !== "string" || filename.trim() === "") {
|
|
2870
|
+
throw structuredError("`filename` is required", {
|
|
2871
|
+
error_type: "missing_required_field",
|
|
2872
|
+
field: "filename",
|
|
2873
|
+
how_to: "pass the original filename you're about to upload",
|
|
2874
|
+
});
|
|
2875
|
+
}
|
|
2876
|
+
const sizeBytes = params.size_bytes;
|
|
2877
|
+
if (typeof sizeBytes !== "number" || !Number.isFinite(sizeBytes) || sizeBytes <= 0) {
|
|
2878
|
+
throw structuredError("`size_bytes` must be a positive number", {
|
|
2879
|
+
error_type: "invalid_query",
|
|
2880
|
+
field: "size_bytes",
|
|
2881
|
+
how_to: "pass the exact byte length of the file you're about to upload",
|
|
2882
|
+
});
|
|
2883
|
+
}
|
|
2884
|
+
if (sizeBytes > MAX_TICKET_UPLOAD_BYTES) {
|
|
2885
|
+
throw structuredError(
|
|
2886
|
+
`size_bytes (${sizeBytes}) exceeds the ${MAX_TICKET_UPLOAD_BYTES} byte (100 MiB) upload cap`,
|
|
2887
|
+
{
|
|
2888
|
+
error_type: "file_too_large",
|
|
2889
|
+
field: "size_bytes",
|
|
2890
|
+
limit: MAX_TICKET_UPLOAD_BYTES,
|
|
2891
|
+
got: sizeBytes,
|
|
2892
|
+
how_to: "REST /storage/upload shares this same 100 MiB ceiling — split the file or upload it out-of-band",
|
|
2893
|
+
},
|
|
2894
|
+
);
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
const note = requireNote(db, noteRef);
|
|
2898
|
+
if (ticketSeam.noteVisible && !(await ticketSeam.noteVisible(note))) {
|
|
2899
|
+
// Uniform not_found — a tag-scoped caller learns nothing about
|
|
2900
|
+
// an out-of-scope note's existence (same posture as every other
|
|
2901
|
+
// scope-gated tool in this file).
|
|
2902
|
+
throw structuredError(`Note not found: "${noteRef}"`, { error_type: "not_found", field: "note" });
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
const ext = sanitizeAttachmentExtension(filename);
|
|
2906
|
+
if (BLOCKED_ATTACHMENT_EXTENSIONS.has(ext)) {
|
|
2907
|
+
throw structuredError(`File type ${ext} not allowed (active/executable content)`, {
|
|
2908
|
+
error_type: "blocked_upload_extension",
|
|
2909
|
+
field: "filename",
|
|
2910
|
+
extension: ext,
|
|
2911
|
+
how_to: "rename with a non-executable extension, or store the content as note text instead",
|
|
2912
|
+
});
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
const mimeType =
|
|
2916
|
+
typeof params.mime_type === "string" && params.mime_type.trim() !== ""
|
|
2917
|
+
? params.mime_type
|
|
2918
|
+
: mimeForAttachmentExtension(ext);
|
|
2919
|
+
|
|
2920
|
+
const now = Date.now();
|
|
2921
|
+
const expiresAt = now + computeTicketTtlMs(sizeBytes);
|
|
2922
|
+
const id = generateTicketId();
|
|
2923
|
+
const ticket: AttachmentTicket = {
|
|
2924
|
+
id,
|
|
2925
|
+
kind: "upload",
|
|
2926
|
+
vaultName: ticketSeam.vaultName,
|
|
2927
|
+
createdAt: now,
|
|
2928
|
+
expiresAt,
|
|
2929
|
+
noteId: note.id,
|
|
2930
|
+
filename,
|
|
2931
|
+
mimeType,
|
|
2932
|
+
sizeBytes,
|
|
2933
|
+
transcribe: params.transcribe === true,
|
|
2934
|
+
};
|
|
2935
|
+
await ticketSeam.provider.put(ticket);
|
|
2936
|
+
|
|
2937
|
+
const url = `${ticketSeam.urlBase}/tickets/${id}`;
|
|
2938
|
+
return {
|
|
2939
|
+
method: "PUT",
|
|
2940
|
+
url,
|
|
2941
|
+
headers: { "content-type": mimeType },
|
|
2942
|
+
expires_at: new Date(expiresAt).toISOString(),
|
|
2943
|
+
max_bytes: sizeBytes,
|
|
2944
|
+
curl_example: `curl -X PUT -H 'content-type: ${mimeType}' --data-binary @${filename} '${url}'`,
|
|
2945
|
+
};
|
|
2946
|
+
},
|
|
2947
|
+
},
|
|
2948
|
+
{
|
|
2949
|
+
name: "request-attachment-download",
|
|
2950
|
+
requiredVerb: "read",
|
|
2951
|
+
description:
|
|
2952
|
+
"Mint a short-lived, single-use download URL for an existing attachment's bytes. Bytes never pass through this tool — you get back a URL (+ a ready-to-run `curl_example`) your runtime's shell spends directly; no MCP session credential is needed to spend it. Pass the `attachment_id` from a note's `include_attachments: true` rows (query-notes) or `GET .../attachments`. The ticket's `expires_at` follows the same size-scaled window as upload tickets (10 minutes base, up to 30) and can be spent exactly once.",
|
|
2953
|
+
inputSchema: {
|
|
2954
|
+
type: "object",
|
|
2955
|
+
properties: {
|
|
2956
|
+
attachment_id: { type: "string", description: "The attachment's id (from a note's attachment rows)" },
|
|
2957
|
+
},
|
|
2958
|
+
required: ["attachment_id"],
|
|
2959
|
+
},
|
|
2960
|
+
execute: async (params) => {
|
|
2961
|
+
const attachmentId = params.attachment_id;
|
|
2962
|
+
if (typeof attachmentId !== "string" || attachmentId.trim() === "") {
|
|
2963
|
+
throw structuredError("`attachment_id` is required", {
|
|
2964
|
+
error_type: "missing_required_field",
|
|
2965
|
+
field: "attachment_id",
|
|
2966
|
+
how_to: "pass the attachment id from a note's attachment rows",
|
|
2967
|
+
});
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
const attachment = await store.getAttachment(attachmentId);
|
|
2971
|
+
if (!attachment) {
|
|
2972
|
+
throw structuredError(`Attachment not found: "${attachmentId}"`, {
|
|
2973
|
+
error_type: "not_found",
|
|
2974
|
+
field: "attachment_id",
|
|
2975
|
+
});
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
if (ticketSeam.noteVisible) {
|
|
2979
|
+
const owningNote = await store.getNote(attachment.noteId);
|
|
2980
|
+
// A missing owning note (shouldn't happen — ON DELETE CASCADE —
|
|
2981
|
+
// but never trust it) collapses to the same not_found as an
|
|
2982
|
+
// out-of-scope note: no oracle either way.
|
|
2983
|
+
if (!owningNote || !(await ticketSeam.noteVisible(owningNote))) {
|
|
2984
|
+
throw structuredError(`Attachment not found: "${attachmentId}"`, {
|
|
2985
|
+
error_type: "not_found",
|
|
2986
|
+
field: "attachment_id",
|
|
2987
|
+
});
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
const metaSize = attachment.metadata?.size;
|
|
2992
|
+
const declaredSize = typeof metaSize === "number" ? metaSize : undefined;
|
|
2993
|
+
const now = Date.now();
|
|
2994
|
+
const expiresAt = now + computeTicketTtlMs(declaredSize);
|
|
2995
|
+
const id = generateTicketId();
|
|
2996
|
+
const ticket: AttachmentTicket = {
|
|
2997
|
+
id,
|
|
2998
|
+
kind: "download",
|
|
2999
|
+
vaultName: ticketSeam.vaultName,
|
|
3000
|
+
createdAt: now,
|
|
3001
|
+
expiresAt,
|
|
3002
|
+
attachmentId: attachment.id,
|
|
3003
|
+
mimeType: attachment.mimeType,
|
|
3004
|
+
sizeBytes: declaredSize,
|
|
3005
|
+
};
|
|
3006
|
+
await ticketSeam.provider.put(ticket);
|
|
3007
|
+
|
|
3008
|
+
const url = `${ticketSeam.urlBase}/tickets/${id}`;
|
|
3009
|
+
return {
|
|
3010
|
+
method: "GET",
|
|
3011
|
+
url,
|
|
3012
|
+
mime_type: attachment.mimeType,
|
|
3013
|
+
...(declaredSize !== undefined ? { size_bytes: declaredSize } : {}),
|
|
3014
|
+
expires_at: new Date(expiresAt).toISOString(),
|
|
3015
|
+
curl_example: `curl -o downloaded${sanitizeAttachmentExtension(attachment.path) || ""} '${url}'`,
|
|
3016
|
+
};
|
|
3017
|
+
},
|
|
3018
|
+
},
|
|
3019
|
+
);
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
return tools;
|
|
2628
3023
|
}
|
|
2629
3024
|
|
|
2630
3025
|
// ---------------------------------------------------------------------------
|