@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
package/README.md
CHANGED
|
@@ -18,13 +18,14 @@ MCP server for Apple Notes with semantic search and CRUD operations. Claude sear
|
|
|
18
18
|
- **Semantic Search** - Find notes by meaning, not keywords
|
|
19
19
|
- **Full CRUD** - Create, read, update, delete, and move notes
|
|
20
20
|
- **Incremental Indexing** - Re-embed only changed notes
|
|
21
|
+
- **Background Index Jobs** - Async full/incremental indexing with progress polling
|
|
21
22
|
- **Dual Embedding** - Local HuggingFace or OpenRouter API
|
|
22
23
|
|
|
23
|
-
## What's New in 1.
|
|
24
|
+
## What's New in 1.8.1
|
|
24
25
|
|
|
25
|
-
- **
|
|
26
|
-
- **
|
|
27
|
-
- **
|
|
26
|
+
- **Faster `list-notes` folder filtering** - `list-notes` now queries only the requested folder instead of scanning all notes first
|
|
27
|
+
- **Duplicate folder name correctness** - Folder filtering now aggregates matching folders across accounts
|
|
28
|
+
- **Large vault performance** - Folder-scoped listing is significantly faster on larger note libraries
|
|
28
29
|
|
|
29
30
|
## Installation
|
|
30
31
|
|
|
@@ -76,10 +77,19 @@ Configuration stored in `~/.apple-notes-mcp/.env`:
|
|
|
76
77
|
| `EMBEDDING_MODEL` | Model name (local or OpenRouter) | `Xenova/multilingual-e5-small` |
|
|
77
78
|
| `EMBEDDING_DIMS` | Embedding dimensions | `4096` |
|
|
78
79
|
| `READONLY_MODE` | Block all write operations | `false` |
|
|
79
|
-
| `INDEX_TTL` | Auto-
|
|
80
|
+
| `INDEX_TTL` | Auto-refresh-on-search interval in seconds (disabled when unset) | - |
|
|
81
|
+
| `SEARCH_REFRESH_TIMEOUT_MS` | Max time search waits for refresh before using stale index | `2000` |
|
|
82
|
+
| `INDEX_JOB_RETENTION_SECONDS` | How long completed/failed index jobs remain queryable | `3600` |
|
|
80
83
|
| `EMBEDDING_BATCH_SIZE` | Batch size for embedding generation | `50` |
|
|
81
84
|
| `DEBUG` | Enable debug logging | `false` |
|
|
82
85
|
|
|
86
|
+
### Search Auto-Refresh Policy
|
|
87
|
+
|
|
88
|
+
- `search-notes` does **not** force refresh on every request.
|
|
89
|
+
- If `INDEX_TTL` is unset, auto-refresh is disabled and search uses the current index.
|
|
90
|
+
- If `INDEX_TTL` is set, refresh runs only after TTL expiration.
|
|
91
|
+
- If refresh fails or takes longer than `SEARCH_REFRESH_TIMEOUT_MS`, search falls back to stale index results instead of timing out.
|
|
92
|
+
|
|
83
93
|
To reconfigure:
|
|
84
94
|
|
|
85
95
|
```bash
|
|
@@ -121,6 +131,8 @@ limit: 10 # max notes to return (1-100)
|
|
|
121
131
|
folder: "Work" # filter by folder (case-insensitive)
|
|
122
132
|
```
|
|
123
133
|
|
|
134
|
+
When `folder` is provided, the server fetches only matching folders from Apple Notes. This keeps folder-scoped requests fast even when your vault has hundreds of notes.
|
|
135
|
+
|
|
124
136
|
**Examples:**
|
|
125
137
|
- Get 5 newest notes: `{ sort_by: "created", order: "desc", limit: 5 }`
|
|
126
138
|
- Recently modified: `{ sort_by: "modified", limit: 10 }`
|
|
@@ -164,10 +176,48 @@ Index notes for semantic search.
|
|
|
164
176
|
```
|
|
165
177
|
mode: "incremental" # incremental (default) or full
|
|
166
178
|
force: false # force reindex even if TTL hasn't expired
|
|
179
|
+
background: false # optional; defaults to false (synchronous mode)
|
|
167
180
|
```
|
|
168
181
|
|
|
169
182
|
Use `mode: "full"` to create the chunk index for better long-note search. First full index takes longer as it generates chunks, but subsequent searches run fast.
|
|
170
183
|
|
|
184
|
+
For large vaults, prefer background indexing:
|
|
185
|
+
|
|
186
|
+
#### `start-index-job`
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
mode: "full" # full or incremental
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Returns a job snapshot with `id`, `status`, and `progress`.
|
|
193
|
+
Progress updates in smaller steps across fetch, embed, and persist phases.
|
|
194
|
+
|
|
195
|
+
#### `get-index-job`
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
job_id: "<job-id>"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Poll until status is `completed`, `failed`, or `cancelled`.
|
|
202
|
+
You may see `cancelling` as a transitional status.
|
|
203
|
+
|
|
204
|
+
#### `list-index-jobs`
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
limit: 10 # optional, 1-50
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
#### `cancel-index-job`
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
job_id: "<job-id>"
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Requests best-effort cancellation for a running job. Cancellation is cooperative:
|
|
217
|
+
- A long-running step must reach a cancellation checkpoint.
|
|
218
|
+
- Partial work may remain.
|
|
219
|
+
- Start a new job after the current one reaches `cancelled`.
|
|
220
|
+
|
|
171
221
|
#### `reindex-note`
|
|
172
222
|
Re-index a single note after manual edits.
|
|
173
223
|
|
|
@@ -186,6 +236,9 @@ content: "# Heading\n\nMarkdown content..."
|
|
|
186
236
|
folder: "Work" # optional, defaults to Notes
|
|
187
237
|
```
|
|
188
238
|
|
|
239
|
+
After create, update, delete, or move, the server auto-syncs vector and chunk indexes in best-effort mode.
|
|
240
|
+
If sync partly fails, the tool response includes an `index sync warning`. Run `reindex-note` or `index-notes`.
|
|
241
|
+
|
|
189
242
|
#### `update-note`
|
|
190
243
|
Update an existing note.
|
|
191
244
|
|
|
@@ -333,6 +386,15 @@ Set `READONLY_MODE=false` in `.env` to enable write operations.
|
|
|
333
386
|
### Notes missing from search
|
|
334
387
|
Run `index-notes` to update the search index. Use `mode: full` if incremental misses changes.
|
|
335
388
|
|
|
389
|
+
### "iCloud account not available" / `Can't get account "iCloud"`
|
|
390
|
+
This error comes from a different Apple Notes MCP implementation that uses tool `search_notes` and argument `Keywords`.
|
|
391
|
+
|
|
392
|
+
This project uses:
|
|
393
|
+
- tool: `search-notes`
|
|
394
|
+
- argument: `query`
|
|
395
|
+
|
|
396
|
+
If your client calls `search_notes` with `Keywords`, point your MCP config to `apple-notes-mcp` and restart the client.
|
|
397
|
+
|
|
336
398
|
### JXA errors
|
|
337
399
|
Ensure Apple Notes runs and contains notes. Grant automation permissions when prompted.
|
|
338
400
|
|
package/package.json
CHANGED
package/src/config/constants.ts
CHANGED
|
@@ -19,6 +19,7 @@ export const RRF_K = 60; // Reciprocal Rank Fusion constant
|
|
|
19
19
|
|
|
20
20
|
// Timeouts and retries
|
|
21
21
|
export const OPENROUTER_TIMEOUT_MS = 30000;
|
|
22
|
+
export const DEFAULT_SEARCH_REFRESH_TIMEOUT_MS = 2000;
|
|
22
23
|
|
|
23
24
|
// Cache settings
|
|
24
25
|
export const EMBEDDING_CACHE_MAX_SIZE = 1000;
|
|
@@ -52,6 +53,10 @@ export const DEFAULT_CHUNK_OVERLAP = 100;
|
|
|
52
53
|
// Batch processing
|
|
53
54
|
export const DEFAULT_EMBEDDING_BATCH_SIZE = 50;
|
|
54
55
|
|
|
56
|
+
// Background indexing jobs
|
|
57
|
+
export const DEFAULT_INDEX_JOB_RETENTION_SECONDS = 3600;
|
|
58
|
+
export const MAX_INDEX_JOB_HISTORY = 100;
|
|
59
|
+
|
|
55
60
|
/**
|
|
56
61
|
* Get embedding batch size from environment or use default.
|
|
57
62
|
* Lower values reduce peak memory usage but increase processing time.
|
package/src/config/env.test.ts
CHANGED
|
@@ -28,6 +28,8 @@ describe("validateEnv", () => {
|
|
|
28
28
|
delete process.env.READONLY_MODE;
|
|
29
29
|
delete process.env.EMBEDDING_DIMS;
|
|
30
30
|
delete process.env.INDEX_TTL;
|
|
31
|
+
delete process.env.SEARCH_REFRESH_TIMEOUT_MS;
|
|
32
|
+
delete process.env.INDEX_JOB_RETENTION_SECONDS;
|
|
31
33
|
const { validateEnv } = await import("./env.js");
|
|
32
34
|
expect(() => validateEnv()).not.toThrow();
|
|
33
35
|
});
|
|
@@ -55,4 +57,16 @@ describe("validateEnv", () => {
|
|
|
55
57
|
const { validateEnv } = await import("./env.js");
|
|
56
58
|
expect(() => validateEnv()).toThrow();
|
|
57
59
|
});
|
|
60
|
+
|
|
61
|
+
it("validates SEARCH_REFRESH_TIMEOUT_MS is numeric", async () => {
|
|
62
|
+
process.env.SEARCH_REFRESH_TIMEOUT_MS = "not-a-number";
|
|
63
|
+
const { validateEnv } = await import("./env.js");
|
|
64
|
+
expect(() => validateEnv()).toThrow();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("validates INDEX_JOB_RETENTION_SECONDS is numeric", async () => {
|
|
68
|
+
process.env.INDEX_JOB_RETENTION_SECONDS = "abc";
|
|
69
|
+
const { validateEnv } = await import("./env.js");
|
|
70
|
+
expect(() => validateEnv()).toThrow();
|
|
71
|
+
});
|
|
58
72
|
});
|
package/src/config/env.ts
CHANGED
|
@@ -6,6 +6,8 @@ const EnvSchema = z.object({
|
|
|
6
6
|
EMBEDDING_DIMS: z.string().regex(/^\d+$/).optional(),
|
|
7
7
|
READONLY_MODE: z.enum(["true", "false"]).optional(),
|
|
8
8
|
INDEX_TTL: z.string().regex(/^\d+$/).optional(),
|
|
9
|
+
SEARCH_REFRESH_TIMEOUT_MS: z.string().regex(/^\d+$/).optional(),
|
|
10
|
+
INDEX_JOB_RETENTION_SECONDS: z.string().regex(/^\d+$/).optional(),
|
|
9
11
|
DEBUG: z.enum(["true", "false"]).optional(),
|
|
10
12
|
});
|
|
11
13
|
|
package/src/db/lancedb.test.ts
CHANGED
|
@@ -72,6 +72,20 @@ describe("LanceDBStore", () => {
|
|
|
72
72
|
await store.index([createTestRecord("Existing")]);
|
|
73
73
|
await expect(store.delete("Non Existent")).resolves.not.toThrow();
|
|
74
74
|
});
|
|
75
|
+
|
|
76
|
+
it("deleteByIdAndFolderAndTitle removes only matching record", async () => {
|
|
77
|
+
const base = createTestRecord("Shared");
|
|
78
|
+
const other = { ...createTestRecord("Shared"), id: "other-id" };
|
|
79
|
+
|
|
80
|
+
await store.index([base, other]);
|
|
81
|
+
expect(await store.count()).toBe(2);
|
|
82
|
+
|
|
83
|
+
await store.deleteByIdAndFolderAndTitle(base.id, base.folder, base.title);
|
|
84
|
+
|
|
85
|
+
expect(await store.count()).toBe(1);
|
|
86
|
+
const remaining = await store.getByTitle("Shared");
|
|
87
|
+
expect(remaining?.id).toBe("other-id");
|
|
88
|
+
});
|
|
75
89
|
});
|
|
76
90
|
|
|
77
91
|
describe("getAll", () => {
|
package/src/db/lancedb.ts
CHANGED
|
@@ -20,6 +20,13 @@ export interface NoteRecord {
|
|
|
20
20
|
[key: string]: unknown; // Index signature for LanceDB compatibility
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export interface IndexMetadataRecord {
|
|
24
|
+
id: string;
|
|
25
|
+
title: string;
|
|
26
|
+
folder: string;
|
|
27
|
+
indexed_at: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
// Schema for chunked notes (Parent Document Retriever pattern)
|
|
24
31
|
export interface ChunkRecord {
|
|
25
32
|
chunk_id: string; // `${note_id}_chunk_${index}`
|
|
@@ -48,9 +55,11 @@ export interface VectorStore {
|
|
|
48
55
|
update(record: NoteRecord): Promise<void>;
|
|
49
56
|
delete(title: string): Promise<void>;
|
|
50
57
|
deleteByFolderAndTitle(folder: string, title: string): Promise<void>;
|
|
58
|
+
deleteByIdAndFolderAndTitle(id: string, folder: string, title: string): Promise<void>;
|
|
51
59
|
search(queryVector: number[], limit: number): Promise<SearchResult[]>;
|
|
52
60
|
searchFTS(query: string, limit: number): Promise<SearchResult[]>;
|
|
53
61
|
getByTitle(title: string): Promise<NoteRecord | null>;
|
|
62
|
+
getIndexMetadata(): Promise<IndexMetadataRecord[]>;
|
|
54
63
|
getAll(): Promise<NoteRecord[]>;
|
|
55
64
|
count(): Promise<number>;
|
|
56
65
|
clear(): Promise<void>;
|
|
@@ -251,6 +260,18 @@ export class LanceDBStore implements VectorStore {
|
|
|
251
260
|
debug(`Deleted record: ${folder}/${title}`);
|
|
252
261
|
}
|
|
253
262
|
|
|
263
|
+
async deleteByIdAndFolderAndTitle(id: string, folder: string, title: string): Promise<void> {
|
|
264
|
+
const table = await this.ensureTable();
|
|
265
|
+
const validTitle = validateTitle(title);
|
|
266
|
+
const escapedId = escapeForFilter(id);
|
|
267
|
+
const escapedTitle = escapeForFilter(validTitle);
|
|
268
|
+
const escapedFolder = escapeForFilter(folder);
|
|
269
|
+
await table.delete(
|
|
270
|
+
`id = '${escapedId}' AND folder = '${escapedFolder}' AND title = '${escapedTitle}'`
|
|
271
|
+
);
|
|
272
|
+
debug(`Deleted record by id: ${id} (${folder}/${title})`);
|
|
273
|
+
}
|
|
274
|
+
|
|
254
275
|
async search(queryVector: number[], limit: number): Promise<SearchResult[]> {
|
|
255
276
|
const table = await this.ensureTable();
|
|
256
277
|
|
|
@@ -295,6 +316,22 @@ export class LanceDBStore implements VectorStore {
|
|
|
295
316
|
return results[0] as unknown as NoteRecord;
|
|
296
317
|
}
|
|
297
318
|
|
|
319
|
+
async getIndexMetadata(): Promise<IndexMetadataRecord[]> {
|
|
320
|
+
const table = await this.ensureTable();
|
|
321
|
+
|
|
322
|
+
const results = await table
|
|
323
|
+
.query()
|
|
324
|
+
.select(["id", "title", "folder", "indexed_at"])
|
|
325
|
+
.toArray();
|
|
326
|
+
|
|
327
|
+
return results.map((row): IndexMetadataRecord => ({
|
|
328
|
+
id: (row.id as string) ?? "",
|
|
329
|
+
title: row.title as string,
|
|
330
|
+
folder: row.folder as string,
|
|
331
|
+
indexed_at: row.indexed_at as string,
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
|
|
298
335
|
async getAll(): Promise<NoteRecord[]> {
|
|
299
336
|
const table = await this.ensureTable();
|
|
300
337
|
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,13 @@ import { indexNotes, reindexNote } from "./search/indexer.js";
|
|
|
32
32
|
import { fullChunkIndex, hasChunkIndex } from "./search/chunk-indexer.js";
|
|
33
33
|
import { searchChunks } from "./search/chunk-search.js";
|
|
34
34
|
import { refreshIfNeeded } from "./search/refresh.js";
|
|
35
|
+
import { getIndexJobManager } from "./indexing/job-manager.js";
|
|
36
|
+
import {
|
|
37
|
+
syncAfterCreate,
|
|
38
|
+
syncAfterUpdate,
|
|
39
|
+
syncAfterDelete,
|
|
40
|
+
syncAfterMove,
|
|
41
|
+
} from "./search/write-sync.js";
|
|
35
42
|
import { listTags, searchByTag, findRelatedNotes } from "./graph/queries.js";
|
|
36
43
|
import { exportGraph } from "./graph/export.js";
|
|
37
44
|
import { findTables, parseTable } from "./notes/tables.js";
|
|
@@ -85,6 +92,23 @@ const SearchNotesSchema = z.object({
|
|
|
85
92
|
const IndexNotesSchema = z.object({
|
|
86
93
|
mode: z.enum(["full", "incremental"]).default("incremental"),
|
|
87
94
|
force: z.boolean().default(false),
|
|
95
|
+
background: z.boolean().optional(),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const StartIndexJobSchema = z.object({
|
|
99
|
+
mode: z.enum(["full", "incremental"]).default("incremental"),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const GetIndexJobSchema = z.object({
|
|
103
|
+
job_id: z.string().min(1),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const ListIndexJobsSchema = z.object({
|
|
107
|
+
limit: z.number().min(1).max(50).default(10),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const CancelIndexJobSchema = z.object({
|
|
111
|
+
job_id: z.string().min(1),
|
|
88
112
|
});
|
|
89
113
|
|
|
90
114
|
const ReindexNoteSchema = z.object({
|
|
@@ -254,10 +278,71 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
254
278
|
type: "boolean",
|
|
255
279
|
description: "Force reindex even if TTL hasn't expired (default: false)"
|
|
256
280
|
},
|
|
281
|
+
background: {
|
|
282
|
+
type: "boolean",
|
|
283
|
+
description: "Run indexing as async background job (default: false)"
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
required: [],
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: "start-index-job",
|
|
291
|
+
description: "Start background indexing job and return job id for polling",
|
|
292
|
+
inputSchema: {
|
|
293
|
+
type: "object",
|
|
294
|
+
properties: {
|
|
295
|
+
mode: {
|
|
296
|
+
type: "string",
|
|
297
|
+
enum: ["full", "incremental"],
|
|
298
|
+
description: "Index mode (default: incremental)",
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
required: [],
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: "get-index-job",
|
|
306
|
+
description: "Get background indexing job status and progress",
|
|
307
|
+
inputSchema: {
|
|
308
|
+
type: "object",
|
|
309
|
+
properties: {
|
|
310
|
+
job_id: {
|
|
311
|
+
type: "string",
|
|
312
|
+
description: "Job ID returned from start-index-job or index-notes",
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
required: ["job_id"],
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: "list-index-jobs",
|
|
320
|
+
description: "List recent background indexing jobs",
|
|
321
|
+
inputSchema: {
|
|
322
|
+
type: "object",
|
|
323
|
+
properties: {
|
|
324
|
+
limit: {
|
|
325
|
+
type: "number",
|
|
326
|
+
description: "Max jobs to return (default: 10)",
|
|
327
|
+
},
|
|
257
328
|
},
|
|
258
329
|
required: [],
|
|
259
330
|
},
|
|
260
331
|
},
|
|
332
|
+
{
|
|
333
|
+
name: "cancel-index-job",
|
|
334
|
+
description: "Request cancellation for a running background indexing job",
|
|
335
|
+
inputSchema: {
|
|
336
|
+
type: "object",
|
|
337
|
+
properties: {
|
|
338
|
+
job_id: {
|
|
339
|
+
type: "string",
|
|
340
|
+
description: "Job ID returned from start-index-job or index-notes",
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
required: ["job_id"],
|
|
344
|
+
},
|
|
345
|
+
},
|
|
261
346
|
{
|
|
262
347
|
name: "reindex-note",
|
|
263
348
|
description: "Re-index a single note after manual edits",
|
|
@@ -586,6 +671,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
586
671
|
|
|
587
672
|
case "index-notes": {
|
|
588
673
|
const params = IndexNotesSchema.parse(args);
|
|
674
|
+
const jobs = getIndexJobManager();
|
|
675
|
+
|
|
676
|
+
const runInBackground = params.background ?? false;
|
|
677
|
+
if (runInBackground) {
|
|
678
|
+
const job = jobs.start({ mode: params.mode });
|
|
679
|
+
return textResponse(
|
|
680
|
+
JSON.stringify(
|
|
681
|
+
{
|
|
682
|
+
message: `Started ${params.mode} index job`,
|
|
683
|
+
job_id: job.id,
|
|
684
|
+
status: job.status,
|
|
685
|
+
progress: job.progress,
|
|
686
|
+
},
|
|
687
|
+
null,
|
|
688
|
+
2
|
|
689
|
+
)
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
589
693
|
const result = await indexNotes(params.mode);
|
|
590
694
|
|
|
591
695
|
let message = `Indexed ${result.indexed} notes in ${(result.timeMs / 1000).toFixed(1)}s`;
|
|
@@ -626,6 +730,40 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
626
730
|
return textResponse(message);
|
|
627
731
|
}
|
|
628
732
|
|
|
733
|
+
case "start-index-job": {
|
|
734
|
+
const params = StartIndexJobSchema.parse(args);
|
|
735
|
+
const jobs = getIndexJobManager();
|
|
736
|
+
const job = jobs.start({ mode: params.mode });
|
|
737
|
+
return textResponse(JSON.stringify(job, null, 2));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
case "get-index-job": {
|
|
741
|
+
const params = GetIndexJobSchema.parse(args);
|
|
742
|
+
const jobs = getIndexJobManager();
|
|
743
|
+
const job = jobs.get(params.job_id);
|
|
744
|
+
if (!job) {
|
|
745
|
+
return errorResponse(`Index job not found: "${params.job_id}"`);
|
|
746
|
+
}
|
|
747
|
+
return textResponse(JSON.stringify(job, null, 2));
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
case "list-index-jobs": {
|
|
751
|
+
const params = ListIndexJobsSchema.parse(args);
|
|
752
|
+
const jobs = getIndexJobManager();
|
|
753
|
+
const list = jobs.list(params.limit);
|
|
754
|
+
return textResponse(JSON.stringify(list, null, 2));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
case "cancel-index-job": {
|
|
758
|
+
const params = CancelIndexJobSchema.parse(args);
|
|
759
|
+
const jobs = getIndexJobManager();
|
|
760
|
+
const job = jobs.cancel(params.job_id);
|
|
761
|
+
if (!job) {
|
|
762
|
+
return errorResponse(`Index job not found: "${params.job_id}"`);
|
|
763
|
+
}
|
|
764
|
+
return textResponse(JSON.stringify(job, null, 2));
|
|
765
|
+
}
|
|
766
|
+
|
|
629
767
|
case "reindex-note": {
|
|
630
768
|
const params = ReindexNoteSchema.parse(args);
|
|
631
769
|
await reindexNote(params.title);
|
|
@@ -726,9 +864,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
726
864
|
// Write tools
|
|
727
865
|
case "create-note": {
|
|
728
866
|
const params = CreateNoteSchema.parse(args);
|
|
729
|
-
await createNote(params.title, params.content, params.folder);
|
|
730
|
-
const location =
|
|
731
|
-
|
|
867
|
+
const result = await createNote(params.title, params.content, params.folder);
|
|
868
|
+
const location = `${result.folder}/${result.title}`;
|
|
869
|
+
|
|
870
|
+
const syncResult = await syncAfterCreate(result);
|
|
871
|
+
const syncWarning = syncResult.warnings.length > 0
|
|
872
|
+
? ` (index sync warning: ${syncResult.warnings.join("; ")})`
|
|
873
|
+
: "";
|
|
874
|
+
|
|
875
|
+
return textResponse(`Created note: "${location}"${syncWarning}`);
|
|
732
876
|
}
|
|
733
877
|
|
|
734
878
|
case "update-note": {
|
|
@@ -742,15 +886,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
742
886
|
: "";
|
|
743
887
|
|
|
744
888
|
if (params.reindex) {
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
} catch (reindexError) {
|
|
751
|
-
debug("Reindex after update failed:", reindexError);
|
|
752
|
-
return textResponse(`Updated note: "${location}"${renamedMsg} (reindexing failed, run index-notes to update)`);
|
|
753
|
-
}
|
|
889
|
+
const syncResult = await syncAfterUpdate(result);
|
|
890
|
+
const syncWarning = syncResult.warnings.length > 0
|
|
891
|
+
? ` (index sync warning: ${syncResult.warnings.join("; ")})`
|
|
892
|
+
: "";
|
|
893
|
+
return textResponse(`Updated and reindexed note: "${location}"${renamedMsg}${syncWarning}`);
|
|
754
894
|
}
|
|
755
895
|
|
|
756
896
|
return textResponse(`Updated note: "${location}"${renamedMsg}`);
|
|
@@ -761,14 +901,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
761
901
|
if (!params.confirm) {
|
|
762
902
|
return errorResponse("Add confirm: true to delete the note");
|
|
763
903
|
}
|
|
764
|
-
await deleteNote(params.title);
|
|
765
|
-
|
|
904
|
+
const result = await deleteNote(params.title);
|
|
905
|
+
const syncResult = await syncAfterDelete(result);
|
|
906
|
+
const syncWarning = syncResult.warnings.length > 0
|
|
907
|
+
? ` (index sync warning: ${syncResult.warnings.join("; ")})`
|
|
908
|
+
: "";
|
|
909
|
+
return textResponse(`Deleted note: "${params.title}"${syncWarning}`);
|
|
766
910
|
}
|
|
767
911
|
|
|
768
912
|
case "move-note": {
|
|
769
913
|
const params = MoveNoteSchema.parse(args);
|
|
770
|
-
await moveNote(params.title, params.folder);
|
|
771
|
-
|
|
914
|
+
const result = await moveNote(params.title, params.folder);
|
|
915
|
+
const syncResult = await syncAfterMove(result);
|
|
916
|
+
const syncWarning = syncResult.warnings.length > 0
|
|
917
|
+
? ` (index sync warning: ${syncResult.warnings.join("; ")})`
|
|
918
|
+
: "";
|
|
919
|
+
return textResponse(`Moved note: "${params.title}" to folder "${params.folder}"${syncWarning}`);
|
|
772
920
|
}
|
|
773
921
|
|
|
774
922
|
case "edit-table": {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { IndexCancelledError, isIndexCancelledError } from "./contracts.js";
|
|
3
|
+
|
|
4
|
+
describe("indexing contracts", () => {
|
|
5
|
+
it("creates cancellable error type", () => {
|
|
6
|
+
const err = new IndexCancelledError("user requested cancel");
|
|
7
|
+
expect(isIndexCancelledError(err)).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("does not treat generic Error as cancelled", () => {
|
|
11
|
+
expect(isIndexCancelledError(new Error("x"))).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface IndexProgressEvent {
|
|
2
|
+
stage: "fetch" | "prepare" | "embed" | "persist" | "delete" | "rebuild-fts" | "done";
|
|
3
|
+
current: number;
|
|
4
|
+
total: number;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface IndexRunOptions {
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
onProgress?: (event: IndexProgressEvent) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class IndexCancelledError extends Error {
|
|
14
|
+
constructor(message = "Indexing cancelled") {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "IndexCancelledError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isIndexCancelledError(error: unknown): error is IndexCancelledError {
|
|
21
|
+
return error instanceof IndexCancelledError;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function throwIfCancelled(signal?: AbortSignal): void {
|
|
25
|
+
if (signal?.aborted) {
|
|
26
|
+
throw new IndexCancelledError();
|
|
27
|
+
}
|
|
28
|
+
}
|