@openparachute/vault 0.7.3-rc.13 → 0.7.3-rc.2

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.
Files changed (53) hide show
  1. package/README.md +3 -5
  2. package/core/src/conformance.ts +1 -2
  3. package/core/src/content-range.test.ts +0 -127
  4. package/core/src/content-range.ts +0 -100
  5. package/core/src/contract-typed-index.test.ts +3 -4
  6. package/core/src/core.test.ts +4 -521
  7. package/core/src/expand.ts +3 -11
  8. package/core/src/indexed-fields.test.ts +3 -9
  9. package/core/src/indexed-fields.ts +1 -9
  10. package/core/src/mcp.ts +10 -601
  11. package/core/src/notes.ts +4 -201
  12. package/core/src/schema-defaults.ts +1 -85
  13. package/core/src/search-fts-v25.test.ts +1 -9
  14. package/core/src/search-query.test.ts +0 -42
  15. package/core/src/search-query.ts +0 -27
  16. package/core/src/seed-packs.test.ts +1 -117
  17. package/core/src/seed-packs.ts +1 -217
  18. package/core/src/store.ts +3 -59
  19. package/core/src/tag-schemas.ts +12 -27
  20. package/core/src/types.ts +1 -7
  21. package/core/src/vault-projection.ts +0 -55
  22. package/package.json +1 -1
  23. package/src/add-pack.test.ts +0 -32
  24. package/src/auth-hub-jwt.test.ts +1 -118
  25. package/src/auth.ts +0 -64
  26. package/src/cli.ts +1 -5
  27. package/src/contract-errors.test.ts +1 -2
  28. package/src/mcp-http.ts +4 -33
  29. package/src/mcp-tools.ts +14 -61
  30. package/src/oauth-discovery.ts +0 -31
  31. package/src/onboarding-seed.test.ts +0 -64
  32. package/src/routes.ts +95 -92
  33. package/src/routing.test.ts +4 -229
  34. package/src/routing.ts +23 -152
  35. package/src/scopes.ts +0 -22
  36. package/src/server.ts +0 -7
  37. package/src/storage.test.ts +1 -200
  38. package/src/transcription-worker.test.ts +0 -151
  39. package/src/transcription-worker.ts +52 -113
  40. package/src/vault.test.ts +11 -48
  41. package/core/src/attachment/bytes-provider.ts +0 -65
  42. package/core/src/attachment/policy.test.ts +0 -66
  43. package/core/src/attachment/policy.ts +0 -131
  44. package/core/src/attachment/tickets.test.ts +0 -45
  45. package/core/src/attachment/tickets.ts +0 -117
  46. package/core/src/attachment-tickets-tool.test.ts +0 -286
  47. package/core/src/display-title.test.ts +0 -190
  48. package/core/src/lede.test.ts +0 -96
  49. package/core/src/search-title-boost.test.ts +0 -125
  50. package/src/attachment-bytes.ts +0 -68
  51. package/src/attachment-tickets.test.ts +0 -475
  52. package/src/attachment-tickets.ts +0 -340
  53. package/src/read-attachment.test.ts +0 -436
