@disco_trooper/apple-notes-mcp 1.0.1 → 1.2.0

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,174 @@
1
+ /**
2
+ * Note title resolution and disambiguation.
3
+ *
4
+ * Handles resolving user input (title, folder/title, id:xxx) to a specific note.
5
+ */
6
+
7
+ import { runJxa } from "run-jxa";
8
+ import { createDebugLogger } from "../utils/debug.js";
9
+ import type { NoteSuggestion } from "../errors/index.js";
10
+ import { getNoteById } from "./read.js";
11
+
12
+ const debug = createDebugLogger("NOTES");
13
+
14
+ /**
15
+ * Result of resolving a note title to a specific note.
16
+ */
17
+ export interface ResolvedNote {
18
+ /** Whether resolution was successful */
19
+ success: boolean;
20
+ /** The matched note (if exactly one match) */
21
+ note?: {
22
+ title: string;
23
+ folder: string;
24
+ id: string;
25
+ };
26
+ /** Error message if resolution failed */
27
+ error?: string;
28
+ /** Rich suggestions when multiple matches found (includes ID and created date) */
29
+ suggestions?: NoteSuggestion[];
30
+ }
31
+
32
+ /**
33
+ * Execute JXA code safely with error handling.
34
+ */
35
+ async function executeJxa<T>(code: string): Promise<T> {
36
+ try {
37
+ const result = await runJxa(code);
38
+ return result as T;
39
+ } catch (error) {
40
+ debug("JXA execution error:", error);
41
+ throw new Error(
42
+ `JXA execution failed: ${error instanceof Error ? error.message : String(error)}`
43
+ );
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Resolve a note title input to a unique note.
49
+ *
50
+ * Handles:
51
+ * - "id:xxx" format for direct ID lookup
52
+ * - Exact title match
53
+ * - "folder/title" format for disambiguation
54
+ * - Returns suggestions when multiple matches exist
55
+ *
56
+ * @param input - The note title, "folder/title", or "id:xxx" string
57
+ * @returns Resolution result with success status, note info, or suggestions
58
+ */
59
+ export async function resolveNoteTitle(input: string): Promise<ResolvedNote> {
60
+ debug(`Resolving note title: ${input}`);
61
+
62
+ // Check for id:xxx format for direct ID lookup
63
+ if (input.startsWith("id:")) {
64
+ const noteId = input.slice(3);
65
+ debug(`ID prefix detected, looking up note by ID: ${noteId}`);
66
+ const noteDetails = await getNoteById(noteId);
67
+ if (!noteDetails) {
68
+ return {
69
+ success: false,
70
+ error: `Note not found with ID: "${noteId}"`,
71
+ };
72
+ }
73
+ return {
74
+ success: true,
75
+ note: {
76
+ id: noteDetails.id,
77
+ title: noteDetails.title,
78
+ folder: noteDetails.folder,
79
+ },
80
+ };
81
+ }
82
+
83
+ // Check for folder/title format
84
+ let targetFolder: string | null = null;
85
+ let targetTitle = input;
86
+
87
+ if (input.includes("/")) {
88
+ const parts = input.split("/");
89
+ targetFolder = parts.slice(0, -1).join("/");
90
+ targetTitle = parts[parts.length - 1];
91
+ debug(`Parsed folder: ${targetFolder}, title: ${targetTitle}`);
92
+ }
93
+
94
+ const escapedTitle = JSON.stringify(targetTitle);
95
+ const escapedFolder = targetFolder ? JSON.stringify(targetFolder) : "null";
96
+
97
+ const jxaCode = `
98
+ const app = Application('Notes');
99
+ app.includeStandardAdditions = true;
100
+
101
+ const targetTitle = ${escapedTitle};
102
+ const targetFolder = ${escapedFolder};
103
+
104
+ let foundNotes = [];
105
+ const folders = app.folders();
106
+
107
+ for (const folder of folders) {
108
+ const folderName = folder.name();
109
+
110
+ // Skip if folder filter is specified and doesn't match
111
+ if (targetFolder !== null && folderName !== targetFolder) {
112
+ continue;
113
+ }
114
+
115
+ const notes = folder.notes.whose({ name: targetTitle });
116
+
117
+ for (let i = 0; i < notes.length; i++) {
118
+ try {
119
+ const note = notes[i];
120
+ const props = note.properties();
121
+ foundNotes.push({
122
+ id: note.id(),
123
+ title: note.name(),
124
+ folder: folderName,
125
+ created: props.creationDate ? props.creationDate.toISOString() : ''
126
+ });
127
+ } catch (e) {
128
+ // Skip notes that can't be accessed
129
+ }
130
+ }
131
+ }
132
+
133
+ return JSON.stringify(foundNotes);
134
+ `;
135
+
136
+ const result = await executeJxa<string>(jxaCode);
137
+ const notes = JSON.parse(result) as Array<{
138
+ id: string;
139
+ title: string;
140
+ folder: string;
141
+ created: string;
142
+ }>;
143
+
144
+ if (notes.length === 0) {
145
+ debug("No matching notes found");
146
+ return {
147
+ success: false,
148
+ error: `Note not found: "${input}"`,
149
+ };
150
+ }
151
+
152
+ if (notes.length === 1) {
153
+ debug(`Resolved to unique note: ${notes[0].folder}/${notes[0].title}`);
154
+ return {
155
+ success: true,
156
+ note: notes[0],
157
+ };
158
+ }
159
+
160
+ // Multiple matches - return rich suggestions with ID and created date
161
+ const suggestions: NoteSuggestion[] = notes.map((n) => ({
162
+ id: n.id,
163
+ folder: n.folder,
164
+ title: n.title,
165
+ created: n.created,
166
+ }));
167
+ debug(`Multiple matches found: ${suggestions.map(s => `${s.folder}/${s.title}`).join(", ")}`);
168
+
169
+ return {
170
+ success: false,
171
+ error: `Multiple notes found with title "${targetTitle}". Use ID prefix to specify.`,
172
+ suggestions,
173
+ };
174
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseTable, updateTableCell, findTables } from "./tables.js";
3
+
4
+ const SAMPLE_TABLE_HTML = `<object><table cellspacing="0" cellpadding="0" style="border-collapse: collapse">
5
+ <tbody>
6
+ <tr><td valign="top" style="border-style: solid"><div><b>Typ</b></div></td><td valign="top" style="border-style: solid"><div><b>Částka</b></div></td></tr>
7
+ <tr><td valign="top" style="border-style: solid"><div>Sociální</div></td><td valign="top" style="border-style: solid"><div>9 154 Kč</div></td></tr>
8
+ <tr><td valign="top" style="border-style: solid"><div>Zdravotní</div></td><td valign="top" style="border-style: solid"><div>3 848 Kč</div></td></tr>
9
+ </tbody>
10
+ </table></object>`;
11
+
12
+ describe("parseTable", () => {
13
+ it("should parse table rows and cells", () => {
14
+ const result = parseTable(SAMPLE_TABLE_HTML);
15
+ expect(result.rows).toHaveLength(3);
16
+ expect(result.rows[0]).toEqual(["Typ", "Částka"]);
17
+ expect(result.rows[1]).toEqual(["Sociální", "9 154 Kč"]);
18
+ expect(result.rows[2]).toEqual(["Zdravotní", "3 848 Kč"]);
19
+ });
20
+
21
+ it("should preserve bold formatting info", () => {
22
+ const result = parseTable(SAMPLE_TABLE_HTML);
23
+ expect(result.formatting[0][0].bold).toBe(true);
24
+ expect(result.formatting[1][0].bold).toBe(false);
25
+ });
26
+
27
+ it("should return empty for non-table HTML", () => {
28
+ const result = parseTable("<div>Not a table</div>");
29
+ expect(result.rows).toHaveLength(0);
30
+ });
31
+ });
32
+
33
+ describe("updateTableCell", () => {
34
+ it("should update cell content", () => {
35
+ const updated = updateTableCell(SAMPLE_TABLE_HTML, 1, 0, "✅ Sociální");
36
+ expect(updated).toContain("✅ Sociální");
37
+ });
38
+
39
+ it("should preserve table structure", () => {
40
+ const updated = updateTableCell(SAMPLE_TABLE_HTML, 1, 0, "✅ Sociální");
41
+ expect(updated).toContain("<object>");
42
+ expect(updated).toContain("</table></object>");
43
+ });
44
+
45
+ it("should throw for out of bounds row", () => {
46
+ expect(() => updateTableCell(SAMPLE_TABLE_HTML, 10, 0, "test")).toThrow("Row 10 out of bounds");
47
+ });
48
+
49
+ it("should throw for out of bounds column", () => {
50
+ expect(() => updateTableCell(SAMPLE_TABLE_HTML, 0, 10, "test")).toThrow("Column 10 out of bounds");
51
+ });
52
+ });
53
+
54
+ describe("findTables", () => {
55
+ it("should find single table", () => {
56
+ const tables = findTables(SAMPLE_TABLE_HTML);
57
+ expect(tables).toHaveLength(1);
58
+ });
59
+
60
+ it("should find multiple tables", () => {
61
+ const html = `<div>${SAMPLE_TABLE_HTML}</div><p>text</p>${SAMPLE_TABLE_HTML}`;
62
+ const tables = findTables(html);
63
+ expect(tables).toHaveLength(2);
64
+ });
65
+
66
+ it("should return empty for no tables", () => {
67
+ const tables = findTables("<div>No tables here</div>");
68
+ expect(tables).toHaveLength(0);
69
+ });
70
+ });
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Apple Notes table HTML parsing and editing utilities.
3
+ *
4
+ * Apple Notes wraps tables in <object> tags with a specific structure:
5
+ * <object><table><tbody><tr><td><div>content</div></td>...</tr>...</tbody></table></object>
6
+ */
7
+
8
+ export interface CellFormatting {
9
+ bold: boolean;
10
+ }
11
+
12
+ export interface TableData {
13
+ rows: string[][];
14
+ formatting: CellFormatting[][];
15
+ raw: string;
16
+ }
17
+
18
+ /**
19
+ * Parse Apple Notes table HTML into structured data.
20
+ */
21
+ export function parseTable(html: string): TableData {
22
+ const result: TableData = {
23
+ rows: [],
24
+ formatting: [],
25
+ raw: html,
26
+ };
27
+
28
+ // Match table content inside <object>
29
+ const tableMatch = html.match(/<object[^>]*>[\s\S]*?<table[\s\S]*?<tbody>([\s\S]*?)<\/tbody>[\s\S]*?<\/table>[\s\S]*?<\/object>/i);
30
+ if (!tableMatch) {
31
+ return result;
32
+ }
33
+
34
+ const tbodyContent = tableMatch[1];
35
+
36
+ // Match all rows
37
+ const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
38
+ let rowMatch;
39
+
40
+ while ((rowMatch = rowRegex.exec(tbodyContent)) !== null) {
41
+ const rowContent = rowMatch[1];
42
+ const cells: string[] = [];
43
+ const cellFormats: CellFormatting[] = [];
44
+
45
+ // Match all cells in this row
46
+ const cellRegex = /<td[^>]*>[\s\S]*?<div[^>]*>([\s\S]*?)<\/div>[\s\S]*?<\/td>/gi;
47
+ let cellMatch;
48
+
49
+ while ((cellMatch = cellRegex.exec(rowContent)) !== null) {
50
+ const cellContent = cellMatch[1];
51
+ // Extract text, stripping HTML tags
52
+ const text = cellContent.replace(/<[^>]+>/g, "").trim();
53
+ const isBold = /<b>/i.test(cellContent);
54
+
55
+ cells.push(text);
56
+ cellFormats.push({ bold: isBold });
57
+ }
58
+
59
+ if (cells.length > 0) {
60
+ result.rows.push(cells);
61
+ result.formatting.push(cellFormats);
62
+ }
63
+ }
64
+
65
+ return result;
66
+ }
67
+
68
+ /**
69
+ * Find the HTML of a specific row in a table.
70
+ */
71
+ function findRowHtml(tableHtml: string, rowIndex: number): { match: RegExpMatchArray; content: string } | null {
72
+ const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
73
+ let currentRow = 0;
74
+ let rowMatch;
75
+
76
+ while ((rowMatch = rowRegex.exec(tableHtml)) !== null) {
77
+ if (currentRow === rowIndex) {
78
+ return { match: rowMatch, content: rowMatch[1] };
79
+ }
80
+ currentRow++;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Escape HTML special characters to prevent injection.
87
+ */
88
+ function escapeHtml(text: string): string {
89
+ return text
90
+ .replace(/&/g, "&amp;")
91
+ .replace(/</g, "&lt;")
92
+ .replace(/>/g, "&gt;")
93
+ .replace(/"/g, "&quot;");
94
+ }
95
+
96
+ /**
97
+ * Update a specific cell within a row's HTML.
98
+ */
99
+ function updateCellInRow(rowContent: string, columnIndex: number, value: string, isBold: boolean): string {
100
+ const cellRegex = /(<td[^>]*>[\s\S]*?<div[^>]*>)([\s\S]*?)(<\/div>[\s\S]*?<\/td>)/gi;
101
+ let currentCol = 0;
102
+ let result = rowContent;
103
+ let cellMatch;
104
+
105
+ const replacements: Array<{original: string; replacement: string}> = [];
106
+
107
+ // Escape HTML to prevent injection
108
+ const escapedValue = escapeHtml(value);
109
+
110
+ while ((cellMatch = cellRegex.exec(rowContent)) !== null) {
111
+ if (currentCol === columnIndex) {
112
+ const prefix = cellMatch[1];
113
+ const suffix = cellMatch[3];
114
+ const newContent = isBold ? `<b>${escapedValue}</b>` : escapedValue;
115
+ replacements.push({
116
+ original: cellMatch[0],
117
+ replacement: `${prefix}${newContent}${suffix}`
118
+ });
119
+ }
120
+ currentCol++;
121
+ }
122
+
123
+ for (const r of replacements) {
124
+ result = result.replace(r.original, r.replacement);
125
+ }
126
+
127
+ return result;
128
+ }
129
+
130
+ /**
131
+ * Update a specific cell in an Apple Notes table HTML.
132
+ *
133
+ * @param html - The table HTML
134
+ * @param row - Row index (0-based, 0 = header)
135
+ * @param column - Column index (0-based)
136
+ * @param value - New cell value
137
+ * @returns Updated HTML
138
+ */
139
+ export function updateTableCell(html: string, row: number, column: number, value: string): string {
140
+ const parsed = parseTable(html);
141
+
142
+ if (row >= parsed.rows.length) {
143
+ throw new Error(`Row ${row} out of bounds (table has ${parsed.rows.length} rows)`);
144
+ }
145
+
146
+ if (column >= parsed.rows[row].length) {
147
+ throw new Error(`Column ${column} out of bounds (row has ${parsed.rows[row].length} columns)`);
148
+ }
149
+
150
+ const rowData = findRowHtml(html, row);
151
+ if (!rowData) {
152
+ throw new Error(`Could not find row ${row} in table HTML`);
153
+ }
154
+
155
+ const isBold = parsed.formatting[row][column].bold;
156
+ const updatedRowContent = updateCellInRow(rowData.content, column, value, isBold);
157
+
158
+ return html.replace(rowData.match[0], `<tr>${updatedRowContent}</tr>`);
159
+ }
160
+
161
+ /**
162
+ * Find all tables in note HTML content.
163
+ * Returns array of table HTML strings.
164
+ */
165
+ export function findTables(html: string): string[] {
166
+ const tables: string[] = [];
167
+ const tableRegex = /<object[^>]*>[\s\S]*?<table[\s\S]*?<\/table>[\s\S]*?<\/object>/gi;
168
+ let match;
169
+
170
+ while ((match = tableRegex.exec(html)) !== null) {
171
+ tables.push(match[0]);
172
+ }
173
+
174
+ return tables;
175
+ }
@@ -37,9 +37,6 @@ export interface SearchOptions {
37
37
  include_content?: boolean;
38
38
  }
39
39
 
40
- // SearchResult is imported from ../types/index.js
41
- // RRF_K is imported from ../config/constants.js
42
-
43
40
  /**
44
41
  * Calculate RRF score for a result at a given rank.
45
42
  * Formula: 1 / (k + rank)
@@ -163,15 +160,16 @@ async function hybridSearch(
163
160
  const contentMap = new Map<string, DBSearchResult>();
164
161
 
165
162
  // Process vector search results
163
+ // Use id as key to avoid collisions with duplicate titles in different folders
166
164
  vectorResults.forEach((item, rank) => {
167
- const key = item.title;
165
+ const key = item.id ?? item.title;
168
166
  scoreMap.set(key, (scoreMap.get(key) || 0) + rrfScore(rank));
169
167
  contentMap.set(key, item);
170
168
  });
171
169
 
172
170
  // Process FTS results
173
171
  ftsResults.forEach((item, rank) => {
174
- const key = item.title;
172
+ const key = item.id ?? item.title;
175
173
  scoreMap.set(key, (scoreMap.get(key) || 0) + rrfScore(rank));
176
174
  if (!contentMap.has(key)) {
177
175
  contentMap.set(key, item);
@@ -257,6 +255,7 @@ export async function searchNotes(
257
255
  // Transform to SearchResult format
258
256
  const results: SearchResult[] = dbResults.map((r) => {
259
257
  const result: SearchResult = {
258
+ id: r.id,
260
259
  title: r.title,
261
260
  folder: r.folder,
262
261
  preview: generatePreview(r.content),
@@ -276,7 +275,6 @@ export async function searchNotes(
276
275
  }
277
276
 
278
277
  // Re-export types for convenience
279
- export type { SearchMode as Mode };
280
278
  export type { SearchResult } from "../types/index.js";
281
279
 
282
280
  // Export utility functions for testing
@@ -13,14 +13,14 @@ import { getAllNotes, getNoteByFolderAndTitle, getNoteByTitle, type NoteInfo } f
13
13
  import { createDebugLogger } from "../utils/debug.js";
14
14
  import { truncateForEmbedding } from "../utils/text.js";
15
15
  import { EMBEDDING_DELAY_MS } from "../config/constants.js";
16
+ import { NoteNotFoundError } from "../errors/index.js";
16
17
 
17
18
  /**
18
19
  * Extract note title from folder/title key.
19
20
  * Handles nested folders correctly by taking the last segment.
20
21
  */
21
22
  export function extractTitleFromKey(key: string): string {
22
- const parts = key.split("/");
23
- return parts[parts.length - 1];
23
+ return key.split("/").at(-1) ?? key;
24
24
  }
25
25
 
26
26
  // Debug logging
@@ -98,6 +98,7 @@ export async function fullIndex(): Promise<IndexResult> {
98
98
  const vector = await getEmbedding(content);
99
99
 
100
100
  const record: NoteRecord = {
101
+ id: noteDetails.id,
101
102
  title: noteDetails.title,
102
103
  content: noteDetails.content,
103
104
  vector,
@@ -234,6 +235,7 @@ export async function incrementalIndex(): Promise<IndexResult> {
234
235
  const vector = await getEmbedding(content);
235
236
 
236
237
  const record: NoteRecord = {
238
+ id: noteDetails.id,
237
239
  title: noteDetails.title,
238
240
  content: noteDetails.content,
239
241
  vector,
@@ -270,6 +272,12 @@ export async function incrementalIndex(): Promise<IndexResult> {
270
272
  }
271
273
  }
272
274
 
275
+ // Rebuild FTS index if any changes were made
276
+ if (toAdd.length > 0 || toUpdate.length > 0 || toDelete.length > 0) {
277
+ debug("Rebuilding FTS index after incremental changes");
278
+ await store.rebuildFtsIndex();
279
+ }
280
+
273
281
  const timeMs = Date.now() - startTime;
274
282
  debug(`Incremental index complete: ${timeMs}ms`);
275
283
 
@@ -296,7 +304,7 @@ export async function reindexNote(title: string): Promise<void> {
296
304
 
297
305
  const noteDetails = await getNoteByTitle(title);
298
306
  if (!noteDetails) {
299
- throw new Error(`Note not found: "${title}"`);
307
+ throw new NoteNotFoundError(title);
300
308
  }
301
309
 
302
310
  if (!noteDetails.content.trim()) {
@@ -307,6 +315,7 @@ export async function reindexNote(title: string): Promise<void> {
307
315
  const vector = await getEmbedding(content);
308
316
 
309
317
  const record: NoteRecord = {
318
+ id: noteDetails.id,
310
319
  title: noteDetails.title,
311
320
  content: noteDetails.content,
312
321
  vector,
@@ -319,6 +328,10 @@ export async function reindexNote(title: string): Promise<void> {
319
328
  const store = getVectorStore();
320
329
  await store.update(record);
321
330
 
331
+ // Rebuild FTS index after single note update
332
+ debug("Rebuilding FTS index after single note reindex");
333
+ await store.rebuildFtsIndex();
334
+
322
335
  debug(`Reindexed: ${title}`);
323
336
  }
324
337
 
@@ -7,6 +7,8 @@
7
7
  * Contains the full content of the note.
8
8
  */
9
9
  export interface DBSearchResult {
10
+ /** Apple Notes unique identifier */
11
+ id?: string;
10
12
  /** Note title */
11
13
  title: string;
12
14
  /** Folder containing the note */
@@ -24,6 +26,8 @@ export interface DBSearchResult {
24
26
  * Contains a preview instead of full content by default.
25
27
  */
26
28
  export interface SearchResult {
29
+ /** Apple Notes unique identifier */
30
+ id?: string;
27
31
  /** Note title */
28
32
  title: string;
29
33
  /** Folder containing the note */
@@ -10,8 +10,6 @@ const COLORS = {
10
10
  dim: "\x1b[2m",
11
11
  cyan: "\x1b[36m",
12
12
  yellow: "\x1b[33m",
13
- red: "\x1b[31m",
14
- green: "\x1b[32m",
15
13
  } as const;
16
14
 
17
15
  /**
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { truncateForEmbedding } from "./text.js";
3
+ import { MAX_INPUT_LENGTH } from "../config/constants.js";
4
+
5
+ describe("truncateForEmbedding", () => {
6
+ it("should return text unchanged if within limit", () => {
7
+ const text = "Short text";
8
+ expect(truncateForEmbedding(text)).toBe(text);
9
+ });
10
+
11
+ it("should truncate text exceeding default limit", () => {
12
+ const text = "a".repeat(MAX_INPUT_LENGTH + 100);
13
+ const result = truncateForEmbedding(text);
14
+ expect(result.length).toBe(MAX_INPUT_LENGTH);
15
+ expect(result).toBe("a".repeat(MAX_INPUT_LENGTH));
16
+ });
17
+
18
+ it("should use custom maxLength when provided", () => {
19
+ const text = "Hello World";
20
+ const result = truncateForEmbedding(text, 5);
21
+ expect(result).toBe("Hello");
22
+ });
23
+
24
+ it("should handle empty string", () => {
25
+ expect(truncateForEmbedding("")).toBe("");
26
+ });
27
+
28
+ it("should handle text exactly at limit", () => {
29
+ const text = "a".repeat(MAX_INPUT_LENGTH);
30
+ expect(truncateForEmbedding(text)).toBe(text);
31
+ });
32
+ });
package/CLAUDE.md DELETED
@@ -1,56 +0,0 @@
1
- # CLAUDE.md
2
-
3
- ## Project Overview
4
-
5
- MCP server for Apple Notes with semantic search and CRUD operations.
6
-
7
- ## Tech Stack
8
-
9
- - **Runtime**: Bun
10
- - **Language**: TypeScript
11
- - **Database**: LanceDB (vector store)
12
- - **Embeddings**: HuggingFace Transformers (local) or OpenRouter API
13
- - **Apple Notes**: JXA (JavaScript for Automation)
14
-
15
- ## Commands
16
-
17
- ```bash
18
- bun run start # Start MCP server
19
- bun run setup # Interactive setup wizard
20
- bun run dev # Watch mode
21
- bun run check # Type check
22
- bun run test # Run tests (uses vitest, NOT bun test)
23
- ```
24
-
25
- ## Project Structure
26
-
27
- ```
28
- src/
29
- ├── index.ts # MCP server entry (stdio transport)
30
- ├── server.ts # Smithery-compatible export
31
- ├── setup.ts # Interactive setup wizard
32
- ├── config/ # Constants and env validation
33
- ├── db/ # LanceDB vector store
34
- ├── embeddings/ # Local and OpenRouter embeddings
35
- ├── notes/ # Apple Notes CRUD via JXA
36
- ├── search/ # Hybrid search and indexing
37
- └── utils/ # Debug logging, errors, text utils
38
- ```
39
-
40
- ## Key Patterns
41
-
42
- - **Dual embedding support**: Detects `OPENROUTER_API_KEY` to choose provider
43
- - **Hybrid search**: Combines vector + keyword search with RRF fusion
44
- - **Incremental indexing**: Only re-embeds changed notes
45
- - **Folder/title disambiguation**: Use `Folder/Note Title` format for duplicates
46
-
47
- ## Testing
48
-
49
- Always use `bun run test` (vitest), never `bun test` (incompatible bun runner).
50
-
51
- ## Environment Variables
52
-
53
- See README.md for full list. Key ones:
54
- - `OPENROUTER_API_KEY` - Enables cloud embeddings
55
- - `READONLY_MODE` - Blocks write operations
56
- - `DEBUG` - Enables debug logging