total-recall-brain 3.26.0 → 3.28.0

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.
Files changed (65) hide show
  1. package/bin/total-recall.mjs +31 -1
  2. package/docs/ARCHITECTURE.md +76 -9
  3. package/frontend/dist/assets/index-U7o-qU99.js +5742 -0
  4. package/frontend/dist/index.html +1 -1
  5. package/frontend/src/App.tsx +39 -1
  6. package/frontend/src/api/index.ts +1 -0
  7. package/frontend/src/api/plugins.ts +167 -0
  8. package/frontend/src/pages/ChatPage.tsx +44 -17
  9. package/frontend/src/pages/IntegrationsPage.tsx +7 -0
  10. package/frontend/src/pages/PluginsPage.spec.tsx +17 -0
  11. package/frontend/src/pages/PluginsPage.tsx +1331 -0
  12. package/package.json +1 -1
  13. package/scaffold/.agent/plugins/.gitkeep +0 -0
  14. package/scaffold/.agent/plugins/code-quality/README.md +16 -0
  15. package/scaffold/.agent/plugins/code-quality/cli.mjs +15 -0
  16. package/scaffold/.agent/plugins/code-quality/plugin.json +34 -0
  17. package/scaffold/.agent/plugins/git-sentinel/README.md +16 -0
  18. package/scaffold/.agent/plugins/git-sentinel/cli.mjs +86 -0
  19. package/scaffold/.agent/plugins/git-sentinel/plugin.json +34 -0
  20. package/scaffold/.agent/plugins/meta-harness/README.md +16 -0
  21. package/scaffold/.agent/plugins/meta-harness/cli.mjs +34 -0
  22. package/scaffold/.agent/plugins/meta-harness/plugin.json +34 -0
  23. package/scaffold/.agent/plugins/system-monitor/README.md +16 -0
  24. package/scaffold/.agent/plugins/system-monitor/cli.mjs +79 -0
  25. package/scaffold/.agent/plugins/system-monitor/generator.mjs +20 -0
  26. package/scaffold/.agent/plugins/system-monitor/plugin.json +38 -0
  27. package/scaffold/.agent/skills/meta-harness/SKILL.md +113 -0
  28. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +23 -0
  29. package/src/cli/agent.mjs +173 -0
  30. package/src/cli/connect.mjs +89 -4
  31. package/src/cli/deploy-ui.mjs +6 -0
  32. package/src/cli/harness.mjs +135 -0
  33. package/src/cli/ingest-openwiki.mjs +71 -2
  34. package/src/cli/init.mjs +1 -0
  35. package/src/cli/lint.mjs +47 -3
  36. package/src/cli/mesh.mjs +286 -0
  37. package/src/cli/plugin/create.mjs +186 -0
  38. package/src/cli/plugin/index.mjs +139 -0
  39. package/src/cli/plugin/install.mjs +141 -0
  40. package/src/cli/plugin/list.mjs +83 -0
  41. package/src/cli/plugin/remove.mjs +51 -0
  42. package/src/cli/plugin/search.mjs +37 -0
  43. package/src/cli/skill-projection.mjs +14 -0
  44. package/src/cli/wizard.html +9 -0
  45. package/src/core/agent-manager.mjs +290 -0
  46. package/src/core/cache-prune.mjs +341 -0
  47. package/src/core/context-cache.mjs +130 -0
  48. package/src/core/daemon-loop.mjs +47 -1
  49. package/src/core/evolving-context.mjs +171 -0
  50. package/src/core/mesh-activity.mjs +140 -0
  51. package/src/core/mesh.mjs +174 -1
  52. package/src/core/meta-harness.mjs +251 -0
  53. package/src/core/plugin-context.mjs +206 -0
  54. package/src/core/plugin-loader.mjs +210 -0
  55. package/src/core/runtime.mjs +65 -13
  56. package/src/core/surface.mjs +32 -10
  57. package/src/server/api.mjs +18 -2
  58. package/src/server/index.mjs +8 -2
  59. package/src/server/rest.mjs +4 -0
  60. package/src/server/routes/integrations.mjs +5 -1
  61. package/src/server/routes/mesh-presence.mjs +143 -0
  62. package/src/server/routes/models.mjs +42 -30
  63. package/src/server/routes/plugins.mjs +517 -0
  64. package/src/server/tools.mjs +266 -0
  65. package/frontend/dist/assets/index-Bl3I11B0.js +0 -5742
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import yaml from 'yaml';
4
4
  import os from 'os';
