@disco_trooper/apple-notes-mcp 1.0.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/CLAUDE.md +56 -0
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/package.json +61 -0
- package/src/config/constants.ts +41 -0
- package/src/config/env.test.ts +58 -0
- package/src/config/env.ts +25 -0
- package/src/db/lancedb.test.ts +141 -0
- package/src/db/lancedb.ts +263 -0
- package/src/db/validation.test.ts +76 -0
- package/src/db/validation.ts +57 -0
- package/src/embeddings/index.test.ts +54 -0
- package/src/embeddings/index.ts +111 -0
- package/src/embeddings/local.test.ts +70 -0
- package/src/embeddings/local.ts +191 -0
- package/src/embeddings/openrouter.test.ts +21 -0
- package/src/embeddings/openrouter.ts +285 -0
- package/src/index.ts +387 -0
- package/src/notes/crud.test.ts +199 -0
- package/src/notes/crud.ts +257 -0
- package/src/notes/read.test.ts +131 -0
- package/src/notes/read.ts +504 -0
- package/src/search/index.test.ts +52 -0
- package/src/search/index.ts +283 -0
- package/src/search/indexer.test.ts +42 -0
- package/src/search/indexer.ts +335 -0
- package/src/server.ts +386 -0
- package/src/setup.ts +540 -0
- package/src/types/index.ts +39 -0
- package/src/utils/debug.test.ts +41 -0
- package/src/utils/debug.ts +51 -0
- package/src/utils/errors.test.ts +29 -0
- package/src/utils/errors.ts +46 -0
- package/src/utils/text.ts +23 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
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
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 disco-trooper
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# apple-notes-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for Apple Notes with semantic search and CRUD operations. Claude searches, reads, creates, updates, and manages your Apple Notes through natural language.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Semantic Search** - Find notes by meaning, not keywords
|
|
8
|
+
- **Hybrid Search** - Combine vector and keyword search for better results
|
|
9
|
+
- **Full CRUD** - Create, read, update, delete, and move notes
|
|
10
|
+
- **Incremental Indexing** - Re-embed only changed notes
|
|
11
|
+
- **Dual Embedding Support** - Local HuggingFace or OpenRouter API
|
|
12
|
+
- **Claude Code Integration** - Works with Claude Code CLI
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- macOS (uses Apple Notes via JXA)
|
|
17
|
+
- [Bun](https://bun.sh) runtime
|
|
18
|
+
- Apple Notes app with notes
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
git clone https://github.com/disco-trooper/apple-notes-mcp.git
|
|
24
|
+
cd apple-notes-mcp
|
|
25
|
+
bun install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
Run the setup wizard:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
bun run setup
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The wizard:
|
|
37
|
+
1. Chooses your embedding provider (local or OpenRouter)
|
|
38
|
+
2. Configures API keys if needed
|
|
39
|
+
3. Sets auto-indexing preferences
|
|
40
|
+
4. Adds to Claude Code configuration
|
|
41
|
+
5. Indexes your notes
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
Store configuration in `.env`:
|
|
46
|
+
|
|
47
|
+
| Variable | Description | Default |
|
|
48
|
+
|----------|-------------|---------|
|
|
49
|
+
| `OPENROUTER_API_KEY` | OpenRouter API key (enables cloud embeddings) | - |
|
|
50
|
+
| `EMBEDDING_MODEL` | Model name (local or OpenRouter) | `Xenova/multilingual-e5-small` |
|
|
51
|
+
| `EMBEDDING_DIMS` | Embedding dimensions | `4096` |
|
|
52
|
+
| `READONLY_MODE` | Block all write operations | `false` |
|
|
53
|
+
| `INDEX_TTL` | Auto-reindex interval in seconds | - |
|
|
54
|
+
| `DEBUG` | Enable debug logging | `false` |
|
|
55
|
+
|
|
56
|
+
### Embedding Providers
|
|
57
|
+
|
|
58
|
+
**Local (default)**: Uses HuggingFace Transformers with `Xenova/multilingual-e5-small`. Free, runs locally, ~200MB download.
|
|
59
|
+
|
|
60
|
+
**OpenRouter**: Uses cloud API. Fast, requires no local resources, needs API key from [openrouter.ai](https://openrouter.ai).
|
|
61
|
+
|
|
62
|
+
See [docs/models.md](docs/models.md) for model comparison.
|
|
63
|
+
|
|
64
|
+
## Tools
|
|
65
|
+
|
|
66
|
+
### Search & Discovery
|
|
67
|
+
|
|
68
|
+
#### `search-notes`
|
|
69
|
+
Search notes with hybrid vector + fulltext search.
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
query: "meeting notes from last week"
|
|
73
|
+
folder: "Work" # optional, filter by folder
|
|
74
|
+
limit: 10 # default: 20
|
|
75
|
+
mode: "hybrid" # hybrid, keyword, or semantic
|
|
76
|
+
include_content: false # include full content vs preview
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
#### `list-notes`
|
|
80
|
+
Count indexed notes.
|
|
81
|
+
|
|
82
|
+
#### `list-folders`
|
|
83
|
+
List all Apple Notes folders.
|
|
84
|
+
|
|
85
|
+
#### `get-note`
|
|
86
|
+
Get a note's full content by title.
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
title: "My Note" # or "Work/My Note" for disambiguation
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Indexing
|
|
93
|
+
|
|
94
|
+
#### `index-notes`
|
|
95
|
+
Index all notes for semantic search.
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
mode: "incremental" # incremental (default) or full
|
|
99
|
+
force: false # force reindex even if TTL hasn't expired
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
#### `reindex-note`
|
|
103
|
+
Re-index a single note after manual edits.
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
title: "My Note"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### CRUD Operations
|
|
110
|
+
|
|
111
|
+
#### `create-note`
|
|
112
|
+
Create a note in Apple Notes.
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
title: "New Note"
|
|
116
|
+
content: "# Heading\n\nMarkdown content..."
|
|
117
|
+
folder: "Work" # optional, defaults to Notes
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
#### `update-note`
|
|
121
|
+
Update an existing note.
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
title: "My Note"
|
|
125
|
+
content: "Updated markdown content..."
|
|
126
|
+
reindex: true # re-embed after update (default: true)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
#### `delete-note`
|
|
130
|
+
Delete a note (requires confirmation).
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
title: "My Note"
|
|
134
|
+
confirm: true # must be true to delete
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
#### `move-note`
|
|
138
|
+
Move a note to another folder.
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
title: "My Note"
|
|
142
|
+
folder: "Archive"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Claude Code Setup
|
|
146
|
+
|
|
147
|
+
### Automatic (via setup wizard)
|
|
148
|
+
|
|
149
|
+
Run `bun run setup` and select "Add to Claude Code configuration".
|
|
150
|
+
|
|
151
|
+
### Manual
|
|
152
|
+
|
|
153
|
+
Add to `~/.claude.json`:
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"mcpServers": {
|
|
158
|
+
"apple-notes": {
|
|
159
|
+
"command": "bun",
|
|
160
|
+
"args": ["run", "/path/to/apple-notes-mcp/src/index.ts"],
|
|
161
|
+
"env": {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Usage Examples
|
|
168
|
+
|
|
169
|
+
After setup, use natural language with Claude:
|
|
170
|
+
|
|
171
|
+
- "Search my notes for project ideas"
|
|
172
|
+
- "Create a note called 'Meeting Notes' in the Work folder"
|
|
173
|
+
- "What's in my note about vacation plans?"
|
|
174
|
+
- "Move the 'Old Project' note to Archive"
|
|
175
|
+
- "Index my notes" (after adding notes in Apple Notes)
|
|
176
|
+
|
|
177
|
+
## Troubleshooting
|
|
178
|
+
|
|
179
|
+
### "Note not found"
|
|
180
|
+
Use full path format `Folder/Note Title` when multiple notes share the same name.
|
|
181
|
+
|
|
182
|
+
### Slow first search
|
|
183
|
+
Local embeddings download the model on first use (~200MB). Subsequent searches run fast.
|
|
184
|
+
|
|
185
|
+
### "READONLY_MODE is enabled"
|
|
186
|
+
Set `READONLY_MODE=false` in `.env` to enable write operations.
|
|
187
|
+
|
|
188
|
+
### Notes missing from search
|
|
189
|
+
Run `index-notes` to update the search index. Use `mode: full` if incremental misses changes.
|
|
190
|
+
|
|
191
|
+
### JXA errors
|
|
192
|
+
Ensure Apple Notes runs and contains notes. Grant automation permissions when prompted.
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
# Type check
|
|
198
|
+
bun run check
|
|
199
|
+
|
|
200
|
+
# Run with debug logging
|
|
201
|
+
DEBUG=true bun run start
|
|
202
|
+
|
|
203
|
+
# Watch mode
|
|
204
|
+
bun run dev
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Contributing
|
|
208
|
+
|
|
209
|
+
PRs welcome! Please:
|
|
210
|
+
- Run `bun run check` before submitting
|
|
211
|
+
- Add tests for new functionality
|
|
212
|
+
- Update documentation as needed
|
|
213
|
+
|
|
214
|
+
## License
|
|
215
|
+
|
|
216
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@disco_trooper/apple-notes-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for Apple Notes with semantic search and CRUD operations",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"module": "src/server.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "bun run src/index.ts",
|
|
10
|
+
"setup": "bun run src/setup.ts",
|
|
11
|
+
"dev": "bun --watch run src/index.ts",
|
|
12
|
+
"check": "bun run tsc --noEmit",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
18
|
+
"@lancedb/lancedb": "^0.13.0",
|
|
19
|
+
"@huggingface/transformers": "^3.0.0",
|
|
20
|
+
"turndown": "^7.2.0",
|
|
21
|
+
"marked": "^15.0.0",
|
|
22
|
+
"run-jxa": "^3.0.0",
|
|
23
|
+
"zod": "^3.24.0",
|
|
24
|
+
"@clack/prompts": "^0.8.0",
|
|
25
|
+
"dotenv": "^16.4.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@smithery/cli": "^3.0.3",
|
|
29
|
+
"@types/bun": "^1.1.0",
|
|
30
|
+
"@types/turndown": "^5.0.0",
|
|
31
|
+
"typescript": "^5.7.0",
|
|
32
|
+
"vitest": "^4.0.16"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"mcp",
|
|
36
|
+
"apple-notes",
|
|
37
|
+
"semantic-search",
|
|
38
|
+
"rag",
|
|
39
|
+
"claude"
|
|
40
|
+
],
|
|
41
|
+
"author": "disco-trooper",
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"bin": {
|
|
44
|
+
"apple-notes-mcp": "./src/index.ts"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"src",
|
|
48
|
+
"README.md",
|
|
49
|
+
"CLAUDE.md"
|
|
50
|
+
],
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=18"
|
|
53
|
+
},
|
|
54
|
+
"os": [
|
|
55
|
+
"darwin"
|
|
56
|
+
],
|
|
57
|
+
"repository": {
|
|
58
|
+
"type": "git",
|
|
59
|
+
"url": "https://github.com/disco-trooper/apple-notes-mcp"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application constants extracted from magic numbers throughout the codebase.
|
|
3
|
+
* Centralizes configuration values for easy maintenance and documentation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Search defaults
|
|
7
|
+
export const DEFAULT_SEARCH_LIMIT = 20;
|
|
8
|
+
export const MAX_SEARCH_LIMIT = 100;
|
|
9
|
+
export const PREVIEW_LENGTH = 200;
|
|
10
|
+
|
|
11
|
+
// Embedding settings
|
|
12
|
+
export const DEFAULT_LOCAL_EMBEDDING_DIMS = 384;
|
|
13
|
+
export const DEFAULT_OPENROUTER_EMBEDDING_DIMS = 4096;
|
|
14
|
+
export const DEFAULT_OPENROUTER_MODEL = "qwen/qwen3-embedding-8b";
|
|
15
|
+
|
|
16
|
+
// Vector search
|
|
17
|
+
export const DEFAULT_VECTOR_SEARCH_LIMIT = 50;
|
|
18
|
+
export const RRF_K = 60; // Reciprocal Rank Fusion constant
|
|
19
|
+
|
|
20
|
+
// Timeouts and retries
|
|
21
|
+
export const OPENROUTER_TIMEOUT_MS = 30000;
|
|
22
|
+
|
|
23
|
+
// Cache settings
|
|
24
|
+
export const EMBEDDING_CACHE_MAX_SIZE = 1000;
|
|
25
|
+
|
|
26
|
+
// Input processing
|
|
27
|
+
export const MAX_INPUT_LENGTH = 8000;
|
|
28
|
+
export const MAX_TITLE_LENGTH = 500;
|
|
29
|
+
export const ERROR_MESSAGE_MAX_LENGTH = 200;
|
|
30
|
+
|
|
31
|
+
// Retry and backoff
|
|
32
|
+
export const MAX_RETRIES = 3;
|
|
33
|
+
export const RATE_LIMIT_BACKOFF_BASE_MS = 2000;
|
|
34
|
+
|
|
35
|
+
// Indexing
|
|
36
|
+
export const EMBEDDING_DELAY_MS = 300;
|
|
37
|
+
|
|
38
|
+
// Search tuning
|
|
39
|
+
export const HYBRID_SEARCH_MIN_FETCH = 40;
|
|
40
|
+
export const FOLDER_FILTER_MULTIPLIER = 3;
|
|
41
|
+
export const PREVIEW_TRUNCATE_RATIO = 0.7;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// We need to test validateEnv with different env vars
|
|
4
|
+
// The module reads process.env at import time, so we need dynamic imports
|
|
5
|
+
|
|
6
|
+
describe("validateEnv", () => {
|
|
7
|
+
const originalEnv = { ...process.env };
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.resetModules();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
process.env = { ...originalEnv };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("accepts valid configuration", async () => {
|
|
18
|
+
process.env.DEBUG = "true";
|
|
19
|
+
process.env.READONLY_MODE = "false";
|
|
20
|
+
const { validateEnv } = await import("./env.js");
|
|
21
|
+
expect(() => validateEnv()).not.toThrow();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("accepts empty configuration", async () => {
|
|
25
|
+
// Clear relevant env vars
|
|
26
|
+
delete process.env.OPENROUTER_API_KEY;
|
|
27
|
+
delete process.env.DEBUG;
|
|
28
|
+
delete process.env.READONLY_MODE;
|
|
29
|
+
delete process.env.EMBEDDING_DIMS;
|
|
30
|
+
delete process.env.INDEX_TTL;
|
|
31
|
+
const { validateEnv } = await import("./env.js");
|
|
32
|
+
expect(() => validateEnv()).not.toThrow();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("validates OPENROUTER_API_KEY format", async () => {
|
|
36
|
+
process.env.OPENROUTER_API_KEY = "invalid-key";
|
|
37
|
+
const { validateEnv } = await import("./env.js");
|
|
38
|
+
expect(() => validateEnv()).toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("accepts valid OPENROUTER_API_KEY", async () => {
|
|
42
|
+
process.env.OPENROUTER_API_KEY = "sk-or-valid-key-123";
|
|
43
|
+
const { validateEnv } = await import("./env.js");
|
|
44
|
+
expect(() => validateEnv()).not.toThrow();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("validates EMBEDDING_DIMS is numeric", async () => {
|
|
48
|
+
process.env.EMBEDDING_DIMS = "not-a-number";
|
|
49
|
+
const { validateEnv } = await import("./env.js");
|
|
50
|
+
expect(() => validateEnv()).toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("validates DEBUG is boolean string", async () => {
|
|
54
|
+
process.env.DEBUG = "yes";
|
|
55
|
+
const { validateEnv } = await import("./env.js");
|
|
56
|
+
expect(() => validateEnv()).toThrow();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const EnvSchema = z.object({
|
|
4
|
+
OPENROUTER_API_KEY: z.string().startsWith("sk-or-").optional(),
|
|
5
|
+
EMBEDDING_MODEL: z.string().optional(),
|
|
6
|
+
EMBEDDING_DIMS: z.string().regex(/^\d+$/).optional(),
|
|
7
|
+
READONLY_MODE: z.enum(["true", "false"]).optional(),
|
|
8
|
+
INDEX_TTL: z.string().regex(/^\d+$/).optional(),
|
|
9
|
+
DEBUG: z.enum(["true", "false"]).optional(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export type Env = z.infer<typeof EnvSchema>;
|
|
13
|
+
|
|
14
|
+
export function validateEnv(): Env {
|
|
15
|
+
const result = EnvSchema.safeParse(process.env);
|
|
16
|
+
|
|
17
|
+
if (!result.success) {
|
|
18
|
+
const errors = result.error.errors
|
|
19
|
+
.map((e) => ` ${e.path.join(".")}: ${e.message}`)
|
|
20
|
+
.join("\n");
|
|
21
|
+
throw new Error(`Invalid environment configuration:\n${errors}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return result.data;
|
|
25
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { LanceDBStore } from "./lancedb.js";
|
|
3
|
+
import type { NoteRecord } from "./lancedb.js";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
|
|
7
|
+
describe("LanceDBStore", () => {
|
|
8
|
+
let store: LanceDBStore;
|
|
9
|
+
let testDbPath: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
testDbPath = path.join("/tmp", `lancedb-test-${Date.now()}`);
|
|
13
|
+
store = new LanceDBStore(testDbPath);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (fs.existsSync(testDbPath)) {
|
|
18
|
+
fs.rmSync(testDbPath, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const createTestRecord = (title: string): NoteRecord => ({
|
|
23
|
+
title,
|
|
24
|
+
folder: "Test",
|
|
25
|
+
content: `Content of ${title}`,
|
|
26
|
+
modified: new Date().toISOString(),
|
|
27
|
+
created: new Date().toISOString(),
|
|
28
|
+
indexed_at: new Date().toISOString(),
|
|
29
|
+
vector: Array(384).fill(0.1),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("index and getByTitle", () => {
|
|
33
|
+
it("indexes and retrieves a record", async () => {
|
|
34
|
+
const record = createTestRecord("Test Note");
|
|
35
|
+
await store.index([record]);
|
|
36
|
+
const retrieved = await store.getByTitle("Test Note");
|
|
37
|
+
expect(retrieved).not.toBeNull();
|
|
38
|
+
expect(retrieved?.title).toBe("Test Note");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns null for non-existent title", async () => {
|
|
42
|
+
await store.index([createTestRecord("Other")]); // Initialize table
|
|
43
|
+
const retrieved = await store.getByTitle("Does Not Exist");
|
|
44
|
+
expect(retrieved).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("count", () => {
|
|
49
|
+
it("returns correct count", async () => {
|
|
50
|
+
await store.index([createTestRecord("Note 1"), createTestRecord("Note 2")]);
|
|
51
|
+
expect(await store.count()).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns 0 for empty store", async () => {
|
|
55
|
+
expect(await store.count()).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("delete", () => {
|
|
60
|
+
it("deletes existing record", async () => {
|
|
61
|
+
await store.index([createTestRecord("Delete Me")]);
|
|
62
|
+
await store.delete("Delete Me");
|
|
63
|
+
const retrieved = await store.getByTitle("Delete Me");
|
|
64
|
+
expect(retrieved).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does not throw when deleting non-existent record", async () => {
|
|
68
|
+
await store.index([createTestRecord("Existing")]);
|
|
69
|
+
await expect(store.delete("Non Existent")).resolves.not.toThrow();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("getAll", () => {
|
|
74
|
+
it("returns all indexed records", async () => {
|
|
75
|
+
await store.index([
|
|
76
|
+
createTestRecord("Note 1"),
|
|
77
|
+
createTestRecord("Note 2"),
|
|
78
|
+
createTestRecord("Note 3"),
|
|
79
|
+
]);
|
|
80
|
+
const all = await store.getAll();
|
|
81
|
+
expect(all).toHaveLength(3);
|
|
82
|
+
const titles = all.map((r) => r.title);
|
|
83
|
+
expect(titles).toContain("Note 1");
|
|
84
|
+
expect(titles).toContain("Note 2");
|
|
85
|
+
expect(titles).toContain("Note 3");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("update", () => {
|
|
90
|
+
it("updates existing record content", async () => {
|
|
91
|
+
await store.index([createTestRecord("Update Me")]);
|
|
92
|
+
|
|
93
|
+
const updatedRecord = createTestRecord("Update Me");
|
|
94
|
+
updatedRecord.content = "New updated content";
|
|
95
|
+
await store.update(updatedRecord);
|
|
96
|
+
|
|
97
|
+
const retrieved = await store.getByTitle("Update Me");
|
|
98
|
+
expect(retrieved?.content).toBe("New updated content");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("adds new record if title does not exist", async () => {
|
|
102
|
+
await store.index([createTestRecord("Existing")]);
|
|
103
|
+
|
|
104
|
+
const newRecord = createTestRecord("New Note");
|
|
105
|
+
await store.update(newRecord);
|
|
106
|
+
|
|
107
|
+
const retrieved = await store.getByTitle("New Note");
|
|
108
|
+
expect(retrieved).not.toBeNull();
|
|
109
|
+
expect(retrieved?.title).toBe("New Note");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("clear", () => {
|
|
114
|
+
it("removes all records", async () => {
|
|
115
|
+
await store.index([
|
|
116
|
+
createTestRecord("Note 1"),
|
|
117
|
+
createTestRecord("Note 2"),
|
|
118
|
+
]);
|
|
119
|
+
expect(await store.count()).toBe(2);
|
|
120
|
+
|
|
121
|
+
await store.clear();
|
|
122
|
+
expect(await store.count()).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("search", () => {
|
|
127
|
+
it("returns results based on vector similarity", async () => {
|
|
128
|
+
await store.index([
|
|
129
|
+
createTestRecord("Note 1"),
|
|
130
|
+
createTestRecord("Note 2"),
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
const queryVector = Array(384).fill(0.1);
|
|
134
|
+
const results = await store.search(queryVector, 2);
|
|
135
|
+
|
|
136
|
+
expect(results).toHaveLength(2);
|
|
137
|
+
expect(results[0]).toHaveProperty("title");
|
|
138
|
+
expect(results[0]).toHaveProperty("score");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|