@disco_trooper/apple-notes-mcp 1.7.0 → 1.8.2
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 +67 -5
- package/package.json +1 -1
- package/src/config/constants.ts +5 -0
- package/src/config/env.test.ts +14 -0
- package/src/config/env.ts +2 -0
- package/src/db/lancedb.test.ts +14 -0
- package/src/db/lancedb.ts +37 -0
- package/src/index.ts +164 -16
- package/src/indexing/contracts.test.ts +13 -0
- package/src/indexing/contracts.ts +28 -0
- package/src/indexing/job-manager.test.ts +185 -0
- package/src/indexing/job-manager.ts +377 -0
- package/src/notes/crud.test.ts +33 -6
- package/src/notes/crud.ts +62 -7
- package/src/notes/read.test.ts +139 -5
- package/src/notes/read.ts +58 -5
- package/src/search/chunk-indexer.ts +69 -4
- package/src/search/indexer.progress.test.ts +75 -0
- package/src/search/indexer.ts +149 -38
- package/src/search/refresh-policy.test.ts +25 -0
- package/src/search/refresh-policy.ts +33 -0
- package/src/search/refresh.test.ts +146 -25
- package/src/search/refresh.ts +207 -47
- package/src/search/write-sync.test.ts +133 -0
- package/src/search/write-sync.ts +155 -0
|
@@ -1,24 +1,36 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
|
|
3
3
|
vi.mock("../notes/read.js", () => ({
|
|
4
4
|
getAllNotes: vi.fn(),
|
|
5
|
+
getNoteByFolderAndTitle: vi.fn(),
|
|
5
6
|
}));
|
|
6
7
|
|
|
7
8
|
vi.mock("../db/lancedb.js", () => ({
|
|
8
9
|
getVectorStore: vi.fn(),
|
|
10
|
+
getChunkStore: vi.fn(),
|
|
9
11
|
}));
|
|
10
12
|
|
|
11
13
|
vi.mock("./indexer.js", () => ({
|
|
12
14
|
incrementalIndex: vi.fn(),
|
|
13
15
|
}));
|
|
14
16
|
|
|
17
|
+
vi.mock("./chunk-indexer.js", () => ({
|
|
18
|
+
hasChunkIndex: vi.fn().mockResolvedValue(false),
|
|
19
|
+
updateChunksForNotes: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
15
22
|
describe("checkForChanges", () => {
|
|
16
23
|
beforeEach(() => {
|
|
17
24
|
vi.resetModules();
|
|
18
25
|
vi.clearAllMocks();
|
|
19
26
|
});
|
|
20
27
|
|
|
21
|
-
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
delete process.env.INDEX_TTL;
|
|
30
|
+
delete process.env.SEARCH_REFRESH_TIMEOUT_MS;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns true if notes were modified after indexing", async () => {
|
|
22
34
|
const { getAllNotes } = await import("../notes/read.js");
|
|
23
35
|
const { getVectorStore } = await import("../db/lancedb.js");
|
|
24
36
|
|
|
@@ -27,8 +39,8 @@ describe("checkForChanges", () => {
|
|
|
27
39
|
]);
|
|
28
40
|
|
|
29
41
|
vi.mocked(getVectorStore).mockReturnValue({
|
|
30
|
-
|
|
31
|
-
{ title: "Note 1", folder: "Work", indexed_at: "2026-01-09T12:00:00Z" },
|
|
42
|
+
getIndexMetadata: vi.fn().mockResolvedValue([
|
|
43
|
+
{ id: "1", title: "Note 1", folder: "Work", indexed_at: "2026-01-09T12:00:00Z" },
|
|
32
44
|
]),
|
|
33
45
|
} as any);
|
|
34
46
|
|
|
@@ -38,7 +50,7 @@ describe("checkForChanges", () => {
|
|
|
38
50
|
expect(hasChanges).toBe(true);
|
|
39
51
|
});
|
|
40
52
|
|
|
41
|
-
it("
|
|
53
|
+
it("returns false if no changes", async () => {
|
|
42
54
|
const { getAllNotes } = await import("../notes/read.js");
|
|
43
55
|
const { getVectorStore } = await import("../db/lancedb.js");
|
|
44
56
|
|
|
@@ -47,8 +59,8 @@ describe("checkForChanges", () => {
|
|
|
47
59
|
]);
|
|
48
60
|
|
|
49
61
|
vi.mocked(getVectorStore).mockReturnValue({
|
|
50
|
-
|
|
51
|
-
{ title: "Note 1", folder: "Work", indexed_at: "2026-01-09T12:00:00Z" },
|
|
62
|
+
getIndexMetadata: vi.fn().mockResolvedValue([
|
|
63
|
+
{ id: "1", title: "Note 1", folder: "Work", indexed_at: "2026-01-09T12:00:00Z" },
|
|
52
64
|
]),
|
|
53
65
|
} as any);
|
|
54
66
|
|
|
@@ -58,7 +70,7 @@ describe("checkForChanges", () => {
|
|
|
58
70
|
expect(hasChanges).toBe(false);
|
|
59
71
|
});
|
|
60
72
|
|
|
61
|
-
it("
|
|
73
|
+
it("returns true if a new note was added", async () => {
|
|
62
74
|
const { getAllNotes } = await import("../notes/read.js");
|
|
63
75
|
const { getVectorStore } = await import("../db/lancedb.js");
|
|
64
76
|
|
|
@@ -68,8 +80,8 @@ describe("checkForChanges", () => {
|
|
|
68
80
|
]);
|
|
69
81
|
|
|
70
82
|
vi.mocked(getVectorStore).mockReturnValue({
|
|
71
|
-
|
|
72
|
-
{ title: "Note 1", folder: "Work", indexed_at: "2026-01-09T12:00:00Z" },
|
|
83
|
+
getIndexMetadata: vi.fn().mockResolvedValue([
|
|
84
|
+
{ id: "1", title: "Note 1", folder: "Work", indexed_at: "2026-01-09T12:00:00Z" },
|
|
73
85
|
]),
|
|
74
86
|
} as any);
|
|
75
87
|
|
|
@@ -79,15 +91,15 @@ describe("checkForChanges", () => {
|
|
|
79
91
|
expect(hasChanges).toBe(true);
|
|
80
92
|
});
|
|
81
93
|
|
|
82
|
-
it("
|
|
94
|
+
it("returns true if a note was deleted", async () => {
|
|
83
95
|
const { getAllNotes } = await import("../notes/read.js");
|
|
84
96
|
const { getVectorStore } = await import("../db/lancedb.js");
|
|
85
97
|
|
|
86
98
|
vi.mocked(getAllNotes).mockResolvedValue([]);
|
|
87
99
|
|
|
88
100
|
vi.mocked(getVectorStore).mockReturnValue({
|
|
89
|
-
|
|
90
|
-
{ title: "Note 1", folder: "Work", indexed_at: "2026-01-09T12:00:00Z" },
|
|
101
|
+
getIndexMetadata: vi.fn().mockResolvedValue([
|
|
102
|
+
{ id: "1", title: "Note 1", folder: "Work", indexed_at: "2026-01-09T12:00:00Z" },
|
|
91
103
|
]),
|
|
92
104
|
} as any);
|
|
93
105
|
|
|
@@ -97,7 +109,7 @@ describe("checkForChanges", () => {
|
|
|
97
109
|
expect(hasChanges).toBe(true);
|
|
98
110
|
});
|
|
99
111
|
|
|
100
|
-
it("
|
|
112
|
+
it("returns true if no index exists and notes exist", async () => {
|
|
101
113
|
const { getAllNotes } = await import("../notes/read.js");
|
|
102
114
|
const { getVectorStore } = await import("../db/lancedb.js");
|
|
103
115
|
|
|
@@ -106,7 +118,7 @@ describe("checkForChanges", () => {
|
|
|
106
118
|
]);
|
|
107
119
|
|
|
108
120
|
vi.mocked(getVectorStore).mockReturnValue({
|
|
109
|
-
|
|
121
|
+
getIndexMetadata: vi.fn().mockRejectedValue(new Error("Table not found")),
|
|
110
122
|
} as any);
|
|
111
123
|
|
|
112
124
|
const { checkForChanges } = await import("./refresh.js");
|
|
@@ -120,19 +132,41 @@ describe("refreshIfNeeded", () => {
|
|
|
120
132
|
beforeEach(() => {
|
|
121
133
|
vi.resetModules();
|
|
122
134
|
vi.clearAllMocks();
|
|
135
|
+
delete process.env.INDEX_TTL;
|
|
136
|
+
delete process.env.SEARCH_REFRESH_TIMEOUT_MS;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
afterEach(() => {
|
|
140
|
+
vi.useRealTimers();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("skips refresh when INDEX_TTL is not configured", async () => {
|
|
144
|
+
const { incrementalIndex } = await import("./indexer.js");
|
|
145
|
+
const { getVectorStore } = await import("../db/lancedb.js");
|
|
146
|
+
|
|
147
|
+
const { refreshIfNeeded } = await import("./refresh.js");
|
|
148
|
+
const refreshed = await refreshIfNeeded();
|
|
149
|
+
|
|
150
|
+
expect(refreshed).toBe(false);
|
|
151
|
+
expect(incrementalIndex).not.toHaveBeenCalled();
|
|
152
|
+
expect(getVectorStore).not.toHaveBeenCalled();
|
|
123
153
|
});
|
|
124
154
|
|
|
125
|
-
it("
|
|
155
|
+
it("triggers incremental index when TTL expired and changes detected", async () => {
|
|
156
|
+
process.env.INDEX_TTL = "1";
|
|
157
|
+
|
|
126
158
|
const { incrementalIndex } = await import("./indexer.js");
|
|
127
159
|
const { getAllNotes } = await import("../notes/read.js");
|
|
128
160
|
const { getVectorStore } = await import("../db/lancedb.js");
|
|
129
161
|
|
|
130
162
|
vi.mocked(getAllNotes).mockResolvedValue([
|
|
131
|
-
{ title: "
|
|
163
|
+
{ title: "Note 1", folder: "Work", created: "2026-01-01", modified: "2026-01-10T12:00:00Z" },
|
|
132
164
|
]);
|
|
133
165
|
|
|
134
166
|
vi.mocked(getVectorStore).mockReturnValue({
|
|
135
|
-
|
|
167
|
+
getIndexMetadata: vi.fn().mockResolvedValue([
|
|
168
|
+
{ id: "1", title: "Note 1", folder: "Work", indexed_at: "2020-01-01T00:00:00Z" },
|
|
169
|
+
]),
|
|
136
170
|
} as any);
|
|
137
171
|
|
|
138
172
|
vi.mocked(incrementalIndex).mockResolvedValue({
|
|
@@ -146,21 +180,45 @@ describe("refreshIfNeeded", () => {
|
|
|
146
180
|
const refreshed = await refreshIfNeeded();
|
|
147
181
|
|
|
148
182
|
expect(refreshed).toBe(true);
|
|
149
|
-
expect(incrementalIndex).
|
|
183
|
+
expect(incrementalIndex).toHaveBeenCalledOnce();
|
|
150
184
|
});
|
|
151
185
|
|
|
152
|
-
it("
|
|
186
|
+
it("skips refresh when TTL is not expired", async () => {
|
|
187
|
+
process.env.INDEX_TTL = "86400";
|
|
188
|
+
|
|
189
|
+
const { incrementalIndex } = await import("./indexer.js");
|
|
190
|
+
const { getVectorStore } = await import("../db/lancedb.js");
|
|
191
|
+
|
|
192
|
+
vi.mocked(getVectorStore).mockReturnValue({
|
|
193
|
+
getIndexMetadata: vi.fn().mockResolvedValue([
|
|
194
|
+
{
|
|
195
|
+
id: "1",
|
|
196
|
+
title: "Note 1",
|
|
197
|
+
folder: "Work",
|
|
198
|
+
indexed_at: new Date().toISOString(),
|
|
199
|
+
},
|
|
200
|
+
]),
|
|
201
|
+
} as any);
|
|
202
|
+
|
|
203
|
+
const { refreshIfNeeded } = await import("./refresh.js");
|
|
204
|
+
const refreshed = await refreshIfNeeded();
|
|
205
|
+
|
|
206
|
+
expect(refreshed).toBe(false);
|
|
207
|
+
expect(incrementalIndex).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("returns false when refresh throws", async () => {
|
|
211
|
+
process.env.INDEX_TTL = "1";
|
|
212
|
+
|
|
153
213
|
const { incrementalIndex } = await import("./indexer.js");
|
|
154
214
|
const { getAllNotes } = await import("../notes/read.js");
|
|
155
215
|
const { getVectorStore } = await import("../db/lancedb.js");
|
|
156
216
|
|
|
157
|
-
vi.mocked(getAllNotes).
|
|
158
|
-
{ title: "Note 1", folder: "Work", created: "2026-01-01", modified: "2026-01-08T12:00:00Z" },
|
|
159
|
-
]);
|
|
217
|
+
vi.mocked(getAllNotes).mockRejectedValue(new Error("JXA failed"));
|
|
160
218
|
|
|
161
219
|
vi.mocked(getVectorStore).mockReturnValue({
|
|
162
|
-
|
|
163
|
-
{ title: "Note 1", folder: "Work", indexed_at: "
|
|
220
|
+
getIndexMetadata: vi.fn().mockResolvedValue([
|
|
221
|
+
{ id: "1", title: "Note 1", folder: "Work", indexed_at: "2020-01-01T00:00:00Z" },
|
|
164
222
|
]),
|
|
165
223
|
} as any);
|
|
166
224
|
|
|
@@ -170,4 +228,67 @@ describe("refreshIfNeeded", () => {
|
|
|
170
228
|
expect(refreshed).toBe(false);
|
|
171
229
|
expect(incrementalIndex).not.toHaveBeenCalled();
|
|
172
230
|
});
|
|
231
|
+
|
|
232
|
+
it("returns false when refresh exceeds timeout budget", async () => {
|
|
233
|
+
vi.useFakeTimers();
|
|
234
|
+
|
|
235
|
+
process.env.INDEX_TTL = "1";
|
|
236
|
+
process.env.SEARCH_REFRESH_TIMEOUT_MS = "5";
|
|
237
|
+
|
|
238
|
+
const { incrementalIndex } = await import("./indexer.js");
|
|
239
|
+
const { getAllNotes } = await import("../notes/read.js");
|
|
240
|
+
const { getVectorStore } = await import("../db/lancedb.js");
|
|
241
|
+
|
|
242
|
+
vi.mocked(getAllNotes).mockResolvedValue([
|
|
243
|
+
{ title: "Note 1", folder: "Work", created: "2026-01-01", modified: "2026-01-10T12:00:00Z" },
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
vi.mocked(getVectorStore).mockReturnValue({
|
|
247
|
+
getIndexMetadata: vi.fn().mockResolvedValue([
|
|
248
|
+
{ id: "1", title: "Note 1", folder: "Work", indexed_at: "2020-01-01T00:00:00Z" },
|
|
249
|
+
]),
|
|
250
|
+
} as any);
|
|
251
|
+
|
|
252
|
+
vi.mocked(incrementalIndex).mockImplementation(
|
|
253
|
+
() => new Promise(() => {
|
|
254
|
+
// Intentionally never resolves
|
|
255
|
+
})
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const { refreshIfNeeded } = await import("./refresh.js");
|
|
259
|
+
const pending = refreshIfNeeded();
|
|
260
|
+
|
|
261
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
262
|
+
await expect(pending).resolves.toBe(false);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("coalesces concurrent refresh calls into a single run", async () => {
|
|
266
|
+
process.env.INDEX_TTL = "1";
|
|
267
|
+
|
|
268
|
+
const { incrementalIndex } = await import("./indexer.js");
|
|
269
|
+
const { getAllNotes } = await import("../notes/read.js");
|
|
270
|
+
const { getVectorStore } = await import("../db/lancedb.js");
|
|
271
|
+
|
|
272
|
+
vi.mocked(getAllNotes).mockResolvedValue([
|
|
273
|
+
{ title: "Note 1", folder: "Work", created: "2026-01-01", modified: "2026-01-10T12:00:00Z" },
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
vi.mocked(getVectorStore).mockReturnValue({
|
|
277
|
+
getIndexMetadata: vi.fn().mockResolvedValue([
|
|
278
|
+
{ id: "1", title: "Note 1", folder: "Work", indexed_at: "2020-01-01T00:00:00Z" },
|
|
279
|
+
]),
|
|
280
|
+
} as any);
|
|
281
|
+
|
|
282
|
+
vi.mocked(incrementalIndex).mockImplementation(async () => {
|
|
283
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
284
|
+
return { total: 1, indexed: 1, errors: 0, timeMs: 100 };
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const { refreshIfNeeded } = await import("./refresh.js");
|
|
288
|
+
const [first, second] = await Promise.all([refreshIfNeeded(), refreshIfNeeded()]);
|
|
289
|
+
|
|
290
|
+
expect(first).toBe(true);
|
|
291
|
+
expect(second).toBe(true);
|
|
292
|
+
expect(incrementalIndex).toHaveBeenCalledTimes(1);
|
|
293
|
+
});
|
|
173
294
|
});
|
package/src/search/refresh.ts
CHANGED
|
@@ -5,12 +5,26 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { getAllNotes, getNoteByFolderAndTitle, type NoteInfo } from "../notes/read.js";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getVectorStore,
|
|
10
|
+
getChunkStore,
|
|
11
|
+
type IndexMetadataRecord,
|
|
12
|
+
} from "../db/lancedb.js";
|
|
9
13
|
import { incrementalIndex } from "./indexer.js";
|
|
10
14
|
import { updateChunksForNotes, hasChunkIndex } from "./chunk-indexer.js";
|
|
11
15
|
import { createDebugLogger } from "../utils/debug.js";
|
|
16
|
+
import { DEFAULT_SEARCH_REFRESH_TIMEOUT_MS } from "../config/constants.js";
|
|
17
|
+
import { shouldAutoRefreshByTtl } from "./refresh-policy.js";
|
|
12
18
|
|
|
13
19
|
const debug = createDebugLogger("REFRESH");
|
|
20
|
+
let refreshInFlight: Promise<boolean> | null = null;
|
|
21
|
+
|
|
22
|
+
interface LegacyIndexRecord {
|
|
23
|
+
id?: string;
|
|
24
|
+
title: string;
|
|
25
|
+
folder: string;
|
|
26
|
+
indexed_at: string;
|
|
27
|
+
}
|
|
14
28
|
|
|
15
29
|
/**
|
|
16
30
|
* Detected changes in notes.
|
|
@@ -22,27 +36,144 @@ interface DetectedChanges {
|
|
|
22
36
|
deleted: string[]; // note IDs
|
|
23
37
|
}
|
|
24
38
|
|
|
39
|
+
async function getIndexMetadata(): Promise<IndexMetadataRecord[]> {
|
|
40
|
+
const store = getVectorStore() as {
|
|
41
|
+
getIndexMetadata?: () => Promise<IndexMetadataRecord[]>;
|
|
42
|
+
getAll?: () => Promise<LegacyIndexRecord[]>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (typeof store.getIndexMetadata === "function") {
|
|
46
|
+
return store.getIndexMetadata();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof store.getAll === "function") {
|
|
50
|
+
const legacyRecords = await store.getAll();
|
|
51
|
+
return legacyRecords.map((record) => ({
|
|
52
|
+
id: record.id ?? "",
|
|
53
|
+
title: record.title,
|
|
54
|
+
folder: record.folder,
|
|
55
|
+
indexed_at: record.indexed_at,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
throw new Error("Vector store does not expose index metadata methods");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getLatestIndexedAtMs(records: IndexMetadataRecord[]): number | null {
|
|
63
|
+
let latestMs: number | null = null;
|
|
64
|
+
|
|
65
|
+
for (const record of records) {
|
|
66
|
+
const indexedAtMs = Date.parse(record.indexed_at);
|
|
67
|
+
if (Number.isNaN(indexedAtMs)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (latestMs === null || indexedAtMs > latestMs) {
|
|
71
|
+
latestMs = indexedAtMs;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return latestMs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getSearchRefreshTimeoutMs(): number {
|
|
79
|
+
const raw = process.env.SEARCH_REFRESH_TIMEOUT_MS;
|
|
80
|
+
if (!raw) {
|
|
81
|
+
return DEFAULT_SEARCH_REFRESH_TIMEOUT_MS;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const parsed = Number.parseInt(raw, 10);
|
|
85
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
86
|
+
return DEFAULT_SEARCH_REFRESH_TIMEOUT_MS;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return parsed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> {
|
|
93
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
94
|
+
|
|
95
|
+
const timeoutPromise = new Promise<null>((resolve) => {
|
|
96
|
+
timeoutHandle = setTimeout(() => resolve(null), timeoutMs);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
101
|
+
} finally {
|
|
102
|
+
if (timeoutHandle !== undefined) {
|
|
103
|
+
clearTimeout(timeoutHandle);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function runRefresh(existingRecords: IndexMetadataRecord[]): Promise<boolean> {
|
|
109
|
+
const changes = await detectChanges(existingRecords);
|
|
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
|
+
}
|
|
152
|
+
|
|
25
153
|
/**
|
|
26
154
|
* Check for note changes and return details about what changed.
|
|
27
155
|
*/
|
|
28
|
-
export async function detectChanges(
|
|
156
|
+
export async function detectChanges(
|
|
157
|
+
existingIndexMetadata?: IndexMetadataRecord[]
|
|
158
|
+
): Promise<DetectedChanges> {
|
|
29
159
|
debug("Checking for changes...");
|
|
30
160
|
|
|
31
161
|
const currentNotes = await getAllNotes();
|
|
32
|
-
const store = getVectorStore();
|
|
33
162
|
|
|
34
|
-
let existingRecords;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
163
|
+
let existingRecords = existingIndexMetadata;
|
|
164
|
+
if (!existingRecords) {
|
|
165
|
+
try {
|
|
166
|
+
existingRecords = await getIndexMetadata();
|
|
167
|
+
} catch {
|
|
168
|
+
// No index exists yet
|
|
169
|
+
debug("No existing index found");
|
|
170
|
+
return {
|
|
171
|
+
hasChanges: currentNotes.length > 0,
|
|
172
|
+
added: currentNotes,
|
|
173
|
+
modified: [],
|
|
174
|
+
deleted: [],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
46
177
|
}
|
|
47
178
|
|
|
48
179
|
// Build lookup maps
|
|
@@ -106,46 +237,75 @@ export async function checkForChanges(): Promise<boolean> {
|
|
|
106
237
|
* @returns true if index was refreshed, false if no changes
|
|
107
238
|
*/
|
|
108
239
|
export async function refreshIfNeeded(): Promise<boolean> {
|
|
109
|
-
|
|
240
|
+
try {
|
|
241
|
+
const ttlRaw = process.env.INDEX_TTL;
|
|
242
|
+
if (!ttlRaw) {
|
|
243
|
+
debug("Auto-refresh disabled (INDEX_TTL not set)");
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
110
246
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
247
|
+
const parsedTtlSeconds = Number.parseInt(ttlRaw, 10);
|
|
248
|
+
if (!Number.isFinite(parsedTtlSeconds) || parsedTtlSeconds <= 0) {
|
|
249
|
+
debug("Auto-refresh disabled (INDEX_TTL invalid)");
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
114
252
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
253
|
+
const timeoutMs = getSearchRefreshTimeoutMs();
|
|
254
|
+
if (refreshInFlight) {
|
|
255
|
+
debug("Auto-refresh already in progress, waiting for existing run");
|
|
256
|
+
const sharedResult = await withTimeout(refreshInFlight, timeoutMs);
|
|
257
|
+
if (sharedResult === null) {
|
|
258
|
+
debug(`Auto-refresh wait timed out after ${timeoutMs}ms; returning stale index results`);
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
return sharedResult;
|
|
262
|
+
}
|
|
119
263
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (hasChunks && (changes.added.length > 0 || changes.modified.length > 0)) {
|
|
123
|
-
debug("Updating chunk index for changed notes...");
|
|
264
|
+
const refreshTask = (async () => {
|
|
265
|
+
let existingRecords: IndexMetadataRecord[] = [];
|
|
124
266
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
267
|
+
try {
|
|
268
|
+
existingRecords = await getIndexMetadata();
|
|
269
|
+
} catch {
|
|
270
|
+
// No index yet - treat as empty metadata
|
|
271
|
+
existingRecords = [];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const lastIndexedAtMs = getLatestIndexedAtMs(existingRecords);
|
|
275
|
+
const shouldRefresh = shouldAutoRefreshByTtl(
|
|
276
|
+
ttlRaw,
|
|
277
|
+
Date.now(),
|
|
278
|
+
lastIndexedAtMs
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
if (!shouldRefresh) {
|
|
282
|
+
debug("Auto-refresh skipped by TTL policy");
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return runRefresh(existingRecords);
|
|
287
|
+
})()
|
|
288
|
+
.catch((error) => {
|
|
289
|
+
debug("Auto-refresh failed; returning stale index results", error);
|
|
290
|
+
return false;
|
|
131
291
|
})
|
|
132
|
-
|
|
292
|
+
.finally(() => {
|
|
293
|
+
if (refreshInFlight === refreshTask) {
|
|
294
|
+
refreshInFlight = null;
|
|
295
|
+
}
|
|
296
|
+
});
|
|
133
297
|
|
|
134
|
-
|
|
135
|
-
const validNotes = notesWithContent.filter((n) => n !== null);
|
|
298
|
+
refreshInFlight = refreshTask;
|
|
136
299
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
debug(`
|
|
300
|
+
const refreshed = await withTimeout(refreshTask, timeoutMs);
|
|
301
|
+
if (refreshed === null) {
|
|
302
|
+
debug(`Auto-refresh timed out after ${timeoutMs}ms; returning stale index results`);
|
|
303
|
+
return false;
|
|
140
304
|
}
|
|
141
305
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
debug(`Deleted chunks for ${changes.deleted.length} notes`);
|
|
147
|
-
}
|
|
306
|
+
return refreshed;
|
|
307
|
+
} catch (error) {
|
|
308
|
+
debug("Unexpected auto-refresh failure; returning stale index results", error);
|
|
309
|
+
return false;
|
|
148
310
|
}
|
|
149
|
-
|
|
150
|
-
return true;
|
|
151
311
|
}
|