@disco_trooper/apple-notes-mcp 1.2.0 → 1.4.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 (39) hide show
  1. package/README.md +136 -24
  2. package/package.json +13 -9
  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 +209 -2
  10. package/src/db/lancedb.ts +373 -7
  11. package/src/embeddings/cache.test.ts +150 -0
  12. package/src/embeddings/cache.ts +204 -0
  13. package/src/embeddings/index.ts +21 -2
  14. package/src/embeddings/local.ts +61 -10
  15. package/src/embeddings/openrouter.ts +233 -11
  16. package/src/graph/export.test.ts +81 -0
  17. package/src/graph/export.ts +163 -0
  18. package/src/graph/extract.test.ts +90 -0
  19. package/src/graph/extract.ts +52 -0
  20. package/src/graph/queries.test.ts +156 -0
  21. package/src/graph/queries.ts +224 -0
  22. package/src/index.ts +376 -10
  23. package/src/notes/crud.test.ts +148 -3
  24. package/src/notes/crud.ts +250 -5
  25. package/src/notes/read.ts +83 -68
  26. package/src/search/chunk-indexer.test.ts +353 -0
  27. package/src/search/chunk-indexer.ts +254 -0
  28. package/src/search/chunk-search.test.ts +327 -0
  29. package/src/search/chunk-search.ts +298 -0
  30. package/src/search/indexer.ts +151 -109
  31. package/src/search/refresh.test.ts +173 -0
  32. package/src/search/refresh.ts +151 -0
  33. package/src/setup.ts +46 -67
  34. package/src/utils/chunker.test.ts +182 -0
  35. package/src/utils/chunker.ts +170 -0
  36. package/src/utils/content-filter.test.ts +225 -0
  37. package/src/utils/content-filter.ts +275 -0
  38. package/src/utils/runtime.test.ts +70 -0
  39. package/src/utils/runtime.ts +40 -0
@@ -7,13 +7,13 @@
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";
16
15
  import { NoteNotFoundError } from "../errors/index.js";
16
+ import { extractMetadata } from "../graph/extract.js";
17
17
 
