@disco_trooper/apple-notes-mcp 1.1.0 → 1.3.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 +104 -24
- package/package.json +11 -12
- 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 +254 -2
- package/src/db/lancedb.ts +385 -38
- package/src/embeddings/cache.test.ts +150 -0
- package/src/embeddings/cache.ts +204 -0
- package/src/embeddings/index.ts +22 -4
- package/src/embeddings/local.ts +57 -17
- package/src/embeddings/openrouter.ts +233 -11
- package/src/errors/index.test.ts +64 -0
- package/src/errors/index.ts +62 -0
- 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 +309 -23
- package/src/notes/conversion.ts +62 -0
- package/src/notes/crud.test.ts +41 -8
- package/src/notes/crud.ts +75 -64
- package/src/notes/read.test.ts +58 -3
- package/src/notes/read.ts +142 -210
- package/src/notes/resolve.ts +174 -0
- package/src/notes/tables.ts +69 -40
- package/src/search/chunk-indexer.test.ts +353 -0
- package/src/search/chunk-indexer.ts +207 -0
- package/src/search/chunk-search.test.ts +327 -0
- package/src/search/chunk-search.ts +298 -0
- package/src/search/index.ts +4 -6
- package/src/search/indexer.ts +164 -109
- package/src/setup.ts +46 -67
- package/src/types/index.ts +4 -0
- 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/debug.ts +0 -2
- package/src/utils/runtime.test.ts +70 -0
- package/src/utils/runtime.ts +40 -0
- package/src/utils/text.test.ts +32 -0
- package/CLAUDE.md +0 -56
- package/src/server.ts +0 -427
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LRU Cache for query embeddings.
|
|
3
|
+
* Dramatically speeds up hybrid search by caching repeated queries.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createDebugLogger } from "../utils/debug.js";
|
|
7
|
+
|
|
8
|
+
const debug = createDebugLogger("EMBED_CACHE");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Simple LRU Cache implementation for embeddings.
|
|
12
|
+
*/
|
|
13
|
+
class LRUCache<K, V> {
|
|
14
|
+
private cache = new Map<K, V>();
|
|
15
|
+
private readonly maxSize: number;
|
|
16
|
+
|
|
17
|
+
constructor(maxSize: number) {
|
|
18
|
+
this.maxSize = maxSize;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get(key: K): V | undefined {
|
|
22
|
+
const value = this.cache.get(key);
|
|
23
|
+
if (value !== undefined) {
|
|
24
|
+
// Move to end (most recently used)
|
|
25
|
+
this.cache.delete(key);
|
|
26
|
+
this.cache.set(key, value);
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
set(key: K, value: V): void {
|
|
32
|
+
// Delete if exists (to update position)
|
|
33
|
+
if (this.cache.has(key)) {
|
|
34
|
+
this.cache.delete(key);
|
|
35
|
+
}
|
|
36
|
+
// Evict oldest if at capacity
|
|
37
|
+
else if (this.cache.size >= this.maxSize) {
|
|
38
|
+
const firstKey = this.cache.keys().next().value;
|
|
39
|
+
if (firstKey !== undefined) {
|
|
40
|
+
this.cache.delete(firstKey);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
this.cache.set(key, value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
has(key: K): boolean {
|
|
47
|
+
return this.cache.has(key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
clear(): void {
|
|
51
|
+
this.cache.clear();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get size(): number {
|
|
55
|
+
return this.cache.size;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Normalize query for better cache hit rate.
|
|
61
|
+
* - Lowercase
|
|
62
|
+
* - Trim whitespace
|
|
63
|
+
* - Collapse multiple spaces
|
|
64
|
+
*/
|
|
65
|
+
function normalizeQuery(query: string): string {
|
|
66
|
+
return query.toLowerCase().trim().replace(/\s+/g, " ");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Cache statistics for monitoring.
|
|
71
|
+
*/
|
|
72
|
+
export interface CacheStats {
|
|
73
|
+
hits: number;
|
|
74
|
+
misses: number;
|
|
75
|
+
size: number;
|
|
76
|
+
hitRate: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Embedding cache with LRU eviction.
|
|
81
|
+
*/
|
|
82
|
+
class EmbeddingCache {
|
|
83
|
+
private cache: LRUCache<string, number[]>;
|
|
84
|
+
private modelVersion: string;
|
|
85
|
+
private hits = 0;
|
|
86
|
+
private misses = 0;
|
|
87
|
+
|
|
88
|
+
constructor(maxSize = 1000, modelVersion = "default") {
|
|
89
|
+
this.cache = new LRUCache(maxSize);
|
|
90
|
+
this.modelVersion = modelVersion;
|
|
91
|
+
debug(`Embedding cache initialized (max: ${maxSize})`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create cache key from query and model version.
|
|
96
|
+
*/
|
|
97
|
+
private makeKey(query: string): string {
|
|
98
|
+
const normalized = normalizeQuery(query);
|
|
99
|
+
return `${this.modelVersion}:${normalized}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get cached embedding for query.
|
|
104
|
+
* Returns undefined if not cached.
|
|
105
|
+
*/
|
|
106
|
+
get(query: string): number[] | undefined {
|
|
107
|
+
const key = this.makeKey(query);
|
|
108
|
+
const cached = this.cache.get(key);
|
|
109
|
+
|
|
110
|
+
if (cached) {
|
|
111
|
+
this.hits++;
|
|
112
|
+
debug(`Cache HIT for "${query.slice(0, 30)}..." (hits: ${this.hits})`);
|
|
113
|
+
return cached;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.misses++;
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Store embedding in cache.
|
|
122
|
+
*/
|
|
123
|
+
set(query: string, embedding: number[]): void {
|
|
124
|
+
const key = this.makeKey(query);
|
|
125
|
+
this.cache.set(key, embedding);
|
|
126
|
+
debug(`Cached embedding for "${query.slice(0, 30)}..." (size: ${this.cache.size})`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get or compute embedding using provided function.
|
|
131
|
+
* This is the main API for cached embedding retrieval.
|
|
132
|
+
*/
|
|
133
|
+
async getOrCompute(
|
|
134
|
+
query: string,
|
|
135
|
+
computeFn: (q: string) => Promise<number[]>
|
|
136
|
+
): Promise<number[]> {
|
|
137
|
+
const cached = this.get(query);
|
|
138
|
+
if (cached) {
|
|
139
|
+
return cached;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const embedding = await computeFn(query);
|
|
143
|
+
this.set(query, embedding);
|
|
144
|
+
return embedding;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Invalidate cache (e.g., when model changes).
|
|
149
|
+
*/
|
|
150
|
+
clear(): void {
|
|
151
|
+
this.cache.clear();
|
|
152
|
+
this.hits = 0;
|
|
153
|
+
this.misses = 0;
|
|
154
|
+
debug("Cache cleared");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Update model version and clear cache.
|
|
159
|
+
*/
|
|
160
|
+
setModelVersion(version: string): void {
|
|
161
|
+
if (version !== this.modelVersion) {
|
|
162
|
+
debug(`Model version changed: ${this.modelVersion} -> ${version}`);
|
|
163
|
+
this.modelVersion = version;
|
|
164
|
+
this.clear();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Get cache statistics.
|
|
170
|
+
*/
|
|
171
|
+
getStats(): CacheStats {
|
|
172
|
+
const total = this.hits + this.misses;
|
|
173
|
+
return {
|
|
174
|
+
hits: this.hits,
|
|
175
|
+
misses: this.misses,
|
|
176
|
+
size: this.cache.size,
|
|
177
|
+
hitRate: total > 0 ? this.hits / total : 0,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Singleton instance
|
|
183
|
+
let cacheInstance: EmbeddingCache | null = null;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get the embedding cache singleton.
|
|
187
|
+
*/
|
|
188
|
+
export function getEmbeddingCache(): EmbeddingCache {
|
|
189
|
+
if (!cacheInstance) {
|
|
190
|
+
// Max 1000 queries * ~1.5KB per embedding = ~1.5MB
|
|
191
|
+
cacheInstance = new EmbeddingCache(1000);
|
|
192
|
+
}
|
|
193
|
+
return cacheInstance;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Reset the cache (useful for testing).
|
|
198
|
+
*/
|
|
199
|
+
export function resetEmbeddingCache(): void {
|
|
200
|
+
if (cacheInstance) {
|
|
201
|
+
cacheInstance.clear();
|
|
202
|
+
}
|
|
203
|
+
cacheInstance = null;
|
|
204
|
+
}
|
package/src/embeddings/index.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* - Local HuggingFace (fallback)
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { getOpenRouterEmbedding, getOpenRouterDimensions } from "./openrouter.js";
|
|
10
|
-
import { getLocalEmbedding, getLocalDimensions, getLocalModelName } from "./local.js";
|
|
9
|
+
import { getOpenRouterEmbedding, getOpenRouterDimensions, getOpenRouterEmbeddingBatch } from "./openrouter.js";
|
|
10
|
+
import { getLocalEmbedding, getLocalDimensions, getLocalModelName, getLocalEmbeddingBatch } from "./local.js";
|
|
11
11
|
import { createDebugLogger } from "../utils/debug.js";
|
|
12
12
|
|
|
13
13
|
// Debug logging
|
|
@@ -41,10 +41,9 @@ export function detectProvider(): EmbeddingProvider {
|
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* Get the current embedding provider.
|
|
44
|
-
* Call detectProvider() first to ensure detection has occurred.
|
|
45
44
|
*/
|
|
46
45
|
export function getProvider(): EmbeddingProvider {
|
|
47
|
-
return detectedProvider
|
|
46
|
+
return detectedProvider ?? detectProvider();
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
/**
|
|
@@ -63,6 +62,23 @@ export async function getEmbedding(text: string): Promise<number[]> {
|
|
|
63
62
|
}
|
|
64
63
|
}
|
|
65
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Generate embeddings for multiple texts in batch.
|
|
67
|
+
* Uses native batch API for both OpenRouter and local providers.
|
|
68
|
+
*
|
|
69
|
+
* @param texts - Array of texts to embed
|
|
70
|
+
* @returns Promise resolving to array of embedding vectors
|
|
71
|
+
*/
|
|
72
|
+
export async function getEmbeddingBatch(texts: string[]): Promise<number[][]> {
|
|
73
|
+
const provider = getProvider();
|
|
74
|
+
|
|
75
|
+
if (provider === "openrouter") {
|
|
76
|
+
return getOpenRouterEmbeddingBatch(texts);
|
|
77
|
+
} else {
|
|
78
|
+
return getLocalEmbeddingBatch(texts);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
/**
|
|
67
83
|
* Get the embedding dimensions for the current provider.
|
|
68
84
|
*
|
|
@@ -101,10 +117,12 @@ export function getProviderDescription(): string {
|
|
|
101
117
|
export {
|
|
102
118
|
getOpenRouterEmbedding,
|
|
103
119
|
getOpenRouterDimensions,
|
|
120
|
+
getOpenRouterEmbeddingBatch,
|
|
104
121
|
} from "./openrouter.js";
|
|
105
122
|
|
|
106
123
|
export {
|
|
107
124
|
getLocalEmbedding,
|
|
125
|
+
getLocalEmbeddingBatch,
|
|
108
126
|
getLocalDimensions,
|
|
109
127
|
getLocalModelName,
|
|
110
128
|
isModelLoaded,
|
package/src/embeddings/local.ts
CHANGED
|
@@ -25,13 +25,12 @@ const debug = createDebugLogger("LOCAL");
|
|
|
25
25
|
|
|
26
26
|
// Lazy-loaded pipeline
|
|
27
27
|
type FeatureExtractionPipeline = (
|
|
28
|
-
text: string,
|
|
28
|
+
text: string | string[],
|
|
29
29
|
options?: { pooling?: string; normalize?: boolean }
|
|
30
30
|
) => Promise<{ tolist: () => number[][] }>;
|
|
31
31
|
|
|
32
32
|
let pipelineInstance: FeatureExtractionPipeline | null = null;
|
|
33
33
|
let pipelinePromise: Promise<FeatureExtractionPipeline> | null = null;
|
|
34
|
-
let resolvedModel: string | null = null;
|
|
35
34
|
|
|
36
35
|
/**
|
|
37
36
|
* Get the configured model name.
|
|
@@ -41,6 +40,27 @@ function getModelName(): string {
|
|
|
41
40
|
return process.env.EMBEDDING_MODEL || DEFAULT_MODEL;
|
|
42
41
|
}
|
|
43
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Check if the model is an E5 model that requires prefixed input.
|
|
45
|
+
*/
|
|
46
|
+
function isE5Model(): boolean {
|
|
47
|
+
return getModelName().toLowerCase().includes("e5");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Prepare text for embedding by adding E5 prefix if needed.
|
|
52
|
+
*/
|
|
53
|
+
function prepareText(text: string): string {
|
|
54
|
+
return isE5Model() ? `passage: ${text}` : text;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Prepare multiple texts for embedding by adding E5 prefix if needed.
|
|
59
|
+
*/
|
|
60
|
+
function prepareTexts(texts: string[]): string[] {
|
|
61
|
+
return isE5Model() ? texts.map(t => `passage: ${t}`) : texts;
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
/**
|
|
45
65
|
* Lazy-load the HuggingFace transformers pipeline.
|
|
46
66
|
* Only loads once, subsequent calls return the cached instance.
|
|
@@ -82,8 +102,6 @@ async function getPipeline(): Promise<FeatureExtractionPipeline> {
|
|
|
82
102
|
debug(`Model loaded in ${loadTime}ms`);
|
|
83
103
|
|
|
84
104
|
pipelineInstance = pipe;
|
|
85
|
-
resolvedModel = modelName;
|
|
86
|
-
|
|
87
105
|
return pipe;
|
|
88
106
|
} catch (error) {
|
|
89
107
|
// Reset promise so next call retries
|
|
@@ -119,19 +137,11 @@ export async function getLocalEmbedding(text: string): Promise<number[]> {
|
|
|
119
137
|
const startTime = Date.now();
|
|
120
138
|
|
|
121
139
|
try {
|
|
122
|
-
|
|
123
|
-
// or "query: " for search queries - using passage for general text
|
|
124
|
-
const modelName = getModelName();
|
|
125
|
-
const isE5Model = modelName.toLowerCase().includes("e5");
|
|
126
|
-
const inputText = isE5Model ? `passage: ${text}` : text;
|
|
127
|
-
|
|
128
|
-
// Run inference with mean pooling and normalization
|
|
129
|
-
const output = await pipe(inputText, {
|
|
140
|
+
const output = await pipe(prepareText(text), {
|
|
130
141
|
pooling: "mean",
|
|
131
142
|
normalize: true,
|
|
132
143
|
});
|
|
133
144
|
|
|
134
|
-
// Extract the embedding vector
|
|
135
145
|
const embedding = output.tolist()[0];
|
|
136
146
|
|
|
137
147
|
const inferenceTime = Date.now() - startTime;
|
|
@@ -183,9 +193,39 @@ export function isModelLoaded(): boolean {
|
|
|
183
193
|
}
|
|
184
194
|
|
|
185
195
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
196
|
+
* Generate embeddings for multiple texts in a single batch call.
|
|
197
|
+
* More efficient than calling getLocalEmbedding for each text individually.
|
|
198
|
+
*
|
|
199
|
+
* @param texts - Array of texts to embed
|
|
200
|
+
* @returns Promise resolving to array of embedding vectors
|
|
201
|
+
* @throws Error if model loading or inference fails
|
|
188
202
|
*/
|
|
189
|
-
export function
|
|
190
|
-
|
|
203
|
+
export async function getLocalEmbeddingBatch(texts: string[]): Promise<number[][]> {
|
|
204
|
+
if (!texts || texts.length === 0) {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const pipe = await getPipeline();
|
|
209
|
+
|
|
210
|
+
debug(`Generating batch embeddings for ${texts.length} texts`);
|
|
211
|
+
const startTime = Date.now();
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const output = await pipe(prepareTexts(texts), {
|
|
215
|
+
pooling: "mean",
|
|
216
|
+
normalize: true,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const embeddings = output.tolist() as number[][];
|
|
220
|
+
|
|
221
|
+
const inferenceTime = Date.now() - startTime;
|
|
222
|
+
debug(`Batch embeddings generated in ${inferenceTime}ms (${embeddings.length} vectors, ${embeddings[0]?.length ?? 0} dims)`);
|
|
223
|
+
|
|
224
|
+
return embeddings;
|
|
225
|
+
} catch (error) {
|
|
226
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
227
|
+
debug(`Batch embedding generation failed: ${message}`);
|
|
228
|
+
|
|
229
|
+
throw new Error(`Failed to generate batch embeddings: ${message}`);
|
|
230
|
+
}
|
|
191
231
|
}
|
|
@@ -108,6 +108,27 @@ class OpenRouterError extends Error {
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/** HTTP status codes that should not be retried */
|
|
112
|
+
const NON_RETRYABLE_STATUS_CODES = [400, 401, 403, 404];
|
|
113
|
+
|
|
114
|
+
/** Common headers for OpenRouter API requests */
|
|
115
|
+
const API_HEADERS = {
|
|
116
|
+
"Content-Type": "application/json",
|
|
117
|
+
"HTTP-Referer": "https://github.com/apple-notes-mcp",
|
|
118
|
+
"X-Title": "Apple Notes MCP",
|
|
119
|
+
} as const;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check if an error should trigger a retry or fail immediately.
|
|
123
|
+
* Returns true if the error is non-retryable.
|
|
124
|
+
*/
|
|
125
|
+
function isNonRetryableError(error: unknown): boolean {
|
|
126
|
+
if (error instanceof OpenRouterError && error.statusCode) {
|
|
127
|
+
return NON_RETRYABLE_STATUS_CODES.includes(error.statusCode);
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
111
132
|
/**
|
|
112
133
|
* Get embedding vector for text using OpenRouter API
|
|
113
134
|
*
|
|
@@ -157,9 +178,7 @@ export async function getOpenRouterEmbedding(text: string): Promise<number[]> {
|
|
|
157
178
|
method: "POST",
|
|
158
179
|
headers: {
|
|
159
180
|
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
|
|
160
|
-
|
|
161
|
-
"HTTP-Referer": "https://github.com/apple-notes-mcp",
|
|
162
|
-
"X-Title": "Apple Notes MCP",
|
|
181
|
+
...API_HEADERS,
|
|
163
182
|
},
|
|
164
183
|
body: JSON.stringify({
|
|
165
184
|
model: EMBEDDING_MODEL,
|
|
@@ -224,17 +243,12 @@ export async function getOpenRouterEmbedding(text: string): Promise<number[]> {
|
|
|
224
243
|
`Request timed out after ${OPENROUTER_TIMEOUT_MS}ms`,
|
|
225
244
|
408
|
|
226
245
|
);
|
|
227
|
-
// Don't throw - fall through to retry logic below
|
|
228
246
|
} else {
|
|
229
247
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
230
248
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (nonRetryable.includes(error.statusCode)) {
|
|
235
|
-
debug(`Non-retryable error (${error.statusCode}), failing immediately`);
|
|
236
|
-
throw error;
|
|
237
|
-
}
|
|
249
|
+
if (isNonRetryableError(error)) {
|
|
250
|
+
debug(`Non-retryable error, failing immediately`);
|
|
251
|
+
throw error;
|
|
238
252
|
}
|
|
239
253
|
}
|
|
240
254
|
|
|
@@ -283,3 +297,211 @@ export function clearEmbeddingCache(): void {
|
|
|
283
297
|
export function getEmbeddingCacheSize(): number {
|
|
284
298
|
return embeddingCache.size;
|
|
285
299
|
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Batch size for embedding requests.
|
|
303
|
+
* OpenRouter supports up to 2048 inputs per request, but 50-100 is optimal.
|
|
304
|
+
*/
|
|
305
|
+
const BATCH_SIZE = 50;
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Number of concurrent batch API calls.
|
|
309
|
+
* Higher values increase throughput but may hit rate limits.
|
|
310
|
+
*/
|
|
311
|
+
const CONCURRENT_BATCHES = 3;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Split an array into chunks of specified size.
|
|
315
|
+
*/
|
|
316
|
+
function chunk<T>(array: T[], size: number): T[][] {
|
|
317
|
+
const chunks: T[][] = [];
|
|
318
|
+
for (let i = 0; i < array.length; i += size) {
|
|
319
|
+
chunks.push(array.slice(i, i + size));
|
|
320
|
+
}
|
|
321
|
+
return chunks;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Process a single batch of texts and return embeddings.
|
|
326
|
+
* Internal helper for concurrent batch processing.
|
|
327
|
+
*/
|
|
328
|
+
async function processSingleBatch(
|
|
329
|
+
batchTexts: string[],
|
|
330
|
+
batchIndices: number[],
|
|
331
|
+
cacheKeys: string[],
|
|
332
|
+
results: (number[] | null)[],
|
|
333
|
+
batchNumber: number,
|
|
334
|
+
totalBatches: number
|
|
335
|
+
): Promise<void> {
|
|
336
|
+
debug(`Processing batch ${batchNumber}/${totalBatches} (${batchTexts.length} texts)`);
|
|
337
|
+
|
|
338
|
+
let lastError: Error | null = null;
|
|
339
|
+
|
|
340
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
341
|
+
const controller = new AbortController();
|
|
342
|
+
const timeoutId = setTimeout(() => controller.abort(), OPENROUTER_TIMEOUT_MS * 2);
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const response = await fetch(API_URL, {
|
|
346
|
+
method: "POST",
|
|
347
|
+
headers: {
|
|
348
|
+
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
|
|
349
|
+
...API_HEADERS,
|
|
350
|
+
},
|
|
351
|
+
body: JSON.stringify({
|
|
352
|
+
model: EMBEDDING_MODEL,
|
|
353
|
+
input: batchTexts,
|
|
354
|
+
dimensions: EMBEDDING_DIMS,
|
|
355
|
+
}),
|
|
356
|
+
signal: controller.signal,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
if (response.status === 429) {
|
|
360
|
+
clearTimeout(timeoutId);
|
|
361
|
+
const waitTime = getBackoffDelay(attempt, RATE_LIMIT_BACKOFF_BASE_MS);
|
|
362
|
+
debug(`Batch ${batchNumber}: Rate limited (429), waiting ${waitTime}ms`);
|
|
363
|
+
await sleep(waitTime);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!response.ok) {
|
|
368
|
+
const errorBody = await response.text();
|
|
369
|
+
throw new OpenRouterError(
|
|
370
|
+
`OpenRouter API error: ${response.status} - ${errorBody}`,
|
|
371
|
+
response.status,
|
|
372
|
+
errorBody
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const data = await response.json() as {
|
|
377
|
+
data?: Array<{ embedding?: number[]; index?: number }>;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
if (!data?.data || data.data.length !== batchTexts.length) {
|
|
381
|
+
throw new OpenRouterError(
|
|
382
|
+
`Invalid API response: expected ${batchTexts.length} embeddings, got ${data?.data?.length ?? 0}`,
|
|
383
|
+
response.status,
|
|
384
|
+
JSON.stringify(data)
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Store results and cache them
|
|
389
|
+
for (let j = 0; j < data.data.length; j++) {
|
|
390
|
+
const embedding = data.data[j].embedding;
|
|
391
|
+
if (!embedding) {
|
|
392
|
+
throw new OpenRouterError(
|
|
393
|
+
`Missing embedding at index ${j}`,
|
|
394
|
+
response.status,
|
|
395
|
+
JSON.stringify(data)
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
results[batchIndices[j]] = embedding;
|
|
400
|
+
embeddingCache.set(cacheKeys[batchIndices[j]], embedding);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return; // Success
|
|
404
|
+
|
|
405
|
+
} catch (error) {
|
|
406
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
407
|
+
lastError = new OpenRouterError(
|
|
408
|
+
`Batch request timed out after ${OPENROUTER_TIMEOUT_MS * 2}ms`,
|
|
409
|
+
408
|
|
410
|
+
);
|
|
411
|
+
} else {
|
|
412
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
413
|
+
|
|
414
|
+
if (isNonRetryableError(error)) {
|
|
415
|
+
throw error;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
420
|
+
const waitTime = getBackoffDelay(attempt);
|
|
421
|
+
debug(`Batch ${batchNumber} error: ${lastError.message}, retrying in ${waitTime}ms`);
|
|
422
|
+
await sleep(waitTime);
|
|
423
|
+
}
|
|
424
|
+
} finally {
|
|
425
|
+
clearTimeout(timeoutId);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
throw new OpenRouterError(
|
|
430
|
+
`Failed to get batch ${batchNumber} embeddings after ${MAX_RETRIES} attempts: ${lastError?.message}`
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Get embedding vectors for multiple texts using concurrent batch API calls.
|
|
436
|
+
* Much faster than calling getOpenRouterEmbedding individually.
|
|
437
|
+
*
|
|
438
|
+
* @param texts - Array of input texts to embed
|
|
439
|
+
* @returns Promise resolving to array of embedding vectors
|
|
440
|
+
* @throws OpenRouterError if API call fails
|
|
441
|
+
*/
|
|
442
|
+
export async function getOpenRouterEmbeddingBatch(texts: string[]): Promise<number[][]> {
|
|
443
|
+
if (!OPENROUTER_API_KEY) {
|
|
444
|
+
throw new OpenRouterError(
|
|
445
|
+
"OPENROUTER_API_KEY environment variable is not set"
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (texts.length === 0) {
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Truncate all inputs and check cache
|
|
454
|
+
const truncatedTexts = texts.map(t => truncateForEmbedding(t));
|
|
455
|
+
const cacheKeys = truncatedTexts.map(t => getCacheKey(t));
|
|
456
|
+
|
|
457
|
+
// Separate cached and uncached
|
|
458
|
+
const results: (number[] | null)[] = new Array(texts.length).fill(null);
|
|
459
|
+
const uncachedIndices: number[] = [];
|
|
460
|
+
const uncachedTexts: string[] = [];
|
|
461
|
+
|
|
462
|
+
for (let i = 0; i < truncatedTexts.length; i++) {
|
|
463
|
+
const cached = embeddingCache.get(cacheKeys[i]);
|
|
464
|
+
if (cached) {
|
|
465
|
+
results[i] = cached;
|
|
466
|
+
} else {
|
|
467
|
+
uncachedIndices.push(i);
|
|
468
|
+
uncachedTexts.push(truncatedTexts[i]);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
debug(`Batch: ${texts.length} total, ${uncachedIndices.length} uncached`);
|
|
473
|
+
|
|
474
|
+
if (uncachedTexts.length === 0) {
|
|
475
|
+
return results as number[][];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Split into batches
|
|
479
|
+
const textBatches = chunk(uncachedTexts, BATCH_SIZE);
|
|
480
|
+
const indexBatches = chunk(uncachedIndices, BATCH_SIZE);
|
|
481
|
+
const totalBatches = textBatches.length;
|
|
482
|
+
|
|
483
|
+
debug(`Processing ${totalBatches} batches with ${CONCURRENT_BATCHES} concurrent requests`);
|
|
484
|
+
|
|
485
|
+
// Process batches with concurrency limit
|
|
486
|
+
const batchGroups = chunk(
|
|
487
|
+
textBatches.map((texts, i) => ({ texts, indices: indexBatches[i], batchNumber: i + 1 })),
|
|
488
|
+
CONCURRENT_BATCHES
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
for (const group of batchGroups) {
|
|
492
|
+
await Promise.all(
|
|
493
|
+
group.map(batch =>
|
|
494
|
+
processSingleBatch(
|
|
495
|
+
batch.texts,
|
|
496
|
+
batch.indices,
|
|
497
|
+
cacheKeys,
|
|
498
|
+
results,
|
|
499
|
+
batch.batchNumber,
|
|
500
|
+
totalBatches
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return results as number[][];
|
|
507
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// src/errors/index.test.ts
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
NoteNotFoundError,
|
|
5
|
+
ReadOnlyModeError,
|
|
6
|
+
DuplicateNoteError,
|
|
7
|
+
FolderNotFoundError,
|
|
8
|
+
TableOutOfBoundsError,
|
|
9
|
+
} from "./index.js";
|
|
10
|
+
|
|
11
|
+
describe("Error Classes", () => {
|
|
12
|
+
describe("NoteNotFoundError", () => {
|
|
13
|
+
it("should have correct name and message", () => {
|
|
14
|
+
const error = new NoteNotFoundError("My Note");
|
|
15
|
+
expect(error.name).toBe("NoteNotFoundError");
|
|
16
|
+
expect(error.message).toBe('Note not found: "My Note"');
|
|
17
|
+
expect(error.title).toBe("My Note");
|
|
18
|
+
expect(error instanceof Error).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("ReadOnlyModeError", () => {
|
|
23
|
+
it("should have correct name and message", () => {
|
|
24
|
+
const error = new ReadOnlyModeError();
|
|
25
|
+
expect(error.name).toBe("ReadOnlyModeError");
|
|
26
|
+
expect(error.message).toBe("Operation disabled in read-only mode");
|
|
27
|
+
expect(error instanceof Error).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("DuplicateNoteError", () => {
|
|
32
|
+
it("should have correct name and suggestions", () => {
|
|
33
|
+
const suggestions = [
|
|
34
|
+
{ id: "id-1", folder: "Work", title: "Note", created: "2026-01-09T10:00:00.000Z" },
|
|
35
|
+
{ id: "id-2", folder: "Personal", title: "Note", created: "2026-01-09T11:00:00.000Z" },
|
|
36
|
+
];
|
|
37
|
+
const error = new DuplicateNoteError("Note", suggestions);
|
|
38
|
+
expect(error.name).toBe("DuplicateNoteError");
|
|
39
|
+
expect(error.title).toBe("Note");
|
|
40
|
+
expect(error.suggestions).toEqual(suggestions);
|
|
41
|
+
expect(error.message).toContain("Multiple notes found");
|
|
42
|
+
expect(error.message).toContain("id:id-1");
|
|
43
|
+
expect(error.message).toContain("id:id-2");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("FolderNotFoundError", () => {
|
|
48
|
+
it("should have correct name and message", () => {
|
|
49
|
+
const error = new FolderNotFoundError("Work");
|
|
50
|
+
expect(error.name).toBe("FolderNotFoundError");
|
|
51
|
+
expect(error.folder).toBe("Work");
|
|
52
|
+
expect(error.message).toBe('Folder not found: "Work"');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("TableOutOfBoundsError", () => {
|
|
57
|
+
it("should have correct name and message", () => {
|
|
58
|
+
const error = new TableOutOfBoundsError("Row 5 out of bounds (table has 3 rows)");
|
|
59
|
+
expect(error.name).toBe("TableOutOfBoundsError");
|
|
60
|
+
expect(error.message).toBe("Row 5 out of bounds (table has 3 rows)");
|
|
61
|
+
expect(error instanceof Error).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|