@disco_trooper/apple-notes-mcp 1.1.0 → 1.3.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.
Files changed (49) hide show
  1. package/README.md +104 -24
  2. package/package.json +11 -12
  3. package/src/config/claude.test.ts +47 -0
  4. package/src/config/claude.ts +106 -0
  5. package/src/config/constants.ts +11 -2
  6. package/src/config/paths.test.ts +40 -0
  7. package/src/config/paths.ts +86 -0
  8. package/src/db/arrow-fix.test.ts +101 -0
  9. package/src/db/lancedb.test.ts +254 -2
  10. package/src/db/lancedb.ts +385 -38
  11. package/src/embeddings/cache.test.ts +150 -0
  12. package/src/embeddings/cache.ts +204 -0
  13. package/src/embeddings/index.ts +22 -4
  14. package/src/embeddings/local.ts +57 -17
  15. package/src/embeddings/openrouter.ts +233 -11
  16. package/src/errors/index.test.ts +64 -0
  17. package/src/errors/index.ts +62 -0
  18. package/src/graph/export.test.ts +81 -0
  19. package/src/graph/export.ts +163 -0
  20. package/src/graph/extract.test.ts +90 -0
  21. package/src/graph/extract.ts +52 -0
  22. package/src/graph/queries.test.ts +156 -0
  23. package/src/graph/queries.ts +224 -0
  24. package/src/index.ts +309 -23
  25. package/src/notes/conversion.ts +62 -0
  26. package/src/notes/crud.test.ts +41 -8
  27. package/src/notes/crud.ts +75 -64
  28. package/src/notes/read.test.ts +58 -3
  29. package/src/notes/read.ts +142 -210
  30. package/src/notes/resolve.ts +174 -0
  31. package/src/notes/tables.ts +69 -40
  32. package/src/search/chunk-indexer.test.ts +353 -0
  33. package/src/search/chunk-indexer.ts +207 -0
  34. package/src/search/chunk-search.test.ts +327 -0
  35. package/src/search/chunk-search.ts +298 -0
  36. package/src/search/index.ts +4 -6
  37. package/src/search/indexer.ts +164 -109
  38. package/src/setup.ts +46 -67
  39. package/src/types/index.ts +4 -0
  40. package/src/utils/chunker.test.ts +182 -0
  41. package/src/utils/chunker.ts +170 -0
  42. package/src/utils/content-filter.test.ts +225 -0
  43. package/src/utils/content-filter.ts +275 -0
  44. package/src/utils/debug.ts +0 -2
  45. package/src/utils/runtime.test.ts +70 -0
  46. package/src/utils/runtime.ts +40 -0
  47. package/src/utils/text.test.ts +32 -0
  48. package/CLAUDE.md +0 -56
  49. package/src/server.ts +0 -427
package/src/notes/read.ts CHANGED
@@ -6,39 +6,15 @@
6
6
  */
7
7
 
8
8
  import { runJxa } from "run-jxa";
9
- import TurndownService from "turndown";
10
9
  import { createDebugLogger } from "../utils/debug.js";
10
+ import { htmlToMarkdown } from "./conversion.js";
11
+
12
+ // Re-export for backwards compatibility
13
+ export { resolveNoteTitle, type ResolvedNote } from "./resolve.js";
11
14
 
12
15
  // Debug logging
13
16
  const debug = createDebugLogger("NOTES");
14
17
 
15
- // Initialize Turndown for HTML to Markdown conversion
16
- const turndownService = new TurndownService({
17
- headingStyle: "atx",
18
- codeBlockStyle: "fenced",
19
- bulletListMarker: "-",
20
- });
21
-
22
- // Add custom rule to handle Apple Notes attachment placeholders
23
- turndownService.addRule("attachments", {
24
- filter: (node) => {
25
- // Apple Notes uses object tags for attachments
26
- return node.nodeName === "OBJECT" || node.nodeName === "IMG";
27
- },
28
- replacement: (_content, node) => {
29
- // Turndown uses its own Node type, cast to access attributes
30
- const element = node as unknown as {
31
- getAttribute: (name: string) => string | null;
32
- };
33
- const filename =
34
- element.getAttribute("data-filename") ||
35
- element.getAttribute("alt") ||
36
- element.getAttribute("src")?.split("/").pop() ||
37
- "unknown";
38
- return `[Attachment: ${filename}]`;
39
- },
40
- });
41
-
42
18
  // -----------------------------------------------------------------------------
43
19
  // Types
44
20
  // -----------------------------------------------------------------------------
@@ -63,25 +39,20 @@ export interface NoteDetails extends NoteInfo {
63
39
  id: string;
64
40
  }
65
41
 