18
18
  /**
19
19
  * Extract note title from folder/title key.
@@ -50,90 +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`);
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`);
70
133
 
71
- const records: NoteRecord[] = [];
72
- let errors = 0;
73
- const failedNotes: string[] = [];
134
+ // Filter empty notes and prepare for embedding
135
+ const preparedNotes = allNotes
136
+ .map(prepareNoteForEmbedding)
137
+ .filter((note): note is PreparedNote => note !== null);
74
138
 
75
- for (let i = 0; i < notes.length; i++) {
76
- const noteInfo = notes[i];
77
- debug(`Processing ${i + 1}/${notes.length}: ${noteInfo.title}`);
139
+ debug(`Prepared ${preparedNotes.length} notes for embedding`);
78
140
 
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
- }
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);
89
144
 
90
- // Skip empty notes
91
- if (!noteDetails.content.trim()) {
92
- debug(`Skipping empty note: ${noteInfo.title}`);
93
- continue;
94
- }
95
-
96
- // Generate embedding
97
- const content = truncateForEmbedding(noteDetails.content);
98
- const vector = await getEmbedding(content);
99
-
100
- const record: NoteRecord = {
101
- id: noteDetails.id,
102
- title: noteDetails.title,
103
- content: noteDetails.content,
104
- vector,
105
- folder: noteDetails.folder,
106
- created: noteDetails.created,
107
- modified: noteDetails.modified,
108
- indexed_at: new Date().toISOString(),
109
- };
110
-
111
- records.push(record);
112
-
113
- // Delay to avoid rate limiting
114
- if (i < notes.length - 1) {
115
- await sleep(EMBEDDING_DELAY_MS);
116
- }
117
- } catch (error) {
118
- debug(`Error processing ${noteInfo.title}:`, error);
119
- failedNotes.push(`${noteInfo.folder}/${noteInfo.title}`);
120
- errors++;
121
- }
145
+ let vectors: number[][];
146
+ try {
147
+ vectors = await getEmbeddingBatch(textsToEmbed);
148
+ } catch (error) {
149
+ debug("Batch embedding failed:", error);
150
+ throw error;
122
151
  }
123
152
 
124
- // 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
+
125
162
  const store = getVectorStore();
126
163
  await store.index(records);
127
164
 
128
165
  const timeMs = Date.now() - startTime;
129
- 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`);
130
168
 
131
169
  return {
132
- total: notes.length,
170
+ total: allNotes.length,
133
171
  indexed: records.length,
134
- errors,
172
+ errors: 0,
135
173
  timeMs,
136
- failedNotes: failedNotes.length > 0 ? failedNotes : undefined,
137
174
  };
138
175
  }
139
176
 
@@ -212,48 +249,63 @@ export async function incrementalIndex(): Promise<IndexResult> {
212
249
  let errors = 0;
213
250
  const failedNotes: string[] = [];
214
251
 
215
- // Process additions and updates
252
+ // Process additions and updates in batch
216
253
  const toProcess = [...toAdd, ...toUpdate];
217
- for (let i = 0; i < toProcess.length; i++) {
218
- const noteInfo = toProcess[i];
219
- debug(`Processing ${i + 1}/${toProcess.length}: ${noteInfo.title}`);
220
254
 
221
- try {
222
- // Use folder and title separately to handle "/" in titles
223
- const noteDetails = await getNoteByFolderAndTitle(noteInfo.folder, noteInfo.title);
224
- 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);
225
275
  failedNotes.push(`${noteInfo.folder}/${noteInfo.title}`);
226
276
  errors++;
227
- continue;
228
277
  }
278
+ }
229
279
 
230
- if (!noteDetails.content.trim()) {
231
- 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;
232
291
  }
233
292
 
234
- const content = truncateForEmbedding(noteDetails.content);
235
- const vector = await getEmbedding(content);
236
-
237
- const record: NoteRecord = {
238
- id: noteDetails.id,
239
- title: noteDetails.title,
240
- content: noteDetails.content,
241
- vector,
242
- folder: noteDetails.folder,
243
- created: noteDetails.created,
244
- modified: noteDetails.modified,
245
- indexed_at: new Date().toISOString(),
246
- };
247
-
248
- await store.update(record);
249
-
250
- if (i < toProcess.length - 1) {
251
- 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
+ }
252
308
  }
253
- } catch (error) {
254
- debug(`Error processing ${noteInfo.title}:`, error);
255
- failedNotes.push(`${noteInfo.folder}/${noteInfo.title}`);
256
- errors++;
257
309
  }
258
310
  }
259
311
 
@@ -307,23 +359,13 @@ export async function reindexNote(title: string): Promise<void> {
307
359
  throw new NoteNotFoundError(title);
308
360
  }
309
361
 
310
- if (!noteDetails.content.trim()) {
362
+ const prepared = prepareNoteForEmbedding(noteDetails);
363
+ if (!prepared) {
311
364
  throw new Error(`Note is empty: "${title}"`);
312
365
  }
313
366
 
314
- const content = truncateForEmbedding(noteDetails.content);
315
- const vector = await getEmbedding(content);
316
-
317
- const record: NoteRecord = {
318
- id: noteDetails.id,
319
- title: noteDetails.title,
320
- content: noteDetails.content,
321
- vector,
322
- folder: noteDetails.folder,
323
- created: noteDetails.created,
324
- modified: noteDetails.modified,
325
- indexed_at: new Date().toISOString(),
326
- };
367
+ const vector = await getEmbedding(prepared.truncatedContent);
368
+ const record = buildNoteRecord(prepared, vector, new Date().toISOString());
327
369
 
328
370
  const store = getVectorStore();
329
371
  await store.update(record);
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("../notes/read.js", () => ({
4
+ getAllNotes: vi.fn(),
5
+ }));
6
+
7
+ vi.mock("../db/lancedb.js", () => ({
8
+ getVectorStore: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("./indexer.js", () => ({
12
+ incrementalIndex: vi.fn(),
13
+ }));
14
+
15
+ describe("checkForChanges", () => {
16
+ beforeEach(() => {
17
+ vi.resetModules();
18
+ vi.clearAllMocks();
19
+ });
20
+
21
+ it("should return true if notes were modified after indexing", async () => {
22
+ const { getAllNotes } = await import("../notes/read.js");
23
+ const { getVectorStore } = await import("../db/lancedb.js");
24
+
25
+ vi.mocked(getAllNotes).mockResolvedValue([
26
+ { title: "Note 1", folder: "Work", created: "2026-01-01", modified: "2026-01-10T12:00:00Z" },
27
+ ]);
28
+
29
+ vi.mocked(getVectorStore).mockReturnValue({
30
+ getAll: vi.fn().mockResolvedValue([
31
+ { title: "Note 1", folder: "Work", indexed_at: "2026-01-09T12:00:00Z" },
32
+ ]),
33
+ } as any);
34
+
35
+ const { checkForChanges } = await import("./refresh.js");
36
+ const hasChanges = await checkForChanges();
37
+
38
+ expect(hasChanges).toBe(true);
39
+ });
40
+
41
+ it("should return false if no changes", async () => {
42
+ const { getAllNotes } = await import("../notes/read.js");
43
+ const { getVectorStore } = await import("../db/lancedb.js");
44
+
45
+ vi.mocked(getAllNotes).mockResolvedValue([
46
+ { title: "Note 1", folder: "Work", created: "2026-01-01", modified: "2026-01-08T12:00:00Z" },
47
+ ]);
48
+
49
+ vi.mocked(getVectorStore).mockReturnValue({
50
+ getAll: vi.fn().mockResolvedValue([
51
+ { title: "Note 1", folder: "Work", indexed_at: "2026-01-09T12:00:00Z" },
52
+ ]),
53
+ } as any);
54
+
55
+ const { checkForChanges } = await import("./refresh.js");
56
+ const hasChanges = await checkForChanges();
57
+
58
+ expect(hasChanges).toBe(false);
59
+ });
60
+
61
+ it("should return true if new note added", async () => {
62
+ const { getAllNotes } = await import("../notes/read.js");
63
+ const { getVectorStore } = await import("../db/lancedb.js");
64
+
65
+ vi.mocked(getAllNotes).mockResolvedValue([
66
+ { title: "Note 1", folder: "Work", created: "2026-01-01", modified: "2026-01-08T12:00:00Z" },
67
+ { title: "New Note", folder: "Work", created: "2026-01-10", modified: "2026-01-10T12:00:00Z" },
68
+ ]);
69
+
70
+ vi.mocked(getVectorStore).mockReturnValue({
71
+ getAll: vi.fn().mockResolvedValue([
72
+ { title: "Note 1", folder: "Work", indexed_at: "2026-01-09T12:00:00Z" },
73
+ ]),
74
+ } as any);
75
+
76
+ const { checkForChanges } = await import("./refresh.js");
77
+ const hasChanges = await checkForChanges();
78
+
79
+ expect(hasChanges).toBe(true);
80
+ });
81
+
82
+ it("should return true if note deleted", async () => {
83
+ const { getAllNotes } = await import("../notes/read.js");
84
+ const { getVectorStore } = await import("../db/lancedb.js");
85
+
86
+ vi.mocked(getAllNotes).mockResolvedValue([]);
87
+
88
+ vi.mocked(getVectorStore).mockReturnValue({
89
+ getAll: vi.fn().mockResolvedValue([
90
+ { title: "Note 1", folder: "Work", indexed_at: "2026-01-09T12:00:00Z" },
91
+ ]),
92
+ } as any);
93
+
94
+ const { checkForChanges } = await import("./refresh.js");
95
+ const hasChanges = await checkForChanges();
96
+
97
+ expect(hasChanges).toBe(true);
98
+ });
99
+
100
+ it("should return true if no index exists and notes exist", async () => {
101
+ const { getAllNotes } = await import("../notes/read.js");
102
+ const { getVectorStore } = await import("../db/lancedb.js");
103
+
104
+ vi.mocked(getAllNotes).mockResolvedValue([
105
+ { title: "Note 1", folder: "Work", created: "2026-01-01", modified: "2026-01-08T12:00:00Z" },
106
+ ]);
107
+
108
+ vi.mocked(getVectorStore).mockReturnValue({
109
+ getAll: vi.fn().mockRejectedValue(new Error("Table not found")),
110
+ } as any);
111
+
112
+ const { checkForChanges } = await import("./refresh.js");
113
+ const hasChanges = await checkForChanges();
114
+
115
+ expect(hasChanges).toBe(true);
116
+ });
117
+ });
118
+
119
+ describe("refreshIfNeeded", () => {
120
+ beforeEach(() => {
121
+ vi.resetModules();
122
+ vi.clearAllMocks();
123
+ });
124
+
125
+ it("should trigger incremental index if changes detected", async () => {
126
+ const { incrementalIndex } = await import("./indexer.js");
127
+ const { getAllNotes } = await import("../notes/read.js");
128
+ const { getVectorStore } = await import("../db/lancedb.js");
129
+
130
+ vi.mocked(getAllNotes).mockResolvedValue([
131
+ { title: "New Note", folder: "Work", created: "2026-01-10", modified: "2026-01-10T12:00:00Z" },
132
+ ]);
133
+
134
+ vi.mocked(getVectorStore).mockReturnValue({
135
+ getAll: vi.fn().mockResolvedValue([]),
136
+ } as any);
137
+
138
+ vi.mocked(incrementalIndex).mockResolvedValue({
139
+ total: 1,
140
+ indexed: 1,
141
+ errors: 0,
142
+ timeMs: 100,
143
+ });
144
+
145
+ const { refreshIfNeeded } = await import("./refresh.js");
146
+ const refreshed = await refreshIfNeeded();
147
+
148
+ expect(refreshed).toBe(true);
149
+ expect(incrementalIndex).toHaveBeenCalled();
150
+ });
151
+
152
+ it("should not trigger index if no changes", async () => {
153
+ const { incrementalIndex } = await import("./indexer.js");
154
+ const { getAllNotes } = await import("../notes/read.js");
155
+ const { getVectorStore } = await import("../db/lancedb.js");
156
+
157
+ vi.mocked(getAllNotes).mockResolvedValue([
158
+ { title: "Note 1", folder: "Work", created: "2026-01-01", modified: "2026-01-08T12:00:00Z" },
159
+ ]);
160
+
161
+ vi.mocked(getVectorStore).mockReturnValue({
162
+ getAll: vi.fn().mockResolvedValue([
163
+ { title: "Note 1", folder: "Work", indexed_at: "2026-01-09T12:00:00Z" },
164
+ ]),
165
+ } as any);
166
+
167
+ const { refreshIfNeeded } = await import("./refresh.js");
168
+ const refreshed = await refreshIfNeeded();
169
+
170
+ expect(refreshed).toBe(false);
171
+ expect(incrementalIndex).not.toHaveBeenCalled();
172
+ });
173
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Smart refresh: check for note changes before search.
3
+ * Triggers incremental index if notes have been modified.
4
+ * Also updates chunk index for changed notes.
5
+ */
6
+
7
+ import { getAllNotes, getNoteByFolderAndTitle, type NoteInfo } from "../notes/read.js";
8
+ import { getVectorStore, getChunkStore } from "../db/lancedb.js";
9
+ import { incrementalIndex } from "./indexer.js";
10
+ import { updateChunksForNotes, hasChunkIndex } from "./chunk-indexer.js";
11
+ import { createDebugLogger } from "../utils/debug.js";
12
+
13
+ const debug = createDebugLogger("REFRESH");
14
+
15
+ /**
16
+ * Detected changes in notes.
17
+ */
18
+ interface DetectedChanges {
19
+ hasChanges: boolean;
20
+ added: NoteInfo[];
21
+ modified: NoteInfo[];
22
+ deleted: string[]; // note IDs
23
+ }
24
+
25
+ /**
26
+ * Check for note changes and return details about what changed.
27
+ */
28
+ export async function detectChanges(): Promise<DetectedChanges> {
29
+ debug("Checking for changes...");
30
+
31
+ const currentNotes = await getAllNotes();
32
+ const store = getVectorStore();
33
+
34
+ let existingRecords;
35
+ try {
36
+ existingRecords = await store.getAll();
37
+ } catch {
38
+ // No index exists yet
39
+ debug("No existing index found");
40
+ return {
41
+ hasChanges: currentNotes.length > 0,
42
+ added: currentNotes,
43
+ modified: [],
44
+ deleted: [],
45
+ };
46
+ }
47
+
48
+ // Build lookup maps
49
+ const existingByKey = new Map<string, { indexed_at: string; id: string }>();
50
+ for (const record of existingRecords) {
51
+ const key = `${record.folder}/${record.title}`;
52
+ existingByKey.set(key, { indexed_at: record.indexed_at, id: record.id });
53
+ }
54
+
55
+ const added: NoteInfo[] = [];
56
+ const modified: NoteInfo[] = [];
57
+ const deleted: string[] = [];
58
+
59
+ // Check for new or modified notes
60
+ for (const note of currentNotes) {
61
+ const key = `${note.folder}/${note.title}`;
62
+ const existing = existingByKey.get(key);
63
+
64
+ if (!existing) {
65
+ debug(`New note detected: ${key}`);
66
+ added.push(note);
67
+ } else {
68
+ const noteModified = new Date(note.modified).getTime();
69
+ const recordIndexed = new Date(existing.indexed_at).getTime();
70
+
71
+ if (noteModified > recordIndexed) {
72
+ debug(`Modified note detected: ${key}`);
73
+ modified.push(note);
74
+ }
75
+ }
76
+ }
77
+
78
+ // Check for deleted notes
79
+ const currentKeys = new Set(currentNotes.map((n) => `${n.folder}/${n.title}`));
80
+ for (const [key, { id }] of existingByKey) {
81
+ if (!currentKeys.has(key)) {
82
+ debug(`Deleted note detected: ${key}`);
83
+ deleted.push(id);
84
+ }
85
+ }
86
+
87
+ const hasChanges = added.length > 0 || modified.length > 0 || deleted.length > 0;
88
+ debug(`Changes: ${added.length} added, ${modified.length} modified, ${deleted.length} deleted`);
89
+
90
+ return { hasChanges, added, modified, deleted };
91
+ }
92
+
93
+ /**
94
+ * Check if any notes have been modified since last index.
95
+ * @returns true if changes detected, false otherwise
96
+ */
97
+ export async function checkForChanges(): Promise<boolean> {
98
+ const changes = await detectChanges();
99
+ return changes.hasChanges;
100
+ }
101
+
102
+ /**
103
+ * Refresh index if changes are detected.
104
+ * Updates both main index AND chunk index.
105
+ *
106
+ * @returns true if index was refreshed, false if no changes
107
+ */
108
+ export async function refreshIfNeeded(): Promise<boolean> {
109
+ const changes = await detectChanges();
110
+
111
+ if (!changes.hasChanges) {
112
+ return false;
113
+ }
114
+
115
+ // Update main index
116
+ debug("Changes detected, running incremental index...");
117
+ const result = await incrementalIndex();
118
+ debug(`Main index refresh: ${result.indexed} notes updated in ${result.timeMs}ms`);
119
+
120
+ // Update chunk index if it exists and there are changes
121
+ const hasChunks = await hasChunkIndex();
122
+ if (hasChunks && (changes.added.length > 0 || changes.modified.length > 0)) {
123
+ debug("Updating chunk index for changed notes...");
124
+
125
+ // Fetch full content for changed notes
126
+ const changedNotes = [...changes.added, ...changes.modified];
127
+ const notesWithContent = await Promise.all(
128
+ changedNotes.map(async (n) => {
129
+ const note = await getNoteByFolderAndTitle(n.folder, n.title);
130
+ return note;
131
+ })
132
+ );
133
+
134
+ // Filter out nulls (notes that couldn't be fetched)
135
+ const validNotes = notesWithContent.filter((n) => n !== null);
136
+
137
+ if (validNotes.length > 0) {
138
+ const chunksCreated = await updateChunksForNotes(validNotes);
139
+ debug(`Chunk index refresh: ${chunksCreated} chunks for ${validNotes.length} notes`);
140
+ }
141
+
142
+ // Delete chunks for deleted notes
143
+ if (changes.deleted.length > 0) {
144
+ const chunkStore = getChunkStore();
145
+ await chunkStore.deleteChunksByNoteIds(changes.deleted);
146
+ debug(`Deleted chunks for ${changes.deleted.length} notes`);
147
+ }
148
+ }
149
+
150
+ return true;
151
+ }