5
- import { spawnSync } from 'node:child_process';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
6
  import { logger } from './logger.mjs';
7
7
  import { agentDir, brainDir, googleApiKey, tavilyApiKey, braveApiKey, exaApiKey, serperApiKey, githubToken, embedModel } from './config.mjs';
8
8
  import { checkBudgetSafety } from './usage-tracker.mjs';
@@ -57,10 +57,10 @@ export function findBinaryInPath(binaryName) {
57
57
  // fails with HTTP 400 at exit code 0.
58
58
  const DEFAULT_AGENTS = [
59
59
  { name: 'agy', binary: 'agy', flags: '--output-format json -p', priority: 1, enabled: true, exec: 'flag' },
60
+ { name: 'antigravity', binary: 'agy', flags: '--output-format json -p', priority: 1, enabled: true, exec: 'flag' },
60
61
  { name: 'claude', binary: 'claude', flags: '--output-format json --permission-mode bypassPermissions --setting-sources local --tools "" -p', priority: 2, enabled: true, exec: 'flag' },
61
62
  { name: 'codex', binary: 'codex', flags: '-m gpt-5.5 --sandbox workspace-write --json --skip-git-repo-check', priority: 3, enabled: true, exec: 'subcommand' },
62
- { name: 'antigravity', binary: 'antigravity', flags: '--sandbox=false --yolo -o json', priority: 8, enabled: false, exec: 'flag' },
63
- { name: 'gemini', binary: 'gemini', flags: '--sandbox=false --yolo -o json', priority: 9, enabled: false, exec: 'flag' },
63
+ { name: 'gemini', binary: 'agy', flags: '--output-format json -p', priority: 9, enabled: false, exec: 'flag' },
64
64
  { name: 'grok', binary: 'grok', flags: '--output-format plain --always-approve --permission-mode bypassPermissions', priority: 10, enabled: false, exec: 'flag' },
65
65
  ];
66
66
 
@@ -251,7 +251,10 @@ function resolveAgent(config) {
251
251
 
252
252
  for (const agent of agents.sort((a, b) => a.priority - b.priority)) {
253
253
  if (!agent.enabled) continue;
254
- const binaryPath = findBinaryInPath(agent.binary);
254
+ let binaryPath = findBinaryInPath(agent.binary);
255
+ if (!binaryPath && (agent.name === 'antigravity' || agent.name === 'gemini' || agent.binary === 'antigravity')) {
256
+ binaryPath = findBinaryInPath('agy');
257
+ }
255
258
  if (binaryPath) {
256
259
  return {
257
260
  ...agent,
@@ -364,11 +367,24 @@ export async function callLocalRuntime(prompt, system, config) {
364
367
  .map(tok => tok.replace(/^(['"])(.*)\1$/, '$2')),
365
368
  );
366
369
  }
370
+ // If -p or --print is in args, remove it so options like --model can precede it,
371
+ // and we will push -p immediately before fullPrompt.
372
+ const hadPrintFlag = args.includes('-p') || args.includes('--print');
373
+ if (hadPrintFlag) {
374
+ const pIdx = args.indexOf('-p');
375
+ if (pIdx !== -1) args.splice(pIdx, 1);
376
+ const printIdx = args.indexOf('--print');
377
+ if (printIdx !== -1) args.splice(printIdx, 1);
378
+ }
367
379
  if (agent.model) {
368
- args.push('-m', agent.model);
380
+ if (agent.binary === 'agy' || agent.name === 'agy' || (agent.binaryPath && agent.binaryPath.endsWith('/agy'))) {
381
+ args.push('--model', agent.model);
382
+ } else {
383
+ args.push('-m', agent.model);
384
+ }
369
385
  }
370
- // `exec: flag` agents need an explicit `-p` only if their flags omit it.
371
- if (agent.exec !== 'subcommand' && !args.includes('-p') && !args.includes('--print')) {
386
+ // `exec: flag` agents need an explicit `-p` right before fullPrompt
387
+ if (agent.exec !== 'subcommand' || hadPrintFlag) {
372
388
  args.push('-p');
373
389
  }
374
390
  args.push(fullPrompt);
@@ -415,14 +431,50 @@ export async function callLocalRuntime(prompt, system, config) {
415
431
  };
416
432
 
417
433
  logger.info('api', 'SPAWN_ENV GOOGLE_API_KEY length: ' + (spawnEnv.GOOGLE_API_KEY ? spawnEnv.GOOGLE_API_KEY.length : 0));
418
- const result = spawnSync(agent.binaryPath, args, {
419
- encoding: 'utf8',
420
- timeout,
421
- maxBuffer: 10 * 1024 * 1024,
422
- env: spawnEnv,
423
- cwd: process.cwd(),
434
+ const runAsync = () => new Promise((resolve) => {
435
+ let child;
436
+ try {
437
+ child = spawn(agent.binaryPath, args, {
438
+ env: spawnEnv,
439
+ cwd: process.cwd(),
440
+ });
441
+ } catch (err) {
442
+ return resolve({ status: 1, stdout: '', stderr: err.message });
443
+ }
444
+
445
+ let stdout = '';
446
+ let stderr = '';
447
+ let timer = null;
448
+ if (timeout) {
449
+ timer = setTimeout(() => {
450
+ try { child.kill('SIGTERM'); } catch {}
451
+ }, timeout);
452
+ }
453
+
454
+ child.on('error', (err) => {
455
+ if (timer) clearTimeout(timer);
456
+ resolve({ status: 1, stdout, stderr: err.message });
457
+ });
458
+
459
+ if (child.stdout) {
460
+ child.stdout.on('data', (d) => {
461
+ if (stdout.length < 10 * 1024 * 1024) stdout += d.toString();
462
+ });
463
+ }
464
+ if (child.stderr) {
465
+ child.stderr.on('data', (d) => {
466
+ if (stderr.length < 10 * 1024 * 1024) stderr += d.toString();
467
+ });
468
+ }
469
+
470
+ child.on('close', (code) => {
471
+ if (timer) clearTimeout(timer);
472
+ resolve({ status: code ?? 0, stdout, stderr });
473
+ });
424
474
  });
425
475
 
476
+ const result = await runAsync();
477
+
426
478
  const softFailure = detectAgentFailure(result.stdout || '');
427
479
 
428
480
  if (result.status === 0 && !softFailure) {
@@ -10,6 +10,7 @@ import {
10
10
  buildMemoryLayerIndex,
11
11
  inferMemoryLayer,
12
12
  } from './memory-layers.mjs';
13
+ import { assemblePluginContexts } from './plugin-context.mjs';
13
14
 
14
15
  /**
15
16
  * Extract [[slug]] wikilink references and relative Markdown link targets from body text.
@@ -306,7 +307,7 @@ async function compactNode(node, derivedDir, force = false) {
306
307
  return compacted;
307
308
  }
308
309
 
309
- export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide', derivedDir, force = false } = {}) {
310
+ export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide', derivedDir, force = false, vaultDir, projectRoot } = {}) {
310
311
  // 1. Filter expired rules. Compilation must remain a pure projection step;
311
312
  // archival is handled by explicit memory operations.
312
313
  const now = new Date();
@@ -481,6 +482,21 @@ export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide',
481
482
  }
482
483
  }
483
484
 
485
+ // 5. Inject Evolving Plugin Context
486
+ try {
487
+ const pluginContext = await assemblePluginContexts({
488
+ projectRoot: projectRoot || (skillsDir ? path.dirname(path.dirname(skillsDir)) : process.cwd()),
489
+ vaultDir,
490
+ nodes,
491
+ derivedDir
492
+ });
493
+ if (pluginContext) {
494
+ combined += pluginContext;
495
+ }
496
+ } catch (err) {
497
+ console.error('Error injecting plugin context:', err);
498
+ }
499
+
484
500
  return combined.trim();
485
501
  }
486
502
 
@@ -546,15 +562,21 @@ async function writeShim(shimPath, skillsDir, nodes = [], { vaultDir, derivedDir
546
562
  * Map of client names → shim file paths they require.
547
563
  */
548
564
  const CLIENT_SHIMS = {
549
- cursor: ['.cursorrules'],
550
- claude: ['CLAUDE.md'],
551
- 'claude-code': ['CLAUDE.md'],
552
- cline: ['.clinerules/total-recall.md'],
553
- antigravity: ['AGENTS.md', '.agents/rules/AGENTS.md'],
554
- gemini: ['GEMINI.md', '.agents/rules/GEMINI.md'],
555
- codex: ['AGENTS.md'],
556
- vscode: ['.github/copilot-instructions.md', '.vscode/copilot-instructions.md'],
557
- githubCopilot: ['.github/copilot-instructions.md']
565
+ cursor: ['.cursorrules'],
566
+ claude: ['CLAUDE.md'],
567
+ 'claude-code': ['CLAUDE.md'],
568
+ cline: ['.clinerules/total-recall.md'],
569
+ antigravity: ['AGENTS.md', '.agents/rules/AGENTS.md'],
570
+ gemini: ['GEMINI.md', '.agents/rules/GEMINI.md'],
571
+ codex: ['AGENTS.md'],
572
+ vscode: ['.github/copilot-instructions.md', '.vscode/copilot-instructions.md'],
573
+ githubCopilot: ['.github/copilot-instructions.md'],
574
+ pi: ['AGENTS.md'],
575
+ hermes: ['.hermes/memories/MEMORY.md'],
576
+ 'hermes-agent': ['.hermes/memories/MEMORY.md'],
577
+ dsh: ['AGENTS.md'],
578
+ 'deepseek-harness': ['AGENTS.md'],
579
+ openclaw: ['MEMORY.md', 'AGENTS.md']
558
580
  };
559
581
 
560
582
  /**
@@ -384,11 +384,17 @@ apiRouter.post('/v1/chat/completions', requireScope('chat:write'), async (req, r
384
384
  if (targetAgentName.includes(':')) {
385
385
  const parts = targetAgentName.split(':');
386
386
  targetAgentName = parts[0];
387
- subModel = parts[1];
387
+ subModel = parts.slice(1).join(':');
388
388
  } else if (targetAgentName.includes('/')) {
389
389
  const parts = targetAgentName.split('/');
390
390
  targetAgentName = parts[0];
391
- subModel = parts[1];
391
+ subModel = parts.slice(1).join('/');
392
+ }
393
+
394
+ // If user selected 'antigravity' or 'gemini', check if 'agy' is available
395
+ if (targetAgentName === 'antigravity' || targetAgentName === 'gemini') {
396
+ const hasAgy = activeConfig.agents.some(a => a.name === 'agy');
397
+ if (hasAgy) targetAgentName = 'agy';
392
398
  }
393
399
 
394
400
  const idx = activeConfig.agents.findIndex(a => a.name === targetAgentName);
@@ -396,6 +402,7 @@ apiRouter.post('/v1/chat/completions', requireScope('chat:write'), async (req, r
396
402
  // Deep copy the agents list so we don't mutate the cached config
397
403
  activeConfig.agents = activeConfig.agents.map(a => ({ ...a }));
398
404
  activeConfig.agents[idx].priority = 0;
405
+ activeConfig.agents[idx].enabled = true;
399
406
  if (subModel) {
400
407
  activeConfig.agents[idx].model = subModel;
401
408
  }
@@ -432,6 +439,14 @@ CODE / MEMORY:
432
439
  - 'execute_code': Run Node.js to call APIs, process data, or perform calculations.
433
440
  - 'update_design': Write markdown to DESIGN.md when asked to create a UI or document.
434
441
 
442
+ MESH NETWORK TOOLS (WireGuard / Headscale mesh management):
443
+ - 'mesh_list_nodes': List all nodes on the mesh network (hostname, IP, online status, role, OS, SSH login account).
444
+ - 'mesh_status': Check mesh control plane (Headscale) availability, network status, and self node info.
445
+ - 'mesh_exec': Execute a shell command remotely on any peer node via SSH (e.g. 'uptime', 'docker ps', 'git status').
446
+ - 'mesh_set_access': Record or update SSH login account (user, port, key, host override) on a node entity.
447
+ - 'mesh_ping': Test reachability and measure round-trip latency to a mesh node.
448
+ - 'mesh_mint_preauthkey': Mint a pre-auth enrollment key to connect a new machine to the mesh.
449
+
435
450
  MANDATORY TOOL USE AND CITATION RULES:
436
451
  1. FORCE SEARCH: If the user asks you to "search", "lookup", "find", or asks about current/recent events or anything you do not explicitly have in your memory vault, you MUST output a tool call to 'search_web' immediately. Do not write a conversational introduction first.
437
452
  2. CITATION MANDATE: When answering using results from 'search_web' or 'browser_navigate', you MUST explicitly cite your sources by embedding clickable markdown links (e.g., [Source Title](URL)) directly in your response. Never summarize web facts without providing their corresponding URLs.
@@ -447,6 +462,7 @@ Example:
447
462
  }
448
463
  </tool_call>
449
464
  Do not add any other conversational text when outputting the fallback tool call. Output it immediately as your entire response.
465
+ 5. MESH OPERATIONS: When asked about nodes on the mesh, running commands across the network, checking mesh connectivity, or configuring node login access, you MUST use the appropriate 'mesh_*' tool.
450
466
 
451
467
  You have a REAL browser AND full desktop control. Use them. Navigate, click, type, scrape — do not just describe what you would do. For web tasks prefer browser tools. For native apps or desktop workflows use computer_screenshot first to orient yourself.`;
452
468
 
@@ -236,8 +236,14 @@ app.get('/health', requireAuthOrLocal, async (req, res) => {
236
236
  // CLI agent availability
237
237
  const { findBinaryInPath } = await import('../core/runtime.mjs');
238
238
  const cliAgents = [];
239
- for (const bin of ['antigravity', 'grok', 'gemini', 'claude', 'codex']) {
240
- if (findBinaryInPath(bin)) cliAgents.push(bin);
239
+ for (const bin of ['agy', 'antigravity', 'claude', 'codex', 'grok', 'gemini']) {
240
+ if (findBinaryInPath(bin)) {
241
+ if (bin === 'agy') {
242
+ if (!cliAgents.includes('antigravity')) cliAgents.push('antigravity');
243
+ } else if (!cliAgents.includes(bin)) {
244
+ cliAgents.push(bin);
245
+ }
246
+ }
241
247
  }
242
248
 
243
249
  const agentDir = configAgentDir;
@@ -123,6 +123,8 @@ import ttsRouter from './routes/tts.mjs';
123
123
  import updateRouter from './routes/update.mjs';
124
124
  import { rulesRouter } from './routes/rules.mjs';
125
125
  import notificationsRouter from './routes/notifications.mjs';
126
+ import meshPresenceRouter from './routes/mesh-presence.mjs';
127
+ import pluginsRouter from './routes/plugins.mjs';
126
128
  // ollamaUrl removed — CLI agents replace Ollama
127
129
  import {
128
130
  AGENT_DIR,
@@ -239,6 +241,8 @@ router.use(updateRouter);
239
241
  router.use(ssssRouter);
240
242
  router.use(rulesRouter);
241
243
  router.use(notificationsRouter);
244
+ router.use(meshPresenceRouter);
245
+ router.use(pluginsRouter);
242
246
 
243
247
  export { router as restRouter };
244
248
  export default router;
@@ -88,6 +88,9 @@ router.get('/api/integrations/active', requireAuth, (req, res) => {
88
88
  'gemini': [path.join(HOME, '.gemini')],
89
89
  'pi': [path.join(HOME, '.pi', 'agent')],
90
90
  'hermes': [path.join(HOME, '.hermes')],
91
+ 'hermes-agent':[path.join(HOME, '.hermes')],
92
+ 'dsh': [path.join(HOME, '.dsh')],
93
+ 'deepseek-harness': [path.join(HOME, '.dsh')],
91
94
  'openclaw': [path.join(HOME, '.openclaw')],
92
95
  };
93
96
 
@@ -116,7 +119,8 @@ router.post('/api/integrations/connect', requireAuth, (req, res) => {
116
119
  if (!client) return badRequest(res, 'client is required');
117
120
 
118
121
  const validClients = [
119
- 'vscode', 'pi', 'hermes', 'openclaw', 'cursor', 'cline', 'claude-code',
122
+ 'vscode', 'pi', 'hermes', 'hermes-agent', 'dsh', 'deepseek-harness',
123
+ 'openclaw', 'cursor', 'cline', 'claude-code',
120
124
  'codex', 'gemini', 'aider', 'http-api', 'obsidian',
121
125
  'generic', 'antigravity'
122
126
  ];
@@ -0,0 +1,143 @@
1
+ import { Router } from 'express';
2
+ import { requireAuth, requireScope, requireAuthOrLocal } from '../auth.mjs';
3
+ import { getLocalPresence, resolveActiveDevice } from '../../core/mesh-activity.mjs';
4
+ import { getMeshPeers, execMeshCommand } from '../../core/mesh.mjs';
5
+ import { throttledFetch } from '../../core/throttled-fetch.mjs';
6
+ import { serverError, badRequest } from './_shared.mjs';
7
+
8
+ const router = Router();
9
+
10
+ function meshServerPort() {
11
+ const value = Number(process.env.TR_SERVER_PORT || process.env.PORT || 3000);
12
+ return Number.isInteger(value) && value > 0 && value <= 65535 ? value : 3000;
13
+ }
14
+
15
+ /**
16
+ * GET /api/mesh/presence
17
+ * Reports the presence state across the mesh, highlighting the user's active device.
18
+ */
19
+ router.get('/api/mesh/presence', requireAuth, requireScope('config:read'), async (req, res) => {
20
+ try {
21
+ const local = getLocalPresence();
22
+ const includePeers = req.query?.peers !== 'false';
23
+ const peers = [];
24
+
25
+ if (includePeers) {
26
+ const meshPeers = getMeshPeers({ includeSelf: false }) || [];
27
+ const port = meshServerPort();
28
+
29
+ const peerPromises = meshPeers
30
+ .filter((p) => p && p.online && p.ip)
31
+ .map(async (p) => {
32
+ try {
33
+ const peerRes = await throttledFetch(
34
+ `http://${p.ip}:${port}/api/mesh/presence/local`,
35
+ {},
36
+ 1500
37
+ );
38
+ if (peerRes && (peerRes.ok || peerRes.status === 200)) {
39
+ return await peerRes.json();
40
+ }
41
+ } catch {
42
+ // Peer unreachable or offline
43
+ }
44
+ return {
45
+ node_id: p.hostname,
46
+ mesh_ip: p.ip,
47
+ user_active: false,
48
+ idle_seconds: 999999,
49
+ active_surface: 'offline',
50
+ timestamp: new Date().toISOString()
51
+ };
52
+ });
53
+
54
+ const resolved = await Promise.all(peerPromises);
55
+ peers.push(...resolved.filter(Boolean));
56
+ }
57
+
58
+ const allRecords = [local, ...peers];
59
+ const activeDevice = resolveActiveDevice(allRecords);
60
+
61
+ res.json({
62
+ success: true,
63
+ local,
64
+ peers,
65
+ active_device: activeDevice,
66
+ active_surface: local.active_surface,
67
+ timestamp: new Date().toISOString()
68
+ });
69
+ } catch (err) {
70
+ serverError(res, err);
71
+ }
72
+ });
73
+
74
+ /**
75
+ * GET /api/mesh/presence/local
76
+ * Lightweight unauthenticated/mesh-only endpoint for peer heartbeat polling.
77
+ */
78
+ router.get('/api/mesh/presence/local', requireAuthOrLocal, (_req, res) => {
79
+ try {
80
+ const local = getLocalPresence();
81
+ res.json(local);
82
+ } catch (err) {
83
+ serverError(res, err);
84
+ }
85
+ });
86
+
87
+ /**
88
+ * POST /api/mesh/presence/resolve
89
+ * Evaluates arbitrary presence records to determine the user's currently active device.
90
+ * Body: { presence_records: [...] }
91
+ */
92
+ router.post('/api/mesh/presence/resolve', requireAuth, requireScope('config:read'), (req, res) => {
93
+ try {
94
+ const records = Array.isArray(req.body?.presence_records) ? req.body.presence_records : [];
95
+ const active = resolveActiveDevice(records);
96
+ res.json({
97
+ success: true,
98
+ active_device: active,
99
+ timestamp: new Date().toISOString()
100
+ });
101
+ } catch (err) {
102
+ serverError(res, err);
103
+ }
104
+ });
105
+
106
+ /**
107
+ * POST /api/mesh/dispatch
108
+ * Follow the User dynamic dispatch: routes notifications or commands to the most active node.
109
+ * Body: { notification?: string, command?: string, target?: string, payload?: any }
110
+ */
111
+ router.post('/api/mesh/dispatch', requireAuth, requireScope('config:write'), async (req, res) => {
112
+ try {
113
+ const local = getLocalPresence();
114
+ let targetNode = req.body?.target;
115
+
116
+ if (!targetNode) {
117
+ const active = resolveActiveDevice([local]);
118
+ targetNode = active.node_id;
119
+ }
120
+
121
+ const isLocal = targetNode === local.node_id || targetNode === 'local';
122
+ let executionResult = null;
123
+
124
+ if (!isLocal && req.body?.command) {
125
+ executionResult = await execMeshCommand(targetNode, req.body.command);
126
+ }
127
+
128
+ res.json({
129
+ success: true,
130
+ dispatched: true,
131
+ target_node: targetNode,
132
+ is_local: isLocal,
133
+ command_result: executionResult,
134
+ notification: req.body?.notification || null,
135
+ payload: req.body?.payload || null,
136
+ timestamp: new Date().toISOString()
137
+ });
138
+ } catch (err) {
139
+ serverError(res, err);
140
+ }
141
+ });
142
+
143
+ export default router;
@@ -151,43 +151,48 @@ router.get('/v1/models', requireAuthOrLocal, async (req, res) => {
151
151
  }
152
152
  });
153
153
 
154
+ let cachedAgyModels = null;
155
+ let lastAgyModelsFetch = 0;
156
+
154
157
  /**
155
158
  * GET /api/gemini-models
156
159
  * Dynamically fetches available Gemini models.
157
160
  * Strategy: CLI discovery → Google API → static fallback list.
158
161
  */
159
- router.get('/api/gemini-models', requireAuth, async (req, res) => {
162
+ router.get('/api/gemini-models', requireAuthOrLocal, async (req, res) => {
160
163
  try {
161
- // 1. Try antigravity CLI
164
+ // 1. Try Google Antigravity (agy) CLI first (cached 5 minutes)
165
+ if (cachedAgyModels && Date.now() - lastAgyModelsFetch < 1000 * 60 * 5) {
166
+ return res.json({ models: cachedAgyModels, source: 'cli' });
167
+ }
168
+
162
169
  try {
163
170
  const { findBinaryInPath } = await import('../../core/runtime.mjs');
164
- const antigravityPath = findBinaryInPath('antigravity');
165
- if (antigravityPath) {
166
- const result = spawnSync(antigravityPath, ['--help'], { encoding: 'utf8', timeout: 3000 });
171
+ const agyPath = findBinaryInPath('agy');
172
+ if (agyPath) {
173
+ const result = spawnSync(agyPath, ['models'], { encoding: 'utf8', timeout: 8000, input: '' });
167
174
  if (result.status === 0 && result.stdout) {
168
- const output = result.stdout;
175
+ const lines = result.stdout.split('\n');
169
176
  const discovered = [];
170
-
171
- const modelRegex = /gemini-\d+\.\d+(?:-\w+)?/g;
172
- const matches = output.match(modelRegex) || [];
173
- for (const m of matches) {
174
- discovered.push(m);
177
+ for (const line of lines) {
178
+ const trimmed = line.trim();
179
+ if (!trimmed || trimmed.startsWith('Fetching')) continue;
180
+ const parts = trimmed.split('\t');
181
+ if (parts.length >= 2) {
182
+ discovered.push({ id: parts[0].trim(), displayName: parts[1].trim() });
183
+ } else if (parts.length === 1 && parts[0].includes('gemini')) {
184
+ discovered.push({ id: parts[0].trim(), displayName: parts[0].trim() });
185
+ }
175
186
  }
176
-
177
- const versionRegex = /\b(\d+\.\d+-\w+)\b/g;
178
- const versionMatches = output.match(versionRegex) || [];
179
- for (const vm of versionMatches) {
180
- discovered.push(`gemini-${vm}`);
181
- }
182
-
183
- const uniqueModels = [...new Set(discovered)];
184
- if (uniqueModels.length > 0) {
187
+ if (discovered.length > 0) {
185
188
  const pricingMap = await getPricingMap();
186
- const cliModels = uniqueModels.map(modelId => {
187
- const parts = modelId.split('-');
188
- const displayName = parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
189
- return { id: modelId, displayName, pricing: pricingMap[modelId] || pricingMap[`google/${modelId}`] };
190
- });
189
+ const cliModels = discovered.map(m => ({
190
+ id: m.id,
191
+ displayName: m.displayName,
192
+ pricing: pricingMap[m.id] || pricingMap[`google/${m.id}`] || null,
193
+ }));
194
+ cachedAgyModels = cliModels;
195
+ lastAgyModelsFetch = Date.now();
191
196
  return res.json({ models: cliModels, source: 'cli' });
192
197
  }
193
198
  }
@@ -196,7 +201,7 @@ router.get('/api/gemini-models', requireAuth, async (req, res) => {
196
201
  // Fail silently and fall back
197
202
  }
198
203
 
199
- // 2. Try Google API key
204
+ // 3. Try Google API key
200
205
  let apiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
201
206
  if (!apiKey) {
202
207
  try {
@@ -207,10 +212,17 @@ router.get('/api/gemini-models', requireAuth, async (req, res) => {
207
212
  }
208
213
 
209
214
  const fallbackModels = [
215
+ { id: 'gemini-3.8-flash-high', displayName: 'Gemini 3.8 Flash (High)', pricing: null },
216
+ { id: 'gemini-3.8-flash-medium', displayName: 'Gemini 3.8 Flash (Medium)', pricing: null },
217
+ { id: 'gemini-3.8-flash-low', displayName: 'Gemini 3.8 Flash (Low)', pricing: null },
218
+ { id: 'gemini-3.7-flash-high', displayName: 'Gemini 3.7 Flash (High)', pricing: null },
219
+ { id: 'gemini-3.7-flash-medium', displayName: 'Gemini 3.7 Flash (Medium)', pricing: null },
220
+ { id: 'gemini-3.6-flash-high', displayName: 'Gemini 3.6 Flash (High)', pricing: null },
221
+ { id: 'gemini-3.1-pro-high', displayName: 'Gemini 3.1 Pro (High)', pricing: null },
210
222
  { id: 'gemini-3.5-flash', displayName: 'Gemini 3.5 Flash', pricing: null },
211
223
  { id: 'gemini-3.5-pro', displayName: 'Gemini 3.5 Pro', pricing: null },
224
+ { id: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', pricing: null },
212
225
  { id: 'gemini-2.5-pro', displayName: 'Gemini 2.5 Pro', pricing: null },
213
- { id: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', pricing: null }
214
226
  ];
215
227
 
216
228
  if (!apiKey) return res.json({ models: fallbackModels, source: 'missing_key' });
@@ -251,7 +263,7 @@ router.get('/api/gemini-models', requireAuth, async (req, res) => {
251
263
  * GET /api/claude-models
252
264
  * Dynamically fetches available Anthropic models using ANTHROPIC_API_KEY.
253
265
  */
254
- router.get('/api/claude-models', requireAuth, async (req, res) => {
266
+ router.get('/api/claude-models', requireAuthOrLocal, async (req, res) => {
255
267
  try {
256
268
  let apiKey = process.env.ANTHROPIC_API_KEY;
257
269
  if (!apiKey) {
@@ -301,7 +313,7 @@ router.get('/api/claude-models', requireAuth, async (req, res) => {
301
313
  * GET /api/openai-models
302
314
  * Dynamically fetches available OpenAI models using OPENAI_API_KEY.
303
315
  */
304
- router.get('/api/openai-models', requireAuth, async (req, res) => {
316
+ router.get('/api/openai-models', requireAuthOrLocal, async (req, res) => {
305
317
  try {
306
318
  let apiKey = process.env.OPENAI_API_KEY;
307
319
  if (!apiKey) {
@@ -352,7 +364,7 @@ router.get('/api/openai-models', requireAuth, async (req, res) => {
352
364
  * GET /api/openrouter-models
353
365
  * Dynamically fetches the list of available OpenRouter models.
354
366
  */
355
- router.get('/api/openrouter-models', requireAuth, async (req, res) => {
367
+ router.get('/api/openrouter-models', requireAuthOrLocal, async (req, res) => {
356
368
  try {
357
369
  const response = await throttledFetch('https://openrouter.ai/api/v1/models');
358
370
  if (response.ok) {