@lotargo/memory_plugin 1.4.0 → 1.4.5

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.
@@ -1,90 +1,385 @@
1
- import http from "node:http";
2
- import { saveSecrets, deleteSecrets } from "../config/auth_store.js";
3
- import { updateConfig } from "../config/config_manager.js";
4
-
5
- // Starts a temporary loopback HTTP server to listen for callback from browser login
6
- export function startAuthLoopbackServer(port = 48900) {
7
- return new Promise((resolve, reject) => {
8
- const server = http.createServer((req, res) => {
9
- const url = new URL(req.url, `http://${req.headers.host}`);
10
-
11
- if (url.pathname === "/callback") {
12
- const token = url.searchParams.get("token");
13
- const dbUrl = url.searchParams.get("db_url") || url.searchParams.get("dbUrl");
14
-
15
- if (!token || !dbUrl) {
16
- res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
17
- res.end("Ошибка: Токен (token) и URL базы данных (db_url) обязательны!");
18
- server.close();
19
- reject(new Error("Missing token or db_url in auth callback"));
20
- return;
21
- }
22
-
23
- // Save secrets securely and configure Turso Url
24
- saveSecrets({ token, dbUrl });
25
- updateConfig({ tursoUrl: dbUrl });
26
-
27
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
28
- res.end(`
29
- <html>
30
- <body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
31
- <h2 style="color: #2e7d32;">Авторизация успешна!</h2>
32
- <p>Плагин успешно принял учетные данные. Вы можете закрыть эту вкладку.</p>
33
- </body>
34
- </html>
35
- `);
36
-
37
- server.close(() => {
38
- resolve({ token, dbUrl });
39
- });
40
- } else {
41
- res.writeHead(404, { "Content-Type": "text/plain" });
42
- res.end("Not Found");
43
- }
44
- });
45
-
46
- server.on("error", (err) => {
47
- reject(err);
48
- });
49
-
50
- server.listen(port, "127.0.0.1", () => {
51
- console.log(`\n [*] Ожидание авторизации на локальном порту http://localhost:${port}/callback...`);
52
- });
53
- });
54
- }
55
-
56
- // Perform Cloud login flow
57
- export async function loginToCloud({ customPort = 48900, simulated = false, simulatedParams = null } = {}) {
58
- // Opening the browser, etc. (we skip browser auto-open in automated test runs/simulations)
59
- const loginUrl = `https://auth.lotargo.com/login?device_id=memory_plugin&port=${customPort}`;
60
- console.log(`\n [CLOUD] Пожалуйста, откройте системный браузер для авторизации:`);
61
- console.log(` \x1b[36m${loginUrl}\x1b[0m\n`);
62
-
63
- if (simulated && simulatedParams) {
64
- // Send local HTTP request to simulate loopback
65
- return new Promise((resolve, reject) => {
66
- const serverPromise = startAuthLoopbackServer(customPort);
67
-
68
- const req = http.request(
69
- `http://127.0.0.1:${customPort}/callback?token=${encodeURIComponent(simulatedParams.token)}&db_url=${encodeURIComponent(simulatedParams.dbUrl)}`,
70
- { method: "GET" },
71
- (res) => {
72
- res.resume();
73
- }
74
- );
75
- req.on("error", (e) => reject(e));
76
- req.end();
77
-
78
- resolve(serverPromise);
79
- });
80
- }
81
-
82
- return startAuthLoopbackServer(customPort);
83
- }
84
-
85
- // Logout and reset configurations
86
- export function logoutFromCloud() {
87
- const deleted = deleteSecrets();
88
- updateConfig({ tursoUrl: "", mode: "only-local" });
89
- return deleted;
90
- }
1
+ import http from "node:http";
2
+ import crypto from "node:crypto";
3
+ import { spawn } from "node:child_process";
4
+ import { saveSecrets, deleteSecrets } from "../config/auth_store.js";
5
+ import { updateConfig } from "../config/config_manager.js";
6
+
7
+ export const TURSO_API_BASE = () => process.env.TURSO_API_BASE || "https://api.turso.tech";
8
+
9
+ function openBrowser(url) {
10
+ const platform = process.platform;
11
+ try {
12
+ if (platform === "win32") {
13
+ spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
14
+ } else if (platform === "darwin") {
15
+ spawn("open", [url], { stdio: "ignore", detached: true }).unref();
16
+ } else {
17
+ spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
18
+ }
19
+ } catch (err) {
20
+ // Browser auto-open is best-effort; the printed URL can be opened manually.
21
+ }
22
+ }
23
+
24
+ // Starts a temporary loopback HTTP server to receive the OAuth callback.
25
+ // Turso redirects the browser back to the root path: /?jwt=<JWT>&username=<USERNAME>
26
+ export function startAuthLoopbackServer(port = 48900) {
27
+ return new Promise((resolve, reject) => {
28
+ const server = http.createServer((req, res) => {
29
+ const url = new URL(req.url, `http://${req.headers.host}`);
30
+ const token = url.searchParams.get("jwt") || url.searchParams.get("token");
31
+ const username = url.searchParams.get("username");
32
+ const error = url.searchParams.get("error");
33
+
34
+ if (token) {
35
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
36
+ res.end(`
37
+ <!DOCTYPE html>
38
+ <html lang="en">
39
+ <head>
40
+ <meta charset="utf-8" />
41
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
42
+ <title>Authorization Successful</title>
43
+ <style>
44
+ * { margin: 0; padding: 0; box-sizing: border-box; }
45
+ body {
46
+ min-height: 100vh;
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: center;
50
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
51
+ -webkit-font-smoothing: antialiased;
52
+ background: radial-gradient(1200px 600px at 50% -10%, #1c2028 0%, #101218 55%, #0c0e13 100%);
53
+ color: #e8ebf2;
54
+ padding: 24px;
55
+ }
56
+ .card {
57
+ max-width: 420px;
58
+ width: 100%;
59
+ background: #161a21;
60
+ border: 1px solid rgba(255, 255, 255, 0.07);
61
+ border-radius: 20px;
62
+ padding: 46px 38px;
63
+ text-align: center;
64
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
65
+ }
66
+ .badge {
67
+ width: 76px;
68
+ height: 76px;
69
+ margin: 0 auto 26px;
70
+ border-radius: 50%;
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ background: rgba(94, 224, 154, 0.10);
75
+ border: 1px solid rgba(94, 224, 154, 0.28);
76
+ }
77
+ .badge svg { width: 36px; height: 36px; }
78
+ h1 { font-size: 22px; font-weight: 600; letter-spacing: 0.2px; color: #f2f4f8; margin-bottom: 12px; }
79
+ p { font-size: 14px; line-height: 1.65; color: #9aa3b2; }
80
+ .hint { margin-top: 24px; font-size: 12.5px; color: #6f7887; }
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <div class="card">
85
+ <div class="badge">
86
+ <svg viewBox="0 0 24 24" fill="none" stroke="#5ee09a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
87
+ <path d="M20 6 9 17l-5-5" />
88
+ </svg>
89
+ </div>
90
+ <h1>Authorization successful</h1>
91
+ <p>Your credentials were received and stored securely on this device.</p>
92
+ <div class="hint">You can now close this tab and return to the terminal.</div>
93
+ </div>
94
+ </body>
95
+ </html>
96
+ `);
97
+
98
+ server.close(() => {
99
+ resolve({ token, username: username || "" });
100
+ });
101
+ } else if (error) {
102
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
103
+ res.end(`
104
+ <!DOCTYPE html>
105
+ <html lang="en">
106
+ <head>
107
+ <meta charset="utf-8" />
108
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
109
+ <title>Authorization Failed</title>
110
+ <style>
111
+ * { margin: 0; padding: 0; box-sizing: border-box; }
112
+ body {
113
+ min-height: 100vh;
114
+ display: flex;
115
+ align-items: center;
116
+ justify-content: center;
117
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
118
+ -webkit-font-smoothing: antialiased;
119
+ background: radial-gradient(1200px 600px at 50% -10%, #1c2028 0%, #101218 55%, #0c0e13 100%);
120
+ color: #e8ebf2;
121
+ padding: 24px;
122
+ }
123
+ .card {
124
+ max-width: 400px;
125
+ width: 100%;
126
+ background: #161a21;
127
+ border: 1px solid rgba(255, 255, 255, 0.07);
128
+ border-radius: 20px;
129
+ padding: 40px 34px;
130
+ text-align: center;
131
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
132
+ }
133
+ h1 { font-size: 20px; font-weight: 600; color: #f2f4f8; margin-bottom: 12px; }
134
+ p { font-size: 14px; line-height: 1.65; color: #9aa3b2; }
135
+ </style>
136
+ </head>
137
+ <body>
138
+ <div class="card">
139
+ <h1>Authorization failed</h1>
140
+ <p>An error occurred during the login flow. Close this tab, return to the terminal, and try again.</p>
141
+ </div>
142
+ </body>
143
+ </html>
144
+ `);
145
+ server.close(() => reject(new Error(`Authentication error: ${error}`)));
146
+ } else {
147
+ res.writeHead(404, { "Content-Type": "text/plain" });
148
+ res.end("Not Found");
149
+ }
150
+ });
151
+
152
+ server.on("error", (err) => {
153
+ reject(err);
154
+ });
155
+
156
+ server.listen(port, "127.0.0.1", () => {
157
+ console.log(`\n [*] Waiting for authorization on local port http://localhost:${port}/...`);
158
+ });
159
+ });
160
+ }
161
+
162
+ async function apiRequest(token, pathname, { method = "GET", body } = {}) {
163
+ const res = await fetch(`${TURSO_API_BASE()}${pathname}`, {
164
+ method,
165
+ headers: {
166
+ Authorization: `Bearer ${token}`,
167
+ "Content-Type": "application/json",
168
+ Accept: "application/json",
169
+ },
170
+ body: body ? JSON.stringify(body) : undefined,
171
+ });
172
+
173
+ const text = await res.text();
174
+ let data = null;
175
+ try {
176
+ data = JSON.parse(text);
177
+ } catch {}
178
+
179
+ if (!res.ok) {
180
+ const err = new Error(data?.error || `Turso API ${res.status}: ${text}`);
181
+ err.status = res.status;
182
+ throw err;
183
+ }
184
+ return data;
185
+ }
186
+
187
+ // Validate the account JWT obtained from OAuth and return current-user info.
188
+ export async function validateTursoToken(token) {
189
+ return apiRequest(token, "/v1/current-user");
190
+ }
191
+
192
+ export async function listOrganizations(token) {
193
+ const data = await apiRequest(token, "/v1/organizations");
194
+ const orgs = data?.organizations || [];
195
+ return orgs.map((o) => ({
196
+ slug: o.slug || o.Slug || o.id || o.Id || null,
197
+ name: o.name || o.Name || null,
198
+ id: o.id || o.Id || null,
199
+ }));
200
+ }
201
+
202
+ export async function listDatabases(token, org) {
203
+ const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/databases`);
204
+ const dbs = data?.databases || [];
205
+ return dbs.map((d) => ({
206
+ name: d.name || d.Name,
207
+ hostname: d.hostname || d.Hostname,
208
+ id: d.id || d.Id,
209
+ }));
210
+ }
211
+
212
+ export async function createDatabase(token, org, name) {
213
+ try {
214
+ const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/databases`, {
215
+ method: "POST",
216
+ body: { name },
217
+ });
218
+ const d = data?.database || data;
219
+ return { name: d.name || d.Name, hostname: d.hostname || d.Hostname, id: d.id || d.Id };
220
+ } catch (err) {
221
+ // Fresh accounts have no default group; create one, then retry.
222
+ if (!String(err.message || "").toLowerCase().includes("group")) {
223
+ throw err;
224
+ }
225
+ console.log(` [CLOUD] No group found. Creating group "default"...`);
226
+ await createGroup(token, org, "default");
227
+ const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/databases`, {
228
+ method: "POST",
229
+ body: { name, group: "default" },
230
+ });
231
+ const d = data?.database || data;
232
+ return { name: d.name || d.Name, hostname: d.hostname || d.Hostname, id: d.id || d.Id };
233
+ }
234
+ }
235
+
236
+ // Turso's public closest-region endpoint (no auth required).
237
+ // Returns e.g. { server: "aws-eu-west-1", client: "ams" }.
238
+ async function getClosestLocation() {
239
+ if (process.env.TURSO_LOCATION) return process.env.TURSO_LOCATION;
240
+ const fallback = "ams";
241
+ try {
242
+ const res = await fetch("https://region.turso.io/", { signal: AbortSignal.timeout(8000) });
243
+ const data = await res.json().catch(() => null);
244
+ const loc = data?.server || data?.client || null;
245
+ if (loc && /^[a-z0-9-]+$/i.test(loc)) return loc;
246
+ } catch {
247
+ // ignore
248
+ }
249
+ return fallback;
250
+ }
251
+
252
+ export async function createGroup(token, org, name) {
253
+ // Always provide an explicit location: Turso's internal auto-lookup fails
254
+ // with "invalid location: Host not found" when the group has no location.
255
+ const location = await getClosestLocation();
256
+ const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/groups`, {
257
+ method: "POST",
258
+ body: { name, location },
259
+ });
260
+ return data?.group || data;
261
+ }
262
+
263
+ export async function createDatabaseToken(token, org, db, { expiration = "never", authorization = "full-access" } = {}) {
264
+ const data = await apiRequest(
265
+ token,
266
+ `/v1/organizations/${encodeURIComponent(org)}/databases/${encodeURIComponent(db)}/auth/tokens?expiration=${encodeURIComponent(expiration)}&authorization=${encodeURIComponent(authorization)}`,
267
+ { method: "POST", body: {} }
268
+ );
269
+ return data?.jwt || null;
270
+ }
271
+
272
+ function dbHostname(org, dbName) {
273
+ return `${dbName}-${org}.turso.io`;
274
+ }
275
+
276
+ // Perform the full cloud login flow:
277
+ // 1. OAuth browser flow against Turso (api.turso.tech).
278
+ // 2. Validate the received account JWT.
279
+ // 3. Resolve an organization and pick/create a database.
280
+ // 4. Mint a full-access token for that database.
281
+ // 5. Persist the encrypted token + dbUrl and mark the session as authorized.
282
+ export async function loginToCloud({
283
+ customPort = 48900,
284
+ simulated = false,
285
+ simulatedParams = null,
286
+ autoCreate = true,
287
+ databaseName = null,
288
+ } = {}) {
289
+ const state = crypto.randomBytes(16).toString("hex");
290
+ const loginUrl = `${TURSO_API_BASE()}/?port=${customPort}&redirect=true&state=${state}&type=cli`;
291
+
292
+ console.log(`\n [CLOUD] Please open your system browser to authorize:`);
293
+ console.log(` \x1b[36m${loginUrl}\x1b[0m\n`);
294
+
295
+ let received;
296
+ if (simulated && simulatedParams) {
297
+ received = await new Promise((resolve, reject) => {
298
+ const serverPromise = startAuthLoopbackServer(customPort);
299
+ const req = http.request(
300
+ `http://127.0.0.1:${customPort}/?jwt=${encodeURIComponent(simulatedParams.jwt)}&username=${encodeURIComponent(simulatedParams.username)}`,
301
+ { method: "GET" },
302
+ (res) => {
303
+ res.resume();
304
+ }
305
+ );
306
+ req.on("error", (e) => reject(e));
307
+ req.end();
308
+ serverPromise.then(resolve).catch(reject);
309
+ });
310
+ } else {
311
+ openBrowser(loginUrl);
312
+ received = await startAuthLoopbackServer(customPort);
313
+ }
314
+
315
+ const { token, username } = received;
316
+
317
+ // Step 2: validate the account token
318
+ let userInfo = null;
319
+ try {
320
+ userInfo = await validateTursoToken(token);
321
+ } catch (err) {
322
+ throw new Error(`Token validation failed: ${err.message}`);
323
+ }
324
+ const accountUsername = username || userInfo?.username || userInfo?.name || "user";
325
+ console.log(` [OK] Token is valid. User: ${accountUsername}`);
326
+
327
+ // Step 3: resolve organization + database
328
+ const orgs = await listOrganizations(token);
329
+ let org;
330
+ let orgName;
331
+ if (orgs && orgs.length > 0) {
332
+ org = orgs[0].slug || orgs[0].name || orgs[0].id || String(orgs[0]);
333
+ orgName = orgs[0].name || org;
334
+ } else {
335
+ // Personal accounts are not listed in /v1/organizations, but their own
336
+ // username acts as the organization namespace in the Platform API.
337
+ org = accountUsername;
338
+ orgName = accountUsername;
339
+ console.log(` [CLOUD] No organizations found. Using personal account "${org}" as the database namespace.`);
340
+ }
341
+
342
+ const dbs = await listDatabases(token, org);
343
+ if (dbs.length > 0) {
344
+ console.log(`\n [CLOUD] Databases in organization "${orgName}":`);
345
+ dbs.forEach((d, i) => console.log(` ${i + 1}. ${d.name}`));
346
+ }
347
+
348
+ let dbName = databaseName;
349
+ if (!dbName) {
350
+ if (dbs.length > 0) {
351
+ dbName = dbs[0].name;
352
+ console.log(`\n [CLOUD] Using existing database: "${dbName}"`);
353
+ } else if (autoCreate) {
354
+ dbName = `memory-${accountUsername}`;
355
+ console.log(`\n [CLOUD] No database found. Creating "${dbName}"...`);
356
+ await createDatabase(token, org, dbName);
357
+ console.log(` [OK] Database "${dbName}" created.`);
358
+ } else {
359
+ throw new Error("No databases found and autoCreate is disabled.");
360
+ }
361
+ }
362
+
363
+ // Step 4: mint a full-access token for the database
364
+ console.log(" [CLOUD] Issuing database access token...");
365
+ const dbJwt = await createDatabaseToken(token, org, dbName);
366
+ if (!dbJwt) {
367
+ throw new Error("Failed to create database auth token.");
368
+ }
369
+
370
+ const dbUrl = `libsql://${dbHostname(org, dbName)}`;
371
+
372
+ // Step 5: persist secrets and mark authorized
373
+ saveSecrets({ token: dbJwt, dbUrl, username: accountUsername, org, db: dbName, authorized: true });
374
+ updateConfig({ tursoUrl: dbUrl, authorized: true, username: accountUsername });
375
+
376
+ console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Endpoint: ${dbUrl}\x1b[0m`);
377
+ return { token: dbJwt, dbUrl, username: accountUsername, org, db: dbName, authorized: true };
378
+ }
379
+
380
+ // Logout and reset configurations
381
+ export function logoutFromCloud() {
382
+ const deleted = deleteSecrets();
383
+ updateConfig({ tursoUrl: "", mode: "only-local", authorized: false, username: "" });
384
+ return deleted;
385
+ }
package/mcp-server/cli.js CHANGED
@@ -660,28 +660,27 @@ export async function runCli() {
660
660
  return;
661
661
  }
662
662
 
663
- const cliArgs = process.argv.slice(2);
664
663
  if (cliArgs.includes("login")) {
665
- console.log("\n [CLOUD] Запуск процесса авторизации в облаке Turso...");
664
+ console.log("\n [CLOUD] Starting Turso cloud authorization...");
666
665
  const { loginToCloud } = await import("./admin/auth.js");
667
666
  try {
668
667
  const secrets = await loginToCloud();
669
- console.log(`\n \x1b[32m[OK] Успешный вход в облако! Подключен к endpoint: ${secrets.dbUrl}\x1b[0m\n`);
668
+ console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Connected to endpoint: ${secrets.dbUrl}\x1b[0m\n`);
670
669
  } catch (e) {
671
- console.error(`\n \x1b[31m[ERROR] Ошибка авторизации: ${e.message}\x1b[0m\n`);
670
+ console.error(`\n \x1b[31m[ERROR] Authorization failed: ${e.message}\x1b[0m\n`);
672
671
  process.exit(1);
673
672
  }
674
673
  return;
675
674
  }
676
675
 
677
676
  if (cliArgs.includes("logout")) {
678
- console.log("\n [CLOUD] Выход из облака...");
677
+ console.log("\n [CLOUD] Signing out of the cloud...");
679
678
  const { logoutFromCloud } = await import("./admin/auth.js");
680
679
  const deleted = logoutFromCloud();
681
680
  if (deleted) {
682
- console.log(" \x1b[32m[OK] Вы вышли из облака. Секретные ключи удалены. Режим изменен на only-local.\x1b[0m\n");
681
+ console.log(" \x1b[32m[OK] You have been signed out. Encrypted secrets removed. Mode reverted to only-local.\x1b[0m\n");
683
682
  } else {
684
- console.log(" [*] Режим изменен на only-local. Сессионных токенов не было обнаружено.\x1b[0m\n");
683
+ console.log(" [*] Mode reverted to only-local. No session tokens were found.\x1b[0m\n");
685
684
  }
686
685
  return;
687
686
  }
@@ -1830,35 +1829,35 @@ export async function runCli() {
1830
1829
  }
1831
1830
  case "cloud_login": {
1832
1831
  console.clear();
1833
- console.log("\n [CLOUD] Запуск процесса авторизации в облаке Turso...");
1832
+ console.log("\n [CLOUD] Starting Turso cloud authorization...");
1834
1833
  const { loginToCloud } = await import("./admin/auth.js");
1835
1834
  try {
1836
1835
  const secrets = await loginToCloud();
1837
- console.log(`\n \x1b[32m[OK] Успешный вход в облако! Подключен к endpoint: ${secrets.dbUrl}\x1b[0m\n`);
1836
+ console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Connected to endpoint: ${secrets.dbUrl}\x1b[0m\n`);
1838
1837
  } catch (e) {
1839
- console.error(`\n \x1b[31m[ERROR] Ошибка авторизации: ${e.message}\x1b[0m\n`);
1838
+ console.error(`\n \x1b[31m[ERROR] Authorization failed: ${e.message}\x1b[0m\n`);
1840
1839
  }
1841
1840
  await waitForEnter();
1842
1841
  break;
1843
1842
  }
1844
1843
  case "cloud_logout": {
1845
1844
  console.clear();
1846
- console.log("\n [CLOUD] Выход из облака...");
1845
+ console.log("\n [CLOUD] Signing out of the cloud...");
1847
1846
  const { logoutFromCloud } = await import("./admin/auth.js");
1848
1847
  const deleted = logoutFromCloud();
1849
1848
  if (deleted) {
1850
- console.log(" \x1b[32m[OK] Вы вышли из облака. Секретные ключи удалены. Режим изменен на only-local.\x1b[0m\n");
1849
+ console.log(" \x1b[32m[OK] You have been signed out. Encrypted secrets removed. Mode reverted to only-local.\x1b[0m\n");
1851
1850
  } else {
1852
- console.log(" [*] Режим изменен на only-local. Сессионных токенов не было обнаружено.\x1b[0m\n");
1851
+ console.log(" [*] Mode reverted to only-local. No session tokens were found.\x1b[0m\n");
1853
1852
  }
1854
1853
  await waitForEnter();
1855
1854
  break;
1856
1855
  }
1857
1856
  case "cloud_mode": {
1858
1857
  const modeItems = [
1859
- { label: "only-local (Только локальный)", value: "only-local", info: "Полностью приватный автономный режим (все на диске)" },
1860
- { label: "only-cloud (Только облачный)", value: "only-cloud", info: "Полностью облачный бессерверный режим без локального кэширования" },
1861
- { label: "hybrid-sync (Локальный с фоновой синхронизацией)", value: "hybrid-sync", info: "Локальные мгновенные операции с фоновым демоном синхронизации" },
1858
+ { label: "only-local (Local only)", value: "only-local", info: "Fully private, offline-first mode (everything stored on disk)" },
1859
+ { label: "only-cloud (Cloud only)", value: "only-cloud", info: "Fully serverless cloud mode with no local caching" },
1860
+ { label: "hybrid-sync (Local with background sync)", value: "hybrid-sync", info: "Instant local operations with a background sync daemon" },
1862
1861
  ];
1863
1862
  const initialIdx = Math.max(0, modeItems.findIndex((i) => i.value === config.mode));
1864
1863
  const subRes = await selectSimpleMenu({
@@ -2,19 +2,51 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
4
  import os from "node:os";
5
+ import { execSync } from "node:child_process";
5
6
  import { MEMORY_DIR, ensureDirSync } from "../memory.js";
6
7
 
7
8
  const SECRETS_FILE = path.join(MEMORY_DIR, "auth_secrets.enc");
8
9
 
9
- // Generate a deterministic hardware + system fingerprint
10
+ // Stable per-machine identifier. Must NOT rely on volatile values (e.g.
11
+ // os.networkInterfaces() — VPN adapters, hotspot IPs and IPv6 privacy
12
+ // addresses rotate constantly and would silently change the AES key).
13
+ function getMachineId() {
14
+ try {
15
+ if (process.platform === "win32") {
16
+ const out = execSync("reg query HKLM\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid", {
17
+ encoding: "utf8",
18
+ stdio: ["ignore", "pipe", "ignore"],
19
+ });
20
+ const m = out.match(/MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]{36})/i);
21
+ if (m) return m[1].toLowerCase();
22
+ } else if (process.platform === "linux") {
23
+ for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
24
+ try {
25
+ const v = fs.readFileSync(p, "utf8").trim();
26
+ if (v) return v;
27
+ } catch {}
28
+ }
29
+ } else if (process.platform === "darwin") {
30
+ const out = execSync("ioreg -rd1 -c IOPlatformExpertDevice", {
31
+ encoding: "utf8",
32
+ stdio: ["ignore", "pipe", "ignore"],
33
+ });
34
+ const m = out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
35
+ if (m) return m[1];
36
+ }
37
+ } catch {}
38
+ return null;
39
+ }
40
+
41
+ // Generate a deterministic hardware + system fingerprint (stable across reboots,
42
+ // network changes and user sessions on the same machine).
10
43
  function getSystemFingerprint() {
11
44
  const parts = [
45
+ getMachineId() || "no-machine-id",
12
46
  os.hostname() || "localhost",
13
47
  os.userInfo()?.username || "default_user",
14
48
  os.platform() || "unknown",
15
49
  os.arch() || "unknown",
16
- // Fallback if network interfaces list is empty or can't be fetched
17
- JSON.stringify(os.networkInterfaces() || {}),
18
50
  ];
19
51
  return parts.join("|");
20
52
  }
@@ -82,7 +114,11 @@ export function loadSecrets() {
82
114
  const decrypted = decryptData(encrypted);
83
115
  return JSON.parse(decrypted);
84
116
  } catch (err) {
85
- console.error("Failed to decrypt or load cloud secrets:", err.message);
117
+ console.error(
118
+ "Failed to decrypt or load cloud secrets:",
119
+ err.message,
120
+ "— the file was encrypted with a different machine key. Re-run login to recreate it."
121
+ );
86
122
  return null;
87
123
  }
88
124
  }
@@ -17,6 +17,8 @@ export const DEFAULT_CONFIG = {
17
17
  mode: "only-local", // "only-local" | "only-cloud" | "hybrid-sync"
18
18
  tursoUrl: "", // Connection endpoint URL for Turso DB
19
19
  failoverUrl: "", // Failover connection endpoint URL (Fly.io + LiteFS)
20
+ authorized: false, // True once the user completed cloud login (token stored encrypted)
21
+ username: "", // Account username from the Turso OAuth profile
20
22
  };
21
23
 
22
24
  let cachedConfig = null;
@@ -8,6 +8,7 @@ import { loadSecrets } from "../config/auth_store.js";
8
8
  import { createClient } from "@libsql/client";
9
9
 
10
10
  let dbInstance = null;
11
+ let dbInitPromise = null;
11
12
 
12
13
  export const STORAGE_DIR = join(MEMORY_DIR, "storage");
13
14
  export const BLOBS_DIR = join(STORAGE_DIR, "blobs");
@@ -141,14 +142,8 @@ class DatabaseWrapper {
141
142
  }
142
143
  }
143
144
 
144
- export async function getDatabase(customPath = null, forceMode = null) {
145
+ async function openDatabase(customPath, mode) {
145
146
  const config = getConfig();
146
- const mode = forceMode || config.mode || "only-local";
147
-
148
- if (dbInstance && !customPath && dbInstance.mode === mode) {
149
- return dbInstance;
150
- }
151
-
152
147
  let localDb = null;
153
148
  if (mode !== "only-cloud") {
154
149
  const dbPath = customPath || DB_PATH;
@@ -202,7 +197,29 @@ export async function getDatabase(customPath = null, forceMode = null) {
202
197
  return wrappedDb;
203
198
  }
204
199
 
200
+ export async function getDatabase(customPath = null, forceMode = null) {
201
+ const config = getConfig();
202
+ const mode = forceMode || config.mode || "only-local";
203
+
204
+ if (!customPath) {
205
+ if (dbInstance && dbInstance.mode === mode) {
206
+ return dbInstance;
207
+ }
208
+ // Deduplicate concurrent default-DB initialization so migrations never run
209
+ // on multiple connections at once (avoids "database is locked" crashes).
210
+ if (!dbInitPromise) {
211
+ dbInitPromise = openDatabase(null, mode).finally(() => {
212
+ dbInitPromise = null;
213
+ });
214
+ }
215
+ return await dbInitPromise;
216
+ }
217
+
218
+ return openDatabase(customPath, mode);
219
+ }
220
+
205
221
  export function closeDatabase() {
222
+ dbInitPromise = null;
206
223
  if (dbInstance) {
207
224
  dbInstance.close();
208
225
  dbInstance = null;
@@ -175,10 +175,10 @@ server.registerTool(
175
175
  const { getLinksForFact } = await import("./graph/knowledge_linker.js");
176
176
  const results = [];
177
177
 
178
- const formatFactWithLinks = (factLine, key) => {
178
+ const formatFactWithLinks = async (factLine, key) => {
179
179
  let line = displayFact(factLine);
180
180
  try {
181
- const links = getLinksForFact(key, factText(factLine));
181
+ const links = await getLinksForFact(key, factText(factLine));
182
182
  if (links && links.length > 0) {
183
183
  const docStr = links
184
184
  .map((l) => {
@@ -192,14 +192,16 @@ server.registerTool(
192
192
  return line;
193
193
  };
194
194
 
195
- const collect = (entries, key) => {
195
+ const collect = async (entries, key) => {
196
196
  const matched = entries.filter(
197
197
  (e) => matchesQuery(e, query) && matchesTags(e, tags) && inDateRange(e, since, until)
198
198
  );
199
199
  if (!matched.length) return;
200
200
  if (results.length) results.push("");
201
201
  results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
202
- matched.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, key)}`));
202
+ for (let i = 0; i < matched.length; i++) {
203
+ results.push(`${i + 1}. ${await formatFactWithLinks(matched[i], key)}`);
204
+ }
203
205
  results.push(`Store file: ${storeFilePath(key)}`);
204
206
  };
205
207
 
@@ -225,11 +227,11 @@ server.registerTool(
225
227
  const label = project ? target : projectName();
226
228
  if (scope !== "project") {
227
229
  const global = await readMemory(GLOBAL_KEY);
228
- collect(global, GLOBAL_KEY);
230
+ await collect(global, GLOBAL_KEY);
229
231
  }
230
232
  if (scope !== "global") {
231
233
  const local = await readMemory(target);
232
- collect(local, target);
234
+ await collect(local, target);
233
235
  }
234
236
  const filtered = Boolean(query || tags || since || until);
235
237
  const text = results.length
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotargo/memory_plugin",
3
- "version": "1.4.0",
3
+ "version": "1.4.5",
4
4
  "description": "100% local hybrid RAG memory for AI coding agents (OpenCode, Claude Code, Codex, Antigravity). MCP server + plugin: persistent user facts, document ingestion, vector + SQLite FTS5 retrieval across sessions.",
5
5
  "type": "module",
6
6
  "main": "opencode-plugin/index.js",