66
- export interface ResolvedNote {
67
- /** Whether resolution was successful */
68
- success: boolean;
69
- /** The matched note (if exactly one match) */
70
- note?: {
71
- title: string;
72
- folder: string;
73
- id: string;
74
- };
75
- /** Error message if resolution failed */
76
- error?: string;
77
- /** Suggestions when multiple matches found */
78
- suggestions?: string[];
79
- }
80
-
81
42
  // -----------------------------------------------------------------------------
82
- // Internal JXA helpers
43
+ // Internal types and helpers
83
44
  // -----------------------------------------------------------------------------
84
45
 
46
+ /** Raw note data from JXA before markdown conversion */
47
+ interface RawNoteData {
48
+ id: string;
49
+ title: string;
50
+ folder: string;
51
+ created: string;
52
+ modified: string;
53
+ htmlContent: string;
54
+ }
55
+
85
56
  /**
86
57
  * Execute JXA code safely with error handling
87
58
  */
@@ -98,33 +69,18 @@ async function executeJxa<T>(code: string): Promise<T> {
98
69
  }
99
70
 
100
71
  /**
101
- * Convert HTML content to Markdown, handling Apple Notes specifics
72
+ * Convert raw JXA note data to NoteDetails with markdown content
102
73
  */
103
- function htmlToMarkdown(html: string): string {
104
- if (!html) return "";
105
-
106
- // Pre-process: handle Apple Notes specific markup
107
- let processed = html;
108
-
109
- // Replace attachment objects with placeholder text before Turndown
110
- processed = processed.replace(
111
- /<object[^>]*data-filename="([^"]*)"[^>]*>.*?<\/object>/gi,
112
- "[Attachment: $1]"
113
- );
114
-
115
- // Handle inline images
116
- processed = processed.replace(
117
- /<img[^>]*(?:alt="([^"]*)")?[^>]*>/gi,
118
- (_match, alt) => {
119
- const filename = alt || "image";
120
- return `[Attachment: ${filename}]`;
121
- }
122
- );
123
-
124
- // Convert to Markdown
125
- const markdown = turndownService.turndown(processed);
126
-
127
- return markdown.trim();
74
+ function toNoteDetails(raw: RawNoteData): NoteDetails {
75
+ return {
76
+ id: raw.id,
77
+ title: raw.title,
78
+ folder: raw.folder,
79
+ created: raw.created,
80
+ modified: raw.modified,
81
+ content: htmlToMarkdown(raw.htmlContent),
82
+ htmlContent: raw.htmlContent,
83
+ };
128
84
  }
129
85
 
130
86
  // -----------------------------------------------------------------------------
