total-recall-brain 3.1.2 → 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.
- package/extension/background.js +99 -0
- package/extension/content-script.css +16 -0
- package/extension/content-script.js +134 -0
- package/extension/icons/README.md +27 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/lib/brain-client.js +47 -0
- package/extension/manifest.json +39 -0
- package/extension/options/options.css +218 -0
- package/extension/options/options.html +72 -0
- package/extension/options/options.js +99 -0
- package/extension/popup/popup.css +221 -0
- package/extension/popup/popup.html +44 -0
- package/extension/popup/popup.js +107 -0
- package/extension/sidepanel/sidepanel.css +340 -0
- package/extension/sidepanel/sidepanel.html +38 -0
- package/extension/sidepanel/sidepanel.js +229 -0
- package/frontend/README.md +73 -0
- package/frontend/index.html +17 -0
- package/frontend/package.json +31 -0
- package/frontend/src/App.css +2 -0
- package/frontend/src/App.tsx +390 -0
- package/frontend/src/api.ts +688 -0
- package/frontend/src/assets/hero.png +0 -0
- package/frontend/src/assets/react.svg +1 -0
- package/frontend/src/assets/vite.svg +1 -0
- package/frontend/src/components/BrainSelector.tsx +272 -0
- package/frontend/src/components/DaemonLogsTab.tsx +54 -0
- package/frontend/src/components/Graph3D.tsx +850 -0
- package/frontend/src/components/MarkdownUtils.tsx +99 -0
- package/frontend/src/components/ResearchAgendaTab.tsx +707 -0
- package/frontend/src/components/TaskQueueTab.tsx +185 -0
- package/frontend/src/index.css +1589 -0
- package/frontend/src/main.tsx +10 -0
- package/frontend/src/pages/ApiKeysPage.tsx +288 -0
- package/frontend/src/pages/AutomationsPage.tsx +354 -0
- package/frontend/src/pages/ChatPage.tsx +831 -0
- package/frontend/src/pages/DeploymentsPage.tsx +275 -0
- package/frontend/src/pages/FilesPage.tsx +384 -0
- package/frontend/src/pages/HealthPage.tsx +91 -0
- package/frontend/src/pages/IntegrationsPage.tsx +382 -0
- package/frontend/src/pages/LoginPage.tsx +237 -0
- package/frontend/src/pages/MemoryPage.tsx +1192 -0
- package/frontend/src/pages/SandboxPage.tsx +113 -0
- package/frontend/src/pages/SettingsPage.tsx +662 -0
- package/frontend/src/pages/SkillsPage.tsx +618 -0
- package/frontend/src/pages/TasksPage.tsx +392 -0
- package/frontend/src/pages/UsagePage.tsx +260 -0
- package/frontend/src/types.ts +163 -0
- package/frontend/tsconfig.app.json +25 -0
- package/frontend/tsconfig.json +7 -0
- package/frontend/tsconfig.node.json +24 -0
- package/frontend/vite.config.ts +24 -0
- package/package.json +8 -1
- package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +35 -8
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +13 -8
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +28 -1
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +28 -1
- package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +830 -81
- package/src/cli/backup.mjs +172 -1
- package/src/cli/setup.mjs +24 -0
- package/src/server/rest.mjs +56 -7
package/src/cli/backup.mjs
CHANGED
|
@@ -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') {
|
package/src/server/rest.mjs
CHANGED
|
@@ -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-
|
|
1656
|
-
{ id: 'gemini-
|
|
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' });
|