@openparachute/vault 0.7.3-rc.6 → 0.7.3-rc.8
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 +41 -0
- package/core/src/mcp.ts +243 -2
- package/core/src/notes.ts +33 -2
- package/core/src/search-title-boost.test.ts +18 -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 +19 -11
|
@@ -83,6 +83,47 @@ describe("computeDisplayTitle", () => {
|
|
|
83
83
|
const result = computeDisplayTitle(emojiTitle)!;
|
|
84
84
|
expect(Array.from(result).length).toBe(DISPLAY_TITLE_MAX_LEN);
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
describe("leading frontmatter block (misuse path — direct create with raw frontmatter)", () => {
|
|
88
|
+
it("skips a closed frontmatter block and derives the first line of the DOCUMENT", () => {
|
|
89
|
+
expect(computeDisplayTitle("---\ntitle: X\n---\n# Real Title")).toBe("Real Title");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("skips a closed frontmatter block with multiple fields + blank lines after", () => {
|
|
93
|
+
expect(
|
|
94
|
+
computeDisplayTitle("---\ntitle: X\ntags: [a, b]\n---\n\n\nReal Title\nbody"),
|
|
95
|
+
).toBe("Real Title");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("falls back to treating an UNTERMINATED opening `---` as ordinary content", () => {
|
|
99
|
+
// No closing fence anywhere in the content — a note whose real first
|
|
100
|
+
// line is literally "---" (e.g. a markdown horizontal rule opener)
|
|
101
|
+
// must not be mangled.
|
|
102
|
+
expect(computeDisplayTitle("---\nnot frontmatter, just a rule\nmore text")).toBe("---");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns null when content is EXACTLY a closed frontmatter block (nothing after)", () => {
|
|
106
|
+
expect(computeDisplayTitle("---\ntitle: X\n---\n")).toBeNull();
|
|
107
|
+
expect(computeDisplayTitle("---\ntitle: X\n---")).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns null when only blank lines follow a closed frontmatter block", () => {
|
|
111
|
+
expect(computeDisplayTitle("---\ntitle: X\n---\n\n \n")).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("does not treat a closing fence found past the bounded scan window as a frontmatter close", () => {
|
|
115
|
+
// Opening `---` with the closing fence past FRONTMATTER_SCAN_LINES
|
|
116
|
+
// (100): the scan gives up and falls back to ordinary-content
|
|
117
|
+
// behavior, deriving from line 0.
|
|
118
|
+
const filler = Array.from({ length: 150 }, (_, i) => `line ${i}`).join("\n");
|
|
119
|
+
expect(computeDisplayTitle(`---\n${filler}\n---\nReal Title`)).toBe("---");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("normal content with no leading `---` is byte-identical to prior behavior", () => {
|
|
123
|
+
expect(computeDisplayTitle("# Grocery List\nmilk, eggs")).toBe("Grocery List");
|
|
124
|
+
expect(computeDisplayTitle("Just a note\nbody text")).toBe("Just a note");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
86
127
|
});
|
|
87
128
|
|
|
88
129
|
describe("toNoteIndex — displayTitle wiring", () => {
|
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
|
|
@@ -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
|
@@ -2769,6 +2769,15 @@ export const NOTE_INDEX_PREVIEW_LEN = 120;
|
|
|
2769
2769
|
/** Max code points in a computed `displayTitle` (title axis, ratified 2026-07-17). */
|
|
2770
2770
|
export const DISPLAY_TITLE_MAX_LEN = 120;
|
|
2771
2771
|
|
|
2772
|
+
/**
|
|
2773
|
+
* Bounded line-count a leading frontmatter block's closing fence is
|
|
2774
|
+
* searched within (see `computeDisplayTitle`). Frontmatter blocks are a
|
|
2775
|
+
* handful of lines in practice; capping the scan keeps a pathological
|
|
2776
|
+
* "content starts with `---` but never closes it" note O(1)-ish rather
|
|
2777
|
+
* than a full-content scan on every derivation.
|
|
2778
|
+
*/
|
|
2779
|
+
const FRONTMATTER_SCAN_LINES = 100;
|
|
2780
|
+
|
|
2772
2781
|
/**
|
|
2773
2782
|
* Derive a note's display title: the first non-empty line of `content`,
|
|
2774
2783
|
* with a leading markdown heading marker (`#` through `######`) and its
|
|
@@ -2783,11 +2792,33 @@ export const DISPLAY_TITLE_MAX_LEN = 120;
|
|
|
2783
2792
|
* in place of a `null` title — is deliberately NOT this function's job; it
|
|
2784
2793
|
* reports the honest content-derived value (or its absence) and leaves
|
|
2785
2794
|
* rendering to the caller.
|
|
2795
|
+
*
|
|
2796
|
+
* Frontmatter skip: normal ingestion strips a YAML frontmatter block into
|
|
2797
|
+
* `metadata` before create, so `content` never carries one — but a direct
|
|
2798
|
+
* MCP/REST create can paste raw frontmatter-bearing text
|
|
2799
|
+
* (`---\ntitle: X\n---\n# Real Title`), and the first line of that
|
|
2800
|
+
* DOCUMENT is not the first line of its delimiter. When `content` opens
|
|
2801
|
+
* with a `---` line, derivation starts after the matching CLOSING `---`
|
|
2802
|
+
* (searched within the first `FRONTMATTER_SCAN_LINES` lines). An
|
|
2803
|
+
* unterminated opening `---` falls back to the pre-existing behavior —
|
|
2804
|
+
* scanning from line 0 — so a note whose real first line is literally
|
|
2805
|
+
* `---` isn't mangled.
|
|
2786
2806
|
*/
|
|
2787
2807
|
export function computeDisplayTitle(content: string | null | undefined): string | null {
|
|
2788
2808
|
if (!content) return null;
|
|
2789
|
-
|
|
2790
|
-
|
|
2809
|
+
const lines = content.split("\n");
|
|
2810
|
+
let startIndex = 0;
|
|
2811
|
+
if (lines[0]?.trim() === "---") {
|
|
2812
|
+
const scanLimit = Math.min(lines.length, FRONTMATTER_SCAN_LINES);
|
|
2813
|
+
for (let i = 1; i < scanLimit; i++) {
|
|
2814
|
+
if (lines[i]?.trim() === "---") {
|
|
2815
|
+
startIndex = i + 1;
|
|
2816
|
+
break;
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
for (let i = startIndex; i < lines.length; i++) {
|
|
2821
|
+
const stripped = lines[i]!.replace(/^#{1,6}\s*/, "").trim();
|
|
2791
2822
|
if (stripped === "") continue;
|
|
2792
2823
|
// Iterate by Unicode code points so we don't split surrogate pairs mid-character.
|
|
2793
2824
|
const codePoints = Array.from(stripped);
|
|
@@ -104,4 +104,22 @@ describe("search title-boost", () => {
|
|
|
104
104
|
const hits = await store.searchNotes("nonexistent_term_xyz");
|
|
105
105
|
expect(hits).toEqual([]);
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
it("boosts on the post-frontmatter title line, not the `---` delimiter (shares computeDisplayTitle's frontmatter skip)", async () => {
|
|
109
|
+
// Direct-create misuse path: raw frontmatter-bearing content. Without
|
|
110
|
+
// the frontmatter skip, the derived "title" would be the literal
|
|
111
|
+
// string "---", which never matches any query term, so this note
|
|
112
|
+
// would wrongly land in the body-only tier.
|
|
113
|
+
await store.createNote(
|
|
114
|
+
"---\ntitle: irrelevant\n---\n# Budget Review\nQ3 numbers",
|
|
115
|
+
{ path: "frontmatter-led" },
|
|
116
|
+
);
|
|
117
|
+
await store.createNote(
|
|
118
|
+
"Weekly Standup\nsomewhere we discuss the budget in passing",
|
|
119
|
+
{ path: "body-only" },
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const hits = await store.searchNotes("budget review");
|
|
123
|
+
expect(hits[0].path).toBe("frontmatter-led");
|
|
124
|
+
});
|
|
107
125
|
});
|
|
@@ -272,6 +272,42 @@ function sqliteToUserType(t: string): string {
|
|
|
272
272
|
return t.toLowerCase();
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// Attachments orientation block (attachment-tickets design, Wave 0+1)
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The Attachments orientation block, rendered into the connect-time
|
|
281
|
+
* markdown brief (`projectionToMarkdown`) so every agent learns, every
|
|
282
|
+
* session, how to move file bytes into/out of this vault without spending
|
|
283
|
+
* its own context on them (bytes never ride MCP either way).
|
|
284
|
+
*
|
|
285
|
+
* `ticketsEnabled` reflects whether THIS door has wired an
|
|
286
|
+
* `AttachmentTicketProvider` (bun: always, as of this PR; cloud: not yet
|
|
287
|
+
* — its mirror is a separate PR). An unwired door omits BOTH the ticket
|
|
288
|
+
* tools from `tools/list` (see `generateMcpTools`'s `attachmentTickets`
|
|
289
|
+
* opt) AND the ticket-tool sentences here — the brief never dangles a
|
|
290
|
+
* pointer at a tool the agent can't actually call.
|
|
291
|
+
*
|
|
292
|
+
* Kept as a single dense paragraph (not its own multi-line list) to stay
|
|
293
|
+
* inside the connect-time brief's token budget — see
|
|
294
|
+
* `projectionToMarkdown`'s doc comment.
|
|
295
|
+
*/
|
|
296
|
+
export function attachmentsInstructionBlock(opts: { ticketsEnabled: boolean }): string {
|
|
297
|
+
const sentences: string[] = [
|
|
298
|
+
"Notes can carry file attachments (`include_attachments: true` on `query-notes` returns their rows; bytes don't ride MCP).",
|
|
299
|
+
];
|
|
300
|
+
if (opts.ticketsEnabled) {
|
|
301
|
+
sentences.push(
|
|
302
|
+
"To move bytes, call `request-attachment-upload` / `request-attachment-download` — each mints a short-lived, single-use URL (with a ready-to-run `curl_example`) your shell spends directly; no MCP session credential is needed to spend it.",
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
sentences.push(
|
|
306
|
+
"If your runtime holds this vault's own API token, REST works too: upload = `POST {base}/storage/upload` (multipart `file`, ≤100 MB) then `POST {base}/notes/{id}/attachments` `{path, mimeType, transcribe?}`; download = `GET {base}/storage/{path}` with the same `Authorization: Bearer`. Audio attached with `transcribe: true` is transcribed automatically.",
|
|
307
|
+
);
|
|
308
|
+
return sentences.join(" ");
|
|
309
|
+
}
|
|
310
|
+
|
|
275
311
|
// ---------------------------------------------------------------------------
|
|
276
312
|
// Markdown rendering — for getServerInstruction
|
|
277
313
|
// ---------------------------------------------------------------------------
|
|
@@ -302,6 +338,13 @@ export function projectionToMarkdown(args: {
|
|
|
302
338
|
* known and always surfaced. See `chooseHubOrigin` (src/mcp-install.ts).
|
|
303
339
|
*/
|
|
304
340
|
coordinates?: { hubOrigin: string; hubOriginKnown: boolean };
|
|
341
|
+
/**
|
|
342
|
+
* Attachments orientation (see `attachmentsInstructionBlock`). Omitted
|
|
343
|
+
* defaults to `{ ticketsEnabled: false }` — safe-by-default so a caller
|
|
344
|
+
* that hasn't wired ticket support yet (or a test fixture) never
|
|
345
|
+
* accidentally advertises tools it can't back.
|
|
346
|
+
*/
|
|
347
|
+
attachments?: { ticketsEnabled: boolean };
|
|
305
348
|
}): string {
|
|
306
349
|
const { vaultName, description, projection, coordinates } = args;
|
|
307
350
|
const stats = projection.stats;
|
|
@@ -418,6 +461,11 @@ export function projectionToMarkdown(args: {
|
|
|
418
461
|
lines.push("");
|
|
419
462
|
lines.push("If schema or tags change during this session, call `vault-info` to refresh the full projection. Call `list-tags { include_schema: true }` for tag-only details.");
|
|
420
463
|
|
|
464
|
+
lines.push("");
|
|
465
|
+
lines.push("## Attachments");
|
|
466
|
+
lines.push("");
|
|
467
|
+
lines.push(attachmentsInstructionBlock(args.attachments ?? { ticketsEnabled: false }));
|
|
468
|
+
|
|
421
469
|
// Scripting pointer block: the connect-time brief used to dead-end on
|
|
422
470
|
// querying — an agent had no path to "how do I script/automate against this
|
|
423
471
|
// vault." Point at the guide rather than inlining it, to keep this brief
|