@lotargo/memory_plugin 1.4.601 → 1.4.621
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -1
- package/mcp-server/admin/auth.js +266 -42
- package/mcp-server/cli.js +173 -7
- package/mcp-server/config/auth_store.js +124 -12
- package/mcp-server/config/config_manager.js +1 -0
- package/mcp-server/db/database.js +4 -2
- package/mcp-server/db/sync_queue.js +168 -0
- package/mcp-server/index.js +1 -1
- package/mcp-server/memory.js +27 -2
- package/mcp-server/setup.js +41 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -139,7 +139,39 @@ The plugin has been verified inside the **Google Jules** cloud workspace environ
|
|
|
139
139
|
npm install -g @lotargo/memory_plugin && memory_plugin setup
|
|
140
140
|
```
|
|
141
141
|
- **Verification**: All current tools and capabilities have been verified inside the Google Jules cloud workspace. Google Jules automatically discovers the registered MCP server upon workspace initialization and seamlessly interacts with the full set of memory & RAG tools — `remember`, `recall`, `forget`, `update_fact`, `memory_info`, `link_knowledge`, `ingest_document`, `query_knowledge_base`, and `manage_knowledge_base` — including project-scoped memory, knowledge linking, and snapshot export/import.
|
|
142
|
-
- **Current Limitation**: All memory stores and vector indexes operate locally within the workspace environment.
|
|
142
|
+
- **Current Limitation**: All memory stores and vector indexes operate locally within the workspace environment. For cross-session cloud synchronization (Turso `only-cloud` / `hybrid-sync`) inside headless environments like Jules, authenticate without a browser using the token/env methods below.
|
|
143
|
+
|
|
144
|
+
### Headless Turso Authentication (Docker, Google Jules, VPS/VDS)
|
|
145
|
+
|
|
146
|
+
Browser OAuth requires a desktop session, so headless deployments use token- or env-based login. The **Turso account API token is the primary source of truth**: it resolves an org/database and mints a per-database token via the Platform API, exactly like the browser flow, and the resulting session is stored encrypted. In priority order, secrets resolve as **env `TURSO_API_TOKEN` → stored API-token session → env `TURSO_DB_URL`/`TURSO_DB_TOKEN` → stored browser/database session**:
|
|
147
|
+
|
|
148
|
+
| Method | Command | Notes |
|
|
149
|
+
| :----- | :------ | :---- |
|
|
150
|
+
| Account API token | `memory_plugin login --api-key <TOKEN> [--org <ORG>] [--database <DB>]` | Preferred. Validates the token, resolves org/db, mints and stores a per-database token |
|
|
151
|
+
| Direct endpoint | `memory_plugin login --db-url libsql://<db>-<org>.turso.io --db-token <TOKEN>` | No Platform API calls; org/db derived from the URL |
|
|
152
|
+
| Environment | `memory_plugin login --from-env` | Imports `TURSO_DB_URL`+`TURSO_DB_TOKEN` (preferred) or `TURSO_API_TOKEN` |
|
|
153
|
+
| Remove API key | `memory_plugin logout --api-key` | Removes only the API token; the resolved database session is kept |
|
|
154
|
+
| Status | `memory_plugin auth-status` | Shows source (env / api-key / store), authorized flag, API-key flag, endpoint, org, database and mode |
|
|
155
|
+
|
|
156
|
+
One-shot headless setup (no browser, no interactive `login`):
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
memory_plugin setup --api-key <TURSO_API_TOKEN> --mode hybrid-sync # auth + set sync mode in one step
|
|
160
|
+
memory_plugin setup --mode only-cloud # mode only, if already authorized
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Supported environment variables (usable without any `login` step — `loadSecrets()` picks them up automatically):
|
|
164
|
+
|
|
165
|
+
- `TURSO_API_TOKEN` — account API token (requires Platform API access). On first use the plugin mints a per-database JWT on the fly without touching the encrypted store; optional `TURSO_ORG`, `TURSO_DATABASE` / `TURSO_DB_NAME`, `TURSO_USERNAME`
|
|
166
|
+
- `TURSO_DB_URL` / `TURSO_URL` + `TURSO_DB_TOKEN` / `TURSO_TOKEN` — direct database credentials
|
|
167
|
+
|
|
168
|
+
The interactive TUI (`memory_plugin cli` → `[CLOUD] ...`) offers a method chooser: Browser OAuth, account API token, database URL + token, or import from environment — plus `[API KEY] Set / Replace Account API Token` and `[API KEY] Remove Account API Token` menu entries. For example, to run a Google Jules workspace with cloud sync:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
export TURSO_API_TOKEN="eyJhbGciOi..."
|
|
172
|
+
memory_plugin setup --api-key "$TURSO_API_TOKEN" --mode hybrid-sync # or rely on env auto-detection
|
|
173
|
+
memory_plugin auth-status
|
|
174
|
+
```
|
|
143
175
|
|
|
144
176
|
---
|
|
145
177
|
|
package/mcp-server/admin/auth.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
-
import { saveSecrets, deleteSecrets } from "../config/auth_store.js";
|
|
5
|
-
import { updateConfig } from "../config/config_manager.js";
|
|
4
|
+
import { saveSecrets, deleteSecrets, loadSecrets, resolveEnvSecrets, getSecretsSource } from "../config/auth_store.js";
|
|
5
|
+
import { getConfig, updateConfig } from "../config/config_manager.js";
|
|
6
6
|
|
|
7
7
|
export const TURSO_API_BASE = () => process.env.TURSO_API_BASE || "https://api.turso.tech";
|
|
8
8
|
|
|
@@ -273,6 +273,86 @@ function dbHostname(org, dbName) {
|
|
|
273
273
|
return `${dbName}-${org}.turso.io`;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
// Shared post-auth resolution: validate happens in the caller. Steps:
|
|
277
|
+
// 1. Resolve an organization (explicit, first available, or username fallback).
|
|
278
|
+
// 2. Pick or create a database.
|
|
279
|
+
// 3. Mint a full-access token for that database.
|
|
280
|
+
// 4. Persist the encrypted token + dbUrl and mark the session as authorized.
|
|
281
|
+
async function finalizeCloudLogin({ token, username, org = null, databaseName = null, autoCreate = true, persist = true, apiToken = null }) {
|
|
282
|
+
const accountUsername = username || "user";
|
|
283
|
+
|
|
284
|
+
// Step 1: resolve organization + database namespace
|
|
285
|
+
const orgs = await listOrganizations(token);
|
|
286
|
+
let orgSlug;
|
|
287
|
+
let orgName;
|
|
288
|
+
if (orgs && orgs.length > 0) {
|
|
289
|
+
const requested = org ? orgs.find((o) => (o.slug || o.name || o.id) === org) : null;
|
|
290
|
+
const chosen = requested || orgs[0];
|
|
291
|
+
orgSlug = chosen.slug || chosen.name || chosen.id || String(chosen);
|
|
292
|
+
orgName = chosen.name || orgSlug;
|
|
293
|
+
} else {
|
|
294
|
+
// Personal accounts are not listed in /v1/organizations, but their own
|
|
295
|
+
// username acts as the organization namespace in the Platform API.
|
|
296
|
+
orgSlug = accountUsername;
|
|
297
|
+
orgName = accountUsername;
|
|
298
|
+
console.log(` [CLOUD] No organizations found. Using personal account "${orgSlug}" as the database namespace.`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const dbs = await listDatabases(token, orgSlug);
|
|
302
|
+
if (dbs.length > 0) {
|
|
303
|
+
console.log(`\n [CLOUD] Databases in organization "${orgName}":`);
|
|
304
|
+
dbs.forEach((d, i) => console.log(` ${i + 1}. ${d.name}`));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let dbName = databaseName;
|
|
308
|
+
if (!dbName) {
|
|
309
|
+
if (dbs.length > 0) {
|
|
310
|
+
dbName = dbs[0].name;
|
|
311
|
+
console.log(`\n [CLOUD] Using existing database: "${dbName}"`);
|
|
312
|
+
} else if (autoCreate) {
|
|
313
|
+
dbName = `memory-${accountUsername}`;
|
|
314
|
+
console.log(`\n [CLOUD] No database found. Creating "${dbName}"...`);
|
|
315
|
+
await createDatabase(token, orgSlug, dbName);
|
|
316
|
+
console.log(` [OK] Database "${dbName}" created.`);
|
|
317
|
+
} else {
|
|
318
|
+
throw new Error("No databases found and autoCreate is disabled.");
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Step 2: mint a full-access token for the database
|
|
323
|
+
console.log(" [CLOUD] Issuing database access token...");
|
|
324
|
+
const dbJwt = await createDatabaseToken(token, orgSlug, dbName);
|
|
325
|
+
if (!dbJwt) {
|
|
326
|
+
throw new Error("Failed to create database auth token.");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const dbUrl = `libsql://${dbHostname(orgSlug, dbName)}`;
|
|
330
|
+
|
|
331
|
+
// Step 3: persist secrets and mark authorized
|
|
332
|
+
if (persist) {
|
|
333
|
+
saveSecrets({
|
|
334
|
+
token: dbJwt,
|
|
335
|
+
dbUrl,
|
|
336
|
+
username: accountUsername,
|
|
337
|
+
org: orgSlug,
|
|
338
|
+
db: dbName,
|
|
339
|
+
authorized: true,
|
|
340
|
+
...(apiToken ? { apiToken } : {}),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
updateConfig({ tursoUrl: dbUrl, authorized: true, username: accountUsername });
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
token: dbJwt,
|
|
347
|
+
dbUrl,
|
|
348
|
+
username: accountUsername,
|
|
349
|
+
org: orgSlug,
|
|
350
|
+
db: dbName,
|
|
351
|
+
authorized: true,
|
|
352
|
+
...(apiToken ? { apiToken } : {}),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
276
356
|
// Perform the full cloud login flow:
|
|
277
357
|
// 1. OAuth browser flow against Turso (api.turso.tech).
|
|
278
358
|
// 2. Validate the received account JWT.
|
|
@@ -285,6 +365,7 @@ export async function loginToCloud({
|
|
|
285
365
|
simulatedParams = null,
|
|
286
366
|
autoCreate = true,
|
|
287
367
|
databaseName = null,
|
|
368
|
+
org = null,
|
|
288
369
|
} = {}) {
|
|
289
370
|
const state = crypto.randomBytes(16).toString("hex");
|
|
290
371
|
const loginUrl = `${TURSO_API_BASE()}/?port=${customPort}&redirect=true&state=${state}&type=cli`;
|
|
@@ -324,57 +405,200 @@ export async function loginToCloud({
|
|
|
324
405
|
const accountUsername = username || userInfo?.username || userInfo?.name || "user";
|
|
325
406
|
console.log(` [OK] Token is valid. User: ${accountUsername}`);
|
|
326
407
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
408
|
+
const secrets = await finalizeCloudLogin({ token, username: accountUsername, org, databaseName, autoCreate });
|
|
409
|
+
|
|
410
|
+
console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Endpoint: ${secrets.dbUrl}\x1b[0m`);
|
|
411
|
+
return secrets;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Headless login with a Turso account API token (no browser, no loopback).
|
|
415
|
+
// Create one at https://console.turso.tech or via `turso auth api-tokens create`.
|
|
416
|
+
// The same Platform API is used to resolve the organization + database and to
|
|
417
|
+
// mint a per-database token, exactly like the browser flow.
|
|
418
|
+
export async function loginWithApiToken({
|
|
419
|
+
token,
|
|
420
|
+
org = null,
|
|
421
|
+
databaseName = null,
|
|
422
|
+
autoCreate = true,
|
|
423
|
+
username = null,
|
|
424
|
+
persist = true,
|
|
425
|
+
} = {}) {
|
|
426
|
+
if (!token) throw new Error("An account API token is required.");
|
|
427
|
+
console.log("\n [CLOUD] Validating account API token...");
|
|
428
|
+
let userInfo = null;
|
|
429
|
+
try {
|
|
430
|
+
userInfo = await validateTursoToken(token);
|
|
431
|
+
} catch (err) {
|
|
432
|
+
throw new Error(`Token validation failed: ${err.message}`);
|
|
340
433
|
}
|
|
434
|
+
const accountUsername = username || userInfo?.username || userInfo?.name || "user";
|
|
435
|
+
console.log(` [OK] API token is valid. User: ${accountUsername}`);
|
|
341
436
|
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
437
|
+
const secrets = await finalizeCloudLogin({
|
|
438
|
+
token,
|
|
439
|
+
username: accountUsername,
|
|
440
|
+
org,
|
|
441
|
+
databaseName,
|
|
442
|
+
autoCreate,
|
|
443
|
+
persist,
|
|
444
|
+
apiToken: persist ? token : null,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Endpoint: ${secrets.dbUrl}\x1b[0m`);
|
|
448
|
+
return secrets;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Direct headless login with an existing database URL + auth token.
|
|
452
|
+
// No Platform API calls are made; org/db are derived from the endpoint.
|
|
453
|
+
export async function loginWithDatabaseToken({
|
|
454
|
+
token,
|
|
455
|
+
dbUrl,
|
|
456
|
+
username = "",
|
|
457
|
+
org = "",
|
|
458
|
+
db = "",
|
|
459
|
+
validate = true,
|
|
460
|
+
} = {}) {
|
|
461
|
+
if (!token || !dbUrl) throw new Error("Both a database auth token and a libsql:// URL are required.");
|
|
462
|
+
|
|
463
|
+
// Derive org + database name from the endpoint (libsql://<db>-<org>.turso.io)
|
|
464
|
+
let resolvedOrg = org;
|
|
465
|
+
let resolvedDb = db;
|
|
466
|
+
const m = String(dbUrl).match(/^libsql:\/\/(.+)\.turso\.io$/);
|
|
467
|
+
if (m) {
|
|
468
|
+
const host = m[1];
|
|
469
|
+
const sep = host.lastIndexOf("-");
|
|
470
|
+
if (sep > 0) {
|
|
471
|
+
resolvedDb = resolvedDb || host.slice(0, sep);
|
|
472
|
+
resolvedOrg = resolvedOrg || host.slice(sep + 1);
|
|
473
|
+
}
|
|
346
474
|
}
|
|
347
475
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
console.log(
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
} else {
|
|
359
|
-
throw new Error("No databases found and autoCreate is disabled.");
|
|
476
|
+
if (validate) {
|
|
477
|
+
console.log(" [CLOUD] Validating database token against the endpoint...");
|
|
478
|
+
try {
|
|
479
|
+
const { createClient } = await import("@libsql/client");
|
|
480
|
+
const client = createClient({ url: dbUrl, authToken: token });
|
|
481
|
+
await client.execute("SELECT 1");
|
|
482
|
+
client.close();
|
|
483
|
+
console.log(" [OK] Database token validated.");
|
|
484
|
+
} catch (err) {
|
|
485
|
+
throw new Error(`Database token validation failed: ${err.message}`);
|
|
360
486
|
}
|
|
361
487
|
}
|
|
362
488
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
489
|
+
saveSecrets({ token, dbUrl, username, org: resolvedOrg, db: resolvedDb, authorized: true });
|
|
490
|
+
updateConfig({ tursoUrl: dbUrl, authorized: true, username });
|
|
491
|
+
|
|
492
|
+
console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Endpoint: ${dbUrl}\x1b[0m`);
|
|
493
|
+
return { token, dbUrl, username, org: resolvedOrg, db: resolvedDb, authorized: true };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Pick up credentials from the environment or MEMORY_DIR/.env.
|
|
497
|
+
// - TURSO_DB_URL + TURSO_DB_TOKEN: direct endpoint login (preferred, no API calls).
|
|
498
|
+
// - TURSO_API_TOKEN: account API-token flow resolving org/db via the Platform API.
|
|
499
|
+
// When persist is true the resolved secrets are also written to the encrypted store.
|
|
500
|
+
export async function loginFromEnv({ persist = false } = {}) {
|
|
501
|
+
const env = resolveEnvSecrets();
|
|
502
|
+
if (!env) {
|
|
503
|
+
return { ok: false, reason: "No cloud secrets found in the environment or MEMORY_DIR/.env." };
|
|
368
504
|
}
|
|
369
505
|
|
|
370
|
-
|
|
506
|
+
if (env.dbUrl && env.token) {
|
|
507
|
+
const secrets = {
|
|
508
|
+
token: env.token,
|
|
509
|
+
dbUrl: env.dbUrl,
|
|
510
|
+
username: env.username || "",
|
|
511
|
+
org: env.org || "",
|
|
512
|
+
db: env.database || "",
|
|
513
|
+
authorized: true,
|
|
514
|
+
};
|
|
515
|
+
if (persist) saveSecrets(secrets);
|
|
516
|
+
updateConfig({ tursoUrl: env.dbUrl, authorized: true, username: secrets.username });
|
|
517
|
+
console.log(`\n \x1b[32m[OK] Cloud credentials imported from the environment! Endpoint: ${env.dbUrl}\x1b[0m`);
|
|
518
|
+
return { ok: true, secrets, source: "env" };
|
|
519
|
+
}
|
|
371
520
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
521
|
+
if (env.apiToken) {
|
|
522
|
+
// Resolve lazily; the raw API token stays in the environment and is NOT
|
|
523
|
+
// persisted to the encrypted store (explicit `login --api-token` does that).
|
|
524
|
+
const secrets = await loginWithApiToken({
|
|
525
|
+
token: env.apiToken,
|
|
526
|
+
org: env.org || null,
|
|
527
|
+
databaseName: env.database || null,
|
|
528
|
+
username: env.username || null,
|
|
529
|
+
persist: false,
|
|
530
|
+
});
|
|
531
|
+
return { ok: true, secrets: { ...secrets, apiToken: env.apiToken }, source: "env" };
|
|
532
|
+
}
|
|
375
533
|
|
|
376
|
-
|
|
377
|
-
|
|
534
|
+
return {
|
|
535
|
+
ok: false,
|
|
536
|
+
reason: "Incomplete cloud secrets. Set TURSO_DB_URL + TURSO_DB_TOKEN (preferred) or TURSO_API_TOKEN (with optional TURSO_ORG / TURSO_DATABASE).",
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Async resolution of the working cloud credentials (a DB URL + auth token).
|
|
541
|
+
// Used by database.js at startup so a raw TURSO_API_TOKEN environment token
|
|
542
|
+
// (which can only call the Platform API, not libsql) gets minted into a
|
|
543
|
+
// per-database JWT without any interactive step.
|
|
544
|
+
export async function resolveCloudSecrets() {
|
|
545
|
+
const secrets = loadSecrets();
|
|
546
|
+
if (!secrets) return null;
|
|
547
|
+
if (secrets.apiToken && secrets.needsResolution) {
|
|
548
|
+
const resolved = await loginWithApiToken({
|
|
549
|
+
token: secrets.apiToken,
|
|
550
|
+
org: secrets.org || null,
|
|
551
|
+
databaseName: secrets.db || null,
|
|
552
|
+
username: secrets.username || null,
|
|
553
|
+
persist: false,
|
|
554
|
+
});
|
|
555
|
+
return { ...resolved, source: "env" };
|
|
556
|
+
}
|
|
557
|
+
return secrets;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Store/replace a Turso account API token. Alias of the headless login flow:
|
|
561
|
+
// the token is validated, an org/database is resolved and a per-database JWT
|
|
562
|
+
// is minted, then both the API token and the resolved session are persisted.
|
|
563
|
+
export async function setApiKey(token, { org = null, databaseName = null } = {}) {
|
|
564
|
+
if (!token || typeof token !== "string" || !token.trim()) {
|
|
565
|
+
throw new Error("An account API token is required.");
|
|
566
|
+
}
|
|
567
|
+
const secrets = await loginWithApiToken({ token: token.trim(), org, databaseName });
|
|
568
|
+
return { ok: true, secrets };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Remove a stored API token. The resolved database session is kept as a plain
|
|
572
|
+
// browser/database session so an already-synced deployment keeps working.
|
|
573
|
+
export function clearApiKey() {
|
|
574
|
+
const existing = loadSecrets();
|
|
575
|
+
if (!existing || !existing.apiToken) return { removed: false };
|
|
576
|
+
const rest = { ...existing };
|
|
577
|
+
delete rest.apiToken;
|
|
578
|
+
if (rest.token && rest.dbUrl) {
|
|
579
|
+
saveSecrets({ ...rest, authorized: true });
|
|
580
|
+
return { removed: true, keptDbSession: true };
|
|
581
|
+
}
|
|
582
|
+
deleteSecrets();
|
|
583
|
+
return { removed: true, keptDbSession: false };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Non-throwing status report used by `auth-status` and the TUI.
|
|
587
|
+
export function getAuthStatus() {
|
|
588
|
+
const secrets = loadSecrets();
|
|
589
|
+
const config = getConfig();
|
|
590
|
+
const source = getSecretsSource();
|
|
591
|
+
return {
|
|
592
|
+
source: source || "none",
|
|
593
|
+
configured: !!(secrets?.dbUrl || config.tursoUrl),
|
|
594
|
+
authorized: !!config.authorized || source === "env" || source === "api-key",
|
|
595
|
+
hasApiKey: !!secrets?.apiToken,
|
|
596
|
+
dbUrl: secrets?.dbUrl || config.tursoUrl || "",
|
|
597
|
+
username: secrets?.username || config.username || "",
|
|
598
|
+
org: secrets?.org || "",
|
|
599
|
+
database: secrets?.db || "",
|
|
600
|
+
mode: config.mode || "only-local",
|
|
601
|
+
};
|
|
378
602
|
}
|
|
379
603
|
|
|
380
604
|
// Logout and reset configurations
|
package/mcp-server/cli.js
CHANGED
|
@@ -662,9 +662,43 @@ export async function runCli() {
|
|
|
662
662
|
|
|
663
663
|
if (cliArgs.includes("login")) {
|
|
664
664
|
console.log("\n [CLOUD] Starting Turso cloud authorization...");
|
|
665
|
-
const
|
|
665
|
+
const loginIdx = cliArgs.indexOf("login");
|
|
666
|
+
const loginArgs = cliArgs.slice(loginIdx + 1);
|
|
667
|
+
const flagValue = (name) => {
|
|
668
|
+
const i = loginArgs.indexOf(name);
|
|
669
|
+
return i >= 0 && loginArgs[i + 1] ? loginArgs[i + 1] : null;
|
|
670
|
+
};
|
|
671
|
+
const { loginToCloud, loginWithApiToken, loginWithDatabaseToken, loginFromEnv } = await import("./admin/auth.js");
|
|
666
672
|
try {
|
|
667
|
-
|
|
673
|
+
let secrets;
|
|
674
|
+
if (loginArgs.includes("--from-env")) {
|
|
675
|
+
// Headless: pick up TURSO_DB_URL / TURSO_DB_TOKEN / TURSO_API_TOKEN from env or .env
|
|
676
|
+
const res = await loginFromEnv({ persist: false });
|
|
677
|
+
if (!res.ok) throw new Error(res.reason);
|
|
678
|
+
secrets = res.secrets;
|
|
679
|
+
} else if (loginArgs.includes("--db-url") && loginArgs.includes("--db-token")) {
|
|
680
|
+
// Headless: direct database URL + token (no Platform API calls)
|
|
681
|
+
secrets = await loginWithDatabaseToken({
|
|
682
|
+
dbUrl: flagValue("--db-url"),
|
|
683
|
+
token: flagValue("--db-token"),
|
|
684
|
+
username: flagValue("--username") || "",
|
|
685
|
+
org: flagValue("--org") || "",
|
|
686
|
+
db: flagValue("--database") || "",
|
|
687
|
+
validate: !loginArgs.includes("--no-validate"),
|
|
688
|
+
});
|
|
689
|
+
} else if (loginArgs.includes("--token") || loginArgs.includes("--api-token") || loginArgs.includes("--api-key")) {
|
|
690
|
+
// Headless: account API token resolved via the Turso Platform API
|
|
691
|
+
const token = flagValue("--token") || flagValue("--api-token") || flagValue("--api-key");
|
|
692
|
+
if (!token) throw new Error("Missing token value. Usage: memory_plugin login --api-token <TOKEN> [--org <ORG>] [--database <DB>]");
|
|
693
|
+
secrets = await loginWithApiToken({
|
|
694
|
+
token,
|
|
695
|
+
org: flagValue("--org") || null,
|
|
696
|
+
databaseName: flagValue("--database") || null,
|
|
697
|
+
});
|
|
698
|
+
} else {
|
|
699
|
+
// Default: interactive browser OAuth flow
|
|
700
|
+
secrets = await loginToCloud();
|
|
701
|
+
}
|
|
668
702
|
console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Connected to endpoint: ${secrets.dbUrl}\x1b[0m\n`);
|
|
669
703
|
} catch (e) {
|
|
670
704
|
console.error(`\n \x1b[31m[ERROR] Authorization failed: ${e.message}\x1b[0m\n`);
|
|
@@ -674,8 +708,22 @@ export async function runCli() {
|
|
|
674
708
|
}
|
|
675
709
|
|
|
676
710
|
if (cliArgs.includes("logout")) {
|
|
711
|
+
const { logoutFromCloud, clearApiKey } = await import("./admin/auth.js");
|
|
712
|
+
if (cliArgs.includes("--api-key")) {
|
|
713
|
+
// Headless: remove only the stored account API token
|
|
714
|
+
const res = clearApiKey();
|
|
715
|
+
if (res.removed) {
|
|
716
|
+
console.log(
|
|
717
|
+
res.keptDbSession
|
|
718
|
+
? " \x1b[32m[OK] API token removed. The resolved database session is kept and stays authorized.\x1b[0m\n"
|
|
719
|
+
: " \x1b[32m[OK] API token removed. Encrypted secrets purged.\x1b[0m\n"
|
|
720
|
+
);
|
|
721
|
+
} else {
|
|
722
|
+
console.log(" [*] No stored API token to remove.\x1b[0m\n");
|
|
723
|
+
}
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
677
726
|
console.log("\n [CLOUD] Signing out of the cloud...");
|
|
678
|
-
const { logoutFromCloud } = await import("./admin/auth.js");
|
|
679
727
|
const deleted = logoutFromCloud();
|
|
680
728
|
if (deleted) {
|
|
681
729
|
console.log(" \x1b[32m[OK] You have been signed out. Encrypted secrets removed. Mode reverted to only-local.\x1b[0m\n");
|
|
@@ -685,6 +733,22 @@ export async function runCli() {
|
|
|
685
733
|
return;
|
|
686
734
|
}
|
|
687
735
|
|
|
736
|
+
if (cliArgs.includes("auth-status") || cliArgs.includes("auth_status") || cliArgs.includes("auth")) {
|
|
737
|
+
const { getAuthStatus } = await import("./admin/auth.js");
|
|
738
|
+
const st = getAuthStatus();
|
|
739
|
+
console.log("\n [CLOUD] Authentication status:");
|
|
740
|
+
console.log(` Source: ${st.source}`);
|
|
741
|
+
console.log(` Authorized: ${st.authorized ? "YES" : "no"}`);
|
|
742
|
+
console.log(` API Key: ${st.hasApiKey ? "SET" : "not set"}`);
|
|
743
|
+
console.log(` Endpoint: ${st.dbUrl || "(none)"}`);
|
|
744
|
+
console.log(` Username: ${st.username || "(unknown)"}`);
|
|
745
|
+
console.log(` Organization: ${st.org || "(unknown)"}`);
|
|
746
|
+
console.log(` Database: ${st.database || "(unknown)"}`);
|
|
747
|
+
console.log(` Mode: ${st.mode}`);
|
|
748
|
+
console.log("");
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
688
752
|
let running = true;
|
|
689
753
|
let selectedIndex = 0;
|
|
690
754
|
|
|
@@ -793,19 +857,35 @@ export async function runCli() {
|
|
|
793
857
|
{
|
|
794
858
|
label: "[CLOUD] Login to Turso Cloud",
|
|
795
859
|
value: "cloud_login",
|
|
796
|
-
info: "
|
|
860
|
+
info: "Browser OAuth, account API token, database URL+token, or import from environment (.env) — token/env methods work headless in Docker, Google Jules and VPS",
|
|
797
861
|
},
|
|
798
862
|
{
|
|
799
863
|
label: "[CLOUD] Logout",
|
|
800
864
|
value: "cloud_logout",
|
|
801
865
|
info: "Sign out, purge encrypted secrets, and revert mode to only-local",
|
|
802
866
|
},
|
|
867
|
+
{
|
|
868
|
+
label: "[API KEY] Set / Replace Account API Token",
|
|
869
|
+
value: "cloud_api_set",
|
|
870
|
+
info: "Paste a Turso account API token to authorize headless (Docker, Google Jules, VPS) — validated and persisted",
|
|
871
|
+
},
|
|
872
|
+
{
|
|
873
|
+
label: "[API KEY] Remove Account API Token",
|
|
874
|
+
value: "cloud_api_clear",
|
|
875
|
+
info: "Delete the stored API token; the resolved database session is kept",
|
|
876
|
+
},
|
|
803
877
|
{
|
|
804
878
|
label: "Operational Mode",
|
|
805
879
|
badge: config.mode.toUpperCase(),
|
|
806
880
|
value: "cloud_mode",
|
|
807
881
|
info: "Choose Operational Mode: only-local | only-cloud | hybrid-sync",
|
|
808
882
|
},
|
|
883
|
+
{
|
|
884
|
+
label: "Conflict Strategy",
|
|
885
|
+
badge: (config.conflictStrategy || "merge").toUpperCase(),
|
|
886
|
+
value: "conflict_strategy",
|
|
887
|
+
info: "How hybrid-sync resolves differing local vs cloud stores: merge | cloud-wins | local-wins",
|
|
888
|
+
},
|
|
809
889
|
],
|
|
810
890
|
},
|
|
811
891
|
{
|
|
@@ -1829,10 +1909,40 @@ export async function runCli() {
|
|
|
1829
1909
|
}
|
|
1830
1910
|
case "cloud_login": {
|
|
1831
1911
|
console.clear();
|
|
1832
|
-
console.log("\n [CLOUD]
|
|
1833
|
-
const
|
|
1912
|
+
console.log("\n [CLOUD] Turso cloud authorization\n");
|
|
1913
|
+
const methodItems = [
|
|
1914
|
+
{ label: "Browser OAuth (GUI)", value: "browser", info: "Opens the system browser for the loopback OAuth flow (requires a desktop session)" },
|
|
1915
|
+
{ label: "Account API Token", value: "api_token", info: "Paste a Turso account API token — works headless (Docker, Google Jules, VPS/VDS)" },
|
|
1916
|
+
{ label: "Database URL + Token", value: "db_token", info: "Paste a libsql:// endpoint and its database auth token — no Platform API needed" },
|
|
1917
|
+
{ label: "Import From Environment", value: "env", info: "Pick up TURSO_DB_URL / TURSO_DB_TOKEN / TURSO_API_TOKEN from env vars or MEMORY_DIR/.env" },
|
|
1918
|
+
{ label: "< Cancel", value: "cancel", info: "Return to the main menu" },
|
|
1919
|
+
];
|
|
1920
|
+
const methodRes = await selectSimpleMenu({
|
|
1921
|
+
title: "CHOOSE LOGIN METHOD",
|
|
1922
|
+
subtitle: "Browser login needs a GUI. Token / env methods work in Docker, Google Jules and on VPS/VDS.",
|
|
1923
|
+
items: methodItems,
|
|
1924
|
+
});
|
|
1925
|
+
if (methodRes.action !== "select" || methodRes.value === "cancel") break;
|
|
1926
|
+
|
|
1927
|
+
const { loginToCloud, loginWithApiToken, loginWithDatabaseToken, loginFromEnv } = await import("./admin/auth.js");
|
|
1834
1928
|
try {
|
|
1835
|
-
|
|
1929
|
+
let secrets;
|
|
1930
|
+
if (methodRes.value === "browser") {
|
|
1931
|
+
secrets = await loginToCloud();
|
|
1932
|
+
} else if (methodRes.value === "api_token") {
|
|
1933
|
+
const token = await promptText("Paste your Turso account API token\n (create one at https://console.turso.tech or via `turso auth api-tokens create`)");
|
|
1934
|
+
if (!token) throw new Error("Empty API token.");
|
|
1935
|
+
secrets = await loginWithApiToken({ token });
|
|
1936
|
+
} else if (methodRes.value === "db_token") {
|
|
1937
|
+
const dbUrl = await promptText("Paste your database URL (libsql://<database>-<org>.turso.io)");
|
|
1938
|
+
const token = await promptText("Paste your database auth token");
|
|
1939
|
+
if (!dbUrl || !token) throw new Error("Empty URL or token.");
|
|
1940
|
+
secrets = await loginWithDatabaseToken({ dbUrl, token, validate: false });
|
|
1941
|
+
} else if (methodRes.value === "env") {
|
|
1942
|
+
const res = await loginFromEnv({ persist: true });
|
|
1943
|
+
if (!res.ok) throw new Error(res.reason);
|
|
1944
|
+
secrets = res.secrets;
|
|
1945
|
+
}
|
|
1836
1946
|
console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Connected to endpoint: ${secrets.dbUrl}\x1b[0m\n`);
|
|
1837
1947
|
} catch (e) {
|
|
1838
1948
|
console.error(`\n \x1b[31m[ERROR] Authorization failed: ${e.message}\x1b[0m\n`);
|
|
@@ -1853,6 +1963,42 @@ export async function runCli() {
|
|
|
1853
1963
|
await waitForEnter();
|
|
1854
1964
|
break;
|
|
1855
1965
|
}
|
|
1966
|
+
case "cloud_api_set": {
|
|
1967
|
+
console.clear();
|
|
1968
|
+
console.log("\n [API KEY] Set / replace the Turso account API token\n");
|
|
1969
|
+
const { setApiKey } = await import("./admin/auth.js");
|
|
1970
|
+
try {
|
|
1971
|
+
const token = await promptText(
|
|
1972
|
+
"Paste your Turso account API token\n (create one at https://console.turso.tech or via `turso auth api-tokens create`)"
|
|
1973
|
+
);
|
|
1974
|
+
if (!token) throw new Error("Empty API token.");
|
|
1975
|
+
const res = await setApiKey(token);
|
|
1976
|
+
console.log(
|
|
1977
|
+
`\n \x1b[32m[OK] API token stored. Authorized as "${res.secrets.username}" — endpoint: ${res.secrets.dbUrl}\x1b[0m\n`
|
|
1978
|
+
);
|
|
1979
|
+
} catch (e) {
|
|
1980
|
+
console.error(`\n \x1b[31m[ERROR] Failed to set API key: ${e.message}\x1b[0m\n`);
|
|
1981
|
+
}
|
|
1982
|
+
await waitForEnter();
|
|
1983
|
+
break;
|
|
1984
|
+
}
|
|
1985
|
+
case "cloud_api_clear": {
|
|
1986
|
+
console.clear();
|
|
1987
|
+
console.log("\n [API KEY] Removing the stored account API token...");
|
|
1988
|
+
const { clearApiKey } = await import("./admin/auth.js");
|
|
1989
|
+
const res = clearApiKey();
|
|
1990
|
+
if (res.removed) {
|
|
1991
|
+
console.log(
|
|
1992
|
+
res.keptDbSession
|
|
1993
|
+
? " \x1b[32m[OK] API token removed. The resolved database session is kept and stays authorized.\x1b[0m\n"
|
|
1994
|
+
: " \x1b[32m[OK] API token removed. Encrypted secrets purged.\x1b[0m\n"
|
|
1995
|
+
);
|
|
1996
|
+
} else {
|
|
1997
|
+
console.log(" [*] No stored API token to remove.\x1b[0m\n");
|
|
1998
|
+
}
|
|
1999
|
+
await waitForEnter();
|
|
2000
|
+
break;
|
|
2001
|
+
}
|
|
1856
2002
|
case "cloud_mode": {
|
|
1857
2003
|
const modeItems = [
|
|
1858
2004
|
{ label: "only-local (Local only)", value: "only-local", info: "Fully private, offline-first mode (everything stored on disk)" },
|
|
@@ -1872,6 +2018,26 @@ export async function runCli() {
|
|
|
1872
2018
|
}
|
|
1873
2019
|
break;
|
|
1874
2020
|
}
|
|
2021
|
+
case "conflict_strategy": {
|
|
2022
|
+
const strategyItems = [
|
|
2023
|
+
{ label: "merge (Union local + cloud)", value: "merge", info: "Facts from both sides are merged and deduplicated — no data loss (recommended)" },
|
|
2024
|
+
{ label: "cloud-wins (Cloud overwrites local)", value: "cloud-wins", info: "On conflict, the cloud copy replaces the local store" },
|
|
2025
|
+
{ label: "local-wins (Local overwrites cloud)", value: "local-wins", info: "On conflict, the local copy replaces the cloud store" },
|
|
2026
|
+
];
|
|
2027
|
+
const initialIdx = Math.max(0, strategyItems.findIndex((i) => i.value === (config.conflictStrategy || "merge")));
|
|
2028
|
+
const subRes = await selectSimpleMenu({
|
|
2029
|
+
title: "CHOOSE CONFLICT STRATEGY",
|
|
2030
|
+
subtitle: "How hybrid-sync resolves differing local vs cloud stores",
|
|
2031
|
+
items: strategyItems,
|
|
2032
|
+
initialIndex: initialIdx,
|
|
2033
|
+
});
|
|
2034
|
+
|
|
2035
|
+
if (subRes.action === "select") {
|
|
2036
|
+
updateConfig({ conflictStrategy: subRes.value });
|
|
2037
|
+
console.log(`\n [OK] Conflict strategy set to: ${subRes.value}`);
|
|
2038
|
+
}
|
|
2039
|
+
break;
|
|
2040
|
+
}
|
|
1875
2041
|
case "enable_prompt": {
|
|
1876
2042
|
const { enableGlobalPrompt } = await import("./prompt_manager.js");
|
|
1877
2043
|
const results = await enableGlobalPrompt();
|
|
@@ -95,24 +95,69 @@ export function decryptData(encryptedStr) {
|
|
|
95
95
|
return decrypted;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
98
|
+
// Load a KEY=VALUE .env file from MEMORY_DIR (global environment override for
|
|
99
|
+
// headless / Docker / CI deployments). Values may optionally be quoted.
|
|
100
|
+
function loadEnvFile() {
|
|
101
|
+
const envFile = path.join(MEMORY_DIR, ".env");
|
|
102
|
+
if (!fs.existsSync(envFile)) return {};
|
|
103
|
+
try {
|
|
104
|
+
const out = {};
|
|
105
|
+
const raw = fs.readFileSync(envFile, "utf-8");
|
|
106
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
107
|
+
const trimmed = line.trim();
|
|
108
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
109
|
+
const eq = trimmed.indexOf("=");
|
|
110
|
+
if (eq <= 0) continue;
|
|
111
|
+
const key = trimmed.slice(0, eq).trim();
|
|
112
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
113
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
114
|
+
value = value.slice(1, -1);
|
|
115
|
+
}
|
|
116
|
+
if (key) out[key] = value;
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
} catch {
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
104
122
|
}
|
|
105
123
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
124
|
+
const ENV_DB_URL_KEYS = ["TURSO_DB_URL", "TURSO_URL"];
|
|
125
|
+
const ENV_DB_TOKEN_KEYS = ["TURSO_DB_TOKEN", "TURSO_TOKEN"];
|
|
126
|
+
|
|
127
|
+
function firstDefined(source, keys) {
|
|
128
|
+
for (const k of keys) {
|
|
129
|
+
const v = source[k];
|
|
130
|
+
if (v && String(v).trim()) return String(v).trim();
|
|
110
131
|
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Resolve cloud credentials from the environment (process.env) or a
|
|
136
|
+
// MEMORY_DIR/.env file — the headless alternative to browser OAuth login.
|
|
137
|
+
// Supported vars: TURSO_DB_URL / TURSO_URL, TURSO_DB_TOKEN / TURSO_TOKEN,
|
|
138
|
+
// TURSO_API_TOKEN, TURSO_ORG, TURSO_DATABASE / TURSO_DB_NAME, TURSO_USERNAME.
|
|
139
|
+
// Returns null when no cloud secrets are present at all.
|
|
140
|
+
export function resolveEnvSecrets() {
|
|
141
|
+
const fileVars = loadEnvFile();
|
|
142
|
+
const merged = { ...fileVars, ...process.env };
|
|
143
|
+
const dbUrl = firstDefined(merged, ENV_DB_URL_KEYS);
|
|
144
|
+
const token = firstDefined(merged, ENV_DB_TOKEN_KEYS);
|
|
145
|
+
const apiToken = firstDefined(merged, ["TURSO_API_TOKEN"]);
|
|
146
|
+
const org = firstDefined(merged, ["TURSO_ORG"]);
|
|
147
|
+
const database = firstDefined(merged, ["TURSO_DATABASE", "TURSO_DB_NAME"]);
|
|
148
|
+
const username = firstDefined(merged, ["TURSO_USERNAME"]);
|
|
149
|
+
if (!dbUrl && !token && !apiToken) return null;
|
|
150
|
+
return { dbUrl, token, apiToken, org, database, username, source: "env" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Read the encrypted store file ONLY (no environment merge). Returns the raw
|
|
154
|
+
// parsed record, or null when the file is missing / undecryptable.
|
|
155
|
+
function readStoredSecrets() {
|
|
156
|
+
if (!fs.existsSync(SECRETS_FILE)) return null;
|
|
111
157
|
try {
|
|
112
158
|
const encrypted = fs.readFileSync(SECRETS_FILE, "utf-8").trim();
|
|
113
159
|
if (!encrypted) return null;
|
|
114
|
-
|
|
115
|
-
return JSON.parse(decrypted);
|
|
160
|
+
return JSON.parse(decryptData(encrypted));
|
|
116
161
|
} catch (err) {
|
|
117
162
|
console.error(
|
|
118
163
|
"Failed to decrypt or load cloud secrets:",
|
|
@@ -123,6 +168,73 @@ export function loadSecrets() {
|
|
|
123
168
|
}
|
|
124
169
|
}
|
|
125
170
|
|
|
171
|
+
// Where are cloud credentials coming from right now?
|
|
172
|
+
// "env" — TURSO_API_TOKEN / TURSO_DB_URL + TURSO_DB_TOKEN from env or .env
|
|
173
|
+
// "api-key" — a stored Turso account API-token session (takes priority over browser)
|
|
174
|
+
// "store" — a stored browser OAuth / database-token session
|
|
175
|
+
// null — nothing configured
|
|
176
|
+
export function getSecretsSource() {
|
|
177
|
+
const envSecrets = resolveEnvSecrets();
|
|
178
|
+
if (envSecrets && (envSecrets.apiToken || envSecrets.dbUrl)) return "env";
|
|
179
|
+
const stored = readStoredSecrets();
|
|
180
|
+
if (stored) return stored.apiToken ? "api-key" : "store";
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Save secrets securely
|
|
185
|
+
export function saveSecrets(secrets) {
|
|
186
|
+
ensureDirSync();
|
|
187
|
+
const plainText = JSON.stringify(secrets);
|
|
188
|
+
const encrypted = encryptData(plainText);
|
|
189
|
+
fs.writeFileSync(SECRETS_FILE, encrypted, "utf-8");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Load secrets securely. Priority (highest first):
|
|
193
|
+
// 1. Env account API token (TURSO_API_TOKEN) — reused from the store when a
|
|
194
|
+
// session was already minted for this exact token, else returned with
|
|
195
|
+
// needsResolution: true so callers can mint a DB JWT asynchronously.
|
|
196
|
+
// 2. Env database URL + token (TURSO_DB_URL + TURSO_DB_TOKEN).
|
|
197
|
+
// 3. Encrypted store: an API-key session beats a browser/database-token session.
|
|
198
|
+
// The env sources let Docker, Google Jules and VPS deployments work without
|
|
199
|
+
// any interactive login step.
|
|
200
|
+
export function loadSecrets() {
|
|
201
|
+
const envSecrets = resolveEnvSecrets();
|
|
202
|
+
if (envSecrets && envSecrets.apiToken) {
|
|
203
|
+
const stored = readStoredSecrets();
|
|
204
|
+
if (stored && stored.apiToken === envSecrets.apiToken && stored.dbUrl) {
|
|
205
|
+
return { ...stored, source: "api-key" };
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
token: envSecrets.apiToken,
|
|
209
|
+
apiToken: envSecrets.apiToken,
|
|
210
|
+
dbUrl: envSecrets.dbUrl || "",
|
|
211
|
+
org: envSecrets.org || "",
|
|
212
|
+
db: envSecrets.database || "",
|
|
213
|
+
username: envSecrets.username || "",
|
|
214
|
+
authorized: true,
|
|
215
|
+
source: "env",
|
|
216
|
+
needsResolution: true,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (envSecrets && envSecrets.dbUrl && envSecrets.token) {
|
|
220
|
+
return {
|
|
221
|
+
token: envSecrets.token,
|
|
222
|
+
dbUrl: envSecrets.dbUrl,
|
|
223
|
+
org: envSecrets.org || "",
|
|
224
|
+
db: envSecrets.database || "",
|
|
225
|
+
username: envSecrets.username || "",
|
|
226
|
+
authorized: true,
|
|
227
|
+
source: "env",
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const stored = readStoredSecrets();
|
|
231
|
+
if (stored) {
|
|
232
|
+
if (stored.apiToken) return { ...stored, source: "api-key" };
|
|
233
|
+
return stored;
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
126
238
|
// Delete secrets from disk
|
|
127
239
|
export function deleteSecrets() {
|
|
128
240
|
if (fs.existsSync(SECRETS_FILE)) {
|
|
@@ -15,6 +15,7 @@ export const DEFAULT_CONFIG = {
|
|
|
15
15
|
onnxThreads: 0, // ONNX WASM threads: 0 = auto-detect CPU cores, or 1-16
|
|
16
16
|
executionDevice: "cpu", // "cpu" | "webgpu"
|
|
17
17
|
mode: "only-local", // "only-local" | "only-cloud" | "hybrid-sync"
|
|
18
|
+
conflictStrategy: "merge", // "merge" | "cloud-wins" | "local-wins"
|
|
18
19
|
tursoUrl: "", // Connection endpoint URL for Turso DB
|
|
19
20
|
failoverUrl: "", // Failover connection endpoint URL (Fly.io + LiteFS)
|
|
20
21
|
authorized: false, // True once the user completed cloud login (token stored encrypted)
|
|
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync } from "fs";
|
|
|
4
4
|
import { MEMORY_DIR } from "../memory.js";
|
|
5
5
|
import { runMigrations } from "./migrations.js";
|
|
6
6
|
import { getConfig } from "../config/config_manager.js";
|
|
7
|
-
import {
|
|
7
|
+
import { resolveCloudSecrets } from "../admin/auth.js";
|
|
8
8
|
import { createClient } from "@libsql/client";
|
|
9
9
|
|
|
10
10
|
let dbInstance = null;
|
|
@@ -159,7 +159,9 @@ async function openDatabase(customPath, mode) {
|
|
|
159
159
|
let cloudClient = null;
|
|
160
160
|
let failoverClient = null;
|
|
161
161
|
if (mode === "only-cloud" || mode === "hybrid-sync") {
|
|
162
|
-
|
|
162
|
+
// Resolve working cloud credentials. An env TURSO_API_TOKEN (which can only
|
|
163
|
+
// call the Platform API) is lazily minted into a per-database JWT here.
|
|
164
|
+
const secrets = await resolveCloudSecrets();
|
|
163
165
|
const tursoUrl = customPath && customPath.startsWith("libsql:") ? customPath : (secrets?.dbUrl || config.tursoUrl);
|
|
164
166
|
const failoverUrl = config.failoverUrl || "";
|
|
165
167
|
const token = secrets?.token;
|
|
@@ -1,5 +1,15 @@
|
|
|
1
|
+
import { readFile, readdir } from "fs/promises";
|
|
2
|
+
import { join, basename } from "path";
|
|
3
|
+
import { MEMORY_DIR, GLOBAL_KEY, buildMemoryContent, extractFacts, writeMemoryFile, storeFilePath, memoryFileName } from "../memory.js";
|
|
4
|
+
|
|
1
5
|
let isSyncing = false;
|
|
2
6
|
|
|
7
|
+
// Reverse sync (cloud -> local) throttling: only pull at most once per window
|
|
8
|
+
// even if readMemory triggers it frequently (recall hits every keystroke).
|
|
9
|
+
let lastReverseSync = 0;
|
|
10
|
+
let isReverseSyncing = false;
|
|
11
|
+
const REVERSE_SYNC_INTERVAL_MS = 5000;
|
|
12
|
+
|
|
3
13
|
async function processSyncTask(db, task) {
|
|
4
14
|
if (task.action === "write_memory") {
|
|
5
15
|
await db.cloudClient.execute({
|
|
@@ -162,6 +172,161 @@ export async function enqueueSyncTask(action, keyOrId, payload = null) {
|
|
|
162
172
|
});
|
|
163
173
|
}
|
|
164
174
|
|
|
175
|
+
// Map a store key to its local file path, mirroring memory.js naming.
|
|
176
|
+
function localFilePath(key) {
|
|
177
|
+
return join(MEMORY_DIR, memoryFileName(key));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Enumerate local store files as { key, path }.
|
|
181
|
+
async function enumerateLocalStores() {
|
|
182
|
+
const files = await readdir(MEMORY_DIR).catch(() => []);
|
|
183
|
+
const stores = [];
|
|
184
|
+
for (const f of files) {
|
|
185
|
+
if (!f.endsWith(".md")) continue;
|
|
186
|
+
const fp = join(MEMORY_DIR, f);
|
|
187
|
+
let content = "";
|
|
188
|
+
try {
|
|
189
|
+
content = await readFile(fp, "utf-8");
|
|
190
|
+
} catch (e) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const meta = content.match(/<!-- path: (.+?) -->/);
|
|
194
|
+
const key = f === `${GLOBAL_KEY}.md` ? GLOBAL_KEY : (meta ? meta[1].trim() : f.slice(0, -3));
|
|
195
|
+
stores.push({ key, path: fp, file: f });
|
|
196
|
+
}
|
|
197
|
+
return stores;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Reverse sync: pull cloud state down to local stores, resolving conflicts
|
|
201
|
+
// according to config.conflictStrategy ("merge" | "cloud-wins" | "local-wins").
|
|
202
|
+
//
|
|
203
|
+
// Returns a summary of what happened for diagnostics.
|
|
204
|
+
async function pullFromCloud(db) {
|
|
205
|
+
const { getConfig } = await import("../config/config_manager.js");
|
|
206
|
+
const config = getConfig();
|
|
207
|
+
const strategy = config.conflictStrategy || "merge";
|
|
208
|
+
|
|
209
|
+
const summary = { pulled: 0, pushed: 0, merged: 0, cloudWins: 0, localWins: 0, unchanged: 0, conflicts: 0 };
|
|
210
|
+
|
|
211
|
+
// 1. Enumerate cloud notebooks. In hybrid-sync the wrapper's prepare() routes
|
|
212
|
+
// to the LOCAL sqlite, so cloud reads/writes must go through cloudClient directly.
|
|
213
|
+
const cloudRes = await db.cloudClient.execute("SELECT key, content FROM notebooks;");
|
|
214
|
+
const cloudRows = cloudRes.rows || [];
|
|
215
|
+
const cloudByKey = new Map(cloudRows.map((r) => [r.key, r.content || ""]));
|
|
216
|
+
|
|
217
|
+
// 2. Enumerate local store files.
|
|
218
|
+
const localStores = await enumerateLocalStores();
|
|
219
|
+
const localByKey = new Map(localStores.map((s) => [s.key, s.path]));
|
|
220
|
+
const localContentByKey = new Map();
|
|
221
|
+
for (const s of localStores) {
|
|
222
|
+
try {
|
|
223
|
+
localContentByKey.set(s.key, await readFile(s.path, "utf-8"));
|
|
224
|
+
} catch (e) {}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const allKeys = new Set([...cloudByKey.keys(), ...localByKey.keys()]);
|
|
228
|
+
|
|
229
|
+
// Upsert a notebook row directly on the cloud client.
|
|
230
|
+
const upsertCloud = async (key, content) => {
|
|
231
|
+
await db.cloudClient.execute({
|
|
232
|
+
sql: `
|
|
233
|
+
INSERT INTO notebooks (key, content, updated_at)
|
|
234
|
+
VALUES (?, ?, ?)
|
|
235
|
+
ON CONFLICT(key) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at;
|
|
236
|
+
`,
|
|
237
|
+
args: [key, content, Date.now()],
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// 3. Reconcile each key.
|
|
242
|
+
for (const key of allKeys) {
|
|
243
|
+
const cloudContent = cloudByKey.get(key);
|
|
244
|
+
const localPath = localByKey.get(key);
|
|
245
|
+
const localContent = localContentByKey.get(key) || "";
|
|
246
|
+
|
|
247
|
+
const cloudFacts = cloudContent !== undefined ? extractFacts(cloudContent) : null;
|
|
248
|
+
const localFacts = extractFacts(localContent);
|
|
249
|
+
const cloudHas = cloudFacts !== null && cloudFacts.length > 0;
|
|
250
|
+
const localHas = localFacts.length > 0;
|
|
251
|
+
|
|
252
|
+
if (cloudFacts === null) {
|
|
253
|
+
// Store exists only locally -> push up.
|
|
254
|
+
if (localHas) {
|
|
255
|
+
await upsertCloud(key, localContent);
|
|
256
|
+
summary.pushed++;
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!localHas) {
|
|
262
|
+
// Store exists only in cloud -> pull down.
|
|
263
|
+
if (cloudHas) {
|
|
264
|
+
await writeMemoryFile(key, cloudContent);
|
|
265
|
+
summary.pulled++;
|
|
266
|
+
}
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Both exist.
|
|
271
|
+
if (localContent === cloudContent) {
|
|
272
|
+
summary.unchanged++;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
summary.conflicts++;
|
|
277
|
+
if (strategy === "cloud-wins") {
|
|
278
|
+
await writeMemoryFile(key, cloudContent);
|
|
279
|
+
summary.cloudWins++;
|
|
280
|
+
} else if (strategy === "local-wins") {
|
|
281
|
+
await upsertCloud(key, localContent);
|
|
282
|
+
summary.localWins++;
|
|
283
|
+
} else {
|
|
284
|
+
// merge: union of fact lines, deduped, local order first then cloud-only.
|
|
285
|
+
const seen = new Set();
|
|
286
|
+
const mergedFacts = [];
|
|
287
|
+
for (const l of [...localFacts, ...cloudFacts]) {
|
|
288
|
+
if (!seen.has(l)) {
|
|
289
|
+
seen.add(l);
|
|
290
|
+
mergedFacts.push(l);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const mergedContent = buildMemoryContent(key, mergedFacts);
|
|
294
|
+
await writeMemoryFile(key, mergedContent);
|
|
295
|
+
await upsertCloud(key, mergedContent);
|
|
296
|
+
summary.merged++;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return summary;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Trigger a reverse sync now (regardless of throttle). Used after the push queue
|
|
304
|
+
// drains so both directions stay in sync.
|
|
305
|
+
export async function syncFromCloud() {
|
|
306
|
+
if (isReverseSyncing) return { skipped: true };
|
|
307
|
+
isReverseSyncing = true;
|
|
308
|
+
try {
|
|
309
|
+
const { getDatabase } = await import("./database.js");
|
|
310
|
+
const db = await getDatabase();
|
|
311
|
+
if (db.mode !== "hybrid-sync" || !db.cloudClient) return { skipped: true };
|
|
312
|
+
lastReverseSync = Date.now();
|
|
313
|
+
return await pullFromCloud(db);
|
|
314
|
+
} finally {
|
|
315
|
+
isReverseSyncing = false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Throttled reverse sync, safe to call on every recall/read.
|
|
320
|
+
export async function ensureReverseSync() {
|
|
321
|
+
if (Date.now() - lastReverseSync < REVERSE_SYNC_INTERVAL_MS) return { throttled: true };
|
|
322
|
+
return syncFromCloud();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Reset the reverse-sync throttle (used by tests and manual syncs).
|
|
326
|
+
export function resetReverseSyncThrottle() {
|
|
327
|
+
lastReverseSync = 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
165
330
|
export async function triggerBackgroundSync() {
|
|
166
331
|
if (isSyncing) return;
|
|
167
332
|
isSyncing = true;
|
|
@@ -203,6 +368,9 @@ export async function triggerBackgroundSync() {
|
|
|
203
368
|
break;
|
|
204
369
|
}
|
|
205
370
|
}
|
|
371
|
+
|
|
372
|
+
// Push queue drained — now pull cloud state back down (reverse sync).
|
|
373
|
+
await syncFromCloud();
|
|
206
374
|
} catch (err) {
|
|
207
375
|
console.error("Error during background sync execution:", err.message);
|
|
208
376
|
} finally {
|
package/mcp-server/index.js
CHANGED
|
@@ -39,7 +39,7 @@ if (cliArgs.includes("setup") || cliArgs.includes("install") || cliArgs.includes
|
|
|
39
39
|
process.exit(0);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
if (cliArgs.includes("cli") || cliArgs.includes("config") || cliArgs.includes("--cli") || cliArgs.includes("-c") || cliArgs.includes("login") || cliArgs.includes("logout")) {
|
|
42
|
+
if (cliArgs.includes("cli") || cliArgs.includes("config") || cliArgs.includes("--cli") || cliArgs.includes("-c") || cliArgs.includes("login") || cliArgs.includes("logout") || cliArgs.includes("auth-status") || cliArgs.includes("auth_status") || cliArgs.includes("auth")) {
|
|
43
43
|
const { runCli } = await import("./cli.js");
|
|
44
44
|
await runCli();
|
|
45
45
|
process.exit(0);
|
package/mcp-server/memory.js
CHANGED
|
@@ -142,6 +142,15 @@ export async function readMemory(key) {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
const fp = memoryPath(key);
|
|
145
|
+
if (config.mode === "hybrid-sync") {
|
|
146
|
+
// Pull cloud state down first so cloud-only records appear locally.
|
|
147
|
+
try {
|
|
148
|
+
const { ensureReverseSync } = await import("./db/sync_queue.js");
|
|
149
|
+
await ensureReverseSync();
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error("Failed to reverse-sync before read:", err.message);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
145
154
|
if (existsSync(fp)) {
|
|
146
155
|
const content = await readFile(fp, "utf-8");
|
|
147
156
|
return content.split("\n").filter((l) => l.startsWith("- ["));
|
|
@@ -154,7 +163,8 @@ export async function readMemoryRaw(key) {
|
|
|
154
163
|
return (await readMemory(key)).map((e) => e.slice(2));
|
|
155
164
|
}
|
|
156
165
|
|
|
157
|
-
|
|
166
|
+
// Build the markdown store content for a key from a list of fact lines.
|
|
167
|
+
export function buildMemoryContent(key, entries) {
|
|
158
168
|
const lines = [];
|
|
159
169
|
if (key === GLOBAL_KEY) {
|
|
160
170
|
lines.push("# Global Memory", "");
|
|
@@ -164,7 +174,22 @@ export async function writeMemory(key, entries) {
|
|
|
164
174
|
lines.push(`<!-- path: ${key} -->`, "");
|
|
165
175
|
}
|
|
166
176
|
}
|
|
167
|
-
|
|
177
|
+
return lines.join("\n") + "\n" + (entries.length ? entries.join("\n") + "\n" : "");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Extract fact lines (`- [date] ...`) from a store content string.
|
|
181
|
+
export function extractFacts(content) {
|
|
182
|
+
return (content || "").split("\n").filter((l) => l.startsWith("- ["));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Write a store file directly to disk WITHOUT enqueueing a cloud sync task.
|
|
186
|
+
// Used by the sync worker to apply pulled cloud state without re-queueing.
|
|
187
|
+
export async function writeMemoryFile(key, content) {
|
|
188
|
+
await writeFile(memoryPath(key), content);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function writeMemory(key, entries) {
|
|
192
|
+
const content = buildMemoryContent(key, entries);
|
|
168
193
|
|
|
169
194
|
const { getConfig } = await import("./config/config_manager.js");
|
|
170
195
|
const config = getConfig();
|
package/mcp-server/setup.js
CHANGED
|
@@ -15,10 +15,42 @@ export async function runSetup() {
|
|
|
15
15
|
const doAntigravity = !hasSpecificFlag || args.includes("--antigravity") || args.includes("--gemini");
|
|
16
16
|
const doCodex = !hasSpecificFlag || args.includes("--codex");
|
|
17
17
|
|
|
18
|
+
// Headless cloud setup: --api-key <TURSO_API_TOKEN> and/or --mode <only-local|only-cloud|hybrid-sync>
|
|
19
|
+
const VALID_MODES = ["only-local", "only-cloud", "hybrid-sync"];
|
|
20
|
+
const apiKeyArg = flagValue(args, "--api-key");
|
|
21
|
+
const modeArg = flagValue(args, "--mode");
|
|
22
|
+
if (modeArg && !VALID_MODES.includes(modeArg)) {
|
|
23
|
+
console.log(` [WARN] Unknown --mode "${modeArg}". Allowed: ${VALID_MODES.join(", ")}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
console.log("\nSetting up @lotargo/memory_plugin...\n");
|
|
19
27
|
const home = homedir();
|
|
20
28
|
let configuredCount = 0;
|
|
21
29
|
|
|
30
|
+
// 0. Headless cloud authentication (Google Jules / CI / VPS)
|
|
31
|
+
if (apiKeyArg) {
|
|
32
|
+
try {
|
|
33
|
+
const { loginWithApiToken } = await import("./admin/auth.js");
|
|
34
|
+
const secrets = await loginWithApiToken({ token: apiKeyArg });
|
|
35
|
+
if (modeArg && VALID_MODES.includes(modeArg)) {
|
|
36
|
+
const { updateConfig } = await import("./config/config_manager.js");
|
|
37
|
+
updateConfig({ mode: modeArg });
|
|
38
|
+
}
|
|
39
|
+
console.log(` [OK] Cloud: authorized as "${secrets.username}" via API token. Endpoint: ${secrets.dbUrl}`);
|
|
40
|
+
configuredCount++;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.log(" [FAIL] Cloud setup failed:", err.message);
|
|
43
|
+
}
|
|
44
|
+
} else if (modeArg && VALID_MODES.includes(modeArg)) {
|
|
45
|
+
try {
|
|
46
|
+
const { updateConfig } = await import("./config/config_manager.js");
|
|
47
|
+
updateConfig({ mode: modeArg });
|
|
48
|
+
console.log(` [OK] Cloud: sync mode set to "${modeArg}"`);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.log(" [SKIP] Cloud mode update skipped:", err.message);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
22
54
|
// 1. OpenCode (~/.config/opencode/opencode.json)
|
|
23
55
|
if (doOpenCode) {
|
|
24
56
|
try {
|
|
@@ -229,3 +261,12 @@ export async function runSetup() {
|
|
|
229
261
|
console.log(`\nSetup complete. Configured ${configuredCount} environment(s).\n`);
|
|
230
262
|
}
|
|
231
263
|
|
|
264
|
+
// Read the value following --flag, or null when absent / followed by another flag.
|
|
265
|
+
function flagValue(args, flag) {
|
|
266
|
+
const idx = args.indexOf(flag);
|
|
267
|
+
if (idx === -1 || idx + 1 >= args.length) return null;
|
|
268
|
+
const value = args[idx + 1];
|
|
269
|
+
if (value.startsWith("--")) return null;
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotargo/memory_plugin",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.621",
|
|
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",
|