@disco_trooper/apple-notes-mcp 1.1.0 → 1.2.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/package.json +2 -5
- package/src/db/lancedb.test.ts +45 -0
- package/src/db/lancedb.ts +40 -31
- package/src/embeddings/index.ts +1 -2
- package/src/embeddings/local.ts +0 -11
- package/src/errors/index.test.ts +64 -0
- package/src/errors/index.ts +62 -0
- package/src/index.ts +60 -14
- package/src/notes/conversion.ts +62 -0
- package/src/notes/crud.test.ts +15 -6
- package/src/notes/crud.ts +32 -59
- package/src/notes/read.test.ts +58 -3
- package/src/notes/read.ts +90 -173
- package/src/notes/resolve.ts +174 -0
- package/src/notes/tables.ts +69 -40
- package/src/search/index.ts +4 -6
- package/src/search/indexer.ts +16 -3
- package/src/types/index.ts +4 -0
- package/src/utils/debug.ts +0 -2
- package/src/utils/text.test.ts +32 -0
- package/CLAUDE.md +0 -56
- package/src/server.ts +0 -427
package/src/notes/tables.ts
CHANGED
|
@@ -65,6 +65,68 @@ export function parseTable(html: string): TableData {
|
|
|
65
65
|
return result;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Find the HTML of a specific row in a table.
|
|
70
|
+
*/
|
|
71
|
+
function findRowHtml(tableHtml: string, rowIndex: number): { match: RegExpMatchArray; content: string } | null {
|
|
72
|
+
const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
|
73
|
+
let currentRow = 0;
|
|
74
|
+
let rowMatch;
|
|
75
|
+
|
|
76
|
+
while ((rowMatch = rowRegex.exec(tableHtml)) !== null) {
|
|
77
|
+
if (currentRow === rowIndex) {
|
|
78
|
+
return { match: rowMatch, content: rowMatch[1] };
|
|
79
|
+
}
|
|
80
|
+
currentRow++;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Escape HTML special characters to prevent injection.
|
|
87
|
+
*/
|
|
88
|
+
function escapeHtml(text: string): string {
|
|
89
|
+
return text
|
|
90
|
+
.replace(/&/g, "&")
|
|
91
|
+
.replace(/</g, "<")
|
|
92
|
+
.replace(/>/g, ">")
|
|
93
|
+
.replace(/"/g, """);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Update a specific cell within a row's HTML.
|
|
98
|
+
*/
|
|
99
|
+
function updateCellInRow(rowContent: string, columnIndex: number, value: string, isBold: boolean): string {
|
|
100
|
+
const cellRegex = /(<td[^>]*>[\s\S]*?<div[^>]*>)([\s\S]*?)(<\/div>[\s\S]*?<\/td>)/gi;
|
|
101
|
+
let currentCol = 0;
|
|
102
|
+
let result = rowContent;
|
|
103
|
+
let cellMatch;
|
|
104
|
+
|
|
105
|
+
const replacements: Array<{original: string; replacement: string}> = [];
|
|
106
|
+
|
|
107
|
+
// Escape HTML to prevent injection
|
|
108
|
+
const escapedValue = escapeHtml(value);
|
|
109
|
+
|
|
110
|
+
while ((cellMatch = cellRegex.exec(rowContent)) !== null) {
|
|
111
|
+
if (currentCol === columnIndex) {
|
|
112
|
+
const prefix = cellMatch[1];
|
|
113
|
+
const suffix = cellMatch[3];
|
|
114
|
+
const newContent = isBold ? `<b>${escapedValue}</b>` : escapedValue;
|
|
115
|
+
replacements.push({
|
|
116
|
+
original: cellMatch[0],
|
|
117
|
+
replacement: `${prefix}${newContent}${suffix}`
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
currentCol++;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const r of replacements) {
|
|
124
|
+
result = result.replace(r.original, r.replacement);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
68
130
|
/**
|
|
69
131
|
* Update a specific cell in an Apple Notes table HTML.
|
|
70
132
|
*
|
|
@@ -85,48 +147,15 @@ export function updateTableCell(html: string, row: number, column: number, value
|
|
|
85
147
|
throw new Error(`Column ${column} out of bounds (row has ${parsed.rows[row].length} columns)`);
|
|
86
148
|
}
|
|
87
149
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
|
93
|
-
let rowMatch;
|
|
94
|
-
|
|
95
|
-
while ((rowMatch = rowRegex.exec(html)) !== null) {
|
|
96
|
-
if (currentRow === row) {
|
|
97
|
-
const rowContent = rowMatch[1];
|
|
98
|
-
let currentCol = 0;
|
|
99
|
-
let newRowContent = rowContent;
|
|
100
|
-
|
|
101
|
-
const cellRegex = /(<td[^>]*>[\s\S]*?<div[^>]*>)([\s\S]*?)(<\/div>[\s\S]*?<\/td>)/gi;
|
|
102
|
-
let cellMatch;
|
|
103
|
-
const replacements: Array<{original: string; replacement: string}> = [];
|
|
104
|
-
|
|
105
|
-
while ((cellMatch = cellRegex.exec(rowContent)) !== null) {
|
|
106
|
-
if (currentCol === column) {
|
|
107
|
-
const prefix = cellMatch[1];
|
|
108
|
-
const suffix = cellMatch[3];
|
|
109
|
-
const isBold = parsed.formatting[row][column].bold;
|
|
110
|
-
const newContent = isBold ? `<b>${value}</b>` : value;
|
|
111
|
-
replacements.push({
|
|
112
|
-
original: cellMatch[0],
|
|
113
|
-
replacement: `${prefix}${newContent}${suffix}`
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
currentCol++;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
for (const r of replacements) {
|
|
120
|
-
newRowContent = newRowContent.replace(r.original, r.replacement);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
result = result.replace(rowMatch[0], `<tr>${newRowContent}</tr>`);
|
|
124
|
-
break;
|
|
125
|
-
}
|
|
126
|
-
currentRow++;
|
|
150
|
+
const rowData = findRowHtml(html, row);
|
|
151
|
+
if (!rowData) {
|
|
152
|
+
throw new Error(`Could not find row ${row} in table HTML`);
|
|
127
153
|
}
|
|
128
154
|
|
|
129
|
-
|
|
155
|
+
const isBold = parsed.formatting[row][column].bold;
|
|
156
|
+
const updatedRowContent = updateCellInRow(rowData.content, column, value, isBold);
|
|
157
|
+
|
|
158
|
+
return html.replace(rowData.match[0], `<tr>${updatedRowContent}</tr>`);
|
|
130
159
|
}
|
|
131
160
|
|
|
132
161
|
/**
|
package/src/search/index.ts
CHANGED
|
@@ -37,9 +37,6 @@ export interface SearchOptions {
|
|
|
37
37
|
include_content?: boolean;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
// SearchResult is imported from ../types/index.js
|
|
41
|
-
// RRF_K is imported from ../config/constants.js
|
|
42
|
-
|
|
43
40
|
/**
|
|
44
41
|
* Calculate RRF score for a result at a given rank.
|
|
45
42
|
* Formula: 1 / (k + rank)
|
|
@@ -163,15 +160,16 @@ async function hybridSearch(
|
|
|
163
160
|
const contentMap = new Map<string, DBSearchResult>();
|
|
164
161
|
|
|
165
162
|
// Process vector search results
|
|
163
|
+
// Use id as key to avoid collisions with duplicate titles in different folders
|
|
166
164
|
vectorResults.forEach((item, rank) => {
|
|
167
|
-
const key = item.title;
|
|
165
|
+
const key = item.id ?? item.title;
|
|
168
166
|
scoreMap.set(key, (scoreMap.get(key) || 0) + rrfScore(rank));
|
|
169
167
|
contentMap.set(key, item);
|
|
170
168
|
});
|
|
171
169
|
|
|
172
170
|
// Process FTS results
|
|
173
171
|
ftsResults.forEach((item, rank) => {
|
|
174
|
-
const key = item.title;
|
|
172
|
+
const key = item.id ?? item.title;
|
|
175
173
|
scoreMap.set(key, (scoreMap.get(key) || 0) + rrfScore(rank));
|
|
176
174
|
if (!contentMap.has(key)) {
|
|
177
175
|
contentMap.set(key, item);
|
|
@@ -257,6 +255,7 @@ export async function searchNotes(
|
|
|
257
255
|
// Transform to SearchResult format
|
|
258
256
|
const results: SearchResult[] = dbResults.map((r) => {
|
|
259
257
|
const result: SearchResult = {
|
|
258
|
+
id: r.id,
|
|
260
259
|
title: r.title,
|
|
261
260
|
folder: r.folder,
|
|
262
261
|
preview: generatePreview(r.content),
|
|
@@ -276,7 +275,6 @@ export async function searchNotes(
|
|
|
276
275
|
}
|
|
277
276
|
|
|
278
277
|
// Re-export types for convenience
|
|
279
|
-
export type { SearchMode as Mode };
|
|
280
278
|
export type { SearchResult } from "../types/index.js";
|
|
281
279
|
|
|
282
280
|
// Export utility functions for testing
|
package/src/search/indexer.ts
CHANGED
|
@@ -13,14 +13,14 @@ import { getAllNotes, getNoteByFolderAndTitle, getNoteByTitle, type NoteInfo } f
|
|
|
13
13
|
import { createDebugLogger } from "../utils/debug.js";
|
|
14
14
|
import { truncateForEmbedding } from "../utils/text.js";
|
|
15
15
|
import { EMBEDDING_DELAY_MS } from "../config/constants.js";
|
|
16
|
+
import { NoteNotFoundError } from "../errors/index.js";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Extract note title from folder/title key.
|
|
19
20
|
* Handles nested folders correctly by taking the last segment.
|
|
20
21
|
*/
|
|
21
22
|
export function extractTitleFromKey(key: string): string {
|
|
22
|
-
|
|
23
|
-
return parts[parts.length - 1];
|
|
23
|
+
return key.split("/").at(-1) ?? key;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
// Debug logging
|
|
@@ -98,6 +98,7 @@ export async function fullIndex(): Promise<IndexResult> {
|
|
|
98
98
|
const vector = await getEmbedding(content);
|
|
99
99
|
|
|
100
100
|
const record: NoteRecord = {
|
|
101
|
+
id: noteDetails.id,
|
|
101
102
|
title: noteDetails.title,
|
|
102
103
|
content: noteDetails.content,
|
|
103
104
|
vector,
|
|
@@ -234,6 +235,7 @@ export async function incrementalIndex(): Promise<IndexResult> {
|
|
|
234
235
|
const vector = await getEmbedding(content);
|
|
235
236
|
|
|
236
237
|
const record: NoteRecord = {
|
|
238
|
+
id: noteDetails.id,
|
|
237
239
|
title: noteDetails.title,
|
|
238
240
|
content: noteDetails.content,
|
|
239
241
|
vector,
|
|
@@ -270,6 +272,12 @@ export async function incrementalIndex(): Promise<IndexResult> {
|
|
|
270
272
|
}
|
|
271
273
|
}
|
|
272
274
|
|
|
275
|
+
// Rebuild FTS index if any changes were made
|
|
276
|
+
if (toAdd.length > 0 || toUpdate.length > 0 || toDelete.length > 0) {
|
|
277
|
+
debug("Rebuilding FTS index after incremental changes");
|
|
278
|
+
await store.rebuildFtsIndex();
|
|
279
|
+
}
|
|
280
|
+
|
|
273
281
|
const timeMs = Date.now() - startTime;
|
|
274
282
|
debug(`Incremental index complete: ${timeMs}ms`);
|
|
275
283
|
|
|
@@ -296,7 +304,7 @@ export async function reindexNote(title: string): Promise<void> {
|
|
|
296
304
|
|
|
297
305
|
const noteDetails = await getNoteByTitle(title);
|
|
298
306
|
if (!noteDetails) {
|
|
299
|
-
throw new
|
|
307
|
+
throw new NoteNotFoundError(title);
|
|
300
308
|
}
|
|
301
309
|
|
|
302
310
|
if (!noteDetails.content.trim()) {
|
|
@@ -307,6 +315,7 @@ export async function reindexNote(title: string): Promise<void> {
|
|
|
307
315
|
const vector = await getEmbedding(content);
|
|
308
316
|
|
|
309
317
|
const record: NoteRecord = {
|
|
318
|
+
id: noteDetails.id,
|
|
310
319
|
title: noteDetails.title,
|
|
311
320
|
content: noteDetails.content,
|
|
312
321
|
vector,
|
|
@@ -319,6 +328,10 @@ export async function reindexNote(title: string): Promise<void> {
|
|
|
319
328
|
const store = getVectorStore();
|
|
320
329
|
await store.update(record);
|
|
321
330
|
|
|
331
|
+
// Rebuild FTS index after single note update
|
|
332
|
+
debug("Rebuilding FTS index after single note reindex");
|
|
333
|
+
await store.rebuildFtsIndex();
|
|
334
|
+
|
|
322
335
|
debug(`Reindexed: ${title}`);
|
|
323
336
|
}
|
|
324
337
|
|
package/src/types/index.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* Contains the full content of the note.
|
|
8
8
|
*/
|
|
9
9
|
export interface DBSearchResult {
|
|
10
|
+
/** Apple Notes unique identifier */
|
|
11
|
+
id?: string;
|
|
10
12
|
/** Note title */
|
|
11
13
|
title: string;
|
|
12
14
|
/** Folder containing the note */
|
|
@@ -24,6 +26,8 @@ export interface DBSearchResult {
|
|
|
24
26
|
* Contains a preview instead of full content by default.
|
|
25
27
|
*/
|
|
26
28
|
export interface SearchResult {
|
|
29
|
+
/** Apple Notes unique identifier */
|
|
30
|
+
id?: string;
|
|
27
31
|
/** Note title */
|
|
28
32
|
title: string;
|
|
29
33
|
/** Folder containing the note */
|
package/src/utils/debug.ts
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { truncateForEmbedding } from "./text.js";
|
|
3
|
+
import { MAX_INPUT_LENGTH } from "../config/constants.js";
|
|
4
|
+
|
|
5
|
+
describe("truncateForEmbedding", () => {
|
|
6
|
+
it("should return text unchanged if within limit", () => {
|
|
7
|
+
const text = "Short text";
|
|
8
|
+
expect(truncateForEmbedding(text)).toBe(text);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("should truncate text exceeding default limit", () => {
|
|
12
|
+
const text = "a".repeat(MAX_INPUT_LENGTH + 100);
|
|
13
|
+
const result = truncateForEmbedding(text);
|
|
14
|
+
expect(result.length).toBe(MAX_INPUT_LENGTH);
|
|
15
|
+
expect(result).toBe("a".repeat(MAX_INPUT_LENGTH));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should use custom maxLength when provided", () => {
|
|
19
|
+
const text = "Hello World";
|
|
20
|
+
const result = truncateForEmbedding(text, 5);
|
|
21
|
+
expect(result).toBe("Hello");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should handle empty string", () => {
|
|
25
|
+
expect(truncateForEmbedding("")).toBe("");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should handle text exactly at limit", () => {
|
|
29
|
+
const text = "a".repeat(MAX_INPUT_LENGTH);
|
|
30
|
+
expect(truncateForEmbedding(text)).toBe(text);
|
|
31
|
+
});
|
|
32
|
+
});
|
package/CLAUDE.md
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
## Project Overview
|
|
4
|
-
|
|
5
|
-
MCP server for Apple Notes with semantic search and CRUD operations.
|
|
6
|
-
|
|
7
|
-
## Tech Stack
|
|
8
|
-
|
|
9
|
-
- **Runtime**: Bun
|
|
10
|
-
- **Language**: TypeScript
|
|
11
|
-
- **Database**: LanceDB (vector store)
|
|
12
|
-
- **Embeddings**: HuggingFace Transformers (local) or OpenRouter API
|
|
13
|
-
- **Apple Notes**: JXA (JavaScript for Automation)
|
|
14
|
-
|
|
15
|
-
## Commands
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
bun run start # Start MCP server
|
|
19
|
-
bun run setup # Interactive setup wizard
|
|
20
|
-
bun run dev # Watch mode
|
|
21
|
-
bun run check # Type check
|
|
22
|
-
bun run test # Run tests (uses vitest, NOT bun test)
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
## Project Structure
|
|
26
|
-
|
|
27
|
-
```
|
|
28
|
-
src/
|
|
29
|
-
├── index.ts # MCP server entry (stdio transport)
|
|
30
|
-
├── server.ts # Smithery-compatible export
|
|
31
|
-
├── setup.ts # Interactive setup wizard
|
|
32
|
-
├── config/ # Constants and env validation
|
|
33
|
-
├── db/ # LanceDB vector store
|
|
34
|
-
├── embeddings/ # Local and OpenRouter embeddings
|
|
35
|
-
├── notes/ # Apple Notes CRUD via JXA
|
|
36
|
-
├── search/ # Hybrid search and indexing
|
|
37
|
-
└── utils/ # Debug logging, errors, text utils
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
## Key Patterns
|
|
41
|
-
|
|
42
|
-
- **Dual embedding support**: Detects `OPENROUTER_API_KEY` to choose provider
|
|
43
|
-
- **Hybrid search**: Combines vector + keyword search with RRF fusion
|
|
44
|
-
- **Incremental indexing**: Only re-embeds changed notes
|
|
45
|
-
- **Folder/title disambiguation**: Use `Folder/Note Title` format for duplicates
|
|
46
|
-
|
|
47
|
-
## Testing
|
|
48
|
-
|
|
49
|
-
Always use `bun run test` (vitest), never `bun test` (incompatible bun runner).
|
|
50
|
-
|
|
51
|
-
## Environment Variables
|
|
52
|
-
|
|
53
|
-
See README.md for full list. Key ones:
|
|
54
|
-
- `OPENROUTER_API_KEY` - Enables cloud embeddings
|
|
55
|
-
- `READONLY_MODE` - Blocks write operations
|
|
56
|
-
- `DEBUG` - Enables debug logging
|