@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 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. Cross-session cloud synchronization across different Jules runs is planned for upcoming releases.
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
 
@@ -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
- // 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.`);
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 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}`));
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
- 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.");
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
- // 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.");
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
- const dbUrl = `libsql://${dbHostname(org, dbName)}`;
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
- // 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 });
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
- 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 };
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 { loginToCloud } = await import("./admin/auth.js");
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
- const secrets = await loginToCloud();
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: "Perform secure OAuth/Device login flow with loopback listener and local AES-256 key encryption",
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] Starting Turso cloud authorization...");
1833
- const { loginToCloud } = await import("./admin/auth.js");
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
- const secrets = await loginToCloud();
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
- // Save secrets securely
99
- export function saveSecrets(secrets) {
100
- ensureDirSync();
101
- const plainText = JSON.stringify(secrets);
102
- const encrypted = encryptData(plainText);
103
- fs.writeFileSync(SECRETS_FILE, encrypted, "utf-8");
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
- // Load secrets securely
107
- export function loadSecrets() {
108
- if (!fs.existsSync(SECRETS_FILE)) {
109
- return null;
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
- const decrypted = decryptData(encrypted);
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 { loadSecrets } from "../config/auth_store.js";
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
- const secrets = loadSecrets();
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 {
@@ -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);
@@ -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
- export async function writeMemory(key, entries) {
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
- const content = lines.join("\n") + "\n" + (entries.length ? entries.join("\n") + "\n" : "");
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();
@@ -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.601",
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",