@lotargo/memory_plugin 1.3.2 → 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.
@@ -0,0 +1,385 @@
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
+ }
@@ -36,13 +36,13 @@ export function listAvailableSnapshots() {
36
36
  }
37
37
 
38
38
  export async function exportSnapshot({ customDb = null, customBlobDir = BLOBS_DIR, outputPath = null } = {}) {
39
- const db = customDb || getDatabase();
39
+ const db = customDb || await getDatabase();
40
40
 
41
- const documents = db.prepare("SELECT * FROM documents").all();
42
- const sections = db.prepare("SELECT * FROM sections").all();
43
- const mediumChunks = db.prepare("SELECT * FROM medium_chunks").all();
44
- const rawMicroChunks = db.prepare("SELECT * FROM micro_chunks").all();
45
- const graphEdges = db.prepare("SELECT * FROM graph_edges").all();
41
+ const documents = await db.prepare("SELECT * FROM documents").all();
42
+ const sections = await db.prepare("SELECT * FROM sections").all();
43
+ const mediumChunks = await db.prepare("SELECT * FROM medium_chunks").all();
44
+ const rawMicroChunks = await db.prepare("SELECT * FROM micro_chunks").all();
45
+ const graphEdges = await db.prepare("SELECT * FROM graph_edges").all();
46
46
 
47
47
  const microChunks = rawMicroChunks.map((mc) => {
48
48
  let vecBase64 = "";
@@ -92,7 +92,7 @@ export async function exportSnapshot({ customDb = null, customBlobDir = BLOBS_DI
92
92
  }
93
93
 
94
94
  export async function importSnapshot({ customDb = null, customBlobDir = BLOBS_DIR, snapshotPathOrData } = {}) {
95
- const db = customDb || getDatabase();
95
+ const db = customDb || await getDatabase();
96
96
  let snapshot;
97
97
 
98
98
  if (typeof snapshotPathOrData === "string") {
@@ -181,11 +181,11 @@ export async function importSnapshot({ customDb = null, customBlobDir = BLOBS_DI
181
181
  ON CONFLICT(source_id, target_id, relation_type) DO NOTHING
182
182
  `);
183
183
 
184
- db.exec("BEGIN IMMEDIATE;");
184
+ await db.exec("BEGIN IMMEDIATE;");
185
185
  try {
186
186
  if (Array.isArray(snapshot.documents)) {
187
187
  for (const d of snapshot.documents) {
188
- insertDoc.run(
188
+ await insertDoc.run(
189
189
  d.id,
190
190
  d.path,
191
191
  d.blob_hash,
@@ -201,13 +201,13 @@ export async function importSnapshot({ customDb = null, customBlobDir = BLOBS_DI
201
201
 
202
202
  if (Array.isArray(snapshot.sections)) {
203
203
  for (const s of snapshot.sections) {
204
- insertSection.run(s.id, s.doc_id, s.heading, s.breadcrumbs, s.content, s.token_count);
204
+ await insertSection.run(s.id, s.doc_id, s.heading, s.breadcrumbs, s.content, s.token_count);
205
205
  }
206
206
  }
207
207
 
208
208
  if (Array.isArray(snapshot.medium_chunks)) {
209
209
  for (const m of snapshot.medium_chunks) {
210
- insertMedium.run(m.id, m.section_id, m.doc_id, m.content, m.block_type, m.token_count, m.created_at || Date.now());
210
+ await insertMedium.run(m.id, m.section_id, m.doc_id, m.content, m.block_type, m.token_count, m.created_at || Date.now());
211
211
  }
212
212
  }
213
213
 
@@ -217,23 +217,23 @@ export async function importSnapshot({ customDb = null, customBlobDir = BLOBS_DI
217
217
  if (mc.vector) {
218
218
  vecBuf = Buffer.from(mc.vector, "base64");
219
219
  }
220
- insertChunk.run(mc.id, mc.section_id, mc.doc_id, mc.content, vecBuf, mc.token_count, mc.medium_id || null);
220
+ await insertChunk.run(mc.id, mc.section_id, mc.doc_id, mc.content, vecBuf, mc.token_count, mc.medium_id || null);
221
221
 
222
222
  try {
223
- deleteFts.run(mc.id);
223
+ await deleteFts.run(mc.id);
224
224
  } catch {}
225
- insertFts.run(mc.id, mc.content, mc.breadcrumbs || "");
225
+ await insertFts.run(mc.id, mc.content, mc.breadcrumbs || "");
226
226
  }
227
227
  }
228
228
 
229
229
  if (Array.isArray(snapshot.graph_edges)) {
230
230
  for (const e of snapshot.graph_edges) {
231
- insertEdge.run(e.source_id, e.target_id, e.relation_type);
231
+ await insertEdge.run(e.source_id, e.target_id, e.relation_type);
232
232
  }
233
233
  }
234
- db.exec("COMMIT;");
234
+ await db.exec("COMMIT;");
235
235
  } catch (err) {
236
- db.exec("ROLLBACK;");
236
+ await db.exec("ROLLBACK;");
237
237
  throw err;
238
238
  }
239
239
 
@@ -247,30 +247,32 @@ export async function importSnapshot({ customDb = null, customBlobDir = BLOBS_DI
247
247
  };
248
248
  }
249
249
 
250
- export function hardResetDatabase({ customDb = null, customBlobDir = BLOBS_DIR } = {}) {
251
- const db = customDb || getDatabase();
250
+ export async function hardResetDatabase({ customDb = null, customBlobDir = BLOBS_DIR } = {}) {
251
+ const db = customDb || await getDatabase();
252
252
 
253
253
  let docCount = 0;
254
254
  let chunkCount = 0;
255
255
  let blobCount = 0;
256
256
 
257
257
  try {
258
- docCount = db.prepare("SELECT COUNT(*) as cnt FROM documents").get().cnt;
259
- chunkCount = db.prepare("SELECT COUNT(*) as cnt FROM micro_chunks").get().cnt;
258
+ const docRow = await db.prepare("SELECT COUNT(*) as cnt FROM documents").get();
259
+ docCount = docRow ? docRow.cnt : 0;
260
+ const chunkRow = await db.prepare("SELECT COUNT(*) as cnt FROM micro_chunks").get();
261
+ chunkCount = chunkRow ? chunkRow.cnt : 0;
260
262
  } catch {}
261
263
 
262
- db.exec("BEGIN IMMEDIATE;");
264
+ await db.exec("BEGIN IMMEDIATE;");
263
265
  try {
264
- try { db.exec("DELETE FROM micro_chunks_fts;"); } catch {}
265
- try { db.exec("DELETE FROM micro_chunks;"); } catch {}
266
- try { db.exec("DELETE FROM medium_chunks;"); } catch {}
267
- try { db.exec("DELETE FROM sections;"); } catch {}
268
- try { db.exec("DELETE FROM graph_edges;"); } catch {}
269
- try { db.exec("DELETE FROM knowledge_links;"); } catch {}
270
- try { db.exec("DELETE FROM documents;"); } catch {}
271
- db.exec("COMMIT;");
266
+ try { await db.exec("DELETE FROM micro_chunks_fts;"); } catch {}
267
+ try { await db.exec("DELETE FROM micro_chunks;"); } catch {}
268
+ try { await db.exec("DELETE FROM medium_chunks;"); } catch {}
269
+ try { await db.exec("DELETE FROM sections;"); } catch {}
270
+ try { await db.exec("DELETE FROM graph_edges;"); } catch {}
271
+ try { await db.exec("DELETE FROM knowledge_links;"); } catch {}
272
+ try { await db.exec("DELETE FROM documents;"); } catch {}
273
+ await db.exec("COMMIT;");
272
274
  } catch (err) {
273
- db.exec("ROLLBACK;");
275
+ await db.exec("ROLLBACK;");
274
276
  throw err;
275
277
  }
276
278
 
@@ -292,7 +294,7 @@ export function hardResetDatabase({ customDb = null, customBlobDir = BLOBS_DIR }
292
294
  }
293
295
 
294
296
  try {
295
- db.exec("VACUUM;");
297
+ await db.exec("VACUUM;");
296
298
  } catch {}
297
299
 
298
300
  return {