total-recall-brain 3.2.1 → 3.2.2

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.
@@ -57,8 +57,8 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
57
57
 
58
58
  // Model Selector state
59
59
  const [availableModels, setAvailableModels] = useState<string[]>([])
60
- const [selectedModel, setSelectedModel] = useState<string>('')
61
- const [selectedSubModel, setSelectedSubModel] = useState<string>('')
60
+ const [selectedModel, setSelectedModel] = useState<string>(() => localStorage.getItem('selectedModel') || '')
61
+ const [selectedSubModel, setSelectedSubModel] = useState<string>(() => localStorage.getItem('selectedSubModel') || '')
62
62
  const [geminiModels, setGeminiModels] = useState<{ id: string; displayName: string }[]>([])
63
63
  const [showModelDropdown, setShowModelDropdown] = useState(false)
64
64
  const dropdownRef = useRef<HTMLDivElement>(null)
@@ -102,24 +102,44 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
102
102
 
103
103
  fetchHealth()
104
104
  .then(health => {
105
- if (health.cli_agents && health.cli_agents.length > 0) {
106
- setAvailableModels(health.cli_agents)
107
- setSelectedModel(health.cli_agents[0])
105
+ const agents = health.cli_agents && health.cli_agents.length > 0
106
+ ? health.cli_agents
107
+ : ['antigravity', 'gemini', 'claude', 'codex'];
108
+ setAvailableModels(agents);
109
+
110
+ const saved = localStorage.getItem('selectedModel');
111
+ if (saved && agents.includes(saved)) {
112
+ setSelectedModel(saved);
108
113
  } else {
109
- setAvailableModels(['antigravity', 'gemini', 'claude', 'codex'])
110
- setSelectedModel('gemini')
114
+ const defaultAgent = agents.includes('gemini') ? 'gemini' : agents[0];
115
+ setSelectedModel(defaultAgent);
116
+ localStorage.setItem('selectedModel', defaultAgent);
111
117
  }
112
118
  })
113
119
  .catch(() => {
114
- setAvailableModels(['antigravity', 'gemini', 'claude', 'codex'])
115
- setSelectedModel('gemini')
120
+ const agents = ['antigravity', 'gemini', 'claude', 'codex'];
121
+ setAvailableModels(agents);
122
+ const saved = localStorage.getItem('selectedModel');
123
+ if (saved && agents.includes(saved)) {
124
+ setSelectedModel(saved);
125
+ } else {
126
+ setSelectedModel('gemini');
127
+ localStorage.setItem('selectedModel', 'gemini');
128
+ }
116
129
  })
117
130
 
118
131
  fetchGeminiModels()
119
132
  .then(models => {
120
133
  setGeminiModels(models)
121
134
  if (models && models.length > 0) {
122
- setSelectedSubModel(models[0].id)
135
+ const savedSub = localStorage.getItem('selectedSubModel');
136
+ const exists = models.some(m => m.id === savedSub);
137
+ if (savedSub && exists) {
138
+ setSelectedSubModel(savedSub);
139
+ } else {
140
+ setSelectedSubModel(models[0].id);
141
+ localStorage.setItem('selectedSubModel', models[0].id);
142
+ }
123
143
  }
124
144
  })
125
145
  .catch(console.error)
@@ -488,7 +508,9 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
488
508
  className={`model-selector-item ${selectedModel === modelName ? 'selected' : ''}`}
489
509
  onClick={() => {
490
510
  setSelectedModel(modelName)
511
+ localStorage.setItem('selectedModel', modelName)
491
512
  setSelectedSubModel('')
513
+ localStorage.removeItem('selectedSubModel')
492
514
  setShowModelDropdown(false)
493
515
  }}
494
516
  >
@@ -512,7 +534,10 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
512
534
  <span className="selector-label" style={{ fontSize: '12px', color: 'var(--text-tertiary)', fontWeight: 500 }}>Base:</span>
513
535
  <select
514
536
  value={selectedSubModel}
515
- onChange={(e) => setSelectedSubModel(e.target.value)}
537
+ onChange={(e) => {
538
+ setSelectedSubModel(e.target.value)
539
+ localStorage.setItem('selectedSubModel', e.target.value)
540
+ }}
516
541
  className="submodel-select"
517
542
  style={{
518
543
  background: 'rgba(255, 255, 255, 0.04)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.2.1",
3
+ "version": "3.2.2",
4
4
  "description": "Sovereign AI system — database-free, SSSS-powered personal intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,7 +36,7 @@ function commandExists(cmd) {
36
36
  function parseArgs(args) {
37
37
  // Parse layer flag first
38
38
  const { layer, remainingArgs } = parseLayerFlag(args);
39
- const opts = { output: null, encrypt: true, pushGit: null, obsidian: null, help: false, layer };
39
+ const opts = { output: null, encrypt: true, pushGit: null, obsidian: null, install: false, help: false, layer };
40
40
  for (let i = 0; i < remainingArgs.length; i++) {
41
41
  switch (remainingArgs[i]) {
42
42
  case '--output':
@@ -52,6 +52,9 @@ function parseArgs(args) {
52
52
  case '--obsidian':
53
53
  opts.obsidian = remainingArgs[++i];
54
54
  break;
55
+ case '--install':
56
+ opts.install = true;
57
+ break;
55
58
  case '--help':
56
59
  case '-h':
57
60
  opts.help = true;
@@ -246,6 +249,168 @@ function obsidianBackup(vaultPath, layer = 'auto') {
246
249
  console.error(` ✅ Brain synced to Obsidian (${stamp})`);
247
250
  }
248
251
 
252
+ /**
253
+ * --install: Full automatic backup setup.
254
+ * 1. Creates a private GitHub repo via `gh` CLI
255
+ * 2. Writes .gitignore for derived/cache files
256
+ * 3. Inits git, does first push
257
+ * 4. Installs a daily launchd plist (macOS) or cron job (Linux)
258
+ */
259
+ async function installBackup(layer = 'auto') {
260
+ const SKILL_DIR = getSkillDir(layer);
261
+ if (!fs.existsSync(SKILL_DIR)) {
262
+ console.error(` ❌ Meta-skill not found: ${SKILL_DIR}`);
263
+ console.error(' Run: npx total-recall init');
264
+ process.exit(1);
265
+ }
266
+
267
+ if (!commandExists('gh')) {
268
+ console.error(' ❌ GitHub CLI (gh) not found. Install: brew install gh');
269
+ process.exit(1);
270
+ }
271
+
272
+ // Determine a repo name from the project directory
273
+ const projectDir = process.cwd();
274
+ const projectName = path.basename(projectDir).replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
275
+ const isGlobal = SKILL_DIR.startsWith(os.homedir() + '/.agent');
276
+ const repoName = isGlobal ? 'global-brain-backup' : `${projectName}-brain-backup`;
277
+
278
+ // Get GitHub username
279
+ const whoami = spawnSync('gh', ['api', 'user', '--jq', '.login'], { stdio: 'pipe' });
280
+ if (whoami.status !== 0) {
281
+ console.error(' ❌ Not authenticated with GitHub CLI. Run: gh auth login');
282
+ process.exit(1);
283
+ }
284
+ const ghUser = whoami.stdout.toString().trim();
285
+ const remoteUrl = `git@github.com:${ghUser}/${repoName}.git`;
286
+
287
+ console.error(`\n 🧠 Installing automatic backup for brain at:`);
288
+ console.error(` ${SKILL_DIR}`);
289
+ console.error(` → ${ghUser}/${repoName} (private)\n`);
290
+
291
+ // 1. Create GitHub repo (skip if exists)
292
+ const checkRepo = spawnSync('gh', ['repo', 'view', `${ghUser}/${repoName}`, '--json', 'name'], { stdio: 'pipe' });
293
+ if (checkRepo.status !== 0) {
294
+ console.error(' 📦 Creating private GitHub repo...');
295
+ const create = spawnSync('gh', ['repo', 'create', `${ghUser}/${repoName}`, '--private', '--description', `Auto-backup: ${isGlobal ? 'Global brain (identity layer)' : projectName + ' project brain'}`], { stdio: 'pipe' });
296
+ if (create.status !== 0) {
297
+ console.error(` ❌ Failed to create repo: ${create.stderr?.toString()}`);
298
+ process.exit(1);
299
+ }
300
+ console.error(` ✅ Created ${ghUser}/${repoName}`);
301
+ } else {
302
+ console.error(` ✅ Repo ${ghUser}/${repoName} already exists`);
303
+ }
304
+
305
+ // 2. Write .gitignore for derived/cache files
306
+ const gitignorePath = path.join(SKILL_DIR, '.gitignore');
307
+ const gitignoreEntries = ['memory-derived/', 'sessions/', '*.backup', 'logs/', 'node_modules/'];
308
+ let existingIgnore = '';
309
+ if (fs.existsSync(gitignorePath)) {
310
+ existingIgnore = fs.readFileSync(gitignorePath, 'utf8');
311
+ }
312
+ const missing = gitignoreEntries.filter(e => !existingIgnore.includes(e));
313
+ if (missing.length > 0) {
314
+ fs.appendFileSync(gitignorePath, '\n# Auto-added by total-recall backup --install\n' + missing.join('\n') + '\n');
315
+ console.error(` ✅ Updated .gitignore (added ${missing.length} entries)`);
316
+ }
317
+
318
+ // 3. Init git + first push
319
+ await pushGitBackup(remoteUrl, layer);
320
+
321
+ // 4. Install scheduled backup
322
+ const platform = os.platform();
323
+ const nodeBin = process.execPath;
324
+ const trBin = path.join(path.dirname(nodeBin), 'total-recall');
325
+ const label = isGlobal ? 'com.totalrecall.backup.global' : `com.totalrecall.backup.${projectName}`;
326
+
327
+ if (platform === 'darwin') {
328
+ // macOS: launchd plist
329
+ const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
330
+ fs.mkdirSync(plistDir, { recursive: true });
331
+ const logName = isGlobal ? 'backup-global.log' : `backup-${projectName}.log`;
332
+ const logsDir = path.join(os.homedir(), '.agent', 'logs');
333
+ fs.mkdirSync(logsDir, { recursive: true });
334
+
335
+ // Stagger times: global=2:30, projects get 2:00 + hash-based offset
336
+ const minute = isGlobal ? 30 : (projectName.split('').reduce((a, c) => a + c.charCodeAt(0), 0) % 30);
337
+
338
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
339
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
340
+ <plist version="1.0">
341
+ <dict>
342
+ <key>Label</key>
343
+ <string>${label}</string>
344
+ <key>ProgramArguments</key>
345
+ <array>
346
+ <string>${nodeBin}</string>
347
+ <string>${trBin}</string>
348
+ <string>backup</string>
349
+ <string>--push-git</string>
350
+ <string>${remoteUrl}</string>
351
+ </array>
352
+ <key>WorkingDirectory</key>
353
+ <string>${projectDir}</string>
354
+ <key>StartCalendarInterval</key>
355
+ <dict>
356
+ <key>Hour</key>
357
+ <integer>2</integer>
358
+ <key>Minute</key>
359
+ <integer>${minute}</integer>
360
+ </dict>
361
+ <key>EnvironmentVariables</key>
362
+ <dict>
363
+ <key>HOME</key>
364
+ <string>${os.homedir()}</string>
365
+ <key>PATH</key>
366
+ <string>${path.dirname(nodeBin)}:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
367
+ </dict>
368
+ <key>StandardOutPath</key>
369
+ <string>${path.join(logsDir, logName)}</string>
370
+ <key>StandardErrorPath</key>
371
+ <string>${path.join(logsDir, logName)}</string>
372
+ <key>RunAtLoad</key>
373
+ <false/>
374
+ </dict>
375
+ </plist>`;
376
+
377
+ const plistPath = path.join(plistDir, `${label}.plist`);
378
+ fs.writeFileSync(plistPath, plist);
379
+
380
+ // Unload if exists, then load
381
+ spawnSync('launchctl', ['remove', label], { stdio: 'pipe' });
382
+ const load = spawnSync('launchctl', ['load', '-w', plistPath], { stdio: 'pipe' });
383
+ if (load.status === 0) {
384
+ console.error(` ✅ Installed daily backup (2:${String(minute).padStart(2, '0')} AM)`);
385
+ } else {
386
+ console.error(` ⚠️ launchctl load returned ${load.status} — plist saved, may need manual load`);
387
+ }
388
+
389
+ } else if (platform === 'linux') {
390
+ // Linux: cron job
391
+ const cronLine = `0 2 * * * ${nodeBin} ${trBin} backup --push-git ${remoteUrl} >> ${path.join(os.homedir(), '.agent', 'logs', 'backup.log')} 2>&1`;
392
+ const existing = spawnSync('crontab', ['-l'], { stdio: 'pipe' });
393
+ const currentCron = existing.stdout?.toString() || '';
394
+ if (!currentCron.includes(repoName)) {
395
+ const newCron = currentCron.trimEnd() + '\n' + cronLine + '\n';
396
+ const install = spawnSync('crontab', ['-'], { input: newCron, stdio: 'pipe' });
397
+ if (install.status === 0) {
398
+ console.error(' ✅ Installed daily cron job (2:00 AM)');
399
+ } else {
400
+ console.error(' ⚠️ Could not install cron job — add manually:');
401
+ console.error(` ${cronLine}`);
402
+ }
403
+ } else {
404
+ console.error(' ✅ Cron job already exists');
405
+ }
406
+ }
407
+
408
+ console.error(`\n 🎉 Backup fully installed!`);
409
+ console.error(` Repo: github.com/${ghUser}/${repoName}`);
410
+ console.error(` Schedule: Daily at 2:${platform === 'darwin' ? 'XX' : '00'} AM`);
411
+ console.error(` Manual: total-recall backup --push-git ${remoteUrl}`);
412
+ }
413
+
249
414
  export default async function backup(args) {
250
415
  const opts = parseArgs(args);
251
416
  const SKILL_DIR = getSkillDir(opts.layer);
@@ -260,6 +425,12 @@ export default async function backup(args) {
260
425
  process.exit(1);
261
426
  }
262
427
 
428
+ // Install mode: full automatic setup
429
+ if (opts.install) {
430
+ await installBackup(opts.layer);
431
+ return;
432
+ }
433
+
263
434
  // Git-push mode: sovereign diff-based backup
264
435
  if (opts.pushGit) {
265
436
  await pushGitBackup(opts.pushGit, opts.layer);
package/src/cli/setup.mjs CHANGED
@@ -456,6 +456,30 @@ export default async function setup(args) {
456
456
  }
457
457
  }
458
458
 
459
+ // ── Step 1.7: Install automatic brain backup ───────────────────────────────
460
+
461
+ hr();
462
+ console.log('\n 🔄 Automatic Brain Backup');
463
+ console.log(' Set up daily backup of your brain to a private GitHub repo.');
464
+ console.log(' Creates the repo, does the first push, and installs a scheduled job.\n');
465
+
466
+ const wantBackup = yes ? 'y' : (await prompt('Install automatic backup? [Y/n]:')).toLowerCase();
467
+ if (wantBackup !== 'n' && wantBackup !== 'no') {
468
+ if (!dryRun) {
469
+ try {
470
+ const backupHandler = await import(path.join(__dirname, 'backup.mjs'));
471
+ await backupHandler.default(['--install']);
472
+ } catch (e) {
473
+ warn(`Backup install failed: ${e.message}`);
474
+ warn('You can set it up later with: npx total-recall backup --install');
475
+ }
476
+ } else {
477
+ info('[dry-run] would run: total-recall backup --install');
478
+ }
479
+ } else {
480
+ info('Skipping auto-backup. Set it up later: npx total-recall backup --install');
481
+ }
482
+
459
483
  // ── Step 2: Provider-specific provisioning ──────────────────────────────────
460
484
 
461
485
  if (deployTarget === 'hetzner') {
@@ -1623,6 +1623,45 @@ router.get('/api/brains/:id/nodes', requireAuth, requireScope('ssss:read', 'memo
1623
1623
  */
1624
1624
  router.get('/api/gemini-models', requireAuth, async (req, res) => {
1625
1625
  try {
1626
+ // 1. Dynamically fetch available models from the antigravity CLI at runtime
1627
+ try {
1628
+ const { findBinaryInPath } = await import('../core/runtime.mjs');
1629
+ const antigravityPath = findBinaryInPath('antigravity');
1630
+ if (antigravityPath) {
1631
+ const result = spawnSync(antigravityPath, ['--help'], { encoding: 'utf8', timeout: 3000 });
1632
+ if (result.status === 0 && result.stdout) {
1633
+ const output = result.stdout;
1634
+ const discovered = [];
1635
+
1636
+ // Match standard gemini-X.Y-Z names
1637
+ const modelRegex = /gemini-\d+\.\d+(?:-\w+)?/g;
1638
+ const matches = output.match(modelRegex) || [];
1639
+ for (const m of matches) {
1640
+ discovered.push(m);
1641
+ }
1642
+
1643
+ // Match version-style models like X.Y-pro or X.Y-flash and prefix with gemini-
1644
+ const versionRegex = /\b(\d+\.\d+-\w+)\b/g;
1645
+ const versionMatches = output.match(versionRegex) || [];
1646
+ for (const vm of versionMatches) {
1647
+ discovered.push(`gemini-${vm}`);
1648
+ }
1649
+
1650
+ const uniqueModels = [...new Set(discovered)];
1651
+ if (uniqueModels.length > 0) {
1652
+ const cliModels = uniqueModels.map(modelId => {
1653
+ const parts = modelId.split('-');
1654
+ const displayName = parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
1655
+ return { id: modelId, displayName };
1656
+ });
1657
+ return res.json({ models: cliModels, source: 'cli' });
1658
+ }
1659
+ }
1660
+ }
1661
+ } catch (e) {
1662
+ // Fail silently and fall back
1663
+ }
1664
+
1626
1665
  let apiKey = process.env.GOOGLE_API_KEY;
1627
1666
  if (!apiKey) {
1628
1667
  // Find GOOGLE_API_KEY in upwards .env files
@@ -1647,14 +1686,9 @@ router.get('/api/gemini-models', requireAuth, async (req, res) => {
1647
1686
  }
1648
1687
 
1649
1688
  const fallbackModels = [
1650
- { id: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' },
1651
- { id: 'gemini-2.5-pro', displayName: 'Gemini 2.5 Pro' },
1652
- { id: 'gemini-1.5-pro', displayName: 'Gemini 1.5 Pro' },
1653
- { id: 'gemini-1.5-flash', displayName: 'Gemini 1.5 Flash' },
1654
1689
  { id: 'gemini-3.5-flash', displayName: 'Gemini 3.5 Flash' },
1655
- { id: 'gemini-3.1-flash-lite', displayName: 'Gemini 3.1 Flash Lite' },
1656
- { id: 'gemini-2.0-flash-lite', displayName: 'Gemini 2.0 Flash Lite' },
1657
- { id: 'gemini-pro-latest', displayName: 'Gemini Pro Latest' }
1690
+ { id: 'gemini-3.1-pro-preview', displayName: 'Gemini 3.1 Pro Preview' },
1691
+ { id: 'gemini-3.1-flash-lite', displayName: 'Gemini 3.1 Flash Lite' }
1658
1692
  ];
1659
1693
 
1660
1694
  if (!apiKey) {
@@ -1679,6 +1713,21 @@ router.get('/api/gemini-models', requireAuth, async (req, res) => {
1679
1713
  const parts = id.split('-');
1680
1714
  const displayName = parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
1681
1715
  return { id, displayName };
1716
+ })
1717
+ .filter(m => {
1718
+ // Only keep Gemini 3.1 and newer models
1719
+ const match = m.id.match(/^gemini-(\d+)\.(\d+)/);
1720
+ if (match) {
1721
+ const major = parseInt(match[1], 10);
1722
+ const minor = parseInt(match[2], 10);
1723
+ return major > 3 || (major === 3 && minor >= 1);
1724
+ }
1725
+ const majorMatch = m.id.match(/^gemini-(\d+)/);
1726
+ if (majorMatch) {
1727
+ const major = parseInt(majorMatch[1], 10);
1728
+ return major >= 3;
1729
+ }
1730
+ return false;
1682
1731
  });
1683
1732
  if (models.length > 0) {
1684
1733
  return res.json({ models, source: 'dynamic' });