@openparachute/vault 0.7.3-rc.5 → 0.7.3-rc.7
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/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/display-title.test.ts +149 -0
- package/core/src/mcp.ts +245 -4
- package/core/src/notes.ts +111 -4
- 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 +107 -0
- package/core/src/types.ts +6 -0
- package/core/src/vault-projection.ts +48 -0
- package/package.json +1 -1
- package/src/attachment-tickets.test.ts +350 -0
- package/src/attachment-tickets.ts +264 -0
- package/src/mcp-http.ts +13 -1
- package/src/mcp-tools.ts +49 -14
- package/src/routes.ts +12 -91
- package/src/routing.ts +17 -0
- package/src/vault.test.ts +35 -11
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Title axis (ratified 2026-07-17): a note's title IS its first line —
|
|
3
|
+
* derived from content, never stored. This file covers:
|
|
4
|
+
* - `computeDisplayTitle` derivation (first non-empty line, heading
|
|
5
|
+
* markers stripped, truncation, null-on-empty);
|
|
6
|
+
* - `displayTitle` presence on the lean `NoteIndex` shape, both via the
|
|
7
|
+
* MCP `query-notes` list path and the REST `GET /notes` list path.
|
|
8
|
+
*
|
|
9
|
+
* The search title-BOOST (ranking notes whose first line matches query
|
|
10
|
+
* terms above the rest) lives in `search-title-boost.test.ts` — a
|
|
11
|
+
* different concern (ranking) from derivation (this file).
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
14
|
+
import { Database } from "bun:sqlite";
|
|
15
|
+
import { SqliteStore } from "./store.js";
|
|
16
|
+
import { generateMcpTools } from "./mcp.js";
|
|
17
|
+
import { computeDisplayTitle, toNoteIndex, DISPLAY_TITLE_MAX_LEN } from "./notes.js";
|
|
18
|
+
import type { Note } from "./types.js";
|
|
19
|
+
|
|
20
|
+
describe("computeDisplayTitle", () => {
|
|
21
|
+
it("returns the first line when content is a single line", () => {
|
|
22
|
+
expect(computeDisplayTitle("Grocery List")).toBe("Grocery List");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns the first NON-EMPTY line, skipping leading blank lines", () => {
|
|
26
|
+
expect(computeDisplayTitle("\n\n \nReal Title\nbody text")).toBe("Real Title");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("strips a leading markdown heading marker + its whitespace", () => {
|
|
30
|
+
expect(computeDisplayTitle("# Grocery List\nmilk, eggs")).toBe("Grocery List");
|
|
31
|
+
expect(computeDisplayTitle("## Section Two\nbody")).toBe("Section Two");
|
|
32
|
+
expect(computeDisplayTitle("###### h6\nbody")).toBe("h6");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("strips a heading marker with no space after it", () => {
|
|
36
|
+
expect(computeDisplayTitle("###Title\nbody")).toBe("Title");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("treats a heading-marker-only line as blank and moves to the next line", () => {
|
|
40
|
+
expect(computeDisplayTitle("##\nReal Title")).toBe("Real Title");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does not strip markers past the first six (never a valid heading)", () => {
|
|
44
|
+
// 7 leading `#`s: the regex only matches 1-6, so the 7th `#` survives
|
|
45
|
+
// as ordinary content.
|
|
46
|
+
expect(computeDisplayTitle("####### Title")).toBe("# Title");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns null for empty content", () => {
|
|
50
|
+
expect(computeDisplayTitle("")).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns null for null/undefined content", () => {
|
|
54
|
+
expect(computeDisplayTitle(null)).toBeNull();
|
|
55
|
+
expect(computeDisplayTitle(undefined)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns null when content is whitespace-only", () => {
|
|
59
|
+
expect(computeDisplayTitle(" \n\t\n ")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns null when every line is a bare heading marker", () => {
|
|
63
|
+
expect(computeDisplayTitle("#\n##\n### ")).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("truncates to DISPLAY_TITLE_MAX_LEN code points", () => {
|
|
67
|
+
const longTitle = "a".repeat(200);
|
|
68
|
+
const result = computeDisplayTitle(longTitle)!;
|
|
69
|
+
expect(result.length).toBe(DISPLAY_TITLE_MAX_LEN);
|
|
70
|
+
expect(result).toBe("a".repeat(DISPLAY_TITLE_MAX_LEN));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not truncate a title at or under the limit", () => {
|
|
74
|
+
const exact = "a".repeat(DISPLAY_TITLE_MAX_LEN);
|
|
75
|
+
expect(computeDisplayTitle(exact)).toBe(exact);
|
|
76
|
+
const short = "short title";
|
|
77
|
+
expect(computeDisplayTitle(short)).toBe(short);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("truncates by Unicode code point, not UTF-16 code unit (no split surrogate pairs)", () => {
|
|
81
|
+
// Astral-plane emoji are 2 UTF-16 code units each but 1 code point.
|
|
82
|
+
const emojiTitle = "😀".repeat(150);
|
|
83
|
+
const result = computeDisplayTitle(emojiTitle)!;
|
|
84
|
+
expect(Array.from(result).length).toBe(DISPLAY_TITLE_MAX_LEN);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("toNoteIndex — displayTitle wiring", () => {
|
|
89
|
+
const baseNote: Note = {
|
|
90
|
+
id: "n1",
|
|
91
|
+
content: "# Real Title\nbody",
|
|
92
|
+
createdAt: new Date().toISOString(),
|
|
93
|
+
tags: [],
|
|
94
|
+
metadata: {},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
it("carries the computed display title onto the lean shape", () => {
|
|
98
|
+
const index = toNoteIndex(baseNote);
|
|
99
|
+
expect(index.displayTitle).toBe("Real Title");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("is null when content is empty", () => {
|
|
103
|
+
const index = toNoteIndex({ ...baseNote, content: "" });
|
|
104
|
+
expect(index.displayTitle).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("is null when content is missing entirely (defensive — matches preview's ?? \"\" fallback)", () => {
|
|
108
|
+
const { content: _drop, ...rest } = baseNote;
|
|
109
|
+
const index = toNoteIndex(rest as Note);
|
|
110
|
+
expect(index.displayTitle).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
let store: SqliteStore;
|
|
115
|
+
let db: Database;
|
|
116
|
+
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
db = new Database(":memory:");
|
|
119
|
+
store = new SqliteStore(db);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("displayTitle — list-shape presence (MCP)", () => {
|
|
123
|
+
it("query-notes list mode (include_content default false) carries displayTitle", async () => {
|
|
124
|
+
await store.createNote("# Meeting Notes\nagenda: budget", { path: "a" });
|
|
125
|
+
const tools = generateMcpTools(store);
|
|
126
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
127
|
+
const result = await queryNotes.execute({}) as any[];
|
|
128
|
+
expect(result).toHaveLength(1);
|
|
129
|
+
expect(result[0].displayTitle).toBe("Meeting Notes");
|
|
130
|
+
expect(result[0].content).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("query-notes include_content=true does NOT carry displayTitle (full Note shape, unchanged)", async () => {
|
|
134
|
+
await store.createNote("# Meeting Notes\nagenda: budget", { path: "a" });
|
|
135
|
+
const tools = generateMcpTools(store);
|
|
136
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
137
|
+
const result = await queryNotes.execute({ include_content: true }) as any[];
|
|
138
|
+
expect(result[0].content).toBeTruthy();
|
|
139
|
+
expect(result[0].displayTitle).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("displayTitle is null on the lean shape for an empty note", async () => {
|
|
143
|
+
await store.createNote("", { path: "empty" });
|
|
144
|
+
const tools = generateMcpTools(store);
|
|
145
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
146
|
+
const result = await queryNotes.execute({}) as any[];
|
|
147
|
+
expect(result[0].displayTitle).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|
package/core/src/mcp.ts
CHANGED
|
@@ -45,6 +45,18 @@ import {
|
|
|
45
45
|
contentRangeRequiresContent,
|
|
46
46
|
MIN_CONTENT_LENGTH,
|
|
47
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";
|
|
48
60
|
|
|
49
61
|
export interface McpToolDef {
|
|
50
62
|
name: string;
|
|
@@ -80,7 +92,7 @@ export interface McpToolDef {
|
|
|
80
92
|
*/
|
|
81
93
|
function structuredError(
|
|
82
94
|
message: string,
|
|
83
|
-
fields: { error_type: string; field?: string; hint?: string },
|
|
95
|
+
fields: { error_type: string; field?: string; hint?: string } & Record<string, unknown>,
|
|
84
96
|
): Error {
|
|
85
97
|
return Object.assign(new Error(message), fields);
|
|
86
98
|
}
|
|
@@ -246,6 +258,39 @@ export interface GenerateMcpToolsOpts {
|
|
|
246
258
|
* path with no extra fetch.
|
|
247
259
|
*/
|
|
248
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
|
+
};
|
|
249
294
|
}
|
|
250
295
|
|
|
251
296
|
/**
|
|
@@ -312,7 +357,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
312
357
|
});
|
|
313
358
|
};
|
|
314
359
|
|
|
315
|
-
|
|
360
|
+
const tools: McpToolDef[] = [
|
|
316
361
|
|
|
317
362
|
// =====================================================================
|
|
318
363
|
// 1. query-notes — the universal read tool
|
|
@@ -1619,7 +1664,7 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
1619
1664
|
- For batch: pass a \`notes\` array, each with an \`id\` field.
|
|
1620
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.
|
|
1621
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.
|
|
1622
|
-
- \`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.
|
|
1623
1668
|
|
|
1624
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\`.`,
|
|
1625
1670
|
inputSchema: {
|
|
@@ -1693,7 +1738,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1693
1738
|
},
|
|
1694
1739
|
include_content: {
|
|
1695
1740
|
type: "boolean",
|
|
1696
|
-
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.",
|
|
1697
1742
|
},
|
|
1698
1743
|
include_links: {
|
|
1699
1744
|
type: "boolean",
|
|
@@ -2779,6 +2824,202 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2779
2824
|
},
|
|
2780
2825
|
|
|
2781
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;
|
|
2782
3023
|
}
|
|
2783
3024
|
|
|
2784
3025
|
// ---------------------------------------------------------------------------
|
package/core/src/notes.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { computeExpandedTagCounts, loadTagHierarchy, stripTagHash } from "./tag-
|
|
|
25
25
|
import { chunkForInClause, IN_VIA_JSON_EACH, jsonEachParam } from "./sql-in.js";
|
|
26
26
|
import {
|
|
27
27
|
buildLiteralSearchQuery,
|
|
28
|
+
extractLiteralBoostTerms,
|
|
28
29
|
SEARCH_WEIGHT_PATH,
|
|
29
30
|
SEARCH_WEIGHT_CONTENT,
|
|
30
31
|
type SearchMode,
|
|
@@ -1810,6 +1811,61 @@ function searchSyntaxError(rawQuery: string, err: unknown, mode: SearchMode): Qu
|
|
|
1810
1811
|
});
|
|
1811
1812
|
}
|
|
1812
1813
|
|
|
1814
|
+
/**
|
|
1815
|
+
* Post-rank a search result page (title axis, ratified 2026-07-17) so
|
|
1816
|
+
* notes whose display title (first line of content) contains EVERY boost
|
|
1817
|
+
* term outrank the rest — a title-match tier ahead of a body-only-match
|
|
1818
|
+
* tier. Stable partition: within a tier, the incoming order (FTS5
|
|
1819
|
+
* relevance, or an explicit `sort`) is preserved untouched — two notes
|
|
1820
|
+
* that both match, or both don't, keep their relative order.
|
|
1821
|
+
*
|
|
1822
|
+
* No-migration by design (see the search-seam note on `searchNotes`
|
|
1823
|
+
* below): re-orders the already-fetched page in memory instead of adding
|
|
1824
|
+
* a weighted title column to `notes_fts`, which would need a schema
|
|
1825
|
+
* migration + backfill. `notes_fts`'s existing `SEARCH_WEIGHT_PATH` bm25
|
|
1826
|
+
* weighting (above) already boosts by note PATH — a different axis from
|
|
1827
|
+
* the ratified title model (title = first line of CONTENT, not path).
|
|
1828
|
+
* This function is the one that boosts by the content-derived title.
|
|
1829
|
+
*/
|
|
1830
|
+
function boostTitleMatches(notes: Note[], terms: string[]): Note[] {
|
|
1831
|
+
if (terms.length === 0) return notes;
|
|
1832
|
+
return notes
|
|
1833
|
+
.map((note, index) => ({ note, index, titleMatch: titleMatchesAllTerms(note.content, terms) }))
|
|
1834
|
+
.sort((a, b) => {
|
|
1835
|
+
if (a.titleMatch === b.titleMatch) return a.index - b.index; // stable
|
|
1836
|
+
return a.titleMatch ? -1 : 1;
|
|
1837
|
+
})
|
|
1838
|
+
.map((x) => x.note);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
function titleMatchesAllTerms(content: string | null | undefined, terms: string[]): boolean {
|
|
1842
|
+
const title = computeDisplayTitle(content);
|
|
1843
|
+
if (!title) return false;
|
|
1844
|
+
const lower = title.toLowerCase();
|
|
1845
|
+
return terms.every((t) => lower.includes(t));
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
/**
|
|
1849
|
+
* Apply the title-boost post-rank to a fetched search page. Skipped
|
|
1850
|
+
* whenever it would fight the caller's intent:
|
|
1851
|
+
* - an explicit `sort: "asc"|"desc"` means the caller wants chronological
|
|
1852
|
+
* order, not relevance re-ranking;
|
|
1853
|
+
* - `search_mode: "advanced"` text carries raw FTS5 syntax (boolean
|
|
1854
|
+
* operators, column filters, prefix `*`) — naively tokenizing it into
|
|
1855
|
+
* "terms" would misread operators as content (see
|
|
1856
|
+
* `extractLiteralBoostTerms`'s doc comment). Only literal-mode queries
|
|
1857
|
+
* (the default) get boosted today.
|
|
1858
|
+
*/
|
|
1859
|
+
function applySearchTitleBoost(
|
|
1860
|
+
notes: Note[],
|
|
1861
|
+
mode: SearchMode,
|
|
1862
|
+
rawQuery: string,
|
|
1863
|
+
sort: "asc" | "desc" | undefined,
|
|
1864
|
+
): Note[] {
|
|
1865
|
+
if (sort !== undefined || mode !== "literal") return notes;
|
|
1866
|
+
return boostTitleMatches(notes, extractLiteralBoostTerms(rawQuery));
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1813
1869
|
export function searchNotes(
|
|
1814
1870
|
db: Database,
|
|
1815
1871
|
query: string,
|
|
@@ -1846,6 +1902,14 @@ export function searchNotes(
|
|
|
1846
1902
|
// The weights are our own numeric constants (not user input) interpolated
|
|
1847
1903
|
// directly — bm25()'s weight arguments are positional per-column
|
|
1848
1904
|
// multipliers, not general SQL expressions callers can influence.
|
|
1905
|
+
//
|
|
1906
|
+
// NOTE: "title" here means the note's `path` — this predates the title
|
|
1907
|
+
// axis (ratified 2026-07-17), under which a note's title is its first
|
|
1908
|
+
// CONTENT line, not its path. `applySearchTitleBoost` (below) is the
|
|
1909
|
+
// mechanism for THAT axis: a post-rank pass over the already-scored page,
|
|
1910
|
+
// not a third FTS column. The two are complementary, not redundant — a
|
|
1911
|
+
// note can have a matching path with a non-matching first line, or vice
|
|
1912
|
+
// versa.
|
|
1849
1913
|
const scoreExpr = `(-1.0 * bm25(notes_fts, ${SEARCH_WEIGHT_PATH}, ${SEARCH_WEIGHT_CONTENT}))`;
|
|
1850
1914
|
|
|
1851
1915
|
// `sort` honored under search (vault#551 WS2A item 3): default stays FTS5
|
|
@@ -1886,7 +1950,7 @@ export function searchNotes(
|
|
|
1886
1950
|
ORDER BY ${orderBy}
|
|
1887
1951
|
LIMIT ?
|
|
1888
1952
|
`).all(ftsQuery, ...searchTags, limit) as (NoteRow & { score: number })[];
|
|
1889
|
-
return notesWithTags(db, rows, scoresById(rows));
|
|
1953
|
+
return applySearchTitleBoost(notesWithTags(db, rows, scoresById(rows)), mode, query, opts?.sort);
|
|
1890
1954
|
} catch (err) {
|
|
1891
1955
|
// Surface EVERY FTS5 error structured, never a raw rethrow (vault#551):
|
|
1892
1956
|
// advanced mode expects it (the caller passed raw syntax); literal
|
|
@@ -1908,7 +1972,7 @@ export function searchNotes(
|
|
|
1908
1972
|
ORDER BY ${orderBy}
|
|
1909
1973
|
LIMIT ?
|
|
1910
1974
|
`).all(ftsQuery, limit) as (NoteRow & { score: number })[];
|
|
1911
|
-
return notesWithTags(db, rows, scoresById(rows));
|
|
1975
|
+
return applySearchTitleBoost(notesWithTags(db, rows, scoresById(rows)), mode, query, opts?.sort);
|
|
1912
1976
|
} catch (err) {
|
|
1913
1977
|
if (err instanceof QueryError) throw err;
|
|
1914
1978
|
throw searchSyntaxError(query, err, mode);
|
|
@@ -2702,10 +2766,52 @@ export function mergeTags(
|
|
|
2702
2766
|
/** Max code points in a NoteIndex preview. */
|
|
2703
2767
|
export const NOTE_INDEX_PREVIEW_LEN = 120;
|
|
2704
2768
|
|
|
2769
|
+
/** Max code points in a computed `displayTitle` (title axis, ratified 2026-07-17). */
|
|
2770
|
+
export const DISPLAY_TITLE_MAX_LEN = 120;
|
|
2771
|
+
|
|
2772
|
+
/**
|
|
2773
|
+
* Derive a note's display title: the first non-empty line of `content`,
|
|
2774
|
+
* with a leading markdown heading marker (`#` through `######`) and its
|
|
2775
|
+
* following whitespace stripped, truncated to `DISPLAY_TITLE_MAX_LEN` code
|
|
2776
|
+
* points. `null` when content has no non-empty line (empty or
|
|
2777
|
+
* whitespace/heading-marker-only note).
|
|
2778
|
+
*
|
|
2779
|
+
* The ratified title model (2026-07-17): a note's title IS its first line
|
|
2780
|
+
* — derived from content, NEVER stored as its own column. This function is
|
|
2781
|
+
* the one place that derivation happens; callers (`toNoteIndex` below) call
|
|
2782
|
+
* it fresh at read time. Timestamp-path formatting — what a surface shows
|
|
2783
|
+
* in place of a `null` title — is deliberately NOT this function's job; it
|
|
2784
|
+
* reports the honest content-derived value (or its absence) and leaves
|
|
2785
|
+
* rendering to the caller.
|
|
2786
|
+
*/
|
|
2787
|
+
export function computeDisplayTitle(content: string | null | undefined): string | null {
|
|
2788
|
+
if (!content) return null;
|
|
2789
|
+
for (const rawLine of content.split("\n")) {
|
|
2790
|
+
const stripped = rawLine.replace(/^#{1,6}\s*/, "").trim();
|
|
2791
|
+
if (stripped === "") continue;
|
|
2792
|
+
// Iterate by Unicode code points so we don't split surrogate pairs mid-character.
|
|
2793
|
+
const codePoints = Array.from(stripped);
|
|
2794
|
+
return codePoints.length > DISPLAY_TITLE_MAX_LEN
|
|
2795
|
+
? codePoints.slice(0, DISPLAY_TITLE_MAX_LEN).join("")
|
|
2796
|
+
: stripped;
|
|
2797
|
+
}
|
|
2798
|
+
return null;
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2705
2801
|
/**
|
|
2706
2802
|
* Convert a full Note into its lean index shape:
|
|
2707
|
-
* drops `content`, adds `byteSize
|
|
2708
|
-
* Shared between the `query-notes` MCP tool, HTTP
|
|
2803
|
+
* drops `content`, adds `byteSize`, a whitespace-collapsed `preview`, and a
|
|
2804
|
+
* computed `displayTitle`. Shared between the `query-notes` MCP tool, HTTP
|
|
2805
|
+
* /notes endpoints, and /graph.
|
|
2806
|
+
*
|
|
2807
|
+
* Perf note: `displayTitle` costs nothing extra here — every caller of this
|
|
2808
|
+
* function already has the full `Note` (content included) in hand by the
|
|
2809
|
+
* time it's called. `queryNotes`'s two-phase page fetch selects a narrow
|
|
2810
|
+
* `id`-only column list for the ORDER BY/pagination phase, but the second
|
|
2811
|
+
* phase (`fetchNotesByIdsOrdered`) always `SELECT *`s the page's full rows
|
|
2812
|
+
* before `toNoteIndex` ever runs — same as `preview`/`byteSize` today. If a
|
|
2813
|
+
* future lean-list path is added that genuinely avoids a content read, it
|
|
2814
|
+
* must NOT call this function (or `toNoteIndex`) on a content-less row.
|
|
2709
2815
|
*/
|
|
2710
2816
|
export function toNoteIndex(note: Note): NoteIndex {
|
|
2711
2817
|
const content = note.content ?? "";
|
|
@@ -2732,6 +2838,7 @@ export function toNoteIndex(note: Note): NoteIndex {
|
|
|
2732
2838
|
metadata: note.metadata,
|
|
2733
2839
|
byteSize,
|
|
2734
2840
|
preview,
|
|
2841
|
+
displayTitle: computeDisplayTitle(note.content),
|
|
2735
2842
|
...(note.score !== undefined ? { score: note.score } : {}),
|
|
2736
2843
|
};
|
|
2737
2844
|
}
|
|
@@ -132,7 +132,15 @@ describe("search — v24 → v25 migration (legacy vault upgrade)", () => {
|
|
|
132
132
|
);
|
|
133
133
|
insert.run(
|
|
134
134
|
"legacy-1",
|
|
135
|
-
|
|
135
|
+
// Deliberately multi-line, with the "propolis" mention on a LATER
|
|
136
|
+
// line — this fixture isolates the PATH-weighted bm25 axis (below)
|
|
137
|
+
// from the separate content-title-boost axis (title axis, ratified
|
|
138
|
+
// 2026-07-17): that boost derives a note's "title" from its FIRST
|
|
139
|
+
// content line, and a single-line note IS its own title in full, so
|
|
140
|
+
// a one-line body mentioning "propolis" would (correctly, under the
|
|
141
|
+
// newer axis) count as a title match and confound this test's
|
|
142
|
+
// path-only signal. See `search-title-boost.test.ts` for that axis.
|
|
143
|
+
"Notes on foraging patterns\n\na dedicated writeup mentioning propolis only once in passing text",
|
|
136
144
|
"beekeeping-notes",
|
|
137
145
|
"2026-01-01T00:00:00.000Z",
|
|
138
146
|
"2026-01-01T00:00:00.000Z",
|