@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
@@ -7,20 +7,20 @@
7
7
  * - Single note reindexing
8
8
  */
9
9
 
10
- import { getEmbedding } from "../embeddings/index.js";
10
+ import { getEmbedding, getEmbeddingBatch } from "../embeddings/index.js";
11
11
  import { getVectorStore, type NoteRecord } from "../db/lancedb.js";
12
- import { getAllNotes, getNoteByFolderAndTitle, getNoteByTitle, type NoteInfo } from "../notes/read.js";
12
+ import { getAllNotes, getAllNotesWithContent, getNoteByFolderAndTitle, getNoteByTitle, type NoteInfo } from "../notes/read.js";
13
13
  import { createDebugLogger } from "../utils/debug.js";
14
14
  import { truncateForEmbedding } from "../utils/text.js";
15
- import { EMBEDDING_DELAY_MS } from "../config/constants.js";
15
+ import { NoteNotFoundError } from "../errors/index.js";
16
+ import { extractMetadata } from "../graph/extract.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
@@ -50,89 +50,127 @@ export interface IndexResult {
50
50
  }
51
51
 
52
52
  /**
53
- * Sleep for a specified duration.
53
+ * Note data prepared for embedding.
54
54
  */
55
- function sleep(ms: number): Promise<void> {
56
- return new Promise((resolve) => setTimeout(resolve, ms));
55
+ interface PreparedNote {
56
+ id: string;
57
+ title: string;
58
+ content: string;
59
+ truncatedContent: string;
60
+ folder: string;
61
+ created: string;
62
+ modified: string;
63
+ tags: string[];
64
+ outlinks: string[];
65
+ }
66
+
67
+ /**
68
+ * Prepare a note for embedding by extracting metadata and truncating content.
69
+ * Returns null if the note content is empty.
70
+ */
71
+ function prepareNoteForEmbedding(note: {
72
+ id: string;
73
+ title: string;
74
+ content: string;
75
+ folder: string;
76
+ created: string;
77
+ modified: string;
78
+ }): PreparedNote | null {
79
+ if (!note.content.trim()) {
80
+ return null;
81
+ }
82
+
83
+ const metadata = extractMetadata(note.content);
84
+
85
+ return {
86
+ id: note.id,
87
+ title: note.title,
88
+ content: note.content,
89
+ truncatedContent: truncateForEmbedding(note.content),
90
+ folder: note.folder,
91
+ created: note.created,
92
+ modified: note.modified,
93
+ tags: metadata.tags,
94
+ outlinks: metadata.outlinks,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Build a NoteRecord from a PreparedNote and its embedding vector.
100
+ */
101
+ function buildNoteRecord(
102
+ note: PreparedNote,
103
+ vector: number[],
104
+ indexedAt: string
105
+ ): NoteRecord {
106
+ return {
107
+ id: note.id,
108
+ title: note.title,
109
+ content: note.content,
110
+ vector,
111
+ folder: note.folder,
112
+ created: note.created,
113
+ modified: note.modified,
114
+ indexed_at: indexedAt,
115
+ tags: note.tags,
116
+ outlinks: note.outlinks,
117
+ };
57
118
  }
58
119
 
59
120
  /**
60
121
  * Perform full reindexing of all notes.
61
122
  * Drops existing index and rebuilds from scratch.
123
+ * Uses single JXA call + batch embedding for maximum speed.
62
124
  */
63
125
  export async function fullIndex(): Promise<IndexResult> {
64
126
  const startTime = Date.now();
65
127
  debug("Starting full index...");
66
128
 
67
- // Get all notes from Apple Notes
68
- const notes = await getAllNotes();
69
- debug(`Found ${notes.length} notes in Apple Notes`);
70
-
71
- const records: NoteRecord[] = [];
72
- let errors = 0;
73
- const failedNotes: string[] = [];
129
+ // Phase 1: Fetch all notes with content in single JXA call
130
+ debug("Phase 1: Fetching all notes with content (single JXA call)...");
131
+ const allNotes = await getAllNotesWithContent();
132
+ debug(`Fetched ${allNotes.length} notes from Apple Notes`);
74
133
 
75
- for (let i = 0; i < notes.length; i++) {
76
- const noteInfo = notes[i];
77
- debug(`Processing ${i + 1}/${notes.length}: ${noteInfo.title}`);
134
+ // Filter empty notes and prepare for embedding
135
+ const preparedNotes = allNotes
136
+ .map(prepareNoteForEmbedding)
137
+ .filter((note): note is PreparedNote => note !== null);
78
138
 
79
- try {
80
- // Get full note content using folder and title separately
81
- // to handle notes with "/" in their titles
82
- const noteDetails = await getNoteByFolderAndTitle(noteInfo.folder, noteInfo.title);
83
- if (!noteDetails) {
84
- debug(`Could not fetch note: ${noteInfo.title}`);
85
- failedNotes.push(`${noteInfo.folder}/${noteInfo.title}`);
86
- errors++;
87
- continue;
88
- }
139
+ debug(`Prepared ${preparedNotes.length} notes for embedding`);
89
140
 
90
- // Skip empty notes
91
- if (!noteDetails.content.trim()) {
92
- debug(`Skipping empty note: ${noteInfo.title}`);
93
- continue;
94
- }
141
+ // Phase 2: Generate embeddings in batch (with concurrent API calls)
142
+ debug("Phase 2: Generating embeddings in batch...");
143
+ const textsToEmbed = preparedNotes.map(n => n.truncatedContent);
95
144
 
96
- // Generate embedding
97
- const content = truncateForEmbedding(noteDetails.content);
98
- const vector = await getEmbedding(content);
99
-
100
- const record: NoteRecord = {
101
- title: noteDetails.title,
102
- content: noteDetails.content,
103
- vector,
104
- folder: noteDetails.folder,
105
- created: noteDetails.created,
106
- modified: noteDetails.modified,
107
- indexed_at: new Date().toISOString(),
108
- };
109
-
110
- records.push(record);
111
-
112
- // Delay to avoid rate limiting
113
- if (i < notes.length - 1) {
114
- await sleep(EMBEDDING_DELAY_MS);
115
- }
116
- } catch (error) {
117
- debug(`Error processing ${noteInfo.title}:`, error);
118
- failedNotes.push(`${noteInfo.folder}/${noteInfo.title}`);
119
- errors++;
120
- }
145
+ let vectors: number[][];
146
+ try {
147
+ vectors = await getEmbeddingBatch(textsToEmbed);
148
+ } catch (error) {
149
+ debug("Batch embedding failed:", error);
150
+ throw error;
121
151
  }
122
152
 
123
- // Store all records in vector database
153
+ debug(`Generated ${vectors.length} embeddings`);
154
+
155
+ // Phase 3: Build records and store
156
+ debug("Phase 3: Storing in database...");
157
+ const indexedAt = new Date().toISOString();
158
+ const records = preparedNotes.map((note, i) =>
159
+ buildNoteRecord(note, vectors[i], indexedAt)
160
+ );
161
+
124
162
  const store = getVectorStore();
125
163
  await store.index(records);
126
164
 
127
165
  const timeMs = Date.now() - startTime;
128
- debug(`Full index complete: ${records.length} indexed, ${errors} errors, ${timeMs}ms`);
166
+ const skipped = allNotes.length - preparedNotes.length;
167
+ debug(`Full index complete: ${records.length} indexed, ${skipped} empty/skipped, ${timeMs}ms`);
129
168
 
130
169
  return {
131
- total: notes.length,
170
+ total: allNotes.length,
132
171
  indexed: records.length,
133
- errors,
172
+ errors: 0,
134
173
  timeMs,
135
- failedNotes: failedNotes.length > 0 ? failedNotes : undefined,
136
174
  };
137
175
  }
138
176
 
@@ -211,47 +249,63 @@ export async function incrementalIndex(): Promise<IndexResult> {
211
249
  let errors = 0;
212
250
  const failedNotes: string[] = [];
213
251
 
214
- // Process additions and updates
252
+ // Process additions and updates in batch
215
253
  const toProcess = [...toAdd, ...toUpdate];
216
- for (let i = 0; i < toProcess.length; i++) {
217
- const noteInfo = toProcess[i];
218
- debug(`Processing ${i + 1}/${toProcess.length}: ${noteInfo.title}`);
219
254
 
220
- try {
221
- // Use folder and title separately to handle "/" in titles
222
- const noteDetails = await getNoteByFolderAndTitle(noteInfo.folder, noteInfo.title);
223
- if (!noteDetails) {
255
+ if (toProcess.length > 0) {
256
+ // Phase 1: Fetch all note content
257
+ debug(`Phase 1: Fetching ${toProcess.length} notes content...`);
258
+ const preparedNotes: PreparedNote[] = [];
259
+
260
+ for (const noteInfo of toProcess) {
261
+ try {
262
+ const noteDetails = await getNoteByFolderAndTitle(noteInfo.folder, noteInfo.title);
263
+ if (!noteDetails) {
264
+ failedNotes.push(`${noteInfo.folder}/${noteInfo.title}`);
265
+ errors++;
266
+ continue;
267
+ }
268
+
269
+ const prepared = prepareNoteForEmbedding(noteDetails);
270
+ if (prepared) {
271
+ preparedNotes.push(prepared);
272
+ }
273
+ } catch (error) {
274
+ debug(`Error fetching ${noteInfo.title}:`, error);
224
275
  failedNotes.push(`${noteInfo.folder}/${noteInfo.title}`);
225
276
  errors++;
226
- continue;
227
277
  }
278
+ }
228
279
 
229
- if (!noteDetails.content.trim()) {
230
- continue;
280
+ if (preparedNotes.length > 0) {
281
+ // Phase 2: Generate embeddings in batch
282
+ debug(`Phase 2: Generating ${preparedNotes.length} embeddings in batch...`);
283
+ const textsToEmbed = preparedNotes.map(n => n.truncatedContent);
284
+
285
+ let vectors: number[][];
286
+ try {
287
+ vectors = await getEmbeddingBatch(textsToEmbed);
288
+ } catch (error) {
289
+ debug("Batch embedding failed:", error);
290
+ throw error;
231
291
  }
232
292
 
233
- const content = truncateForEmbedding(noteDetails.content);
234
- const vector = await getEmbedding(content);
235
-
236
- const record: NoteRecord = {
237
- title: noteDetails.title,
238
- content: noteDetails.content,
239
- vector,
240
- folder: noteDetails.folder,
241
- created: noteDetails.created,
242
- modified: noteDetails.modified,
243
- indexed_at: new Date().toISOString(),
244
- };
245
-
246
- await store.update(record);
247
-
248
- if (i < toProcess.length - 1) {
249
- await sleep(EMBEDDING_DELAY_MS);
293
+ // Phase 3: Update database
294
+ debug("Phase 3: Updating database...");
295
+ const indexedAt = new Date().toISOString();
296
+
297
+ for (let i = 0; i < preparedNotes.length; i++) {
298
+ const note = preparedNotes[i];
299
+ const record = buildNoteRecord(note, vectors[i], indexedAt);
300
+
301
+ try {
302
+ await store.update(record);
303
+ } catch (error) {
304
+ debug(`Error updating ${note.title}:`, error);
305
+ failedNotes.push(`${note.folder}/${note.title}`);
306
+ errors++;
307
+ }
250
308
  }
251
- } catch (error) {
252
- debug(`Error processing ${noteInfo.title}:`, error);
253
- failedNotes.push(`${noteInfo.folder}/${noteInfo.title}`);
254
- errors++;
255
309
  }
256
310
  }
257
311
 
@@ -270,6 +324,12 @@ export async function incrementalIndex(): Promise<IndexResult> {
270
324
  }
271
325
  }
272
326
 
327
+ // Rebuild FTS index if any changes were made
328
+ if (toAdd.length > 0 || toUpdate.length > 0 || toDelete.length > 0) {
329
+ debug("Rebuilding FTS index after incremental changes");
330
+ await store.rebuildFtsIndex();
331
+ }
332
+
273
333
  const timeMs = Date.now() - startTime;
274
334
  debug(`Incremental index complete: ${timeMs}ms`);
275
335
 
@@ -296,29 +356,24 @@ export async function reindexNote(title: string): Promise<void> {
296
356
 
297
357
  const noteDetails = await getNoteByTitle(title);
298
358
  if (!noteDetails) {
299
- throw new Error(`Note not found: "${title}"`);
359
+ throw new NoteNotFoundError(title);
300
360
  }
301
361
 
302
- if (!noteDetails.content.trim()) {
362
+ const prepared = prepareNoteForEmbedding(noteDetails);
363
+ if (!prepared) {
303
364
  throw new Error(`Note is empty: "${title}"`);
304
365
  }
305
366
 
306
- const content = truncateForEmbedding(noteDetails.content);
307
- const vector = await getEmbedding(content);
308
-
309
- const record: NoteRecord = {
310
- title: noteDetails.title,
311
- content: noteDetails.content,
312
- vector,
313
- folder: noteDetails.folder,
314
- created: noteDetails.created,
315
- modified: noteDetails.modified,
316
- indexed_at: new Date().toISOString(),
317
- };
367
+ const vector = await getEmbedding(prepared.truncatedContent);
368
+ const record = buildNoteRecord(prepared, vector, new Date().toISOString());
318
369
 
319
370
  const store = getVectorStore();
320
371
  await store.update(record);
321
372
 
373
+ // Rebuild FTS index after single note update
374
+ debug("Rebuilding FTS index after single note reindex");
375
+ await store.rebuildFtsIndex();
376
+
322
377
  debug(`Reindexed: ${title}`);
323
378
  }
324
379
 
package/src/setup.ts CHANGED
@@ -12,14 +12,23 @@
12
12
 
13
13
  import * as p from "@clack/prompts";
14
14
  import * as fs from "node:fs";
15
- import * as path from "node:path";
15
+ import {
16
+ getEnvPath,
17
+ ensureConfigDir,
18
+ hasLegacyConfig,
19
+ getLegacyEnvPath,
20
+ hasConfig,
21
+ isNpmInstall,
22
+ } from "./config/paths.js";
23
+ import {
24
+ getClaudeConfigEntry,
25
+ writeClaudeConfig,
26
+ getExistingInstallMethod,
27
+ } from "./config/claude.js";
28
+ import { checkBunRuntime } from "./utils/runtime.js";
29
+
16
30
  // Paths
17
- const PROJECT_DIR = path.dirname(new URL(import.meta.url).pathname);
18
- const ENV_FILE = path.join(PROJECT_DIR, "..", ".env");
19
- const CLAUDE_CONFIG_PATH = path.join(
20
- process.env.HOME || "~",
21
- ".claude.json"
22
- );
31
+ const ENV_FILE = getEnvPath();
23
32
 
24
33
  interface Config {
25
34
  provider: "local" | "openrouter";
@@ -69,6 +78,7 @@ function readExistingEnv(): Record<string, string> {
69
78
  * Write configuration to .env file
70
79
  */
71
80
  function writeEnvFile(config: Config): void {
81
+ ensureConfigDir();
72
82
  const lines: string[] = [
73
83
  "# apple-notes-mcp configuration",
74
84
  "# Generated by setup wizard",
@@ -114,80 +124,29 @@ function writeEnvFile(config: Config): void {
114
124
  fs.writeFileSync(ENV_FILE, lines.join("\n") + "\n");
115
125
  }
116
126
 
117
- /**
118
- * Read Claude Code config if it exists
119
- */
120
- function readClaudeConfig(): Record<string, unknown> | null {
121
- if (!fs.existsSync(CLAUDE_CONFIG_PATH)) {
122
- return null;
123
- }
124
-
125
- try {
126
- const content = fs.readFileSync(CLAUDE_CONFIG_PATH, "utf-8");
127
- return JSON.parse(content);
128
- } catch (error) {
129
- // Config doesn't exist or is invalid JSON
130
- if (process.env.DEBUG === "true") {
131
- console.error("[SETUP] Could not read Claude config:", error);
132
- }
133
- return null;
134
- }
135
- }
136
-
137
127
  /**
138
128
  * Add MCP server to Claude Code config
139
129
  */
140
130
  function addToClaudeConfig(): boolean {
141
- const projectPath = path.resolve(PROJECT_DIR, "..");
142
- const serverEntry = {
143
- command: "bun",
144
- args: ["run", path.join(projectPath, "src", "index.ts")],
145
- env: {},
146
- };
131
+ const entry = getClaudeConfigEntry();
147
132
 
148
- let config = readClaudeConfig();
133
+ // Check for install method change
134
+ const existingMethod = getExistingInstallMethod();
135
+ const currentMethod = isNpmInstall() ? "npm" : "source";
149
136
 
150
- if (!config) {
151
- // Create new config
152
- config = {
153
- mcpServers: {
154
- "apple-notes": serverEntry,
155
- },
156
- };
157
- } else {
158
- // Add to existing config
159
- const mcpServers = (config.mcpServers || {}) as Record<string, unknown>;
160
- mcpServers["apple-notes"] = serverEntry;
161
- config.mcpServers = mcpServers;
137
+ if (existingMethod && existingMethod !== currentMethod) {
138
+ p.log.info(`Updating Claude config from ${existingMethod} to ${currentMethod} installation`);
162
139
  }
163
140
 
164
- try {
165
- fs.writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
166
- return true;
167
- } catch (error) {
168
- if (process.env.DEBUG === "true") {
169
- console.error("[SETUP] Failed to write Claude config:", error);
170
- }
171
- return false;
172
- }
141
+ return writeClaudeConfig(entry);
173
142
  }
174
143
 
175
144
  /**
176
145
  * Generate config snippet for manual setup
177
146
  */
178
147
  function getConfigSnippet(): string {
179
- const projectPath = path.resolve(PROJECT_DIR, "..");
180
- return JSON.stringify(
181
- {
182
- "apple-notes": {
183
- command: "bun",
184
- args: ["run", path.join(projectPath, "src", "index.ts")],
185
- env: {},
186
- },
187
- },
188
- null,
189
- 2
190
- );
148
+ const entry = getClaudeConfigEntry();
149
+ return JSON.stringify({ "apple-notes": entry }, null, 2);
191
150
  }
192
151
 
193
152
  /**
@@ -217,6 +176,7 @@ async function downloadLocalModel(): Promise<void> {
217
176
  * Main setup wizard
218
177
  */
219
178
  async function main(): Promise<void> {
179
+ checkBunRuntime();
220
180
  console.clear();
221
181
 
222
182
  p.intro("apple-notes-mcp Setup Wizard");
@@ -233,6 +193,25 @@ async function main(): Promise<void> {
233
193
  );
234
194
  }
235
195
 
196
+ // Check for legacy config migration
197
+ if (hasLegacyConfig() && !hasConfig()) {
198
+ const migrate = await p.confirm({
199
+ message: "Found config in project directory. Migrate to ~/.apple-notes-mcp/?",
200
+ initialValue: true,
201
+ });
202
+
203
+ if (p.isCancel(migrate)) {
204
+ p.cancel("Setup cancelled.");
205
+ process.exit(0);
206
+ }
207
+
208
+ if (migrate) {
209
+ ensureConfigDir();
210
+ fs.copyFileSync(getLegacyEnvPath(), getEnvPath());
211
+ p.log.success("Config migrated to ~/.apple-notes-mcp/.env");
212
+ }
213
+ }
214
+
236
215
  // Provider selection
237
216
  const provider = await p.select({
238
217
  message: "Which embedding provider would you like to use?",
@@ -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 */