@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.
- package/README.md +136 -24
- package/package.json +13 -9
- package/src/config/claude.test.ts +47 -0
- package/src/config/claude.ts +106 -0
- package/src/config/constants.ts +11 -2
- package/src/config/paths.test.ts +40 -0
- package/src/config/paths.ts +86 -0
- package/src/db/arrow-fix.test.ts +101 -0
- package/src/db/lancedb.test.ts +209 -2
- package/src/db/lancedb.ts +373 -7
- package/src/embeddings/cache.test.ts +150 -0
- package/src/embeddings/cache.ts +204 -0
- package/src/embeddings/index.ts +21 -2
- package/src/embeddings/local.ts +61 -10
- package/src/embeddings/openrouter.ts +233 -11
- package/src/graph/export.test.ts +81 -0
- package/src/graph/export.ts +163 -0
- package/src/graph/extract.test.ts +90 -0
- package/src/graph/extract.ts +52 -0
- package/src/graph/queries.test.ts +156 -0
- package/src/graph/queries.ts +224 -0
- package/src/index.ts +376 -10
- package/src/notes/crud.test.ts +148 -3
- package/src/notes/crud.ts +250 -5
- package/src/notes/read.ts +83 -68
- package/src/search/chunk-indexer.test.ts +353 -0
- package/src/search/chunk-indexer.ts +254 -0
- package/src/search/chunk-search.test.ts +327 -0
- package/src/search/chunk-search.ts +298 -0
- package/src/search/indexer.ts +151 -109
- package/src/search/refresh.test.ts +173 -0
- package/src/search/refresh.ts +151 -0
- package/src/setup.ts +46 -67
- package/src/utils/chunker.test.ts +182 -0
- package/src/utils/chunker.ts +170 -0
- package/src/utils/content-filter.test.ts +225 -0
- package/src/utils/content-filter.ts +275 -0
- package/src/utils/runtime.test.ts +70 -0
- package/src/utils/runtime.ts +40 -0
package/src/search/indexer.ts
CHANGED
|
@@ -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
|
-
*
|
|
53
|
+
* Note data prepared for embedding.
|
|
54
54
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
362
|
+
const prepared = prepareNoteForEmbedding(noteDetails);
|
|
363
|
+
if (!prepared) {
|
|
311
364
|
throw new Error(`Note is empty: "${title}"`);
|
|
312
365
|
}
|
|
313
366
|
|
|
314
|
-
const
|
|
315
|
-
const
|
|
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
|
+
}
|