@@ -178,7 +134,7 @@ export async function getAllNotes(): Promise<NoteInfo[]> {
178
134
  /**
179
135
  * Get a note by its title, with full content
180
136
  *
181
- * @param title - The note title (can be "folder/title" format for disambiguation)
137
+ * @param title - The note title (can be "folder/title" or "id:xxx" format)
182
138
  * @returns Note details with content, or null if not found
183
139
  */
184
140
  export async function getNoteByTitle(
@@ -186,6 +142,13 @@ export async function getNoteByTitle(
186
142
  ): Promise<NoteDetails | null> {
187
143
  debug(`Getting note by title: ${title}`);
188
144
 
145
+ // Check for id:xxx format for direct ID lookup
146
+ if (title.startsWith("id:")) {
147
+ const noteId = title.slice(3);
148
+ debug(`ID prefix detected, looking up note by ID: ${noteId}`);
149
+ return getNoteById(noteId);
150
+ }
151
+
189
152
  // Check for folder/title format
190
153
  let targetFolder: string | null = null;
191
154
  let targetTitle = title;
@@ -242,14 +205,7 @@ export async function getNoteByTitle(
242
205
  `;
243
206
 
244
207
  const result = await executeJxa<string>(jxaCode);
245
- const notes = JSON.parse(result) as Array<{
246
- id: string;
247
- title: string;
248
- folder: string;
249
- created: string;
250
- modified: string;
251
- htmlContent: string;
252
- }>;
208
+ const notes = JSON.parse(result) as RawNoteData[];
253
209
 
254
210
  if (notes.length === 0) {
255
211
  debug("Note not found");
@@ -258,27 +214,71 @@ export async function getNoteByTitle(
258
214
 
259
215
  if (notes.length > 1) {
260
216
  debug(`Multiple notes found with title: ${targetTitle}`);
261
- // If folder wasn't specified and multiple exist, return the first one
262
- // but log a warning
263
- debug(
264
- "Returning first match. Use folder/title format for disambiguation."
265
- );
217
+ debug("Returning first match. Use folder/title format for disambiguation.");
266
218
  }
267
219
 
268
- const note = notes[0];
269
- const content = htmlToMarkdown(note.htmlContent);
220
+ debug(`Found note in folder: ${notes[0].folder}`);
221
+ return toNoteDetails(notes[0]);
222
+ }
270
223
 
271
- debug(`Found note in folder: ${note.folder}`);
224
+ /**
225
+ * Get a note by its Apple Notes ID.
226
+ * Use this for precise access when title-based lookup is ambiguous.
227
+ *
228
+ * @param id - The Apple Notes unique identifier
229
+ * @returns Note details with content, or null if not found
230
+ */
231
+ export async function getNoteById(id: string): Promise<NoteDetails | null> {
232
+ debug(`Getting note by ID: ${id}`);
272
233
 
273
- return {
274
- id: note.id,
275
- title: note.title,
276
- folder: note.folder,
277
- created: note.created,
278
- modified: note.modified,
279
- content,
280
- htmlContent: note.htmlContent,
281
- };
234
+ const escapedId = JSON.stringify(id);
235
+
236
+ const jxaCode = `
237
+ const app = Application('Notes');
238
+ app.includeStandardAdditions = true;
239
+
240
+ const targetId = ${escapedId};
241
+
242
+ try {
243
+ const note = app.notes.byId(targetId);
244
+ const props = note.properties();
245
+
246
+ // Find the folder this note belongs to
247
+ let folderName = 'Notes';
248
+ const folders = app.folders();
249
+ for (const folder of folders) {
250
+ const notes = folder.notes();
251
+ for (let i = 0; i < notes.length; i++) {
252
+ if (notes[i].id() === targetId) {
253
+ folderName = folder.name();
254
+ break;
255
+ }
256
+ }
257
+ }
258
+
259
+ return JSON.stringify({
260
+ id: note.id(),
261
+ title: props.name || '',
262
+ folder: folderName,
263
+ created: props.creationDate ? props.creationDate.toISOString() : '',
264
+ modified: props.modificationDate ? props.modificationDate.toISOString() : '',
265
+ htmlContent: note.body()
266
+ });
267
+ } catch (e) {
268
+ return JSON.stringify(null);
269
+ }
270
+ `;
271
+
272
+ const result = await executeJxa<string>(jxaCode);
273
+ const note = JSON.parse(result) as RawNoteData | null;
274
+
275
+ if (!note) {
276
+ debug("Note not found by ID");
277
+ return null;
278
+ }
279
+
280
+ debug(`Found note: ${note.title} in folder: ${note.folder}`);
281
+ return toNoteDetails(note);
282
282
  }
283
283
 
284
284
  /**
@@ -341,164 +341,96 @@ export async function getNoteByFolderAndTitle(
341
341
  `;
342
342
 
343
343
  const result = await executeJxa<string>(jxaCode);
344
- const notes = JSON.parse(result) as Array<{
345
- id: string;
346
- title: string;
347
- folder: string;
348
- created: string;
349
- modified: string;
350
- htmlContent: string;
351
- }>;
344
+ const notes = JSON.parse(result) as RawNoteData[];
352
345
 
353
346
  if (notes.length === 0) {
354
347
  debug("Note not found");
355
348
  return null;
356
349
  }
357
350
 
358
- const note = notes[0];
359
- const content = htmlToMarkdown(note.htmlContent);
360
-
361
- debug(`Found note in folder: ${note.folder}`);
362
-
363
- return {
364
- id: note.id,
365
- title: note.title,
366
- folder: note.folder,
367
- created: note.created,
368
- modified: note.modified,
369
- content,
370
- htmlContent: note.htmlContent,
371
- };
351
+ debug(`Found note in folder: ${notes[0].folder}`);
352
+ return toNoteDetails(notes[0]);
372
353
  }
373
354
 
374
355
  /**
375
- * Get all folder names from Apple Notes
356
+ * Get all notes with full content in a single JXA call.
357
+ * This is much faster than calling getNoteByFolderAndTitle for each note
358
+ * because it avoids the JXA process spawn overhead per note.
376
359
  *
377
- * @returns Array of folder names
360
+ * @returns Array of note details with content
378
361
  */
379
- export async function getAllFolders(): Promise<string[]> {
380
- debug("Getting all folders...");
362
+ export async function getAllNotesWithContent(): Promise<NoteDetails[]> {
363
+ debug("Getting all notes with content (single JXA call)...");
381
364
 
382
365
  const jxaCode = `
383
366
  const app = Application('Notes');
384
367
  app.includeStandardAdditions = true;
385
368
 
369
+ const allNotes = [];
386
370
  const folders = app.folders();
387
- const folderNames = [];
388
371
 
389
372
  for (const folder of folders) {
390
- try {
391
- folderNames.push(folder.name());
392
- } catch (e) {
393
- // Skip folders that can't be accessed
373
+ const folderName = folder.name();
374
+ const notes = folder.notes();
375
+
376
+ for (let i = 0; i < notes.length; i++) {
377
+ try {
378
+ const note = notes[i];
379
+ const props = note.properties();
380
+ allNotes.push({
381
+ id: note.id(),
382
+ title: props.name || '',
383
+ folder: folderName,
384
+ created: props.creationDate ? props.creationDate.toISOString() : '',
385
+ modified: props.modificationDate ? props.modificationDate.toISOString() : '',
386
+ htmlContent: note.body()
387
+ });
388
+ } catch (e) {
389
+ // Skip notes that can't be accessed
390
+ }
394
391
  }
395
392
  }
396
393
 
397
- return JSON.stringify(folderNames);
394
+ return JSON.stringify(allNotes);
398
395
  `;
399
396
 
400
397
  const result = await executeJxa<string>(jxaCode);
401
- const folders = JSON.parse(result) as string[];
398
+ const notes = JSON.parse(result) as RawNoteData[];
402
399
 
403
- debug(`Found ${folders.length} folders`);
404
- return folders;
400
+ debug(`Fetched ${notes.length} notes with content`);
401
+
402
+ return notes.map(toNoteDetails);
405
403
  }
406
404
 
407
405
  /**
408
- * Resolve a note title input to a unique note
409
- *
410
- * Handles:
411
- * - Exact title match
412
- * - "folder/title" format for disambiguation
413
- * - Returns suggestions when multiple matches exist
406
+ * Get all folder names from Apple Notes
414
407
  *
415
- * @param input - The note title or "folder/title" string
416
- * @returns Resolution result with success status, note info, or suggestions
408
+ * @returns Array of folder names
417
409
  */
418
- export async function resolveNoteTitle(input: string): Promise<ResolvedNote> {
419
- debug(`Resolving note title: ${input}`);
420
-
421
- // Check for folder/title format
422
- let targetFolder: string | null = null;
423
- let targetTitle = input;
424
-
425
- if (input.includes("/")) {
426
- const parts = input.split("/");
427
- targetFolder = parts.slice(0, -1).join("/");
428
- targetTitle = parts[parts.length - 1];
429
- debug(`Parsed folder: ${targetFolder}, title: ${targetTitle}`);
430
- }
431
-
432
- const escapedTitle = JSON.stringify(targetTitle);
433
- const escapedFolder = targetFolder ? JSON.stringify(targetFolder) : "null";
410
+ export async function getAllFolders(): Promise<string[]> {
411
+ debug("Getting all folders...");
434
412
 
435
413
  const jxaCode = `
436
414
  const app = Application('Notes');
437
415
  app.includeStandardAdditions = true;
438
416
 
439
- const targetTitle = ${escapedTitle};
440
- const targetFolder = ${escapedFolder};
441
-
442
- let foundNotes = [];
443
417
  const folders = app.folders();
418
+ const folderNames = [];
444
419
 
445
420
  for (const folder of folders) {
446
- const folderName = folder.name();
447
-
448
- // Skip if folder filter is specified and doesn't match
449
- if (targetFolder !== null && folderName !== targetFolder) {
450
- continue;
451
- }
452
-
453
- const notes = folder.notes.whose({ name: targetTitle });
454
-
455
- for (let i = 0; i < notes.length; i++) {
456
- try {
457
- const note = notes[i];
458
- foundNotes.push({
459
- id: note.id(),
460
- title: note.name(),
461
- folder: folderName
462
- });
463
- } catch (e) {
464
- // Skip notes that can't be accessed
465
- }
421
+ try {
422
+ folderNames.push(folder.name());
423
+ } catch (e) {
424
+ // Skip folders that can't be accessed
466
425
  }
467
426
  }
468
427
 
469
- return JSON.stringify(foundNotes);
428
+ return JSON.stringify(folderNames);
470
429
  `;
471
430
 
472
431
  const result = await executeJxa<string>(jxaCode);
473
- const notes = JSON.parse(result) as Array<{
474
- id: string;
475
- title: string;
476
- folder: string;
477
- }>;
478
-
479
- if (notes.length === 0) {
480
- debug("No matching notes found");
481
- return {
482
- success: false,
483
- error: `Note not found: "${input}"`,
484
- };
485
- }
486
-
487
- if (notes.length === 1) {
488
- debug(`Resolved to unique note: ${notes[0].folder}/${notes[0].title}`);
489
- return {
490
- success: true,
491
- note: notes[0],
492
- };
493
- }
494
-
495
- // Multiple matches - return suggestions
496
- const suggestions = notes.map((n) => `${n.folder}/${n.title}`);
497
- debug(`Multiple matches found: ${suggestions.join(", ")}`);
432
+ const folders = JSON.parse(result) as string[];
498
433
 
499
- return {
500
- success: false,
501
- error: `Multiple notes found with title "${targetTitle}". Please specify the folder.`,
502
- suggestions,
503
- };
434
+ debug(`Found ${folders.length} folders`);
435
+ return folders;
504
436
  }
@@ -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
+ }