@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.
@@ -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
- return [
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` and a whitespace-collapsed `preview`). `validation_status` is preserved on the lean shape when present. Applies uniformly to single and batch responses.",
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` and a whitespace-collapsed `preview`.
2708
- * Shared between the `query-notes` MCP tool, HTTP /notes endpoints, and /graph.
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
- "a dedicated writeup mentioning propolis only once in passing text",
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",