virlow-mcp 3.11.4
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/LICENSE +21 -0
- package/README.md +293 -0
- package/dist/cli.js +2030 -0
- package/package.json +46 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2030 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import * as os2 from "node:os";
|
|
5
|
+
import * as path4 from "node:path";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
|
|
8
|
+
// ../../packages/mcp-core/dist/api.js
|
|
9
|
+
var ApiError = class extends Error {
|
|
10
|
+
status;
|
|
11
|
+
constructor(status, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.name = "ApiError";
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
var VirlowApi = class {
|
|
18
|
+
baseUrl;
|
|
19
|
+
tokens = null;
|
|
20
|
+
refreshInFlight = null;
|
|
21
|
+
onTokensRotated;
|
|
22
|
+
constructor(baseUrl) {
|
|
23
|
+
this.baseUrl = baseUrl;
|
|
24
|
+
}
|
|
25
|
+
setTokens(tokens) {
|
|
26
|
+
this.tokens = tokens;
|
|
27
|
+
}
|
|
28
|
+
async request(method, path5, body, opts = {}) {
|
|
29
|
+
const { auth = true, retryOn401 = true } = opts;
|
|
30
|
+
const headers = { "content-type": "application/json" };
|
|
31
|
+
if (auth && this.tokens)
|
|
32
|
+
headers.authorization = `Bearer ${this.tokens.accessToken}`;
|
|
33
|
+
const res = await fetch(`${this.baseUrl}${path5}`, {
|
|
34
|
+
method,
|
|
35
|
+
headers,
|
|
36
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
37
|
+
});
|
|
38
|
+
if (res.status === 401 && auth && retryOn401 && this.tokens) {
|
|
39
|
+
await this.refresh();
|
|
40
|
+
return this.request(method, path5, body, { auth, retryOn401: false });
|
|
41
|
+
}
|
|
42
|
+
if (res.status === 204)
|
|
43
|
+
return void 0;
|
|
44
|
+
const data = await res.json().catch(() => ({}));
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
const d = data;
|
|
47
|
+
throw new ApiError(res.status, d.error ?? d.message ?? res.statusText);
|
|
48
|
+
}
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
refresh() {
|
|
52
|
+
this.refreshInFlight ??= this.doRefresh().finally(() => {
|
|
53
|
+
this.refreshInFlight = null;
|
|
54
|
+
});
|
|
55
|
+
return this.refreshInFlight;
|
|
56
|
+
}
|
|
57
|
+
async doRefresh() {
|
|
58
|
+
if (!this.tokens)
|
|
59
|
+
throw new ApiError(401, "Not authenticated");
|
|
60
|
+
const rotated = await this.request("POST", "/api/auth/refresh", { refreshToken: this.tokens.refreshToken }, { auth: false, retryOn401: false });
|
|
61
|
+
this.tokens = { accessToken: rotated.accessToken, refreshToken: rotated.refreshToken };
|
|
62
|
+
this.onTokensRotated?.(this.tokens);
|
|
63
|
+
}
|
|
64
|
+
login(email, password) {
|
|
65
|
+
return this.request("POST", "/api/auth/login", { email, password }, { auth: false });
|
|
66
|
+
}
|
|
67
|
+
verify2fa(mfaToken, codeOrRecovery) {
|
|
68
|
+
return this.request("POST", "/api/auth/2fa/verify", { mfaToken, ...codeOrRecovery }, { auth: false });
|
|
69
|
+
}
|
|
70
|
+
me() {
|
|
71
|
+
return this.request("GET", "/api/users/me");
|
|
72
|
+
}
|
|
73
|
+
encryptionStatus() {
|
|
74
|
+
return this.request("GET", "/api/users/me/encryption");
|
|
75
|
+
}
|
|
76
|
+
listNotes(params) {
|
|
77
|
+
const query = new URLSearchParams();
|
|
78
|
+
if (params.page !== void 0)
|
|
79
|
+
query.set("page", String(params.page));
|
|
80
|
+
if (params.limit !== void 0)
|
|
81
|
+
query.set("limit", String(params.limit));
|
|
82
|
+
if (params.folderId !== void 0)
|
|
83
|
+
query.set("folderId", params.folderId);
|
|
84
|
+
if (params.starred !== void 0)
|
|
85
|
+
query.set("starred", String(params.starred));
|
|
86
|
+
if (params.archived !== void 0)
|
|
87
|
+
query.set("archived", String(params.archived));
|
|
88
|
+
if (params.deleted !== void 0)
|
|
89
|
+
query.set("deleted", String(params.deleted));
|
|
90
|
+
const qs = query.toString();
|
|
91
|
+
return this.request("GET", `/api/notes${qs ? `?${qs}` : ""}`);
|
|
92
|
+
}
|
|
93
|
+
getNote(id) {
|
|
94
|
+
return this.request("GET", `/api/notes/${id}`);
|
|
95
|
+
}
|
|
96
|
+
createNote(body) {
|
|
97
|
+
return this.request("POST", "/api/notes", body);
|
|
98
|
+
}
|
|
99
|
+
updateNote(id, body) {
|
|
100
|
+
return this.request("PUT", `/api/notes/${id}`, body);
|
|
101
|
+
}
|
|
102
|
+
listFolders() {
|
|
103
|
+
return this.request("GET", "/api/folders");
|
|
104
|
+
}
|
|
105
|
+
createFolder(name, parentId, kind) {
|
|
106
|
+
const body = {
|
|
107
|
+
name,
|
|
108
|
+
parentId
|
|
109
|
+
};
|
|
110
|
+
if (kind !== void 0)
|
|
111
|
+
body.kind = kind;
|
|
112
|
+
return this.request("POST", "/api/folders", body);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ../../packages/mcp-core/dist/vault.js
|
|
117
|
+
var LockedError = class extends Error {
|
|
118
|
+
};
|
|
119
|
+
var Vault = class {
|
|
120
|
+
autoLockMs;
|
|
121
|
+
_session = null;
|
|
122
|
+
_timerHandle = null;
|
|
123
|
+
/** Listeners registered via `onLock =` or `addLockListener()`. Multiple
|
|
124
|
+
* consumers (MemoryStore, NotesService, ...) each need their cache cleared
|
|
125
|
+
* on lock; a single-slot callback would let the second registration
|
|
126
|
+
* silently clobber the first. */
|
|
127
|
+
lockListeners = [];
|
|
128
|
+
constructor(autoLockMs = 4 * 60 * 60 * 1e3) {
|
|
129
|
+
this.autoLockMs = autoLockMs;
|
|
130
|
+
}
|
|
131
|
+
/** Alias for `addLockListener` kept for call-site brevity (`vault.onLock = fn`).
|
|
132
|
+
* Assigning multiple times ADDS a listener each time — it does not replace
|
|
133
|
+
* a previous one. */
|
|
134
|
+
set onLock(fn) {
|
|
135
|
+
this.addLockListener(fn);
|
|
136
|
+
}
|
|
137
|
+
addLockListener(fn) {
|
|
138
|
+
this.lockListeners.push(fn);
|
|
139
|
+
}
|
|
140
|
+
setUnlocked(session) {
|
|
141
|
+
this._session = session;
|
|
142
|
+
this._scheduleAutoLock();
|
|
143
|
+
}
|
|
144
|
+
lock() {
|
|
145
|
+
if (this._session === null) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
this._clearTimer();
|
|
149
|
+
this._session = null;
|
|
150
|
+
for (const listener of this.lockListeners) {
|
|
151
|
+
listener();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
isUnlocked() {
|
|
155
|
+
return this._session !== null;
|
|
156
|
+
}
|
|
157
|
+
status() {
|
|
158
|
+
if (this._session === null) {
|
|
159
|
+
return { locked: true };
|
|
160
|
+
}
|
|
161
|
+
return { locked: false, email: this._session.email };
|
|
162
|
+
}
|
|
163
|
+
touch() {
|
|
164
|
+
if (this._session === null) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
this._clearTimer();
|
|
168
|
+
this._scheduleAutoLock();
|
|
169
|
+
}
|
|
170
|
+
get session() {
|
|
171
|
+
if (this._session === null) {
|
|
172
|
+
throw new LockedError("Vault is locked. Ask the user to run the unlock tool to sign in and unlock their Virlow vault.");
|
|
173
|
+
}
|
|
174
|
+
return this._session;
|
|
175
|
+
}
|
|
176
|
+
_scheduleAutoLock() {
|
|
177
|
+
this._timerHandle = setTimeout(() => {
|
|
178
|
+
this.lock();
|
|
179
|
+
}, this.autoLockMs);
|
|
180
|
+
this._timerHandle.unref?.();
|
|
181
|
+
}
|
|
182
|
+
_clearTimer() {
|
|
183
|
+
if (this._timerHandle !== null) {
|
|
184
|
+
clearTimeout(this._timerHandle);
|
|
185
|
+
this._timerHandle = null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// ../../packages/crypto/dist/base64.js
|
|
191
|
+
var CHUNK_SIZE = 32768;
|
|
192
|
+
function bytesToBase64(input) {
|
|
193
|
+
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
|
|
194
|
+
const chunks = [];
|
|
195
|
+
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
|
|
196
|
+
const chunk = bytes.subarray(i, Math.min(i + CHUNK_SIZE, bytes.length));
|
|
197
|
+
chunks.push(String.fromCharCode.apply(null, Array.from(chunk)));
|
|
198
|
+
}
|
|
199
|
+
return btoa(chunks.join(""));
|
|
200
|
+
}
|
|
201
|
+
function base64ToBytes(base64) {
|
|
202
|
+
const binary = atob(base64);
|
|
203
|
+
const bytes = new Uint8Array(binary.length);
|
|
204
|
+
for (let i = 0; i < binary.length; i++) {
|
|
205
|
+
bytes[i] = binary.charCodeAt(i);
|
|
206
|
+
}
|
|
207
|
+
return bytes;
|
|
208
|
+
}
|
|
209
|
+
function base64ToArrayBuffer(base64) {
|
|
210
|
+
const bytes = base64ToBytes(base64);
|
|
211
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ../../packages/crypto/dist/constants.js
|
|
215
|
+
var ENCRYPTION_CONFIG = {
|
|
216
|
+
ALGORITHM: "AES-GCM",
|
|
217
|
+
KEY_LENGTH: 256,
|
|
218
|
+
IV_LENGTH: 16,
|
|
219
|
+
ITERATIONS: 25e4,
|
|
220
|
+
SALT_LENGTH: 32
|
|
221
|
+
};
|
|
222
|
+
var VERIFIER_CONSTANT = "virlow-mp-verifier-v1";
|
|
223
|
+
|
|
224
|
+
// ../../packages/crypto/dist/blob.js
|
|
225
|
+
async function encryptBytesBlob(key, bytes) {
|
|
226
|
+
const iv = crypto.getRandomValues(new Uint8Array(ENCRYPTION_CONFIG.IV_LENGTH));
|
|
227
|
+
const ciphertext = await crypto.subtle.encrypt({ name: ENCRYPTION_CONFIG.ALGORITHM, iv }, key, bytes);
|
|
228
|
+
return "v2:" + bytesToBase64(iv) + ":" + bytesToBase64(ciphertext);
|
|
229
|
+
}
|
|
230
|
+
async function decryptBytesBlob(key, blob) {
|
|
231
|
+
if (!blob.startsWith("v2:")) {
|
|
232
|
+
throw new Error("Malformed encrypted blob: missing v2 prefix");
|
|
233
|
+
}
|
|
234
|
+
const rest = blob.slice(3);
|
|
235
|
+
const sep = rest.indexOf(":");
|
|
236
|
+
if (sep === -1) {
|
|
237
|
+
throw new Error("Malformed encrypted blob: missing IV separator");
|
|
238
|
+
}
|
|
239
|
+
const iv = base64ToBytes(rest.slice(0, sep));
|
|
240
|
+
const decrypted = await crypto.subtle.decrypt({ name: ENCRYPTION_CONFIG.ALGORITHM, iv }, key, base64ToArrayBuffer(rest.slice(sep + 1)));
|
|
241
|
+
return new Uint8Array(decrypted);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ../../packages/crypto/dist/keys.js
|
|
245
|
+
async function deriveMasterKey(masterPassword, userId) {
|
|
246
|
+
if (!userId) {
|
|
247
|
+
throw new Error("deriveMasterKey requires a userId");
|
|
248
|
+
}
|
|
249
|
+
const encoder = new TextEncoder();
|
|
250
|
+
const passwordBytes = typeof masterPassword === "string" ? encoder.encode(masterPassword) : masterPassword;
|
|
251
|
+
const userSalt = encoder.encode(`typelets-salt-${userId}-v1`);
|
|
252
|
+
const keyMaterial = await crypto.subtle.importKey("raw", passwordBytes, { name: "PBKDF2" }, false, ["deriveKey"]);
|
|
253
|
+
const key = await crypto.subtle.deriveKey({
|
|
254
|
+
name: "PBKDF2",
|
|
255
|
+
salt: userSalt,
|
|
256
|
+
iterations: ENCRYPTION_CONFIG.ITERATIONS,
|
|
257
|
+
hash: "SHA-256"
|
|
258
|
+
}, keyMaterial, {
|
|
259
|
+
name: ENCRYPTION_CONFIG.ALGORITHM,
|
|
260
|
+
length: ENCRYPTION_CONFIG.KEY_LENGTH
|
|
261
|
+
}, true, ["encrypt", "decrypt"]);
|
|
262
|
+
const exportedKey = await crypto.subtle.exportKey("raw", key);
|
|
263
|
+
return { key, keyString: bytesToBase64(new Uint8Array(exportedKey)) };
|
|
264
|
+
}
|
|
265
|
+
async function importMasterKey(keyString) {
|
|
266
|
+
const keyData = base64ToBytes(keyString);
|
|
267
|
+
return crypto.subtle.importKey("raw", keyData, { name: ENCRYPTION_CONFIG.ALGORITHM }, false, ["encrypt", "decrypt"]);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ../../packages/crypto/dist/notes.js
|
|
271
|
+
async function encryptNoteFields(key, title, content) {
|
|
272
|
+
const ivTitle = crypto.getRandomValues(new Uint8Array(ENCRYPTION_CONFIG.IV_LENGTH));
|
|
273
|
+
const ivContent = crypto.getRandomValues(new Uint8Array(ENCRYPTION_CONFIG.IV_LENGTH));
|
|
274
|
+
const encoder = new TextEncoder();
|
|
275
|
+
const encryptedTitleBuffer = await crypto.subtle.encrypt({ name: ENCRYPTION_CONFIG.ALGORITHM, iv: ivTitle }, key, encoder.encode(title || ""));
|
|
276
|
+
const encryptedContentBuffer = await crypto.subtle.encrypt({ name: ENCRYPTION_CONFIG.ALGORITHM, iv: ivContent }, key, encoder.encode(content || ""));
|
|
277
|
+
return {
|
|
278
|
+
encryptedTitle: bytesToBase64(encryptedTitleBuffer),
|
|
279
|
+
encryptedContent: "v2:" + bytesToBase64(ivContent) + ":" + bytesToBase64(encryptedContentBuffer),
|
|
280
|
+
iv: bytesToBase64(ivTitle)
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
async function decryptNoteFields(key, encryptedTitle, encryptedContent, ivBase64) {
|
|
284
|
+
const ivTitle = base64ToBytes(ivBase64);
|
|
285
|
+
let ivContent = ivTitle;
|
|
286
|
+
let contentCipher = encryptedContent;
|
|
287
|
+
if (encryptedContent.startsWith("v2:")) {
|
|
288
|
+
const rest = encryptedContent.slice(3);
|
|
289
|
+
const sep = rest.indexOf(":");
|
|
290
|
+
ivContent = base64ToBytes(rest.slice(0, sep));
|
|
291
|
+
contentCipher = rest.slice(sep + 1);
|
|
292
|
+
}
|
|
293
|
+
const decryptedTitleBuffer = await crypto.subtle.decrypt({ name: ENCRYPTION_CONFIG.ALGORITHM, iv: ivTitle }, key, base64ToArrayBuffer(encryptedTitle));
|
|
294
|
+
const decryptedContentBuffer = await crypto.subtle.decrypt({ name: ENCRYPTION_CONFIG.ALGORITHM, iv: ivContent }, key, base64ToArrayBuffer(contentCipher));
|
|
295
|
+
const decoder = new TextDecoder();
|
|
296
|
+
return {
|
|
297
|
+
title: decoder.decode(decryptedTitleBuffer),
|
|
298
|
+
content: decoder.decode(decryptedContentBuffer)
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ../../packages/crypto/dist/verifier.js
|
|
303
|
+
async function verifyVerifierBlob(key, blob) {
|
|
304
|
+
try {
|
|
305
|
+
const iv = base64ToBytes(blob.iv);
|
|
306
|
+
const decrypted = await crypto.subtle.decrypt({ name: ENCRYPTION_CONFIG.ALGORITHM, iv }, key, base64ToArrayBuffer(blob.ciphertext));
|
|
307
|
+
return new TextDecoder().decode(decrypted) === VERIFIER_CONSTANT;
|
|
308
|
+
} catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async function canDecryptWithKey(key, ciphertextBase64, ivBase64) {
|
|
313
|
+
try {
|
|
314
|
+
const iv = base64ToBytes(ivBase64);
|
|
315
|
+
await crypto.subtle.decrypt({ name: ENCRYPTION_CONFIG.ALGORITHM, iv }, key, base64ToArrayBuffer(ciphertextBase64));
|
|
316
|
+
return true;
|
|
317
|
+
} catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ../../packages/mcp-core/dist/exposure.js
|
|
323
|
+
function computeExposure(folders, opts = {}) {
|
|
324
|
+
const byId = new Map(folders.map((f) => [f.id, f]));
|
|
325
|
+
const memoriesRootId = opts.memoriesRootId ?? folders.find((f) => f.kind === "memories")?.id ?? null;
|
|
326
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
327
|
+
const isExposed = (folderId) => {
|
|
328
|
+
const cached = resolved.get(folderId);
|
|
329
|
+
if (cached !== void 0)
|
|
330
|
+
return cached;
|
|
331
|
+
const chain = [];
|
|
332
|
+
const seen = /* @__PURE__ */ new Set();
|
|
333
|
+
let current = folderId;
|
|
334
|
+
let exposed = false;
|
|
335
|
+
while (current !== null && !seen.has(current)) {
|
|
336
|
+
seen.add(current);
|
|
337
|
+
const folder = byId.get(current);
|
|
338
|
+
if (folder === void 0)
|
|
339
|
+
break;
|
|
340
|
+
chain.push(folder.id);
|
|
341
|
+
if (folder.mcpEnabled === true || folder.id === memoriesRootId) {
|
|
342
|
+
exposed = true;
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
current = folder.parentId;
|
|
346
|
+
}
|
|
347
|
+
for (const id of chain)
|
|
348
|
+
resolved.set(id, exposed);
|
|
349
|
+
return exposed;
|
|
350
|
+
};
|
|
351
|
+
let hiddenFolderCount = 0;
|
|
352
|
+
for (const folder of folders) {
|
|
353
|
+
if (!isExposed(folder.id))
|
|
354
|
+
hiddenFolderCount++;
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
// A note in no folder is in nothing the user has opened, so it stays
|
|
358
|
+
// hidden. That is consistent with off-by-default, and the tools say so
|
|
359
|
+
// rather than letting it look like the note is gone.
|
|
360
|
+
allows: (folderId) => folderId === null ? false : isExposed(folderId),
|
|
361
|
+
hiddenFolderCount,
|
|
362
|
+
allHidden: folders.length > 0 && hiddenFolderCount === folders.length
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
function filteredNotice(hidden, exposure) {
|
|
366
|
+
const parts = [];
|
|
367
|
+
if (hidden.inClosedFolders > 0) {
|
|
368
|
+
const n = hidden.inClosedFolders;
|
|
369
|
+
const notes = `${n} note${n === 1 ? "" : "s"}`;
|
|
370
|
+
parts.push(exposure.allHidden ? `${notes} hidden: no folder is exposed to MCP yet. Open a folder in the Virlow app, edit it, and turn on "Expose to MCP" to let AI tools read it.` : `${notes} hidden in folders that are not exposed to MCP. Turn on "Expose to MCP" in a folder's settings in the Virlow app to include it.`);
|
|
371
|
+
}
|
|
372
|
+
if (hidden.unfiled > 0) {
|
|
373
|
+
const n = hidden.unfiled;
|
|
374
|
+
parts.push(`${n} note${n === 1 ? "" : "s"} hidden because ${n === 1 ? "it is" : "they are"} not in any folder, and exposure is granted per folder. Move ${n === 1 ? "it" : "them"} into a folder that is exposed to MCP to include ${n === 1 ? "it" : "them"}.`);
|
|
375
|
+
}
|
|
376
|
+
return parts.length === 0 ? "" : `
|
|
377
|
+
|
|
378
|
+
(${parts.join(" ")})`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ../../packages/mcp-core/dist/notes.js
|
|
382
|
+
var SEARCH_SCAN_CAP = 500;
|
|
383
|
+
var SEARCH_PAGE_LIMIT = 50;
|
|
384
|
+
var SNIPPET_RADIUS = 80;
|
|
385
|
+
var NotesService = class {
|
|
386
|
+
api;
|
|
387
|
+
vault;
|
|
388
|
+
/** Per-unlock cache of decrypted (title, content) keyed by `id:updatedAt`
|
|
389
|
+
* so re-reading/re-scanning the same note twice in one unlock doesn't
|
|
390
|
+
* re-run AES-GCM decryption. Cleared whenever the vault locks. */
|
|
391
|
+
decryptCache = /* @__PURE__ */ new Map();
|
|
392
|
+
/** Bumped every time the vault locks (see handleLock()). decryptRow()
|
|
393
|
+
* captures this before its await on decryptNoteFields() and re-checks it
|
|
394
|
+
* afterward, so a decrypt that was still in flight when the vault locked
|
|
395
|
+
* never writes decrypted plaintext into the cache after the lock. */
|
|
396
|
+
epoch = 0;
|
|
397
|
+
/** Serializes updateNote() calls so two overlapping partial edits to the
|
|
398
|
+
* same note (e.g. one changing title, one changing content) never race
|
|
399
|
+
* their read-merge-encrypt-write cycle — without this, both could read
|
|
400
|
+
* the same pre-edit row and the second write would silently clobber the
|
|
401
|
+
* first's change. Mirrors MemoryStore's enqueue()/mutationChain pattern. */
|
|
402
|
+
mutationChain = Promise.resolve();
|
|
403
|
+
constructor(api, vault) {
|
|
404
|
+
this.api = api;
|
|
405
|
+
this.vault = vault;
|
|
406
|
+
this.vault.onLock = () => this.handleLock();
|
|
407
|
+
}
|
|
408
|
+
handleLock() {
|
|
409
|
+
this.decryptCache.clear();
|
|
410
|
+
this.epoch++;
|
|
411
|
+
}
|
|
412
|
+
async listNotes(params) {
|
|
413
|
+
const key = this.requireSession();
|
|
414
|
+
const { notes, pagination } = await this.api.listNotes({ ...params, deleted: false });
|
|
415
|
+
const exposure = await this.exposure();
|
|
416
|
+
const notHidden = notes.filter((row) => row.hidden !== true);
|
|
417
|
+
const visible = notHidden.filter((row) => exposure.allows(row.folderId));
|
|
418
|
+
const withheld = {
|
|
419
|
+
inClosedFolders: notHidden.filter((row) => row.folderId !== null && !exposure.allows(row.folderId)).length,
|
|
420
|
+
unfiled: notHidden.filter((row) => row.folderId === null).length
|
|
421
|
+
};
|
|
422
|
+
const mapped = await Promise.all(visible.map(async (row) => {
|
|
423
|
+
const { title } = await this.decryptRow(key, row);
|
|
424
|
+
return {
|
|
425
|
+
id: row.id,
|
|
426
|
+
title,
|
|
427
|
+
folderId: row.folderId,
|
|
428
|
+
starred: row.starred === true,
|
|
429
|
+
updatedAt: row.updatedAt
|
|
430
|
+
};
|
|
431
|
+
}));
|
|
432
|
+
return { notes: mapped, pagination, withheld, exposure };
|
|
433
|
+
}
|
|
434
|
+
async readNote(id) {
|
|
435
|
+
const key = this.requireSession();
|
|
436
|
+
const row = await this.api.getNote(id);
|
|
437
|
+
const exposure = await this.exposure();
|
|
438
|
+
if (!exposure.allows(row.folderId)) {
|
|
439
|
+
throw new Error(`Note ${id} is in a folder that is not exposed to MCP. Turn on "Expose to MCP" in that folder's settings in the Virlow app to read it here.`);
|
|
440
|
+
}
|
|
441
|
+
const { title, content } = await this.decryptRow(key, row);
|
|
442
|
+
return { id: row.id, title, content, folderId: row.folderId };
|
|
443
|
+
}
|
|
444
|
+
async createNote(title, content, folderId, type) {
|
|
445
|
+
const key = this.requireSession();
|
|
446
|
+
const salt = bytesToBase64(crypto.getRandomValues(new Uint8Array(32)));
|
|
447
|
+
const { encryptedTitle, encryptedContent, iv } = await encryptNoteFields(key, title, content);
|
|
448
|
+
const body = {
|
|
449
|
+
title: "[ENCRYPTED]",
|
|
450
|
+
content: "[ENCRYPTED]",
|
|
451
|
+
encryptedTitle,
|
|
452
|
+
encryptedContent,
|
|
453
|
+
iv,
|
|
454
|
+
salt
|
|
455
|
+
};
|
|
456
|
+
if (folderId !== void 0)
|
|
457
|
+
body.folderId = folderId;
|
|
458
|
+
if (type !== void 0)
|
|
459
|
+
body.type = type;
|
|
460
|
+
const row = await this.api.createNote(body);
|
|
461
|
+
return { id: row.id };
|
|
462
|
+
}
|
|
463
|
+
updateNote(id, changes) {
|
|
464
|
+
return this.enqueue(async () => {
|
|
465
|
+
const key = this.requireSession();
|
|
466
|
+
const existingRow = await this.api.getNote(id);
|
|
467
|
+
const current = await this.decryptRow(key, existingRow);
|
|
468
|
+
const title = changes.title ?? current.title;
|
|
469
|
+
const content = changes.content ?? current.content;
|
|
470
|
+
const salt = bytesToBase64(crypto.getRandomValues(new Uint8Array(32)));
|
|
471
|
+
const { encryptedTitle, encryptedContent, iv } = await encryptNoteFields(key, title, content);
|
|
472
|
+
await this.api.updateNote(id, {
|
|
473
|
+
title: "[ENCRYPTED]",
|
|
474
|
+
content: "[ENCRYPTED]",
|
|
475
|
+
encryptedTitle,
|
|
476
|
+
encryptedContent,
|
|
477
|
+
iv,
|
|
478
|
+
salt
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
async searchNotes(query, limit = 20) {
|
|
483
|
+
const key = this.requireSession();
|
|
484
|
+
const q = query.toLowerCase();
|
|
485
|
+
const results = [];
|
|
486
|
+
let scanned = 0;
|
|
487
|
+
let total = 0;
|
|
488
|
+
let page = 1;
|
|
489
|
+
scan: for (; ; ) {
|
|
490
|
+
const { notes, pagination } = await this.api.listNotes({ page, limit: SEARCH_PAGE_LIMIT, deleted: false });
|
|
491
|
+
total = pagination.total;
|
|
492
|
+
for (const row of notes) {
|
|
493
|
+
if (row.hidden === true)
|
|
494
|
+
continue;
|
|
495
|
+
if (scanned >= SEARCH_SCAN_CAP)
|
|
496
|
+
break scan;
|
|
497
|
+
scanned++;
|
|
498
|
+
const { title, content } = await this.decryptRow(key, row);
|
|
499
|
+
const contentIdx = content.toLowerCase().indexOf(q);
|
|
500
|
+
const titleMatch = title.toLowerCase().includes(q);
|
|
501
|
+
if (titleMatch || contentIdx !== -1) {
|
|
502
|
+
results.push({ id: row.id, title, snippet: this.buildSnippet(title, content, contentIdx) });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (scanned >= SEARCH_SCAN_CAP)
|
|
506
|
+
break;
|
|
507
|
+
if (notes.length === 0 || page >= pagination.pages)
|
|
508
|
+
break;
|
|
509
|
+
page++;
|
|
510
|
+
}
|
|
511
|
+
return { results: results.slice(0, limit), scanned, truncated: total > SEARCH_SCAN_CAP };
|
|
512
|
+
}
|
|
513
|
+
async moveNote(id, folderId) {
|
|
514
|
+
this.requireSession();
|
|
515
|
+
await this.api.updateNote(id, { folderId });
|
|
516
|
+
}
|
|
517
|
+
async setStar(id, starred) {
|
|
518
|
+
this.requireSession();
|
|
519
|
+
await this.api.updateNote(id, { starred });
|
|
520
|
+
}
|
|
521
|
+
async archiveNote(id, archived) {
|
|
522
|
+
this.requireSession();
|
|
523
|
+
await this.api.updateNote(id, { archived });
|
|
524
|
+
}
|
|
525
|
+
async trashNote(id) {
|
|
526
|
+
this.requireSession();
|
|
527
|
+
await this.api.updateNote(id, { deleted: true });
|
|
528
|
+
}
|
|
529
|
+
async listFolders() {
|
|
530
|
+
this.requireSession();
|
|
531
|
+
const { folders } = await this.api.listFolders();
|
|
532
|
+
const exposure = computeExposure(folders);
|
|
533
|
+
const visible = folders.filter((f) => exposure.allows(f.id));
|
|
534
|
+
return {
|
|
535
|
+
// Names are withheld along with contents: a folder the user has not
|
|
536
|
+
// opened should not be inventoried either, only counted.
|
|
537
|
+
folders: visible.map((f) => ({ id: f.id, name: f.name, parentId: f.parentId })),
|
|
538
|
+
withheld: folders.length - visible.length
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
/** Folder exposure for this request. Not cached across calls: the user can
|
|
542
|
+
* flip the switch in the app at any moment and the next tool call must
|
|
543
|
+
* respect it. */
|
|
544
|
+
async exposure() {
|
|
545
|
+
const { folders } = await this.api.listFolders();
|
|
546
|
+
return computeExposure(folders);
|
|
547
|
+
}
|
|
548
|
+
async createFolder(name, parentId) {
|
|
549
|
+
this.requireSession();
|
|
550
|
+
const folder = await this.api.createFolder(name, parentId);
|
|
551
|
+
return { id: folder.id };
|
|
552
|
+
}
|
|
553
|
+
/** Decrypt (with per-unlock caching) or pass through a legacy plaintext
|
|
554
|
+
* row. Legacy rows (no encryptedTitle) carry their real content directly
|
|
555
|
+
* in the plaintext title/content columns. */
|
|
556
|
+
async decryptRow(key, row) {
|
|
557
|
+
if (!row.encryptedTitle) {
|
|
558
|
+
return { title: row.title, content: row.content };
|
|
559
|
+
}
|
|
560
|
+
const cacheKey = `${row.id}:${row.updatedAt}`;
|
|
561
|
+
const cached = this.decryptCache.get(cacheKey);
|
|
562
|
+
if (cached)
|
|
563
|
+
return cached;
|
|
564
|
+
const startEpoch = this.epoch;
|
|
565
|
+
const fields = await decryptNoteFields(key, row.encryptedTitle, row.encryptedContent ?? "", row.iv ?? "");
|
|
566
|
+
if (this.epoch === startEpoch) {
|
|
567
|
+
this.decryptCache.set(cacheKey, fields);
|
|
568
|
+
}
|
|
569
|
+
return fields;
|
|
570
|
+
}
|
|
571
|
+
buildSnippet(title, content, contentMatchIndex) {
|
|
572
|
+
if (contentMatchIndex === -1) {
|
|
573
|
+
return title;
|
|
574
|
+
}
|
|
575
|
+
const windowSize = SNIPPET_RADIUS * 2;
|
|
576
|
+
let start = Math.max(0, contentMatchIndex - SNIPPET_RADIUS);
|
|
577
|
+
const end = Math.min(content.length, start + windowSize);
|
|
578
|
+
if (end - start < windowSize) {
|
|
579
|
+
start = Math.max(0, end - windowSize);
|
|
580
|
+
}
|
|
581
|
+
return content.slice(start, end);
|
|
582
|
+
}
|
|
583
|
+
requireSession() {
|
|
584
|
+
const key = this.vault.session.key;
|
|
585
|
+
this.vault.touch();
|
|
586
|
+
return key;
|
|
587
|
+
}
|
|
588
|
+
/** Chains `fn` onto the mutation queue so overlapping calls run one at a
|
|
589
|
+
* time; the `.catch(() => {})` guard keeps the internal chain alive after
|
|
590
|
+
* a rejection while the promise returned to the caller still carries it. */
|
|
591
|
+
enqueue(fn) {
|
|
592
|
+
const next = this.mutationChain.then(fn, fn);
|
|
593
|
+
this.mutationChain = next.catch(() => {
|
|
594
|
+
});
|
|
595
|
+
return next;
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// ../../packages/memory-core/dist/constants.js
|
|
600
|
+
var EMBEDDING_MODEL_ID = "Xenova/all-MiniLM-L6-v2";
|
|
601
|
+
var EMBEDDING_MODEL_REVISION = "751bff37182d3f1213fa05d7196b954e230abad9";
|
|
602
|
+
var EMBEDDING_MODEL_TAG = "minilm-l6-v2@q8";
|
|
603
|
+
var DEDUPE_UPDATE_THRESHOLD = 0.9;
|
|
604
|
+
var DEDUPE_REPORT_THRESHOLD = 0.7;
|
|
605
|
+
|
|
606
|
+
// ../../packages/memory-core/dist/vectors.js
|
|
607
|
+
function cosineSimilarity(a, b) {
|
|
608
|
+
if (a.length !== b.length) {
|
|
609
|
+
throw new Error(`Embedding dimension mismatch: ${a.length} vs ${b.length}`);
|
|
610
|
+
}
|
|
611
|
+
let dot = 0;
|
|
612
|
+
let normA = 0;
|
|
613
|
+
let normB = 0;
|
|
614
|
+
for (let i = 0; i < a.length; i++) {
|
|
615
|
+
dot += a[i] * b[i];
|
|
616
|
+
normA += a[i] * a[i];
|
|
617
|
+
normB += b[i] * b[i];
|
|
618
|
+
}
|
|
619
|
+
if (normA === 0 || normB === 0)
|
|
620
|
+
return 0;
|
|
621
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
622
|
+
}
|
|
623
|
+
function rankBySimilarity(query, entries, limit) {
|
|
624
|
+
return entries.map((e) => ({ id: e.id, similarity: cosineSimilarity(query, e.embedding) })).sort((x, y) => y.similarity - x.similarity).slice(0, limit);
|
|
625
|
+
}
|
|
626
|
+
function findDuplicate(embedding, entries, threshold = DEDUPE_UPDATE_THRESHOLD) {
|
|
627
|
+
const [best] = rankBySimilarity(embedding, entries, 1);
|
|
628
|
+
return best && best.similarity >= threshold ? best : null;
|
|
629
|
+
}
|
|
630
|
+
function findSimilar(embedding, entries, threshold = DEDUPE_REPORT_THRESHOLD) {
|
|
631
|
+
return rankBySimilarity(embedding, entries, entries.length).filter((e) => e.similarity >= threshold);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ../../packages/memory-core/dist/embedder.js
|
|
635
|
+
import { env, pipeline } from "@huggingface/transformers";
|
|
636
|
+
async function createEmbedder(opts = {}) {
|
|
637
|
+
if (opts.cacheDir) {
|
|
638
|
+
env.cacheDir = opts.cacheDir;
|
|
639
|
+
}
|
|
640
|
+
if (opts.localModelPath) {
|
|
641
|
+
env.allowRemoteModels = false;
|
|
642
|
+
env.localModelPath = opts.localModelPath;
|
|
643
|
+
}
|
|
644
|
+
const extractor = await pipeline("feature-extraction", EMBEDDING_MODEL_ID, {
|
|
645
|
+
dtype: "q8",
|
|
646
|
+
revision: EMBEDDING_MODEL_REVISION
|
|
647
|
+
});
|
|
648
|
+
return {
|
|
649
|
+
async embed(text) {
|
|
650
|
+
const output = await extractor(text, { pooling: "mean", normalize: true });
|
|
651
|
+
return Float32Array.from(output.data);
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ../../packages/mcp-core/dist/memory-note.js
|
|
657
|
+
import { parse as parseYaml } from "yaml";
|
|
658
|
+
var KEY_ORDER = ["source", "tags", "confidence", "created"];
|
|
659
|
+
var FENCE = "---";
|
|
660
|
+
function toCodeEnvelope(body) {
|
|
661
|
+
return JSON.stringify({ language: "markdown", code: body, lastExecution: null });
|
|
662
|
+
}
|
|
663
|
+
function unwrapEnvelope(content) {
|
|
664
|
+
try {
|
|
665
|
+
const parsed = JSON.parse(content);
|
|
666
|
+
if (parsed !== null && typeof parsed === "object" && typeof parsed.code === "string") {
|
|
667
|
+
return parsed.code;
|
|
668
|
+
}
|
|
669
|
+
} catch {
|
|
670
|
+
}
|
|
671
|
+
return content;
|
|
672
|
+
}
|
|
673
|
+
function coerceMeta(raw) {
|
|
674
|
+
const meta = {};
|
|
675
|
+
if (typeof raw.source === "string")
|
|
676
|
+
meta.source = raw.source;
|
|
677
|
+
if (Array.isArray(raw.tags) && raw.tags.every((t) => typeof t === "string")) {
|
|
678
|
+
meta.tags = [...raw.tags];
|
|
679
|
+
}
|
|
680
|
+
if (typeof raw.confidence === "string") {
|
|
681
|
+
meta.confidence = raw.confidence;
|
|
682
|
+
} else if (typeof raw.confidence === "number") {
|
|
683
|
+
meta.confidence = String(raw.confidence);
|
|
684
|
+
}
|
|
685
|
+
if (typeof raw.created === "string") {
|
|
686
|
+
meta.created = raw.created;
|
|
687
|
+
} else if (raw.created instanceof Date) {
|
|
688
|
+
meta.created = raw.created.toISOString();
|
|
689
|
+
}
|
|
690
|
+
return meta;
|
|
691
|
+
}
|
|
692
|
+
function parseMemoryNote(decryptedContent) {
|
|
693
|
+
const body = unwrapEnvelope(decryptedContent);
|
|
694
|
+
if (!body.startsWith(`${FENCE}
|
|
695
|
+
`))
|
|
696
|
+
return { fact: body, meta: {} };
|
|
697
|
+
const close = body.indexOf(`
|
|
698
|
+
${FENCE}
|
|
699
|
+
`, FENCE.length);
|
|
700
|
+
if (close === -1)
|
|
701
|
+
return { fact: body, meta: {} };
|
|
702
|
+
const front = body.slice(FENCE.length + 1, close);
|
|
703
|
+
const fact = body.slice(close + FENCE.length + 2);
|
|
704
|
+
if (front.trim() === "")
|
|
705
|
+
return { fact, meta: {} };
|
|
706
|
+
let raw;
|
|
707
|
+
try {
|
|
708
|
+
raw = parseYaml(front);
|
|
709
|
+
} catch {
|
|
710
|
+
return { fact: body, meta: {} };
|
|
711
|
+
}
|
|
712
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
713
|
+
return { fact: body, meta: {} };
|
|
714
|
+
}
|
|
715
|
+
return { fact, meta: coerceMeta(raw) };
|
|
716
|
+
}
|
|
717
|
+
function yamlScalar(value) {
|
|
718
|
+
return /^[A-Za-z0-9][\w .:@/+-]*$/.test(value) && !value.includes(": ") ? value : JSON.stringify(value);
|
|
719
|
+
}
|
|
720
|
+
function serializeMemoryBody(fact, meta) {
|
|
721
|
+
const lines = [];
|
|
722
|
+
for (const key of KEY_ORDER) {
|
|
723
|
+
const value = meta[key];
|
|
724
|
+
if (value === void 0)
|
|
725
|
+
continue;
|
|
726
|
+
if (key === "tags") {
|
|
727
|
+
const tags = value;
|
|
728
|
+
if (tags.length === 0)
|
|
729
|
+
continue;
|
|
730
|
+
lines.push(`tags: [${tags.map(yamlScalar).join(", ")}]`);
|
|
731
|
+
} else {
|
|
732
|
+
lines.push(`${key}: ${yamlScalar(value)}`);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (lines.length === 0) {
|
|
736
|
+
return fact.startsWith(`${FENCE}
|
|
737
|
+
`) ? `${FENCE}
|
|
738
|
+
${FENCE}
|
|
739
|
+
${fact}` : fact;
|
|
740
|
+
}
|
|
741
|
+
return `${FENCE}
|
|
742
|
+
${lines.join("\n")}
|
|
743
|
+
${FENCE}
|
|
744
|
+
${fact}`;
|
|
745
|
+
}
|
|
746
|
+
function mergeMeta(existing, changes) {
|
|
747
|
+
const merged = { ...existing };
|
|
748
|
+
if (changes.source !== void 0)
|
|
749
|
+
merged.source = changes.source;
|
|
750
|
+
if (changes.tags !== void 0)
|
|
751
|
+
merged.tags = [...changes.tags];
|
|
752
|
+
if (changes.confidence !== void 0)
|
|
753
|
+
merged.confidence = changes.confidence;
|
|
754
|
+
if (changes.created !== void 0 && existing.created === void 0) {
|
|
755
|
+
merged.created = changes.created;
|
|
756
|
+
}
|
|
757
|
+
return merged;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ../../packages/mcp-core/dist/memory-tree.js
|
|
761
|
+
var MEMORIES_ROOT_NAME = "Memories";
|
|
762
|
+
function buildTree(rootId, folders) {
|
|
763
|
+
const namespaces = /* @__PURE__ */ new Map();
|
|
764
|
+
const byFolderId = /* @__PURE__ */ new Map();
|
|
765
|
+
const attach = (folderId, name) => {
|
|
766
|
+
byFolderId.set(folderId, name);
|
|
767
|
+
if (!namespaces.has(name))
|
|
768
|
+
namespaces.set(name, folderId);
|
|
769
|
+
};
|
|
770
|
+
for (const folder of folders) {
|
|
771
|
+
if (folder.parentId === rootId)
|
|
772
|
+
attach(folder.id, folder.name);
|
|
773
|
+
}
|
|
774
|
+
return {
|
|
775
|
+
rootId,
|
|
776
|
+
namespaces,
|
|
777
|
+
namespaceOf: (folderId) => byFolderId.get(folderId),
|
|
778
|
+
folderIds: () => [rootId, ...byFolderId.keys()],
|
|
779
|
+
attach
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
async function resolveMemoryTree(api) {
|
|
783
|
+
const { folders } = await api.listFolders();
|
|
784
|
+
const existing = folders.find((f) => f.kind === "memories");
|
|
785
|
+
if (existing)
|
|
786
|
+
return buildTree(existing.id, folders);
|
|
787
|
+
try {
|
|
788
|
+
const created = await api.createFolder(MEMORIES_ROOT_NAME, null, "memories");
|
|
789
|
+
return buildTree(created.id, [...folders, created]);
|
|
790
|
+
} catch (err) {
|
|
791
|
+
if (!(err instanceof ApiError) || err.status !== 409)
|
|
792
|
+
throw err;
|
|
793
|
+
const retry = await api.listFolders();
|
|
794
|
+
const winner = retry.folders.find((f) => f.kind === "memories");
|
|
795
|
+
if (!winner)
|
|
796
|
+
throw err;
|
|
797
|
+
return buildTree(winner.id, retry.folders);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
async function ensureNamespace(api, tree, name) {
|
|
801
|
+
const existing = tree.namespaces.get(name);
|
|
802
|
+
if (existing)
|
|
803
|
+
return existing;
|
|
804
|
+
const created = await api.createFolder(name, tree.rootId);
|
|
805
|
+
tree.attach(created.id, name);
|
|
806
|
+
return created.id;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ../../packages/mcp-core/dist/memories.js
|
|
810
|
+
var SYNC_PAGE_LIMIT = 100;
|
|
811
|
+
var round3 = (n) => Math.round(n * 1e3) / 1e3;
|
|
812
|
+
var MemoryStore = class {
|
|
813
|
+
api;
|
|
814
|
+
vault;
|
|
815
|
+
embedder;
|
|
816
|
+
cacheFactory;
|
|
817
|
+
cache = /* @__PURE__ */ new Map();
|
|
818
|
+
tree = null;
|
|
819
|
+
embeddings = null;
|
|
820
|
+
embeddingsLoaded = false;
|
|
821
|
+
syncInFlight = null;
|
|
822
|
+
mutationChain = Promise.resolve();
|
|
823
|
+
/** Bumped every time clear() runs (i.e. every lock). Operations capture it
|
|
824
|
+
* at the top and re-check before every cache write, so anything still
|
|
825
|
+
* awaiting the network or a decrypt when the vault locked can never write
|
|
826
|
+
* decrypted plaintext back into a cache the user just emptied. */
|
|
827
|
+
epoch = 0;
|
|
828
|
+
constructor(api, vault, embedder, cacheFactory) {
|
|
829
|
+
this.api = api;
|
|
830
|
+
this.vault = vault;
|
|
831
|
+
this.embedder = embedder;
|
|
832
|
+
this.cacheFactory = cacheFactory;
|
|
833
|
+
this.vault.onLock = () => this.clear();
|
|
834
|
+
}
|
|
835
|
+
clear() {
|
|
836
|
+
this.cache.clear();
|
|
837
|
+
this.tree = null;
|
|
838
|
+
this.embeddings = null;
|
|
839
|
+
this.embeddingsLoaded = false;
|
|
840
|
+
this.epoch++;
|
|
841
|
+
}
|
|
842
|
+
get size() {
|
|
843
|
+
return this.cache.size;
|
|
844
|
+
}
|
|
845
|
+
/** Concurrent callers share one in-flight sync so two overlapping tool
|
|
846
|
+
* calls never double up on network, decrypt, or embedding work. */
|
|
847
|
+
sync() {
|
|
848
|
+
this.syncInFlight ??= this.doSync().finally(() => {
|
|
849
|
+
this.syncInFlight = null;
|
|
850
|
+
});
|
|
851
|
+
return this.syncInFlight;
|
|
852
|
+
}
|
|
853
|
+
async doSync() {
|
|
854
|
+
const key = this.requireSession();
|
|
855
|
+
const startEpoch = this.epoch;
|
|
856
|
+
const result = { added: 0, updated: 0, removed: 0, embedded: 0 };
|
|
857
|
+
const tree = await this.requireTree();
|
|
858
|
+
const embeddings = await this.requireEmbeddings(key);
|
|
859
|
+
const seen = /* @__PURE__ */ new Set();
|
|
860
|
+
for (const folderId of tree.folderIds()) {
|
|
861
|
+
let page = 1;
|
|
862
|
+
for (; ; ) {
|
|
863
|
+
let batch;
|
|
864
|
+
try {
|
|
865
|
+
batch = await this.api.listNotes({
|
|
866
|
+
folderId,
|
|
867
|
+
deleted: false,
|
|
868
|
+
page,
|
|
869
|
+
limit: SYNC_PAGE_LIMIT
|
|
870
|
+
});
|
|
871
|
+
} catch (err) {
|
|
872
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
873
|
+
this.tree = null;
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
throw err;
|
|
877
|
+
}
|
|
878
|
+
for (const row of batch.notes) {
|
|
879
|
+
if (row.hidden === true)
|
|
880
|
+
continue;
|
|
881
|
+
seen.add(row.id);
|
|
882
|
+
const existing = this.cache.get(row.id);
|
|
883
|
+
if (existing && existing.updatedAt === row.updatedAt) {
|
|
884
|
+
if (existing.folderId !== folderId) {
|
|
885
|
+
const moved = {
|
|
886
|
+
...existing,
|
|
887
|
+
folderId,
|
|
888
|
+
...namespaceField(tree.namespaceOf(folderId))
|
|
889
|
+
};
|
|
890
|
+
if (this.epoch === startEpoch)
|
|
891
|
+
this.cache.set(row.id, moved);
|
|
892
|
+
result.updated++;
|
|
893
|
+
}
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
const { title, content } = await this.decryptRow(key, row);
|
|
897
|
+
const { fact, meta } = parseMemoryNote(content);
|
|
898
|
+
let embedding = embeddings.get(row.id, row.updatedAt);
|
|
899
|
+
if (!embedding) {
|
|
900
|
+
embedding = await this.embedder.embed(embeddingInput(title, fact));
|
|
901
|
+
result.embedded++;
|
|
902
|
+
}
|
|
903
|
+
if (this.epoch !== startEpoch)
|
|
904
|
+
continue;
|
|
905
|
+
embeddings.set(row.id, row.updatedAt, embedding);
|
|
906
|
+
this.cache.set(row.id, {
|
|
907
|
+
id: row.id,
|
|
908
|
+
label: title,
|
|
909
|
+
text: fact,
|
|
910
|
+
meta,
|
|
911
|
+
folderId,
|
|
912
|
+
...namespaceField(tree.namespaceOf(folderId)),
|
|
913
|
+
embedding,
|
|
914
|
+
updatedAt: row.updatedAt
|
|
915
|
+
});
|
|
916
|
+
if (existing)
|
|
917
|
+
result.updated++;
|
|
918
|
+
else
|
|
919
|
+
result.added++;
|
|
920
|
+
}
|
|
921
|
+
if (page >= batch.pagination.pages || batch.notes.length === 0)
|
|
922
|
+
break;
|
|
923
|
+
page++;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
if (this.epoch === startEpoch) {
|
|
927
|
+
for (const id of [...this.cache.keys()]) {
|
|
928
|
+
if (seen.has(id))
|
|
929
|
+
continue;
|
|
930
|
+
this.cache.delete(id);
|
|
931
|
+
embeddings.delete(id);
|
|
932
|
+
result.removed++;
|
|
933
|
+
}
|
|
934
|
+
await embeddings.save(key);
|
|
935
|
+
}
|
|
936
|
+
return result;
|
|
937
|
+
}
|
|
938
|
+
add(label, text, namespace, meta = {}) {
|
|
939
|
+
return this.enqueue(async () => {
|
|
940
|
+
await this.sync();
|
|
941
|
+
const key = this.requireSession();
|
|
942
|
+
const startEpoch = this.epoch;
|
|
943
|
+
const embedding = await this.embedder.embed(embeddingInput(label, text));
|
|
944
|
+
const candidates = this.candidatesFor(namespace);
|
|
945
|
+
const hit = findDuplicate(embedding, candidates);
|
|
946
|
+
if (hit) {
|
|
947
|
+
const existing = this.cache.get(hit.id);
|
|
948
|
+
const merged = mergeMeta(existing.meta, meta);
|
|
949
|
+
const row2 = await this.writeNote(key, hit.id, label, text, merged);
|
|
950
|
+
if (this.epoch === startEpoch) {
|
|
951
|
+
this.cache.set(hit.id, {
|
|
952
|
+
...existing,
|
|
953
|
+
label,
|
|
954
|
+
text,
|
|
955
|
+
meta: merged,
|
|
956
|
+
embedding,
|
|
957
|
+
updatedAt: row2.updatedAt
|
|
958
|
+
});
|
|
959
|
+
this.embeddings?.set(hit.id, row2.updatedAt, embedding);
|
|
960
|
+
await this.saveEmbeddings(key, startEpoch);
|
|
961
|
+
}
|
|
962
|
+
return { action: "updated", id: hit.id, similar: [] };
|
|
963
|
+
}
|
|
964
|
+
const tree = await this.requireTree();
|
|
965
|
+
const folderId = namespace !== void 0 ? await ensureNamespace(this.api, tree, namespace) : tree.rootId;
|
|
966
|
+
const stamped = {
|
|
967
|
+
...meta,
|
|
968
|
+
source: meta.source ?? "virlow-mcp",
|
|
969
|
+
created: meta.created ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
970
|
+
};
|
|
971
|
+
const row = await this.writeNote(key, null, label, text, stamped, folderId);
|
|
972
|
+
if (this.epoch === startEpoch) {
|
|
973
|
+
this.cache.set(row.id, {
|
|
974
|
+
id: row.id,
|
|
975
|
+
label,
|
|
976
|
+
text,
|
|
977
|
+
meta: stamped,
|
|
978
|
+
folderId,
|
|
979
|
+
...namespaceField(tree.namespaceOf(folderId)),
|
|
980
|
+
embedding,
|
|
981
|
+
updatedAt: row.updatedAt
|
|
982
|
+
});
|
|
983
|
+
this.embeddings?.set(row.id, row.updatedAt, embedding);
|
|
984
|
+
await this.saveEmbeddings(key, startEpoch);
|
|
985
|
+
}
|
|
986
|
+
const similar = findSimilar(embedding, candidates).map(({ id, similarity }) => {
|
|
987
|
+
const memory = this.cache.get(id);
|
|
988
|
+
return {
|
|
989
|
+
id,
|
|
990
|
+
label: memory.label,
|
|
991
|
+
text: memory.text,
|
|
992
|
+
similarity: round3(similarity)
|
|
993
|
+
};
|
|
994
|
+
});
|
|
995
|
+
return { action: "stored", id: row.id, similar };
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
async search(query, namespace, limit = 10) {
|
|
999
|
+
await this.sync();
|
|
1000
|
+
this.requireSession();
|
|
1001
|
+
const embedding = await this.embedder.embed(query);
|
|
1002
|
+
const ranked = rankBySimilarity(embedding, this.candidatesFor(namespace), limit);
|
|
1003
|
+
return ranked.map(({ id, similarity }) => {
|
|
1004
|
+
const memory = this.cache.get(id);
|
|
1005
|
+
return {
|
|
1006
|
+
id,
|
|
1007
|
+
label: memory.label,
|
|
1008
|
+
text: memory.text,
|
|
1009
|
+
meta: memory.meta,
|
|
1010
|
+
...namespaceField(memory.namespace),
|
|
1011
|
+
similarity: round3(similarity)
|
|
1012
|
+
};
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
/** Requires a prior sync() in the same unlock to have populated the cache. */
|
|
1016
|
+
list(namespace) {
|
|
1017
|
+
this.requireSession();
|
|
1018
|
+
return [...this.cache.values()].filter((m) => namespace === void 0 || m.namespace === namespace || m.namespace === void 0);
|
|
1019
|
+
}
|
|
1020
|
+
update(id, changes) {
|
|
1021
|
+
return this.enqueue(async () => {
|
|
1022
|
+
await this.sync();
|
|
1023
|
+
const key = this.requireSession();
|
|
1024
|
+
const startEpoch = this.epoch;
|
|
1025
|
+
const existing = this.cache.get(id);
|
|
1026
|
+
if (!existing)
|
|
1027
|
+
throw new Error(`Memory ${id} not found`);
|
|
1028
|
+
const label = changes.label ?? existing.label;
|
|
1029
|
+
const text = changes.text ?? existing.text;
|
|
1030
|
+
const meta = mergeMeta(existing.meta, changes.meta ?? {});
|
|
1031
|
+
const row = await this.writeNote(key, id, label, text, meta);
|
|
1032
|
+
const embedding = await this.embedder.embed(embeddingInput(label, text));
|
|
1033
|
+
if (this.epoch !== startEpoch)
|
|
1034
|
+
return;
|
|
1035
|
+
this.cache.set(id, { ...existing, label, text, meta, embedding, updatedAt: row.updatedAt });
|
|
1036
|
+
this.embeddings?.set(id, row.updatedAt, embedding);
|
|
1037
|
+
await this.saveEmbeddings(key, startEpoch);
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
/** Trashes the note — recoverable from the app's Trash, never hard-deleted. */
|
|
1041
|
+
delete(id) {
|
|
1042
|
+
return this.enqueue(async () => {
|
|
1043
|
+
const key = this.requireSession();
|
|
1044
|
+
const startEpoch = this.epoch;
|
|
1045
|
+
await this.api.updateNote(id, { deleted: true });
|
|
1046
|
+
if (this.epoch !== startEpoch)
|
|
1047
|
+
return;
|
|
1048
|
+
this.cache.delete(id);
|
|
1049
|
+
this.embeddings?.delete(id);
|
|
1050
|
+
await this.saveEmbeddings(key, startEpoch);
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
/** Dedupe and search candidates: the named namespace plus the global root. */
|
|
1054
|
+
candidatesFor(namespace) {
|
|
1055
|
+
const result = [];
|
|
1056
|
+
for (const memory of this.cache.values()) {
|
|
1057
|
+
if (memory.namespace === namespace || memory.namespace === void 0) {
|
|
1058
|
+
result.push({ id: memory.id, embedding: memory.embedding });
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
return result;
|
|
1062
|
+
}
|
|
1063
|
+
/** Create or update a memory note, encrypting through the note field
|
|
1064
|
+
* helpers. `noteId === null` creates. */
|
|
1065
|
+
async writeNote(key, noteId, label, text, meta, folderId) {
|
|
1066
|
+
const body = toCodeEnvelope(serializeMemoryBody(text, meta));
|
|
1067
|
+
const salt = bytesToBase64(crypto.getRandomValues(new Uint8Array(32)));
|
|
1068
|
+
const { encryptedTitle, encryptedContent, iv } = await encryptNoteFields(key, label, body);
|
|
1069
|
+
const write = {
|
|
1070
|
+
title: "[ENCRYPTED]",
|
|
1071
|
+
content: "[ENCRYPTED]",
|
|
1072
|
+
encryptedTitle,
|
|
1073
|
+
encryptedContent,
|
|
1074
|
+
iv,
|
|
1075
|
+
salt,
|
|
1076
|
+
type: "code",
|
|
1077
|
+
// Mirrored so the app can filter memories by tag. The frontmatter stays
|
|
1078
|
+
// the source of truth; this column follows it on every write we make.
|
|
1079
|
+
tags: meta.tags ?? []
|
|
1080
|
+
};
|
|
1081
|
+
if (folderId !== void 0)
|
|
1082
|
+
write.folderId = folderId;
|
|
1083
|
+
return noteId === null ? this.api.createNote(write) : this.api.updateNote(noteId, write);
|
|
1084
|
+
}
|
|
1085
|
+
async decryptRow(key, row) {
|
|
1086
|
+
if (row.encryptedTitle && row.encryptedContent && row.iv) {
|
|
1087
|
+
return decryptNoteFields(key, row.encryptedTitle, row.encryptedContent, row.iv);
|
|
1088
|
+
}
|
|
1089
|
+
return { title: row.title, content: row.content };
|
|
1090
|
+
}
|
|
1091
|
+
async requireTree() {
|
|
1092
|
+
this.tree ??= await resolveMemoryTree(this.api);
|
|
1093
|
+
return this.tree;
|
|
1094
|
+
}
|
|
1095
|
+
async requireEmbeddings(key) {
|
|
1096
|
+
this.embeddings ??= this.cacheFactory(this.vault.session.userId);
|
|
1097
|
+
if (!this.embeddingsLoaded) {
|
|
1098
|
+
await this.embeddings.load(key);
|
|
1099
|
+
this.embeddingsLoaded = true;
|
|
1100
|
+
}
|
|
1101
|
+
return this.embeddings;
|
|
1102
|
+
}
|
|
1103
|
+
/** Persisting after a lock would write vectors the user just cleared. */
|
|
1104
|
+
async saveEmbeddings(key, startEpoch) {
|
|
1105
|
+
if (this.epoch !== startEpoch)
|
|
1106
|
+
return;
|
|
1107
|
+
await this.embeddings?.save(key);
|
|
1108
|
+
}
|
|
1109
|
+
requireSession() {
|
|
1110
|
+
const key = this.vault.session.key;
|
|
1111
|
+
this.vault.touch();
|
|
1112
|
+
return key;
|
|
1113
|
+
}
|
|
1114
|
+
/** Serializes add/update/delete so two overlapping mutation tool calls never
|
|
1115
|
+
* interleave — two concurrent near-duplicate adds must run their
|
|
1116
|
+
* dedupe-check-then-write critical section one at a time, or both would miss
|
|
1117
|
+
* the other's pending write and both create. */
|
|
1118
|
+
enqueue(fn) {
|
|
1119
|
+
const next = this.mutationChain.then(fn, fn);
|
|
1120
|
+
this.mutationChain = next.catch(() => {
|
|
1121
|
+
});
|
|
1122
|
+
return next;
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
function embeddingInput(label, fact) {
|
|
1126
|
+
return `${label}
|
|
1127
|
+
${fact}`;
|
|
1128
|
+
}
|
|
1129
|
+
function namespaceField(namespace) {
|
|
1130
|
+
return namespace === void 0 ? {} : { namespace };
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// ../../packages/mcp-core/dist/embedding-cache.js
|
|
1134
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
1135
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
1136
|
+
import path from "node:path";
|
|
1137
|
+
var CACHE_VERSION = 1;
|
|
1138
|
+
var EmbeddingCache = class {
|
|
1139
|
+
file;
|
|
1140
|
+
embeddingModel;
|
|
1141
|
+
dir;
|
|
1142
|
+
records = /* @__PURE__ */ new Map();
|
|
1143
|
+
dirty = false;
|
|
1144
|
+
constructor(opts) {
|
|
1145
|
+
this.dir = opts.dir;
|
|
1146
|
+
this.embeddingModel = opts.embeddingModel;
|
|
1147
|
+
const name = createHash("sha256").update(opts.userId).digest("hex");
|
|
1148
|
+
this.file = path.join(opts.dir, `${name}.bin`);
|
|
1149
|
+
}
|
|
1150
|
+
async load(key) {
|
|
1151
|
+
let parsed;
|
|
1152
|
+
try {
|
|
1153
|
+
const blob = await readFile(this.file, "utf8");
|
|
1154
|
+
const bytes = await decryptBytesBlob(key, blob);
|
|
1155
|
+
parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
1156
|
+
} catch {
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (parsed.version !== CACHE_VERSION)
|
|
1160
|
+
return;
|
|
1161
|
+
if (parsed.embeddingModel !== this.embeddingModel)
|
|
1162
|
+
return;
|
|
1163
|
+
if (!parsed.records || typeof parsed.records !== "object")
|
|
1164
|
+
return;
|
|
1165
|
+
for (const [noteId, record] of Object.entries(parsed.records)) {
|
|
1166
|
+
if (!record || typeof record.v !== "string" || typeof record.u !== "string") {
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
const bytes = base64ToBytes(record.v);
|
|
1170
|
+
if (bytes.byteLength % 4 !== 0)
|
|
1171
|
+
continue;
|
|
1172
|
+
const vector = new Float32Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
|
1173
|
+
this.records.set(noteId, { vector, updatedAt: record.u });
|
|
1174
|
+
}
|
|
1175
|
+
this.dirty = false;
|
|
1176
|
+
}
|
|
1177
|
+
/** The cached vector, but only if the note has not been edited since. */
|
|
1178
|
+
get(noteId, updatedAt) {
|
|
1179
|
+
const record = this.records.get(noteId);
|
|
1180
|
+
return record && record.updatedAt === updatedAt ? record.vector : void 0;
|
|
1181
|
+
}
|
|
1182
|
+
set(noteId, updatedAt, vector) {
|
|
1183
|
+
this.records.set(noteId, { vector, updatedAt });
|
|
1184
|
+
this.dirty = true;
|
|
1185
|
+
}
|
|
1186
|
+
delete(noteId) {
|
|
1187
|
+
if (this.records.delete(noteId))
|
|
1188
|
+
this.dirty = true;
|
|
1189
|
+
}
|
|
1190
|
+
clear() {
|
|
1191
|
+
if (this.records.size > 0)
|
|
1192
|
+
this.dirty = true;
|
|
1193
|
+
this.records.clear();
|
|
1194
|
+
}
|
|
1195
|
+
async save(key) {
|
|
1196
|
+
if (!this.dirty)
|
|
1197
|
+
return;
|
|
1198
|
+
const records = {};
|
|
1199
|
+
for (const [noteId, { vector, updatedAt }] of this.records) {
|
|
1200
|
+
const bytes = new Uint8Array(vector.buffer.slice(vector.byteOffset, vector.byteOffset + vector.byteLength));
|
|
1201
|
+
records[noteId] = { v: bytesToBase64(bytes), u: updatedAt };
|
|
1202
|
+
}
|
|
1203
|
+
const payload = {
|
|
1204
|
+
version: CACHE_VERSION,
|
|
1205
|
+
embeddingModel: this.embeddingModel,
|
|
1206
|
+
records
|
|
1207
|
+
};
|
|
1208
|
+
const blob = await encryptBytesBlob(key, new TextEncoder().encode(JSON.stringify(payload)));
|
|
1209
|
+
await mkdir(this.dir, { recursive: true, mode: 448 });
|
|
1210
|
+
const tmp = `${this.file}.${randomUUID()}.tmp`;
|
|
1211
|
+
await writeFile(tmp, blob, { mode: 384 });
|
|
1212
|
+
await rename(tmp, this.file);
|
|
1213
|
+
this.dirty = false;
|
|
1214
|
+
}
|
|
1215
|
+
get size() {
|
|
1216
|
+
return this.records.size;
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
// ../../packages/mcp-core/dist/memories-enabled.js
|
|
1221
|
+
async function memoriesAllowed(api) {
|
|
1222
|
+
try {
|
|
1223
|
+
const me = await api.me();
|
|
1224
|
+
return me.memoriesEnabled === true;
|
|
1225
|
+
} catch {
|
|
1226
|
+
return false;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
var MEMORIES_DISABLED_MESSAGE = 'AI memories are switched off for this account. Turn on "AI memories" in Settings \u2192 Security in the Virlow app to use this tool.';
|
|
1230
|
+
|
|
1231
|
+
// src/server.ts
|
|
1232
|
+
import os from "node:os";
|
|
1233
|
+
import path2 from "node:path";
|
|
1234
|
+
import { z } from "zod";
|
|
1235
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1236
|
+
|
|
1237
|
+
// src/unlock.ts
|
|
1238
|
+
import { randomBytes } from "node:crypto";
|
|
1239
|
+
import { spawn } from "node:child_process";
|
|
1240
|
+
import * as http from "node:http";
|
|
1241
|
+
var UNLOCK_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
1242
|
+
var MAX_BAD_NONCE_ATTEMPTS = 3;
|
|
1243
|
+
async function performUnlock(api, vault, creds) {
|
|
1244
|
+
const loginResult = await api.login(creds.email, creds.password);
|
|
1245
|
+
let tokens;
|
|
1246
|
+
if (loginResult.mfaRequired) {
|
|
1247
|
+
if (!creds.totpCode) {
|
|
1248
|
+
throw new Error("This account has 2FA enabled \u2014 enter the 6-digit code");
|
|
1249
|
+
}
|
|
1250
|
+
tokens = await api.verify2fa(loginResult.mfaToken, { code: creds.totpCode });
|
|
1251
|
+
} else {
|
|
1252
|
+
tokens = { accessToken: loginResult.accessToken, refreshToken: loginResult.refreshToken };
|
|
1253
|
+
}
|
|
1254
|
+
api.setTokens(tokens);
|
|
1255
|
+
const me = await api.me();
|
|
1256
|
+
const status = await api.encryptionStatus();
|
|
1257
|
+
const { key, keyString } = await deriveMasterKey(creds.masterPassword, me.id);
|
|
1258
|
+
if (status.verifier) {
|
|
1259
|
+
const ok = await verifyVerifierBlob(key, status.verifier);
|
|
1260
|
+
if (!ok) {
|
|
1261
|
+
api.setTokens(null);
|
|
1262
|
+
throw new Error("Master password is incorrect");
|
|
1263
|
+
}
|
|
1264
|
+
} else {
|
|
1265
|
+
const { notes } = await api.listNotes({ limit: 1 });
|
|
1266
|
+
const note = notes[0];
|
|
1267
|
+
if (note?.encryptedTitle && note.iv) {
|
|
1268
|
+
const ok = await canDecryptWithKey(key, note.encryptedTitle, note.iv);
|
|
1269
|
+
if (!ok) {
|
|
1270
|
+
api.setTokens(null);
|
|
1271
|
+
throw new Error("Master password is incorrect");
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
const nonExtractableKey = await importMasterKey(keyString);
|
|
1276
|
+
vault.setUnlocked({ key: nonExtractableKey, userId: me.id, email: me.email });
|
|
1277
|
+
api.onTokensRotated = () => {
|
|
1278
|
+
vault.touch();
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
function renderForm(nonce, error) {
|
|
1282
|
+
const errorHtml = error ? `<p class="error">${escapeHtml(error)}</p>` : "";
|
|
1283
|
+
return `<!doctype html>
|
|
1284
|
+
<html lang="en">
|
|
1285
|
+
<head>
|
|
1286
|
+
<meta charset="utf-8" />
|
|
1287
|
+
<title>Unlock Virlow</title>
|
|
1288
|
+
<style>
|
|
1289
|
+
body { font-family: system-ui, sans-serif; max-width: 420px; margin: 48px auto; padding: 0 16px; color: #1a1a1a; }
|
|
1290
|
+
h1 { font-size: 1.4rem; }
|
|
1291
|
+
label { display: block; margin-top: 12px; font-weight: 600; }
|
|
1292
|
+
input { width: 100%; padding: 8px; margin-top: 4px; box-sizing: border-box; font-size: 1rem; }
|
|
1293
|
+
button { margin-top: 20px; padding: 10px 16px; font-size: 1rem; cursor: pointer; }
|
|
1294
|
+
p.note { color: #555; font-size: 0.9rem; }
|
|
1295
|
+
p.error { color: #b00020; font-weight: 600; }
|
|
1296
|
+
</style>
|
|
1297
|
+
</head>
|
|
1298
|
+
<body>
|
|
1299
|
+
<h1>Unlock Virlow</h1>
|
|
1300
|
+
<p class="note">
|
|
1301
|
+
Credentials entered here are sent only to this local process and the
|
|
1302
|
+
Virlow API. They are never seen by any AI model or logged.
|
|
1303
|
+
</p>
|
|
1304
|
+
${errorHtml}
|
|
1305
|
+
<form method="POST" action="/unlock?nonce=${encodeURIComponent(nonce)}" autocomplete="off">
|
|
1306
|
+
<label for="email">Email</label>
|
|
1307
|
+
<input type="email" id="email" name="email" autocomplete="off" required />
|
|
1308
|
+
|
|
1309
|
+
<label for="password">Password</label>
|
|
1310
|
+
<input type="password" id="password" name="password" autocomplete="off" required />
|
|
1311
|
+
|
|
1312
|
+
<label for="masterPassword">Master password</label>
|
|
1313
|
+
<input type="password" id="masterPassword" name="masterPassword" autocomplete="off" required />
|
|
1314
|
+
|
|
1315
|
+
<label for="totpCode">2FA code (only if 2FA is enabled on your account)</label>
|
|
1316
|
+
<input type="text" id="totpCode" name="totpCode" autocomplete="off" inputmode="numeric" pattern="[0-9]*" />
|
|
1317
|
+
|
|
1318
|
+
<button type="submit">Unlock</button>
|
|
1319
|
+
</form>
|
|
1320
|
+
</body>
|
|
1321
|
+
</html>`;
|
|
1322
|
+
}
|
|
1323
|
+
function renderSuccess() {
|
|
1324
|
+
return `<!doctype html>
|
|
1325
|
+
<html lang="en">
|
|
1326
|
+
<head>
|
|
1327
|
+
<meta charset="utf-8" />
|
|
1328
|
+
<title>Unlock Virlow</title>
|
|
1329
|
+
<style>
|
|
1330
|
+
body { font-family: system-ui, sans-serif; max-width: 420px; margin: 48px auto; padding: 0 16px; color: #1a1a1a; }
|
|
1331
|
+
</style>
|
|
1332
|
+
</head>
|
|
1333
|
+
<body>
|
|
1334
|
+
<h1>Virlow unlocked</h1>
|
|
1335
|
+
<p>You can close this tab and return to your MCP client.</p>
|
|
1336
|
+
</body>
|
|
1337
|
+
</html>`;
|
|
1338
|
+
}
|
|
1339
|
+
function escapeHtml(input) {
|
|
1340
|
+
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1341
|
+
}
|
|
1342
|
+
function readBody(req) {
|
|
1343
|
+
return new Promise((resolve, reject) => {
|
|
1344
|
+
let data = "";
|
|
1345
|
+
req.on("data", (chunk) => {
|
|
1346
|
+
data += chunk.toString("utf8");
|
|
1347
|
+
});
|
|
1348
|
+
req.on("end", () => resolve(data));
|
|
1349
|
+
req.on("error", reject);
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
function openInBrowser(url) {
|
|
1353
|
+
const platform = process.platform;
|
|
1354
|
+
const [cmd, args] = platform === "win32" ? ["cmd", ["/c", "start", "", url]] : platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
1355
|
+
try {
|
|
1356
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
|
1357
|
+
child.on("error", () => {
|
|
1358
|
+
process.stderr.write("virlow-mcp: could not open the unlock page in a browser automatically\n");
|
|
1359
|
+
});
|
|
1360
|
+
child.unref();
|
|
1361
|
+
} catch {
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
function startUnlockServer(api, vault, opts = {}) {
|
|
1365
|
+
const openBrowser = opts.openBrowser ?? true;
|
|
1366
|
+
return new Promise((resolveStart, rejectStart) => {
|
|
1367
|
+
const nonce = randomBytes(32).toString("hex");
|
|
1368
|
+
let badNonceAttempts = 0;
|
|
1369
|
+
let settled = false;
|
|
1370
|
+
let timeoutHandle;
|
|
1371
|
+
let resolveDone;
|
|
1372
|
+
let rejectDone;
|
|
1373
|
+
const done = new Promise((res, rej) => {
|
|
1374
|
+
resolveDone = res;
|
|
1375
|
+
rejectDone = rej;
|
|
1376
|
+
});
|
|
1377
|
+
done.catch(() => {
|
|
1378
|
+
});
|
|
1379
|
+
const finishSuccess = () => {
|
|
1380
|
+
if (settled) return;
|
|
1381
|
+
settled = true;
|
|
1382
|
+
closeServer();
|
|
1383
|
+
resolveDone();
|
|
1384
|
+
};
|
|
1385
|
+
const finishFailure = (err) => {
|
|
1386
|
+
if (settled) return;
|
|
1387
|
+
settled = true;
|
|
1388
|
+
closeServer();
|
|
1389
|
+
rejectDone(err);
|
|
1390
|
+
};
|
|
1391
|
+
const closeServer = () => {
|
|
1392
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
1393
|
+
server.close();
|
|
1394
|
+
};
|
|
1395
|
+
const server = http.createServer((req, res) => {
|
|
1396
|
+
handleRequest(req, res).catch(() => {
|
|
1397
|
+
if (!res.headersSent) {
|
|
1398
|
+
res.statusCode = 500;
|
|
1399
|
+
res.setHeader("content-type", "text/plain");
|
|
1400
|
+
}
|
|
1401
|
+
res.end("Internal error");
|
|
1402
|
+
});
|
|
1403
|
+
});
|
|
1404
|
+
async function handleRequest(req, res) {
|
|
1405
|
+
const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1406
|
+
if (requestUrl.pathname !== "/unlock") {
|
|
1407
|
+
res.statusCode = 404;
|
|
1408
|
+
res.setHeader("content-type", "text/plain");
|
|
1409
|
+
res.end("Not found");
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
const providedNonce = requestUrl.searchParams.get("nonce");
|
|
1413
|
+
if (providedNonce !== nonce) {
|
|
1414
|
+
badNonceAttempts += 1;
|
|
1415
|
+
res.statusCode = 403;
|
|
1416
|
+
res.setHeader("content-type", "text/plain");
|
|
1417
|
+
res.end("Forbidden");
|
|
1418
|
+
if (badNonceAttempts >= MAX_BAD_NONCE_ATTEMPTS) {
|
|
1419
|
+
finishFailure(new Error("Unlock server closed after repeated invalid requests"));
|
|
1420
|
+
}
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
if (req.method === "GET") {
|
|
1424
|
+
res.statusCode = 200;
|
|
1425
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
1426
|
+
res.end(renderForm(nonce));
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
if (req.method === "POST") {
|
|
1430
|
+
const rawBody = await readBody(req);
|
|
1431
|
+
const params = new URLSearchParams(rawBody);
|
|
1432
|
+
const creds = {
|
|
1433
|
+
email: params.get("email") ?? "",
|
|
1434
|
+
password: params.get("password") ?? "",
|
|
1435
|
+
masterPassword: params.get("masterPassword") ?? "",
|
|
1436
|
+
totpCode: params.get("totpCode") || void 0
|
|
1437
|
+
};
|
|
1438
|
+
try {
|
|
1439
|
+
await performUnlock(api, vault, creds);
|
|
1440
|
+
res.statusCode = 200;
|
|
1441
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
1442
|
+
res.end(renderSuccess());
|
|
1443
|
+
finishSuccess();
|
|
1444
|
+
} catch (err) {
|
|
1445
|
+
const message = err instanceof Error ? err.message : "Unlock failed";
|
|
1446
|
+
res.statusCode = 200;
|
|
1447
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
1448
|
+
res.end(renderForm(nonce, message));
|
|
1449
|
+
}
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
res.statusCode = 404;
|
|
1453
|
+
res.setHeader("content-type", "text/plain");
|
|
1454
|
+
res.end("Not found");
|
|
1455
|
+
}
|
|
1456
|
+
server.on("error", (err) => {
|
|
1457
|
+
rejectStart(err);
|
|
1458
|
+
});
|
|
1459
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1460
|
+
const address = server.address();
|
|
1461
|
+
if (address === null || typeof address === "string") {
|
|
1462
|
+
rejectStart(new Error("Failed to start unlock server"));
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
const url = `http://127.0.0.1:${address.port}/unlock?nonce=${nonce}`;
|
|
1466
|
+
timeoutHandle = setTimeout(() => {
|
|
1467
|
+
finishFailure(new Error("Unlock timed out after 5 minutes"));
|
|
1468
|
+
}, UNLOCK_TIMEOUT_MS);
|
|
1469
|
+
timeoutHandle.unref?.();
|
|
1470
|
+
if (openBrowser) {
|
|
1471
|
+
openInBrowser(url);
|
|
1472
|
+
}
|
|
1473
|
+
resolveStart({ url, done });
|
|
1474
|
+
});
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// src/version.ts
|
|
1479
|
+
var VERSION = true ? "3.11.4" : "dev";
|
|
1480
|
+
|
|
1481
|
+
// src/server.ts
|
|
1482
|
+
function textResult(text) {
|
|
1483
|
+
return { content: [{ type: "text", text }] };
|
|
1484
|
+
}
|
|
1485
|
+
function errorResult(text) {
|
|
1486
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
1487
|
+
}
|
|
1488
|
+
function mapError(err) {
|
|
1489
|
+
if (err instanceof LockedError) return err.message;
|
|
1490
|
+
if (err instanceof ApiError) return `API error ${err.status}: ${err.message}`;
|
|
1491
|
+
if (err instanceof Error) return err.message;
|
|
1492
|
+
return String(err);
|
|
1493
|
+
}
|
|
1494
|
+
function wrap(fn) {
|
|
1495
|
+
return async (...args) => {
|
|
1496
|
+
try {
|
|
1497
|
+
return await fn(...args);
|
|
1498
|
+
} catch (err) {
|
|
1499
|
+
return errorResult(mapError(err));
|
|
1500
|
+
}
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
function formatSyncStats(stats) {
|
|
1504
|
+
return `synced ${stats.added} new, ${stats.updated} changed, removed ${stats.removed}`;
|
|
1505
|
+
}
|
|
1506
|
+
function formatMeta(meta) {
|
|
1507
|
+
const parts = [];
|
|
1508
|
+
if (meta.source) parts.push(meta.source);
|
|
1509
|
+
if (meta.tags?.length) parts.push(meta.tags.join(", "));
|
|
1510
|
+
if (meta.confidence) parts.push(`confidence ${meta.confidence}`);
|
|
1511
|
+
return parts.length > 0 ? ` [${parts.join(" \xB7 ")}]` : "";
|
|
1512
|
+
}
|
|
1513
|
+
var truncate = (text, max = 120) => text.length > max ? `${text.slice(0, max)}\u2026` : text;
|
|
1514
|
+
function defaultEmbeddingCacheDir() {
|
|
1515
|
+
return path2.join(os.homedir(), ".virlow-mcp", "cache");
|
|
1516
|
+
}
|
|
1517
|
+
function createServices(opts) {
|
|
1518
|
+
const api = new VirlowApi(opts.apiUrl);
|
|
1519
|
+
const vault = new Vault(opts.autoLockMs);
|
|
1520
|
+
let embedderPromise = null;
|
|
1521
|
+
const getRealEmbedder = () => {
|
|
1522
|
+
embedderPromise ??= createEmbedder({ cacheDir: opts.modelCacheDir }).catch((err) => {
|
|
1523
|
+
embedderPromise = null;
|
|
1524
|
+
throw err;
|
|
1525
|
+
});
|
|
1526
|
+
return embedderPromise;
|
|
1527
|
+
};
|
|
1528
|
+
const lazyEmbedder = {
|
|
1529
|
+
embed: (text) => getRealEmbedder().then((embedder) => embedder.embed(text))
|
|
1530
|
+
};
|
|
1531
|
+
const memoryStore = new MemoryStore(
|
|
1532
|
+
api,
|
|
1533
|
+
vault,
|
|
1534
|
+
lazyEmbedder,
|
|
1535
|
+
(userId) => new EmbeddingCache({
|
|
1536
|
+
dir: opts.embeddingCacheDir ?? defaultEmbeddingCacheDir(),
|
|
1537
|
+
userId,
|
|
1538
|
+
embeddingModel: EMBEDDING_MODEL_TAG
|
|
1539
|
+
})
|
|
1540
|
+
);
|
|
1541
|
+
const notesService = new NotesService(api, vault);
|
|
1542
|
+
return { api, vault, memoryStore, notesService };
|
|
1543
|
+
}
|
|
1544
|
+
function buildServer(opts) {
|
|
1545
|
+
const { api, vault, memoryStore, notesService } = opts.services ?? createServices(opts);
|
|
1546
|
+
const server = new McpServer({ name: "virlow-mcp", version: VERSION });
|
|
1547
|
+
server.registerTool(
|
|
1548
|
+
"unlock",
|
|
1549
|
+
{
|
|
1550
|
+
description: "Open a local browser window where the USER signs in and enters their Virlow master password. This tool returns immediately once the browser is opened \u2014 it does NOT wait for the user to finish. Call the status tool afterward to confirm the vault unlocked. Never ask the user for their password or account credentials yourself, and never relay a password through this tool or any other \u2014 credentials are entered only in the browser form and never seen by any AI model."
|
|
1551
|
+
},
|
|
1552
|
+
wrap(async () => {
|
|
1553
|
+
const { url } = await startUnlockServer(api, vault, { openBrowser: true });
|
|
1554
|
+
return textResult(`Browser opened at ${url}. Complete the unlock form there, then run status to confirm.`);
|
|
1555
|
+
})
|
|
1556
|
+
);
|
|
1557
|
+
server.registerTool(
|
|
1558
|
+
"lock",
|
|
1559
|
+
{
|
|
1560
|
+
description: "Immediately lock the vault, discarding the in-memory decryption key and clearing all cached memories and notes. Use this when the user is done with a session or asks to lock explicitly. Safe to call even if already locked."
|
|
1561
|
+
},
|
|
1562
|
+
wrap(async () => {
|
|
1563
|
+
vault.lock();
|
|
1564
|
+
return textResult("Locked.");
|
|
1565
|
+
})
|
|
1566
|
+
);
|
|
1567
|
+
server.registerTool(
|
|
1568
|
+
"status",
|
|
1569
|
+
{
|
|
1570
|
+
description: "Report whether the vault is currently locked or unlocked, and which account it's unlocked as. Call this after unlock to confirm the user finished the browser form, and before any memory/notes tool if you are unsure of the current lock state."
|
|
1571
|
+
},
|
|
1572
|
+
wrap(async () => {
|
|
1573
|
+
return textResult(JSON.stringify(vault.status()));
|
|
1574
|
+
})
|
|
1575
|
+
);
|
|
1576
|
+
const requireMemories = async () => {
|
|
1577
|
+
if (!vault.isUnlocked()) return;
|
|
1578
|
+
if (!await memoriesAllowed(api)) {
|
|
1579
|
+
throw new Error(MEMORIES_DISABLED_MESSAGE);
|
|
1580
|
+
}
|
|
1581
|
+
};
|
|
1582
|
+
if (opts.memoriesEnabled !== false) {
|
|
1583
|
+
server.registerTool(
|
|
1584
|
+
"add_memory",
|
|
1585
|
+
{
|
|
1586
|
+
description: "Store a distilled fact about the user or project so any AI tool can recall it later. The label is a short title for the memory; the text is the fact itself. Use the namespace argument for project-specific facts (omit it for facts that apply everywhere). If a very similar memory already exists it is updated in place rather than duplicated. Memories are ordinary encrypted notes in the user's Memories folder \u2014 they can read and edit them, metadata included, in the Virlow app. Requires the vault to be unlocked. First use in this process downloads ~30MB of public embedding-model weights.",
|
|
1587
|
+
inputSchema: {
|
|
1588
|
+
label: z.string().min(1).max(80),
|
|
1589
|
+
text: z.string().min(1).max(4e3),
|
|
1590
|
+
namespace: z.string().max(100).optional(),
|
|
1591
|
+
tags: z.array(z.string().max(50)).max(20).optional(),
|
|
1592
|
+
confidence: z.string().max(20).optional(),
|
|
1593
|
+
source: z.string().max(50).optional()
|
|
1594
|
+
}
|
|
1595
|
+
},
|
|
1596
|
+
wrap(
|
|
1597
|
+
async (args) => {
|
|
1598
|
+
const meta = {};
|
|
1599
|
+
if (args.tags !== void 0) meta.tags = args.tags;
|
|
1600
|
+
if (args.confidence !== void 0) meta.confidence = args.confidence;
|
|
1601
|
+
if (args.source !== void 0) meta.source = args.source;
|
|
1602
|
+
await requireMemories();
|
|
1603
|
+
const result = await memoryStore.add(args.label, args.text, args.namespace, meta);
|
|
1604
|
+
const message = result.action === "updated" ? `updated existing memory ${result.id}` : `stored ${result.id}`;
|
|
1605
|
+
if (result.similar.length === 0) return textResult(message);
|
|
1606
|
+
const lines = result.similar.map(
|
|
1607
|
+
(s) => `- [${s.id}] (${s.similarity}) ${s.label} \u2014 ${truncate(s.text)}`
|
|
1608
|
+
);
|
|
1609
|
+
return textResult(`${message}
|
|
1610
|
+
|
|
1611
|
+
similar existing memories:
|
|
1612
|
+
${lines.join("\n")}`);
|
|
1613
|
+
}
|
|
1614
|
+
)
|
|
1615
|
+
);
|
|
1616
|
+
server.registerTool(
|
|
1617
|
+
"search_memory",
|
|
1618
|
+
{
|
|
1619
|
+
description: "Search stored memories by semantic similarity to a query. Call this at the start of a task with the task topic to recall relevant facts before acting. Results are ranked by similarity; use namespace to scope to a project. Requires the vault to be unlocked. First use in this process downloads ~30MB of public embedding-model weights.",
|
|
1620
|
+
inputSchema: {
|
|
1621
|
+
query: z.string().min(1).max(500),
|
|
1622
|
+
namespace: z.string().max(100).optional(),
|
|
1623
|
+
limit: z.number().int().min(1).max(50).optional()
|
|
1624
|
+
}
|
|
1625
|
+
},
|
|
1626
|
+
wrap(async (args) => {
|
|
1627
|
+
await requireMemories();
|
|
1628
|
+
const statsPromise = memoryStore.sync();
|
|
1629
|
+
const resultsPromise = memoryStore.search(args.query, args.namespace, args.limit ?? 10);
|
|
1630
|
+
const [stats, results] = await Promise.all([statsPromise, resultsPromise]);
|
|
1631
|
+
const lines = results.map(
|
|
1632
|
+
(r, i) => `${i + 1}. [${r.id}] (similarity ${r.similarity}) ${r.label} \u2014 ${r.text}${r.namespace ? ` (namespace: ${r.namespace})` : ""}${formatMeta(r.meta)}`
|
|
1633
|
+
);
|
|
1634
|
+
const body = lines.length > 0 ? lines.join("\n") : "(no matching memories found)";
|
|
1635
|
+
return textResult(`${body}
|
|
1636
|
+
|
|
1637
|
+
${formatSyncStats(stats)}`);
|
|
1638
|
+
})
|
|
1639
|
+
);
|
|
1640
|
+
server.registerTool(
|
|
1641
|
+
"list_memories",
|
|
1642
|
+
{
|
|
1643
|
+
description: "List cached memories, optionally filtered by namespace. Syncs with the server first so the list reflects the latest state across devices. Requires the vault to be unlocked.",
|
|
1644
|
+
inputSchema: {
|
|
1645
|
+
namespace: z.string().max(100).optional(),
|
|
1646
|
+
limit: z.number().int().min(1).max(50).optional()
|
|
1647
|
+
}
|
|
1648
|
+
},
|
|
1649
|
+
wrap(async (args) => {
|
|
1650
|
+
await requireMemories();
|
|
1651
|
+
const stats = await memoryStore.sync();
|
|
1652
|
+
const items = memoryStore.list(args.namespace).slice(0, args.limit ?? 50);
|
|
1653
|
+
const lines = items.map(
|
|
1654
|
+
(m, i) => `${i + 1}. [${m.id}] ${m.label} \u2014 ${m.text}${m.namespace ? ` (namespace: ${m.namespace})` : ""}${formatMeta(m.meta)}`
|
|
1655
|
+
);
|
|
1656
|
+
const body = lines.length > 0 ? lines.join("\n") : "(no memories found)";
|
|
1657
|
+
return textResult(`${body}
|
|
1658
|
+
|
|
1659
|
+
${formatSyncStats(stats)}`);
|
|
1660
|
+
})
|
|
1661
|
+
);
|
|
1662
|
+
server.registerTool(
|
|
1663
|
+
"update_memory",
|
|
1664
|
+
{
|
|
1665
|
+
description: "Update an existing memory by id. Any field you omit is left alone, including metadata you do not name and the date the memory was first created. Requires the vault to be unlocked.",
|
|
1666
|
+
inputSchema: {
|
|
1667
|
+
id: z.string().min(1),
|
|
1668
|
+
label: z.string().min(1).max(80).optional(),
|
|
1669
|
+
text: z.string().min(1).max(4e3).optional(),
|
|
1670
|
+
tags: z.array(z.string().max(50)).max(20).optional(),
|
|
1671
|
+
confidence: z.string().max(20).optional()
|
|
1672
|
+
}
|
|
1673
|
+
},
|
|
1674
|
+
wrap(
|
|
1675
|
+
async (args) => {
|
|
1676
|
+
if (args.label === void 0 && args.text === void 0 && args.tags === void 0 && args.confidence === void 0) {
|
|
1677
|
+
throw new Error("update_memory needs at least one of label, text, tags, or confidence");
|
|
1678
|
+
}
|
|
1679
|
+
const meta = {};
|
|
1680
|
+
if (args.tags !== void 0) meta.tags = args.tags;
|
|
1681
|
+
if (args.confidence !== void 0) meta.confidence = args.confidence;
|
|
1682
|
+
const changes = { meta };
|
|
1683
|
+
if (args.label !== void 0) changes.label = args.label;
|
|
1684
|
+
if (args.text !== void 0) changes.text = args.text;
|
|
1685
|
+
await requireMemories();
|
|
1686
|
+
await memoryStore.update(args.id, changes);
|
|
1687
|
+
return textResult(`updated memory ${args.id}`);
|
|
1688
|
+
}
|
|
1689
|
+
)
|
|
1690
|
+
);
|
|
1691
|
+
server.registerTool(
|
|
1692
|
+
"delete_memory",
|
|
1693
|
+
{
|
|
1694
|
+
description: "Move a memory to the Trash by id. It stops being recalled immediately, and the user can restore it from the Virlow app. Requires the vault to be unlocked.",
|
|
1695
|
+
inputSchema: {
|
|
1696
|
+
id: z.string().min(1)
|
|
1697
|
+
}
|
|
1698
|
+
},
|
|
1699
|
+
wrap(async (args) => {
|
|
1700
|
+
await requireMemories();
|
|
1701
|
+
await memoryStore.delete(args.id);
|
|
1702
|
+
return textResult(`trashed memory ${args.id}`);
|
|
1703
|
+
})
|
|
1704
|
+
);
|
|
1705
|
+
}
|
|
1706
|
+
server.registerTool(
|
|
1707
|
+
"search_notes",
|
|
1708
|
+
{
|
|
1709
|
+
description: "Full-text search over note titles and content, returning matching notes with a short snippet. Requires the vault to be unlocked.",
|
|
1710
|
+
inputSchema: {
|
|
1711
|
+
query: z.string().min(1).max(500),
|
|
1712
|
+
limit: z.number().int().min(1).max(50).optional()
|
|
1713
|
+
}
|
|
1714
|
+
},
|
|
1715
|
+
wrap(async (args) => {
|
|
1716
|
+
const { results, scanned, truncated } = await notesService.searchNotes(args.query, args.limit ?? 20);
|
|
1717
|
+
const lines = results.map((r, i) => `${i + 1}. [${r.id}] ${r.title} \u2014 ${r.snippet}`);
|
|
1718
|
+
const body = lines.length > 0 ? lines.join("\n") : "(no matching notes found)";
|
|
1719
|
+
const scanNote = truncated ? `
|
|
1720
|
+
|
|
1721
|
+
(scanned ${scanned} notes; more notes exist than were scanned \u2014 narrow your query for full coverage)` : "";
|
|
1722
|
+
return textResult(`${body}${scanNote}`);
|
|
1723
|
+
})
|
|
1724
|
+
);
|
|
1725
|
+
server.registerTool(
|
|
1726
|
+
"list_notes",
|
|
1727
|
+
{
|
|
1728
|
+
description: "List notes with pagination, optionally filtered by folder or starred/archived state. Requires the vault to be unlocked.",
|
|
1729
|
+
inputSchema: {
|
|
1730
|
+
folderId: z.string().optional(),
|
|
1731
|
+
starred: z.boolean().optional(),
|
|
1732
|
+
archived: z.boolean().optional(),
|
|
1733
|
+
page: z.number().int().min(1).optional(),
|
|
1734
|
+
limit: z.number().int().min(1).max(50).optional()
|
|
1735
|
+
}
|
|
1736
|
+
},
|
|
1737
|
+
wrap(
|
|
1738
|
+
async (args) => {
|
|
1739
|
+
const { notes, pagination, withheld, exposure } = await notesService.listNotes(args);
|
|
1740
|
+
const lines = notes.map(
|
|
1741
|
+
(n, i) => `${i + 1}. [${n.id}]${n.starred ? " *" : ""} ${n.title}`
|
|
1742
|
+
);
|
|
1743
|
+
const body = lines.length > 0 ? lines.join("\n") : "(no notes found)";
|
|
1744
|
+
return textResult(
|
|
1745
|
+
`${body}
|
|
1746
|
+
|
|
1747
|
+
page ${pagination.page} of ${pagination.pages} (${pagination.total} total)` + filteredNotice(withheld, exposure)
|
|
1748
|
+
);
|
|
1749
|
+
}
|
|
1750
|
+
)
|
|
1751
|
+
);
|
|
1752
|
+
server.registerTool(
|
|
1753
|
+
"list_folders",
|
|
1754
|
+
{
|
|
1755
|
+
description: "List the note folders exposed to MCP. Folders the user has not switched on are not listed; the response says how many were withheld. Requires the vault to be unlocked."
|
|
1756
|
+
},
|
|
1757
|
+
wrap(async () => {
|
|
1758
|
+
const { folders, withheld } = await notesService.listFolders();
|
|
1759
|
+
const lines = folders.map((f, i) => `${i + 1}. [${f.id}] ${f.name}`);
|
|
1760
|
+
const body = lines.length > 0 ? lines.join("\n") : "(no folders are exposed to MCP)";
|
|
1761
|
+
const notice = withheld > 0 ? `
|
|
1762
|
+
|
|
1763
|
+
(${withheld} folder${withheld === 1 ? "" : "s"} not shown: not exposed to MCP. Turn on "Expose to MCP" in a folder's settings in the Virlow app to include it.)` : "";
|
|
1764
|
+
return textResult(body + notice);
|
|
1765
|
+
})
|
|
1766
|
+
);
|
|
1767
|
+
server.registerTool(
|
|
1768
|
+
"read_note",
|
|
1769
|
+
{
|
|
1770
|
+
description: "Read the full title and content of a note by id. Requires the vault to be unlocked.",
|
|
1771
|
+
inputSchema: {
|
|
1772
|
+
id: z.string().min(1)
|
|
1773
|
+
}
|
|
1774
|
+
},
|
|
1775
|
+
wrap(async (args) => {
|
|
1776
|
+
const note = await notesService.readNote(args.id);
|
|
1777
|
+
return textResult(`# ${note.title}
|
|
1778
|
+
|
|
1779
|
+
${note.content}`);
|
|
1780
|
+
})
|
|
1781
|
+
);
|
|
1782
|
+
server.registerTool(
|
|
1783
|
+
"create_note",
|
|
1784
|
+
{
|
|
1785
|
+
description: "Create a new note with a title and content, optionally placed in a folder. Requires the vault to be unlocked.",
|
|
1786
|
+
inputSchema: {
|
|
1787
|
+
title: z.string().min(1).max(200),
|
|
1788
|
+
content: z.string().max(5e4).optional(),
|
|
1789
|
+
folderId: z.string().optional(),
|
|
1790
|
+
type: z.string().max(50).optional()
|
|
1791
|
+
}
|
|
1792
|
+
},
|
|
1793
|
+
wrap(async (args) => {
|
|
1794
|
+
const note = await notesService.createNote(args.title, args.content ?? "", args.folderId, args.type);
|
|
1795
|
+
return textResult(`created note ${note.id}`);
|
|
1796
|
+
})
|
|
1797
|
+
);
|
|
1798
|
+
server.registerTool(
|
|
1799
|
+
"update_note",
|
|
1800
|
+
{
|
|
1801
|
+
description: "Update the title and/or content of an existing note by id. Only the provided fields are changed. Requires the vault to be unlocked.",
|
|
1802
|
+
inputSchema: {
|
|
1803
|
+
id: z.string().min(1),
|
|
1804
|
+
title: z.string().min(1).max(200).optional(),
|
|
1805
|
+
content: z.string().max(5e4).optional()
|
|
1806
|
+
}
|
|
1807
|
+
},
|
|
1808
|
+
wrap(async (args) => {
|
|
1809
|
+
await notesService.updateNote(args.id, { title: args.title, content: args.content });
|
|
1810
|
+
return textResult(`updated note ${args.id}`);
|
|
1811
|
+
})
|
|
1812
|
+
);
|
|
1813
|
+
server.registerTool(
|
|
1814
|
+
"move_note",
|
|
1815
|
+
{
|
|
1816
|
+
description: "Move a note into a different folder, or to no folder by passing folderId: null. Requires the vault to be unlocked.",
|
|
1817
|
+
inputSchema: {
|
|
1818
|
+
id: z.string().min(1),
|
|
1819
|
+
folderId: z.string().nullable()
|
|
1820
|
+
}
|
|
1821
|
+
},
|
|
1822
|
+
wrap(async (args) => {
|
|
1823
|
+
await notesService.moveNote(args.id, args.folderId);
|
|
1824
|
+
return textResult(`moved note ${args.id}`);
|
|
1825
|
+
})
|
|
1826
|
+
);
|
|
1827
|
+
server.registerTool(
|
|
1828
|
+
"set_star",
|
|
1829
|
+
{
|
|
1830
|
+
description: "Star or unstar a note by id. Requires the vault to be unlocked.",
|
|
1831
|
+
inputSchema: {
|
|
1832
|
+
id: z.string().min(1),
|
|
1833
|
+
starred: z.boolean()
|
|
1834
|
+
}
|
|
1835
|
+
},
|
|
1836
|
+
wrap(async (args) => {
|
|
1837
|
+
await notesService.setStar(args.id, args.starred);
|
|
1838
|
+
return textResult(args.starred ? `starred note ${args.id}` : `unstarred note ${args.id}`);
|
|
1839
|
+
})
|
|
1840
|
+
);
|
|
1841
|
+
server.registerTool(
|
|
1842
|
+
"archive_note",
|
|
1843
|
+
{
|
|
1844
|
+
description: "Archive or unarchive a note by id. Requires the vault to be unlocked.",
|
|
1845
|
+
inputSchema: {
|
|
1846
|
+
id: z.string().min(1),
|
|
1847
|
+
archived: z.boolean()
|
|
1848
|
+
}
|
|
1849
|
+
},
|
|
1850
|
+
wrap(async (args) => {
|
|
1851
|
+
await notesService.archiveNote(args.id, args.archived);
|
|
1852
|
+
return textResult(args.archived ? `archived note ${args.id}` : `unarchived note ${args.id}`);
|
|
1853
|
+
})
|
|
1854
|
+
);
|
|
1855
|
+
server.registerTool(
|
|
1856
|
+
"trash_note",
|
|
1857
|
+
{
|
|
1858
|
+
description: "Move a note to the trash by id. Requires the vault to be unlocked.",
|
|
1859
|
+
inputSchema: {
|
|
1860
|
+
id: z.string().min(1)
|
|
1861
|
+
}
|
|
1862
|
+
},
|
|
1863
|
+
wrap(async (args) => {
|
|
1864
|
+
await notesService.trashNote(args.id);
|
|
1865
|
+
return textResult(`trashed note ${args.id}`);
|
|
1866
|
+
})
|
|
1867
|
+
);
|
|
1868
|
+
server.registerTool(
|
|
1869
|
+
"create_folder",
|
|
1870
|
+
{
|
|
1871
|
+
description: "Create a new note folder, optionally nested under a parent folder. Requires the vault to be unlocked.",
|
|
1872
|
+
inputSchema: {
|
|
1873
|
+
name: z.string().min(1).max(100),
|
|
1874
|
+
parentId: z.string().nullable().optional()
|
|
1875
|
+
}
|
|
1876
|
+
},
|
|
1877
|
+
wrap(async (args) => {
|
|
1878
|
+
const folder = await notesService.createFolder(args.name, args.parentId);
|
|
1879
|
+
return textResult(`created folder ${folder.id}`);
|
|
1880
|
+
})
|
|
1881
|
+
);
|
|
1882
|
+
return { server, vault };
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// src/http.ts
|
|
1886
|
+
import { createServer as createServer2 } from "node:http";
|
|
1887
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1888
|
+
import { randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
|
|
1889
|
+
import path3 from "node:path";
|
|
1890
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
1891
|
+
function defaultTokenPath(homeDir) {
|
|
1892
|
+
return path3.join(homeDir, ".virlow-mcp", "http-token");
|
|
1893
|
+
}
|
|
1894
|
+
async function loadOrCreateToken(tokenPath) {
|
|
1895
|
+
try {
|
|
1896
|
+
const existing = (await readFile2(tokenPath, "utf8")).trim();
|
|
1897
|
+
if (existing.length > 0) return existing;
|
|
1898
|
+
} catch {
|
|
1899
|
+
}
|
|
1900
|
+
const token = randomBytes2(32).toString("base64url");
|
|
1901
|
+
await mkdir2(path3.dirname(tokenPath), { recursive: true, mode: 448 });
|
|
1902
|
+
await writeFile2(tokenPath, `${token}
|
|
1903
|
+
`, { mode: 384 });
|
|
1904
|
+
return token;
|
|
1905
|
+
}
|
|
1906
|
+
function tokenMatches(provided, expected) {
|
|
1907
|
+
const a = Buffer.from(provided);
|
|
1908
|
+
const b = Buffer.from(expected);
|
|
1909
|
+
if (a.length !== b.length) {
|
|
1910
|
+
timingSafeEqual(b, b);
|
|
1911
|
+
return false;
|
|
1912
|
+
}
|
|
1913
|
+
return timingSafeEqual(a, b);
|
|
1914
|
+
}
|
|
1915
|
+
function bearerFrom(header) {
|
|
1916
|
+
if (!header) return null;
|
|
1917
|
+
const match = /^Bearer[ ]+(.+)$/i.exec(header.trim());
|
|
1918
|
+
return match ? match[1].trim() : null;
|
|
1919
|
+
}
|
|
1920
|
+
async function handleWithFreshTransport(server, req, res) {
|
|
1921
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1922
|
+
sessionIdGenerator: void 0
|
|
1923
|
+
});
|
|
1924
|
+
res.on("close", () => {
|
|
1925
|
+
void transport.close();
|
|
1926
|
+
void server.close();
|
|
1927
|
+
});
|
|
1928
|
+
await server.connect(transport);
|
|
1929
|
+
await transport.handleRequest(req, res);
|
|
1930
|
+
}
|
|
1931
|
+
async function startHttpServer(opts) {
|
|
1932
|
+
const host = opts.host ?? "127.0.0.1";
|
|
1933
|
+
const mcpPath = opts.path ?? "/mcp";
|
|
1934
|
+
const httpServer = createServer2((req, res) => {
|
|
1935
|
+
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
1936
|
+
if (url.pathname === "/health") {
|
|
1937
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1938
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
if (url.pathname !== mcpPath) {
|
|
1942
|
+
res.writeHead(404).end();
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
const provided = bearerFrom(req.headers.authorization);
|
|
1946
|
+
if (provided === null || !tokenMatches(provided, opts.token)) {
|
|
1947
|
+
res.writeHead(401, {
|
|
1948
|
+
"content-type": "application/json",
|
|
1949
|
+
"www-authenticate": "Bearer"
|
|
1950
|
+
});
|
|
1951
|
+
res.end(JSON.stringify({ error: "unauthorized" }));
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
void handleWithFreshTransport(opts.makeServer(), req, res);
|
|
1955
|
+
});
|
|
1956
|
+
await new Promise((resolve, reject) => {
|
|
1957
|
+
httpServer.once("error", reject);
|
|
1958
|
+
httpServer.listen(opts.port ?? 0, host, () => {
|
|
1959
|
+
httpServer.removeListener("error", reject);
|
|
1960
|
+
resolve();
|
|
1961
|
+
});
|
|
1962
|
+
});
|
|
1963
|
+
const address = httpServer.address();
|
|
1964
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
1965
|
+
return {
|
|
1966
|
+
port,
|
|
1967
|
+
host,
|
|
1968
|
+
close: () => new Promise((resolve) => {
|
|
1969
|
+
httpServer.close(() => resolve());
|
|
1970
|
+
})
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// src/cli.ts
|
|
1975
|
+
function flagValue(name) {
|
|
1976
|
+
const prefix = `--${name}=`;
|
|
1977
|
+
const inline = process.argv.find((a) => a.startsWith(prefix));
|
|
1978
|
+
if (inline) return inline.slice(prefix.length);
|
|
1979
|
+
const idx = process.argv.indexOf(`--${name}`);
|
|
1980
|
+
return idx !== -1 ? process.argv[idx + 1] : void 0;
|
|
1981
|
+
}
|
|
1982
|
+
async function main() {
|
|
1983
|
+
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
1984
|
+
process.stdout.write(`${VERSION}
|
|
1985
|
+
`);
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
const apiUrl = process.env.VIRLOW_API_URL ?? "https://api.virlow.com";
|
|
1989
|
+
const modelCacheDir = path4.join(os2.homedir(), ".virlow-mcp", "models");
|
|
1990
|
+
const embeddingCacheDir = path4.join(os2.homedir(), ".virlow-mcp", "cache");
|
|
1991
|
+
const memoriesEnabled = !process.argv.includes("--no-memories");
|
|
1992
|
+
const serverOpts = { apiUrl, modelCacheDir, embeddingCacheDir, memoriesEnabled };
|
|
1993
|
+
const services = createServices(serverOpts);
|
|
1994
|
+
const { server } = buildServer({ ...serverOpts, services });
|
|
1995
|
+
if (process.argv.includes("--http")) {
|
|
1996
|
+
const token = await loadOrCreateToken(
|
|
1997
|
+
flagValue("token-file") ?? defaultTokenPath(os2.homedir())
|
|
1998
|
+
);
|
|
1999
|
+
const portFlag = flagValue("port");
|
|
2000
|
+
const host = flagValue("host") ?? "127.0.0.1";
|
|
2001
|
+
const running = await startHttpServer({
|
|
2002
|
+
makeServer: () => buildServer({ ...serverOpts, services }).server,
|
|
2003
|
+
token,
|
|
2004
|
+
host,
|
|
2005
|
+
...portFlag !== void 0 ? { port: Number(portFlag) } : {}
|
|
2006
|
+
});
|
|
2007
|
+
process.stderr.write(
|
|
2008
|
+
`virlow-mcp: listening on http://${running.host}:${running.port}/mcp
|
|
2009
|
+
`
|
|
2010
|
+
);
|
|
2011
|
+
if (host !== "127.0.0.1") {
|
|
2012
|
+
process.stderr.write(
|
|
2013
|
+
"virlow-mcp: WARNING \u2014 bound to a non-loopback address. Anyone who can reach this port and holds the token can read your decrypted notes.\n"
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
2016
|
+
process.stderr.write(
|
|
2017
|
+
`virlow-mcp: bearer token in ${flagValue("token-file") ?? defaultTokenPath(os2.homedir())}
|
|
2018
|
+
`
|
|
2019
|
+
);
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
const transport = new StdioServerTransport();
|
|
2023
|
+
await server.connect(transport);
|
|
2024
|
+
}
|
|
2025
|
+
main().catch((err) => {
|
|
2026
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2027
|
+
process.stderr.write(`virlow-mcp: fatal error: ${message}
|
|
2028
|
+
`);
|
|
2029
|
+
process.exitCode = 1;
|
|
2030
|
+
});
|