@@ -1,190 +0,0 @@
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
- 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
- });
127
- });
128
-
129
- describe("toNoteIndex — displayTitle wiring", () => {
130
- const baseNote: Note = {
131
- id: "n1",
132
- content: "# Real Title\nbody",
133
- createdAt: new Date().toISOString(),
134
- tags: [],
135
- metadata: {},
136
- };
137
-
138
- it("carries the computed display title onto the lean shape", () => {
139
- const index = toNoteIndex(baseNote);
140
- expect(index.displayTitle).toBe("Real Title");
141
- });
142
-
143
- it("is null when content is empty", () => {
144
- const index = toNoteIndex({ ...baseNote, content: "" });
145
- expect(index.displayTitle).toBeNull();
146
- });
147
-
148
- it("is null when content is missing entirely (defensive — matches preview's ?? \"\" fallback)", () => {
149
- const { content: _drop, ...rest } = baseNote;
150
- const index = toNoteIndex(rest as Note);
151
- expect(index.displayTitle).toBeNull();
152
- });
153
- });
154
-
155
- let store: SqliteStore;
156
- let db: Database;
157
-
158
- beforeEach(() => {
159
- db = new Database(":memory:");
160
- store = new SqliteStore(db);
161
- });
162
-
163
- describe("displayTitle — list-shape presence (MCP)", () => {
164
- it("query-notes list mode (include_content default false) carries displayTitle", async () => {
165
- await store.createNote("# Meeting Notes\nagenda: budget", { path: "a" });
166
- const tools = generateMcpTools(store);
167
- const queryNotes = tools.find((t) => t.name === "query-notes")!;
168
- const result = await queryNotes.execute({}) as any[];
169
- expect(result).toHaveLength(1);
170
- expect(result[0].displayTitle).toBe("Meeting Notes");
171
- expect(result[0].content).toBeUndefined();
172
- });
173
-
174
- it("query-notes include_content=true does NOT carry displayTitle (full Note shape, unchanged)", async () => {
175
- await store.createNote("# Meeting Notes\nagenda: budget", { path: "a" });
176
- const tools = generateMcpTools(store);
177
- const queryNotes = tools.find((t) => t.name === "query-notes")!;
178
- const result = await queryNotes.execute({ include_content: true }) as any[];
179
- expect(result[0].content).toBeTruthy();
180
- expect(result[0].displayTitle).toBeUndefined();
181
- });
182
-
183
- it("displayTitle is null on the lean shape for an empty note", async () => {
184
- await store.createNote("", { path: "empty" });
185
- const tools = generateMcpTools(store);
186
- const queryNotes = tools.find((t) => t.name === "query-notes")!;
187
- const result = await queryNotes.execute({}) as any[];
188
- expect(result[0].displayTitle).toBeNull();
189
- });
190
- });
@@ -1,96 +0,0 @@
1
- /**
2
- * `computeLede` — the first non-empty paragraph AFTER a note's title line,
3
- * used by `expand_mode: "summary"` (core/src/expand.ts) as a fallback when
4
- * `metadata.summary` is absent (summaries-as-content, 2026-07-17 dialogue).
5
- *
6
- * Shares its title-line rule (including the leading-frontmatter skip) with
7
- * `computeDisplayTitle` — see `display-title.test.ts` for that function's
8
- * own coverage. This file covers only the lede-specific behavior: where the
9
- * paragraph starts, how it's collapsed, the length cap, and the
10
- * title-only/no-content null cases.
11
- */
12
- import { describe, it, expect } from "bun:test";
13
- import { computeLede, LEDE_MAX_LEN } from "./notes.js";
14
-
15
- describe("computeLede", () => {
16
- it("returns the first paragraph after a heading title, blank-line separated", () => {
17
- expect(
18
- computeLede("# Title\n\nThis is the lede paragraph.\n\nSecond paragraph — not the lede."),
19
- ).toBe("This is the lede paragraph.");
20
- });
21
-
22
- it("joins a multi-line paragraph into one collapsed line", () => {
23
- expect(
24
- computeLede("# Title\n\nThis lede paragraph\nspans two lines.\n\nSecond paragraph."),
25
- ).toBe("This lede paragraph spans two lines.");
26
- });
27
-
28
- it("treats text immediately following the title (no blank line) as the lede", () => {
29
- expect(computeLede("Grocery List\nmilk, eggs\ncheese")).toBe("milk, eggs cheese");
30
- });
31
-
32
- it("collapses internal whitespace runs to single spaces", () => {
33
- expect(computeLede("Title\n\nLine one with extra spaces.")).toBe(
34
- "Line one with extra spaces.",
35
- );
36
- });
37
-
38
- it("returns null for a title-only note (does not repeat the title)", () => {
39
- expect(computeLede("Just a title")).toBeNull();
40
- expect(computeLede("# Just a title")).toBeNull();
41
- });
42
-
43
- it("returns null when only blank lines follow the title", () => {
44
- expect(computeLede("# Title\n\n\n \n")).toBeNull();
45
- });
46
-
47
- it("returns null for empty, null, or undefined content", () => {
48
- expect(computeLede("")).toBeNull();
49
- expect(computeLede(null)).toBeNull();
50
- expect(computeLede(undefined)).toBeNull();
51
- });
52
-
53
- it("returns null when content is whitespace-only (no title at all)", () => {
54
- expect(computeLede(" \n\t\n ")).toBeNull();
55
- });
56
-
57
- describe("leading frontmatter block", () => {
58
- it("finds the lede after a closed frontmatter block AND its title line", () => {
59
- expect(
60
- computeLede("---\ntitle: X\n---\n# Real Title\n\nThe lede text."),
61
- ).toBe("The lede text.");
62
- });
63
-
64
- it("finds the lede when the title immediately follows frontmatter with no blank line", () => {
65
- expect(
66
- computeLede("---\ntitle: X\n---\nReal Title\nlede text right after."),
67
- ).toBe("lede text right after.");
68
- });
69
-
70
- it("returns null for a frontmatter-only note with no body after the title", () => {
71
- expect(computeLede("---\ntitle: X\n---\nReal Title")).toBeNull();
72
- });
73
- });
74
-
75
- describe("length cap", () => {
76
- it("truncates to LEDE_MAX_LEN code points", () => {
77
- const longParagraph = "a".repeat(1000);
78
- const result = computeLede(`# Title\n\n${longParagraph}`)!;
79
- expect(result.length).toBe(LEDE_MAX_LEN);
80
- expect(result).toBe("a".repeat(LEDE_MAX_LEN));
81
- });
82
-
83
- it("does not truncate a lede at or under the cap", () => {
84
- const exact = "a".repeat(LEDE_MAX_LEN);
85
- expect(computeLede(`# Title\n\n${exact}`)).toBe(exact);
86
- const short = "short lede";
87
- expect(computeLede(`# Title\n\n${short}`)).toBe(short);
88
- });
89
-
90
- it("truncates by Unicode code point, not UTF-16 code unit (no split surrogate pairs)", () => {
91
- const emojiParagraph = "😀".repeat(500);
92
- const result = computeLede(`# Title\n\n${emojiParagraph}`)!;
93
- expect(Array.from(result).length).toBe(LEDE_MAX_LEN);
94
- });
95
- });
96
- });
@@ -1,125 +0,0 @@
1
- /**
2
- * Search title-boost (title axis, ratified 2026-07-17): literal-mode
3
- * search results whose `displayTitle` (first non-empty content line, NOT
4
- * `path`) contains every query term are post-ranked ahead of body-only
5
- * matches. No-migration — an in-memory re-rank of the already-fetched
6
- * page, not a `notes_fts` schema change (see `applySearchTitleBoost` /
7
- * `boostTitleMatches` in `notes.ts`).
8
- *
9
- * This is a DIFFERENT axis from the existing `path`-weighted bm25 ranking
10
- * covered in `search-fts-v25.test.ts` — a note's title here is its first
11
- * CONTENT line, not its `path`.
12
- */
13
- import { describe, it, expect, beforeEach } from "bun:test";
14
- import { Database } from "bun:sqlite";
15
- import { SqliteStore } from "./store.js";
16
-
17
- let store: SqliteStore;
18
- let db: Database;
19
-
20
- beforeEach(() => {
21
- db = new Database(":memory:");
22
- store = new SqliteStore(db);
23
- });
24
-
25
- describe("search title-boost", () => {
26
- it("ranks a first-line (title) match above a body-only match", async () => {
27
- // Body-only match: "budget" appears only deep in the body, first line
28
- // is unrelated.
29
- await store.createNote(
30
- "Weekly Standup\nlots of unrelated notes here, but somewhere we discuss the budget in passing",
31
- { path: "standup-notes" },
32
- );
33
- // First-line (title) match: "Budget" is the first line itself.
34
- await store.createNote("Budget Review\nQ3 numbers", { path: "budget-review" });
35
-
36
- const hits = await store.searchNotes("budget");
37
- expect(hits.length).toBeGreaterThanOrEqual(2);
38
- expect(hits[0].path).toBe("budget-review");
39
- });
40
-
41
- it("requires ALL query terms to be present in the first line to boost (a partial title match doesn't qualify)", async () => {
42
- // "budget" is in the title but "review" is only in the body — a
43
- // PARTIAL title match, which stays in the body-only tier.
44
- await store.createNote("Budget notes\nsee the quarterly review for details", { path: "partial" });
45
- // Both "budget" and "review" are in the title — a FULL title match.
46
- await store.createNote("Budget Review\nQ3 numbers", { path: "full-match" });
47
-
48
- const hits = await store.searchNotes("budget review");
49
- expect(hits.map((n) => n.path)).toEqual(["full-match", "partial"]);
50
- });
51
-
52
- it("ties (both notes in the same tier) keep the existing relevance order", async () => {
53
- // Two title-matching notes plus one body-only note. Comparing literal
54
- // mode (boosted) against advanced mode with the same plain-word query
55
- // (identical FTS5 syntax, so identical underlying bm25 order, but no
56
- // boost pass) isolates the boost's effect: within the title-match
57
- // tier, the boost must preserve whatever relative order the
58
- // underlying relevance ranking already gave — a stable sort, not a
59
- // fresh comparator that could reshuffle ties.
60
- await store.createNote("Project Widget\nfirst, mentions widget again for relevance", { path: "w1" });
61
- await store.createNote("Project Widget\nsecond", { path: "w2" });
62
- await store.createNote("Unrelated\nbody-only mention of widget project", { path: "body-only" });
63
-
64
- const boosted = await store.searchNotes("widget project");
65
- const rawOrder = await store.searchNotes("widget project", { mode: "advanced" });
66
-
67
- const titleTierBoosted = boosted.map((n) => n.path).filter((p) => p === "w1" || p === "w2");
68
- const titleTierRaw = rawOrder.map((n) => n.path).filter((p) => p === "w1" || p === "w2");
69
- expect(titleTierBoosted).toEqual(titleTierRaw);
70
- // The title-match tier precedes the body-only note in the boosted result.
71
- expect(boosted.map((n) => n.path).indexOf("body-only")).toBeGreaterThan(
72
- Math.max(...["w1", "w2"].map((p) => boosted.map((n) => n.path).indexOf(p))),
73
- );
74
- });
75
-
76
- it("does not disturb ordering when an explicit sort is requested", async () => {
77
- await store.createNote("Zebra body-only mention of gadget", { path: "z" });
78
- await store.createNote("Gadget Launch\nfirst line title match", { path: "a" });
79
-
80
- const asc = await store.searchNotes("gadget", { sort: "asc" });
81
- // created_at ASC means "z" (created first) comes before "a" — the
82
- // title-boost must NOT override an explicit sort.
83
- expect(asc.map((n) => n.path)).toEqual(["z", "a"]);
84
- });
85
-
86
- it("does not apply the boost under search_mode: advanced (raw FTS5 syntax, not naively tokenizable)", async () => {
87
- await store.createNote("Body only mentions gadget in passing text here", { path: "body-only" });
88
- await store.createNote("Gadget\nfirst line title match", { path: "title-match" });
89
-
90
- const advanced = await store.searchNotes("gadget", { mode: "advanced" });
91
- const literal = await store.searchNotes("gadget");
92
-
93
- // Literal mode boosts the title match to the front.
94
- expect(literal[0].path).toBe("title-match");
95
- // Advanced mode is unaffected by the boost — order comes from bm25
96
- // relevance alone (title-match still likely ranks first via the
97
- // EXISTING path-weighted bm25, but that's a different mechanism; the
98
- // test here just confirms advanced mode doesn't throw and returns
99
- // both notes, not that ordering matches/mismatches literal mode).
100
- expect(advanced.map((n) => n.path).sort()).toEqual(["body-only", "title-match"]);
101
- });
102
-
103
- it("a query with no results is unaffected (empty array in, empty array out)", async () => {
104
- const hits = await store.searchNotes("nonexistent_term_xyz");
105
- expect(hits).toEqual([]);
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
- });
125
- });
@@ -1,68 +0,0 @@
1
- /**
2
- * Attachment bytes — bun's filesystem implementation of the model-lane
3
- * (Wave 2) `AttachmentBytesProvider` seam (`core/src/attachment/bytes-provider.ts`).
4
- *
5
- * Stateless by design (unlike the ticket provider, there's no in-memory
6
- * state to share across requests) — a fresh instance per MCP session is
7
- * cheap, so `createFsAttachmentBytesProvider` is a plain factory, not a
8
- * shared singleton.
9
- *
10
- * `readRange` uses `Bun.file(path).slice(start, end)` — a bounded,
11
- * positional read (Bun resolves the slice lazily via `pread` under the
12
- * hood), never the whole-file `readFileSync` this design explicitly moves
13
- * away from (see `handleStorage`'s REST `Range` support in `src/routes.ts`
14
- * for the sibling fix on the byte-serve side).
15
- */
16
-
17
- import { existsSync, statSync } from "fs";
18
- import { join, normalize } from "path";
19
- import type { Attachment } from "../core/src/types.ts";
20
- import type { AttachmentBytesProvider } from "../core/src/attachment/bytes-provider.ts";
21
- import { assetsDir } from "./config.ts";
22
- import { transcriptPathFor } from "./transcript-note.ts";
23
- import { getVaultStore } from "./vault-store.ts";
24
-
25
- /**
26
- * Resolve + confine an attachment's on-disk path under this vault's assets
27
- * dir. Same guard as the ticket download spend route (`src/attachment-tickets.ts`)
28
- * and the REST byte-serve route (`src/routes.ts`): normalize, then require
29
- * the result to still start with the (normalized) assets root — a stored
30
- * `path` can never resolve outside it, but a defense-in-depth check costs
31
- * nothing. Returns `null` on a traversal attempt (treated identically to
32
- * "file doesn't exist" by every caller here).
33
- */
34
- function resolveConfinedPath(vaultName: string, attachment: Attachment): string | null {
35
- const assets = assetsDir(vaultName);
36
- const filePath = normalize(join(assets, attachment.path));
37
- if (!filePath.startsWith(normalize(assets))) return null;
38
- return filePath;
39
- }
40
-
41
- class FsAttachmentBytesProvider implements AttachmentBytesProvider {
42
- constructor(private readonly vaultName: string) {}
43
-
44
- async stat(attachment: Attachment): Promise<{ size: number } | null> {
45
- const filePath = resolveConfinedPath(this.vaultName, attachment);
46
- if (!filePath || !existsSync(filePath)) return null;
47
- return { size: statSync(filePath).size };
48
- }
49
-
50
- async readRange(attachment: Attachment, start: number, end: number): Promise<Uint8Array> {
51
- const filePath = resolveConfinedPath(this.vaultName, attachment);
52
- if (!filePath) return new Uint8Array(0);
53
- const slice = Bun.file(filePath).slice(start, end);
54
- return new Uint8Array(await slice.arrayBuffer());
55
- }
56
-
57
- async resolveTranscriptNote(attachment: Attachment): Promise<{ id: string; path: string } | null> {
58
- const store = getVaultStore(this.vaultName);
59
- const note = await store.getNoteByPath(transcriptPathFor(attachment.path));
60
- if (!note) return null;
61
- return { id: note.id, path: note.path ?? transcriptPathFor(attachment.path) };
62
- }
63
- }
64
-
65
- /** Build a fresh `AttachmentBytesProvider` scoped to one vault. Cheap — safe to call per-request. */
66
- export function createFsAttachmentBytesProvider(vaultName: string): AttachmentBytesProvider {
67
- return new FsAttachmentBytesProvider(vaultName);
68
- }