clementine-agent 1.2.1 → 1.2.3

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.
@@ -18,7 +18,7 @@ import cron from 'node-cron';
18
18
  import { TunnelManager } from './tunnel.js';
19
19
  import { AgentManager } from '../agent/agent-manager.js';
20
20
  import { discoverMcpServers, getClaudeIntegrations } from '../agent/mcp-bridge.js';
21
- import { AGENTS_DIR } from '../config.js';
21
+ import { AGENTS_DIR, SESSIONS_FILE } from '../config.js';
22
22
  import { parseTasks } from '../tools/shared.js';
23
23
  import { todayISO } from '../gateway/cron-scheduler.js';
24
24
  import { goalsRouter } from './routes/goals.js';
@@ -1439,44 +1439,92 @@ export async function cmdDashboard(opts) {
1439
1439
  writeFileSync(tokenPath, dashboardToken, { mode: 0o600 });
1440
1440
  // ── Remote access + session management ─────────────────────────────
1441
1441
  const remoteConfig = loadRemoteConfig();
1442
- const sessions = new Map(); // sessionId → expiresAt
1442
+ const sessions = new Map();
1443
1443
  let tunnelManager = null;
1444
- const SESSION_MAX_AGE = 24 * 60 * 60 * 1000; // 24 hours
1444
+ const SESSION_TTL_DEFAULT = 24 * 60 * 60 * 1000; // 24 hours
1445
+ const SESSION_TTL_PERSISTENT = 30 * 24 * 60 * 60 * 1000; // 30 days
1445
1446
  const loginRateLimit = { count: 0, resetAt: Date.now() + 15 * 60 * 1000 };
1447
+ function loadSessions() {
1448
+ if (!existsSync(SESSIONS_FILE))
1449
+ return;
1450
+ try {
1451
+ const raw = JSON.parse(readFileSync(SESSIONS_FILE, 'utf-8'));
1452
+ const now = Date.now();
1453
+ for (const s of raw) {
1454
+ if (s && s.id && s.expiresAt > now)
1455
+ sessions.set(s.id, s);
1456
+ }
1457
+ }
1458
+ catch { /* corrupt file — start fresh */ }
1459
+ }
1460
+ function persistSessions() {
1461
+ try {
1462
+ writeFileSync(SESSIONS_FILE, JSON.stringify(Array.from(sessions.values()), null, 2), { mode: 0o600 });
1463
+ }
1464
+ catch { /* best-effort; in-memory store still works */ }
1465
+ }
1466
+ loadSessions();
1446
1467
  function isRemoteRequest(req) {
1447
1468
  // cloudflared sets CF-Connecting-IP for tunneled traffic
1448
1469
  return Boolean(req.headers['cf-connecting-ip']);
1449
1470
  }
1450
- function hasValidSession(req) {
1471
+ function readSessionId(req) {
1451
1472
  const cookie = req.headers.cookie ?? '';
1452
1473
  const match = cookie.match(/__clem_session=([a-f0-9]+)/);
1453
- if (!match)
1474
+ return match ? match[1] : null;
1475
+ }
1476
+ function hasValidSession(req) {
1477
+ const sessionId = readSessionId(req);
1478
+ if (!sessionId)
1454
1479
  return false;
1455
- const sessionId = match[1];
1456
- const expiresAt = sessions.get(sessionId);
1457
- if (!expiresAt || Date.now() > expiresAt) {
1458
- sessions.delete(sessionId);
1480
+ const record = sessions.get(sessionId);
1481
+ if (!record || Date.now() > record.expiresAt) {
1482
+ if (record) {
1483
+ sessions.delete(sessionId);
1484
+ persistSessions();
1485
+ }
1459
1486
  return false;
1460
1487
  }
1488
+ record.lastUsedAt = Date.now();
1489
+ // Don't persist on every request — the cleanup interval will pick it up
1461
1490
  return true;
1462
1491
  }
1463
- function createSession(res) {
1492
+ function createSession(res, req, persistent = false) {
1464
1493
  const sessionId = randomBytes(32).toString('hex');
1465
- sessions.set(sessionId, Date.now() + SESSION_MAX_AGE);
1494
+ const ttl = persistent ? SESSION_TTL_PERSISTENT : SESSION_TTL_DEFAULT;
1495
+ const now = Date.now();
1496
+ const record = {
1497
+ id: sessionId,
1498
+ expiresAt: now + ttl,
1499
+ persistent,
1500
+ createdAt: now,
1501
+ lastUsedAt: now,
1502
+ userAgent: typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'].slice(0, 200) : undefined,
1503
+ };
1504
+ sessions.set(sessionId, record);
1505
+ persistSessions();
1466
1506
  res.cookie('__clem_session', sessionId, {
1467
1507
  httpOnly: true,
1468
1508
  sameSite: 'lax',
1469
- maxAge: SESSION_MAX_AGE,
1509
+ maxAge: ttl,
1470
1510
  path: '/',
1471
1511
  });
1512
+ return sessionId;
1513
+ }
1514
+ function revokeSession(sessionId) {
1515
+ const existed = sessions.delete(sessionId);
1516
+ if (existed)
1517
+ persistSessions();
1518
+ return existed;
1472
1519
  }
1473
- // Clean expired sessions every 10 minutes
1520
+ // Clean expired sessions every 10 minutes; also persist lastUsedAt updates
1474
1521
  setInterval(() => {
1475
1522
  const now = Date.now();
1476
- for (const [id, exp] of sessions) {
1477
- if (now > exp)
1523
+ for (const [id, rec] of sessions) {
1524
+ if (now > rec.expiresAt)
1478
1525
  sessions.delete(id);
1479
1526
  }
1527
+ persistSessions();
1480
1528
  }, 10 * 60 * 1000);
1481
1529
  // Quick ping — bypasses all middleware, tests /api path routing
1482
1530
  app.get('/api/ping', (_req, res) => { res.json({ pong: true }); });
@@ -1734,7 +1782,7 @@ export async function cmdDashboard(opts) {
1734
1782
  res.status(429).json({ error: 'Too many login attempts. Try again later.' });
1735
1783
  return;
1736
1784
  }
1737
- const { token } = req.body ?? {};
1785
+ const { token, remember } = req.body ?? {};
1738
1786
  if (!token || typeof token !== 'string') {
1739
1787
  res.status(400).json({ error: 'Token is required' });
1740
1788
  return;
@@ -1748,13 +1796,48 @@ export async function cmdDashboard(opts) {
1748
1796
  res.status(401).json({ error: 'Invalid access token' });
1749
1797
  return;
1750
1798
  }
1751
- createSession(res);
1799
+ createSession(res, req, Boolean(remember));
1752
1800
  res.json({ ok: true });
1753
1801
  });
1754
- app.get('/auth/logout', (_req, res) => {
1802
+ app.get('/auth/logout', (req, res) => {
1803
+ const sessionId = readSessionId(req);
1804
+ if (sessionId)
1805
+ revokeSession(sessionId);
1755
1806
  res.clearCookie('__clem_session', { path: '/' });
1756
1807
  res.redirect('/');
1757
1808
  });
1809
+ // List active sessions (cookie-authenticated; bearer-token gate doesn't apply at /auth/*)
1810
+ app.get('/auth/sessions', (req, res) => {
1811
+ if (!hasValidSession(req)) {
1812
+ res.status(401).json({ error: 'Unauthorized' });
1813
+ return;
1814
+ }
1815
+ const currentId = readSessionId(req);
1816
+ const list = Array.from(sessions.values())
1817
+ .sort((a, b) => b.lastUsedAt - a.lastUsedAt)
1818
+ .map(s => ({
1819
+ id: s.id,
1820
+ createdAt: s.createdAt,
1821
+ lastUsedAt: s.lastUsedAt,
1822
+ expiresAt: s.expiresAt,
1823
+ persistent: s.persistent,
1824
+ userAgent: s.userAgent ?? null,
1825
+ current: s.id === currentId,
1826
+ }));
1827
+ res.json({ sessions: list });
1828
+ });
1829
+ app.delete('/auth/sessions/:id', (req, res) => {
1830
+ if (!hasValidSession(req)) {
1831
+ res.status(401).json({ error: 'Unauthorized' });
1832
+ return;
1833
+ }
1834
+ const targetId = req.params.id;
1835
+ const existed = revokeSession(targetId);
1836
+ if (readSessionId(req) === targetId) {
1837
+ res.clearCookie('__clem_session', { path: '/' });
1838
+ }
1839
+ res.json({ ok: existed });
1840
+ });
1758
1841
  // ── Anthropic OAuth routes ──────────────────────────────────────
1759
1842
  // Check current auth status by spawning a lightweight SDK query
1760
1843
  // Anthropic auth status — check daemon's .env for API key presence instead of importing the SDK
@@ -4521,6 +4604,24 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
4521
4604
  // instead of having to wait for auto-extraction to drift in the right
4522
4605
  // direction. Soft-delete via deleted_at; FTS trigger keeps deleted
4523
4606
  // content out of search results.
4607
+ // Memory Health snapshot — single endpoint feeding the dashboard tab.
4608
+ // Read-only aggregate over the existing tables; no caching needed (cheap).
4609
+ app.get('/api/memory/health', async (_req, res) => {
4610
+ try {
4611
+ const gateway = await getGateway();
4612
+ const store = gateway.assistant?.memoryStore;
4613
+ if (!store?.getMemoryHealth) {
4614
+ res.status(503).json({ error: 'Memory store not available' });
4615
+ return;
4616
+ }
4617
+ const graphStore = gateway.assistant?.graphStore;
4618
+ const health = store.getMemoryHealth({ graphStore, topCitedLimit: 10 });
4619
+ res.json({ ok: true, health });
4620
+ }
4621
+ catch (err) {
4622
+ res.status(500).json({ error: String(err) });
4623
+ }
4624
+ });
4524
4625
  app.get('/api/memory/chunks/:id', async (req, res) => {
4525
4626
  try {
4526
4627
  const id = Number(req.params.id);
@@ -10355,31 +10456,24 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
10355
10456
  <!-- Sidebar -->
10356
10457
  <nav class="sidebar">
10357
10458
  <div class="nav-section">
10358
- <div class="nav-section-title">Command Center</div>
10459
+ <div class="nav-section-title">Today</div>
10359
10460
  <div class="nav-item active" data-page="home">
10360
10461
  <span class="nav-icon">&#9679;</span> Home
10361
10462
  </div>
10362
10463
  <div class="nav-item" data-page="chat">
10363
10464
  <span class="nav-icon">&#128172;</span> Chat
10364
10465
  </div>
10365
- </div>
10366
- <div class="nav-section">
10367
- <div class="nav-section-title">Build</div>
10368
- <div class="nav-item" data-page="builder">
10369
- <span class="nav-icon">&#128736;</span> Builder
10370
- </div>
10371
- <div class="nav-item" data-page="team">
10372
- <span class="nav-icon">&#128101;</span> The Office
10466
+ <div class="nav-item" data-page="daily-plan">
10467
+ <span class="nav-icon">&#128197;</span> Daily Plan
10373
10468
  </div>
10374
- <div class="nav-item" data-page="projects">
10375
- <span class="nav-icon">&#128194;</span> Projects
10469
+ <div class="nav-item" data-page="goals">
10470
+ <span class="nav-icon">&#127919;</span> Goals
10376
10471
  </div>
10377
10472
  </div>
10378
10473
  <div class="nav-section">
10379
- <div class="nav-section-title">Team</div>
10380
- <div id="team-nav"></div>
10381
- <div class="team-hire-btn" onclick="showAgentCreateModal()">
10382
- <span style="font-size:14px">+</span> Hire Agent
10474
+ <div class="nav-section-title">Brain</div>
10475
+ <div class="nav-item" data-page="intelligence">
10476
+ <span class="nav-icon">&#129504;</span> Brain
10383
10477
  </div>
10384
10478
  </div>
10385
10479
  <div class="nav-section">
@@ -10388,29 +10482,64 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
10388
10482
  <span class="nav-icon">&#9200;</span> Scheduled Tasks
10389
10483
  <span class="nav-badge" id="nav-cron-count">0</span>
10390
10484
  </div>
10391
- <div class="nav-item" onclick="openSkillStudio()">
10392
- <span class="nav-icon">&#128161;</span> Skill Studio
10485
+ <div class="nav-item" onclick="openAutomationsTab('skills')">
10486
+ <span class="nav-icon">&#128737;</span> Skills
10393
10487
  <span class="nav-badge" id="nav-skill-count" style="display:none">0</span>
10394
10488
  </div>
10489
+ <div class="nav-item" onclick="openAutomationsTab('timers')">
10490
+ <span class="nav-icon">&#9201;</span> Timers
10491
+ </div>
10395
10492
  <div class="nav-item" data-page="workflows">
10396
10493
  <span class="nav-icon">&#128260;</span> Workflows
10397
10494
  </div>
10495
+ <div class="nav-item" data-page="unleashed">
10496
+ <span class="nav-icon">&#128640;</span> Unleashed
10497
+ <span class="nav-badge" id="nav-unleashed-count" style="display:none">0</span>
10498
+ </div>
10398
10499
  </div>
10399
10500
  <div class="nav-section">
10400
- <div class="nav-section-title">Insights</div>
10501
+ <div class="nav-section-title">Team</div>
10502
+ <div class="nav-item" data-page="team">
10503
+ <span class="nav-icon">&#128101;</span> The Office
10504
+ </div>
10401
10505
  <div class="nav-item" data-page="team-status">
10402
10506
  <span class="nav-icon">&#128202;</span> Team Status
10403
10507
  </div>
10404
- <div class="nav-item" data-page="intelligence">
10405
- <span class="nav-icon">&#129504;</span> Brain
10508
+ <div id="team-nav"></div>
10509
+ <div class="team-hire-btn" onclick="showAgentCreateModal()">
10510
+ <span style="font-size:14px">+</span> Hire Agent
10511
+ </div>
10512
+ </div>
10513
+ <div class="nav-section">
10514
+ <div class="nav-section-title">Build</div>
10515
+ <div class="nav-item" data-page="builder">
10516
+ <span class="nav-icon">&#128736;</span> Builder
10517
+ </div>
10518
+ <div class="nav-item" onclick="openSkillStudio()">
10519
+ <span class="nav-icon">&#128161;</span> Skill Studio
10406
10520
  </div>
10521
+ <div class="nav-item" data-page="projects">
10522
+ <span class="nav-icon">&#128194;</span> Projects
10523
+ </div>
10524
+ </div>
10525
+ <div class="nav-section">
10526
+ <div class="nav-section-title">Insights</div>
10407
10527
  <div class="nav-item" data-page="claims">
10408
10528
  <span class="nav-icon">&#128274;</span> Trust &amp; Claims
10409
10529
  <span class="nav-badge" id="nav-trust-score" style="display:none">--</span>
10410
10530
  </div>
10531
+ <div class="nav-item" data-page="metrics">
10532
+ <span class="nav-icon">&#128200;</span> Metrics
10533
+ </div>
10534
+ <div class="nav-item" data-page="memory-health">
10535
+ <span class="nav-icon">&#129504;</span> Memory Health
10536
+ </div>
10411
10537
  <div class="nav-item" data-page="logs">
10412
10538
  <span class="nav-icon">&#128220;</span> Logs
10413
10539
  </div>
10540
+ <div class="nav-item" data-page="sessions">
10541
+ <span class="nav-icon">&#128221;</span> Sessions
10542
+ </div>
10414
10543
  </div>
10415
10544
  <div class="nav-section">
10416
10545
  <div class="nav-section-title">System</div>
@@ -10511,50 +10640,26 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
10511
10640
  </div>
10512
10641
  </div>
10513
10642
 
10514
- <!-- Home Tabs: Activity | Metrics | Agents | Sessions -->
10515
- <div class="tab-bar" id="home-tabs">
10516
- <button class="active" onclick="switchTab('home','activity')">Activity</button>
10517
- <button onclick="switchTab('home','metrics')">Metrics</button>
10518
- <button onclick="switchTab('home','agents')">Agents</button>
10519
- <button onclick="switchTab('home','sessions')">Sessions</button>
10520
- </div>
10521
- <div id="home-tab-content">
10522
- <div class="tab-pane active" id="tab-home-activity">
10523
- <div class="card">
10524
- <div class="card-header" style="display:flex;justify-content:space-between;align-items:center">
10525
- <span>Live Activity</span>
10526
- <div style="display:flex;gap:6px;align-items:center">
10527
- <select id="activity-source-filter" onchange="refreshActivity()" style="font-size:11px;padding:2px 6px;border:1px solid var(--border);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary)">
10528
- <option value="">All Sources</option>
10529
- <option value="cron">Cron</option>
10530
- <option value="activity">Activities</option>
10531
- <option value="send">Emails</option>
10532
- <option value="approval">Approvals</option>
10533
- <option value="memory">Memory</option>
10534
- </select>
10535
- <select id="activity-agent-filter" onchange="refreshActivity()" style="font-size:11px;padding:2px 6px;border:1px solid var(--border);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary)">
10536
- <option value="">All Agents</option>
10537
- </select>
10538
- </div>
10539
- </div>
10540
- <div class="card-body" id="panel-activity"><div class="empty-state">Loading...</div></div>
10643
+ <!-- Live Activity feed primary home content. Metrics, Sessions, and per-agent stats moved to dedicated sidebar pages. -->
10644
+ <div class="card">
10645
+ <div class="card-header" style="display:flex;justify-content:space-between;align-items:center">
10646
+ <span>Live Activity</span>
10647
+ <div style="display:flex;gap:6px;align-items:center">
10648
+ <select id="activity-source-filter" onchange="refreshActivity()" style="font-size:11px;padding:2px 6px;border:1px solid var(--border);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary)">
10649
+ <option value="">All Sources</option>
10650
+ <option value="cron">Cron</option>
10651
+ <option value="activity">Activities</option>
10652
+ <option value="send">Emails</option>
10653
+ <option value="approval">Approvals</option>
10654
+ <option value="memory">Memory</option>
10655
+ </select>
10656
+ <select id="activity-agent-filter" onchange="refreshActivity()" style="font-size:11px;padding:2px 6px;border:1px solid var(--border);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary)">
10657
+ <option value="">All Agents</option>
10658
+ </select>
10541
10659
  </div>
10542
10660
  </div>
10543
- <div class="tab-pane" id="tab-home-metrics">
10544
- <div id="metrics-content-home"><div class="empty-state">Loading metrics...</div></div>
10545
- </div>
10546
- <div class="tab-pane" id="tab-home-agents">
10547
- <div id="panel-agents-compare"><div class="empty-state">Loading agent stats...</div></div>
10548
- </div>
10549
- <div class="tab-pane" id="tab-home-sessions">
10550
- <div id="panel-sessions-home"><div class="empty-state">Loading sessions...</div></div>
10551
- </div>
10661
+ <div class="card-body" id="panel-activity"><div class="empty-state">Loading...</div></div>
10552
10662
  </div>
10553
-
10554
- <div class="card" id="claude-integrations-widget" style="display:none;margin-top:16px"></div>
10555
- <div class="card" id="mcp-status-widget" style="display:none;margin-top:16px"></div>
10556
- <!-- Hidden: Quick controls data target (kept for refreshStatus compat) -->
10557
- <div id="panel-controls" style="display:none"></div>
10558
10663
  </div>
10559
10664
 
10560
10665
  <!-- ═══ Builder Page — Conversational Artifact Creation ═══ -->
@@ -10835,6 +10940,10 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
10835
10940
  <div id="graph-detail-panel" style="margin-top:12px"></div>
10836
10941
  </div>
10837
10942
  <div class="tab-pane" id="tab-intelligence-memory">
10943
+ <div style="margin-bottom:12px;font-size:13px;color:var(--text-muted)">
10944
+ Stats and content browsing. For janitor, integrity, write queue, and staleness diagnostics see
10945
+ <a href="#" onclick="navigateTo('memory-health');return false" style="color:var(--accent)">Memory Health &rarr;</a>
10946
+ </div>
10838
10947
  <div class="grid-2" id="memory-stats"></div>
10839
10948
  <div class="card">
10840
10949
  <div class="card-header">MEMORY.md</div>
@@ -11963,8 +12072,10 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
11963
12072
  </script>
11964
12073
  </div>
11965
12074
 
11966
- <!-- Hidden: Sessions (shown in Home tabs in Phase 2) -->
11967
- <div class="page" id="page-sessions" style="display:none">
12075
+ <!-- ═══ Sessions Page ═══ -->
12076
+ <div class="page" id="page-sessions">
12077
+ <div class="page-title">Sessions</div>
12078
+ <p style="color:var(--text-muted);margin-bottom:16px">Active conversation sessions across channels. Each session is a continuous thread with one user.</p>
11968
12079
  <div id="panel-sessions"><div class="empty-state">Loading...</div></div>
11969
12080
  </div>
11970
12081
 
@@ -12046,11 +12157,23 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12046
12157
  </div>
12047
12158
  </div>
12048
12159
 
12049
- <!-- Hidden: Metrics (shown in Home tabs in Phase 2) -->
12050
- <div class="page" id="page-metrics" style="display:none">
12160
+ <!-- ═══ Metrics Page ═══ -->
12161
+ <div class="page" id="page-metrics">
12162
+ <div class="page-title">Metrics</div>
12163
+ <p style="color:var(--text-muted);margin-bottom:16px">Time-saved estimates, run success rates, and per-job breakdowns.</p>
12051
12164
  <div id="metrics-content"><div class="empty-state">Loading metrics...</div></div>
12052
12165
  </div>
12053
12166
 
12167
+ <!-- ═══ Memory Health Page ═══ -->
12168
+ <div class="page" id="page-memory-health">
12169
+ <div class="page-title">Memory Health</div>
12170
+ <p style="color:var(--text-muted);margin-bottom:16px">Bounded growth, retrieval signal, and curation drift &mdash; everything the janitor manages.</p>
12171
+ <div style="display:flex;gap:8px;margin-bottom:16px">
12172
+ <button class="btn btn-sm" onclick="refreshMemoryHealth()">Refresh</button>
12173
+ </div>
12174
+ <div id="memory-health-content"><div class="empty-state">Loading memory health...</div></div>
12175
+ </div>
12176
+
12054
12177
  <!-- ═══ Daily Plan Page ═══ -->
12055
12178
  <div class="page" id="page-daily-plan">
12056
12179
  <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
@@ -12068,8 +12191,10 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12068
12191
  </details>
12069
12192
  </div>
12070
12193
 
12071
- <!-- Hidden: Goals (moved to Agent Detail in Phase 3) -->
12072
- <div class="page" id="page-goals" style="display:none">
12194
+ <!-- ═══ Goals Page ═══ -->
12195
+ <div class="page" id="page-goals">
12196
+ <div class="page-title">Goals</div>
12197
+ <p style="color:var(--text-muted);margin-bottom:16px">Long-running objectives the team is contributing to. Tracks per-agent contribution and run success rate.</p>
12073
12198
  <div id="goals-progress-content"><div class="empty-state">Loading goals...</div></div>
12074
12199
  </div>
12075
12200
 
@@ -12296,6 +12421,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12296
12421
  <div class="tab-bar" id="settings-tabs">
12297
12422
  <button class="active" onclick="switchTab('settings','general')">General</button>
12298
12423
  <button onclick="switchTab('settings','remote')">Remote Access</button>
12424
+ <button onclick="switchTab('settings','security')">Security</button>
12299
12425
  <button onclick="switchTab('settings','integrations')">Integrations</button>
12300
12426
  <button onclick="switchTab('settings','notifications')">Notifications</button>
12301
12427
  </div>
@@ -12318,6 +12444,21 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12318
12444
  </div>
12319
12445
  </div>
12320
12446
  </div>
12447
+ <div class="tab-pane" id="tab-settings-security">
12448
+ <div class="card">
12449
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
12450
+ <span>Active Sessions</span>
12451
+ <button class="btn-sm" style="font-size:11px" onclick="refreshAuthSessions()">Refresh</button>
12452
+ </div>
12453
+ <div class="card-body" style="padding:0" id="sessions-list">
12454
+ <div class="empty-state" style="padding:24px">Loading sessions...</div>
12455
+ </div>
12456
+ </div>
12457
+ <p style="color:var(--text-muted);font-size:12px;margin-top:12px">
12458
+ Sessions persist across daemon restarts. "Remember me" sessions last 30 days; standard sessions expire after 24 hours.
12459
+ Revoke any device you no longer trust.
12460
+ </p>
12461
+ </div>
12321
12462
  <div class="tab-pane" id="tab-settings-integrations">
12322
12463
  <div class="card" style="margin-bottom:20px">
12323
12464
  <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
@@ -12399,19 +12540,6 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12399
12540
  <div class="tab-pane" id="tab-settings-notifications">
12400
12541
  <div id="digest-settings-content"><div class="empty-state">Loading...</div></div>
12401
12542
  </div>
12402
- <div class="tab-pane" id="tab-settings-projects">
12403
- <p style="color:var(--text-muted);margin-bottom:16px">Link projects to give Clementine automatic access to their tools and MCP servers. When you mention a linked project's keywords in chat, Clementine switches into that project's context automatically.</p>
12404
- <div class="card" style="margin-bottom:20px">
12405
- <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
12406
- <span>Workspace Directories</span>
12407
- <button class="btn btn-sm btn-primary" onclick="promptAddWorkspaceDir()" style="font-size:11px">+ Add Path</button>
12408
- </div>
12409
- <div class="card-body" id="workspace-dirs-list" style="font-size:13px">
12410
- <div class="empty-state">Loading...</div>
12411
- </div>
12412
- </div>
12413
- <div id="panel-projects"><div class="empty-state">Loading...</div></div>
12414
- </div>
12415
12543
  </div>
12416
12544
  </div>
12417
12545
 
@@ -12438,6 +12566,13 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12438
12566
  <div id="panel-workflows-page"><div class="empty-state">Loading workflows...</div></div>
12439
12567
  </div>
12440
12568
 
12569
+ <!-- ═══ Unleashed Page ═══ -->
12570
+ <div class="page" id="page-unleashed">
12571
+ <div class="page-title">Unleashed</div>
12572
+ <p style="color:var(--text-muted);margin-bottom:16px">Long-running autonomous tasks that work in phases with checkpointing. Cancel a task by dropping a CANCEL marker — the agent stops at the next phase boundary.</p>
12573
+ <div id="panel-unleashed"><div class="empty-state">Loading...</div></div>
12574
+ </div>
12575
+
12441
12576
  </div><!-- /content -->
12442
12577
  </div><!-- /layout -->
12443
12578
 
@@ -13097,6 +13232,12 @@ function navigateTo(page, opts) {
13097
13232
  if (page === 'team') { refreshTeam(); }
13098
13233
  if (page === 'projects') { refreshProjects(); }
13099
13234
  if (page === 'workflows') { refreshWorkflows(); }
13235
+ if (page === 'unleashed') { refreshUnleashed(); }
13236
+ if (page === 'goals') { refreshGoalsProgress(); }
13237
+ if (page === 'metrics') { refreshMetrics(); }
13238
+ if (page === 'memory-health') { refreshMemoryHealth(); }
13239
+ if (page === 'sessions') { refreshSessions(); }
13240
+ if (page === 'daily-plan') { refreshDailyPlan(); }
13100
13241
  if (page === 'agent-detail' && opts.agentSlug != null) {
13101
13242
  currentAgentSlug = opts.agentSlug;
13102
13243
  renderAgentDetail(opts.agentSlug);
@@ -13104,6 +13245,69 @@ function navigateTo(page, opts) {
13104
13245
  closeSidebar();
13105
13246
  }
13106
13247
 
13248
+ // Shortcut nav items that drop into a sub-tab on the Automations page
13249
+ function openAutomationsTab(tab) {
13250
+ navigateTo('automations');
13251
+ setTimeout(function() { switchTab('automations', tab); }, 0);
13252
+ }
13253
+
13254
+ async function refreshUnleashed() {
13255
+ var el = document.getElementById('panel-unleashed');
13256
+ if (!el) return;
13257
+ try {
13258
+ var r = await apiFetch('/api/unleashed');
13259
+ var d = await r.json();
13260
+ var tasks = d.tasks || [];
13261
+ var badge = document.getElementById('nav-unleashed-count');
13262
+ if (badge) {
13263
+ var running = tasks.filter(function(t) { return t.status === 'running'; }).length;
13264
+ if (running > 0) { badge.style.display = ''; badge.textContent = String(running); }
13265
+ else { badge.style.display = 'none'; }
13266
+ }
13267
+ if (tasks.length === 0) {
13268
+ el.innerHTML = '<div class="empty-state" style="padding:24px">No unleashed tasks. Schedule a cron job in <a href="#" onclick="navigateTo(\\'automations\\');return false">Scheduled Tasks</a> with mode = "unleashed" to start one.</div>';
13269
+ return;
13270
+ }
13271
+ var html = '<table style="width:100%;border-collapse:collapse;font-size:13px"><thead><tr style="border-bottom:1px solid var(--border);background:var(--bg-tertiary)">';
13272
+ html += '<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-weight:600">Task</th>';
13273
+ html += '<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-weight:600">Status</th>';
13274
+ html += '<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-weight:600">Phase</th>';
13275
+ html += '<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-weight:600">Started</th>';
13276
+ html += '<th style="padding:8px 12px;text-align:right;color:var(--text-muted);font-weight:600">Action</th>';
13277
+ html += '</tr></thead><tbody>';
13278
+ tasks.forEach(function(t) {
13279
+ var statusColor = t.status === 'running' ? 'var(--green)' : t.status === 'completed' ? 'var(--blue)' : t.status === 'cancelled' ? 'var(--text-muted)' : 'var(--orange)';
13280
+ html += '<tr style="border-bottom:1px solid var(--border)">';
13281
+ html += '<td style="padding:10px 12px;font-weight:600">' + esc(t.name || '—') + '</td>';
13282
+ html += '<td style="padding:10px 12px"><span style="color:' + statusColor + ';font-size:11px;font-weight:600;text-transform:uppercase">' + esc(t.status || 'unknown') + '</span></td>';
13283
+ html += '<td style="padding:10px 12px;color:var(--text-muted)">' + esc(t.phase != null ? String(t.phase) : '—') + '</td>';
13284
+ html += '<td style="padding:10px 12px;color:var(--text-muted)">' + esc(t.startedAt || '—') + '</td>';
13285
+ html += '<td style="padding:10px 12px;text-align:right">';
13286
+ if (t.status === 'running') {
13287
+ html += '<button class="btn-sm" style="font-size:11px;color:#ef4444" onclick="cancelUnleashed(\\'' + esc(t.name) + '\\')">Cancel</button>';
13288
+ }
13289
+ html += '</td></tr>';
13290
+ });
13291
+ html += '</tbody></table>';
13292
+ el.innerHTML = html;
13293
+ } catch (ex) {
13294
+ el.innerHTML = '<div class="empty-state" style="padding:24px">Error: ' + esc(String(ex)) + '</div>';
13295
+ }
13296
+ }
13297
+
13298
+ async function cancelUnleashed(name) {
13299
+ if (!confirm('Cancel unleashed task "' + name + '"? It will stop at the next phase boundary.')) return;
13300
+ try {
13301
+ var r = await apiFetch('/api/unleashed/' + encodeURIComponent(name) + '/cancel', { method: 'POST' });
13302
+ var d = await r.json();
13303
+ if (d.ok) toast('Cancel requested', 'success');
13304
+ else toast(d.error || 'Cancel failed', 'error');
13305
+ refreshUnleashed();
13306
+ } catch (ex) {
13307
+ toast('Cancel failed: ' + ex, 'error');
13308
+ }
13309
+ }
13310
+
13107
13311
  // Bind static nav items
13108
13312
  document.querySelectorAll('.nav-item').forEach(item => {
13109
13313
  item.addEventListener('click', () => {
@@ -13141,20 +13345,97 @@ function switchTab(group, tab) {
13141
13345
  if (tab === 'graph') refreshGraph();
13142
13346
  if (tab === 'memory') refreshMemory();
13143
13347
  }
13144
- if (group === 'home') {
13145
- if (tab === 'metrics') refreshHomeMetrics();
13146
- if (tab === 'sessions') refreshHomeSessions();
13147
- if (tab === 'activity') refreshActivity();
13148
- if (tab === 'agents') refreshAgentComparison();
13149
- }
13150
13348
  if (group === 'settings') {
13151
13349
  if (tab === 'integrations') refreshSalesforce();
13152
- if (tab === 'projects') refreshProjects();
13153
13350
  if (tab === 'remote') refreshRemoteAccess();
13351
+ if (tab === 'security') refreshAuthSessions();
13154
13352
  if (tab === 'notifications') refreshDigestSettings();
13155
13353
  }
13156
13354
  }
13157
13355
 
13356
+ async function refreshAuthSessions() {
13357
+ var el = document.getElementById('sessions-list');
13358
+ if (!el) return;
13359
+ try {
13360
+ var r = await fetch('/auth/sessions', { credentials: 'same-origin' });
13361
+ if (!r.ok) {
13362
+ if (r.status === 401) {
13363
+ el.innerHTML = '<div class="empty-state" style="padding:24px">Session-based view is only available for tunneled remote access. Localhost runs do not need login.</div>';
13364
+ return;
13365
+ }
13366
+ el.innerHTML = '<div class="empty-state" style="padding:24px">Failed to load sessions.</div>';
13367
+ return;
13368
+ }
13369
+ var d = await r.json();
13370
+ var rows = (d.sessions || []);
13371
+ if (rows.length === 0) {
13372
+ el.innerHTML = '<div class="empty-state" style="padding:24px">No active sessions.</div>';
13373
+ return;
13374
+ }
13375
+ function fmt(t) {
13376
+ if (!t) return '—';
13377
+ var diff = Date.now() - t;
13378
+ var s = Math.floor(diff / 1000);
13379
+ if (s < 60) return s + 's ago';
13380
+ if (s < 3600) return Math.floor(s / 60) + 'm ago';
13381
+ if (s < 86400) return Math.floor(s / 3600) + 'h ago';
13382
+ return Math.floor(s / 86400) + 'd ago';
13383
+ }
13384
+ function fmtExpires(t) {
13385
+ var diff = t - Date.now();
13386
+ if (diff <= 0) return 'expired';
13387
+ var d = Math.floor(diff / 86400000);
13388
+ if (d > 1) return 'in ' + d + ' days';
13389
+ var h = Math.floor(diff / 3600000);
13390
+ if (h > 1) return 'in ' + h + ' hours';
13391
+ return 'in ' + Math.floor(diff / 60000) + ' min';
13392
+ }
13393
+ var html = '<table style="width:100%;border-collapse:collapse;font-size:13px">';
13394
+ html += '<thead><tr style="border-bottom:1px solid var(--border);background:var(--bg-tertiary)">';
13395
+ html += '<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-weight:600">Device</th>';
13396
+ html += '<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-weight:600">Created</th>';
13397
+ html += '<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-weight:600">Last used</th>';
13398
+ html += '<th style="padding:8px 12px;text-align:left;color:var(--text-muted);font-weight:600">Expires</th>';
13399
+ html += '<th style="padding:8px 12px;text-align:right;color:var(--text-muted);font-weight:600">Action</th>';
13400
+ html += '</tr></thead><tbody>';
13401
+ rows.forEach(function(s) {
13402
+ var ua = s.userAgent || 'Unknown device';
13403
+ var label = s.persistent ? '<span style="background:#f97316;color:#fff;padding:1px 6px;border-radius:3px;font-size:10px;margin-left:6px">Remember</span>' : '';
13404
+ var current = s.current ? '<span style="background:#10b981;color:#fff;padding:1px 6px;border-radius:3px;font-size:10px;margin-left:6px">This device</span>' : '';
13405
+ html += '<tr style="border-bottom:1px solid var(--border)">';
13406
+ html += '<td style="padding:10px 12px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(ua) + label + current + '</td>';
13407
+ html += '<td style="padding:10px 12px;color:var(--text-muted)">' + fmt(s.createdAt) + '</td>';
13408
+ html += '<td style="padding:10px 12px;color:var(--text-muted)">' + fmt(s.lastUsedAt) + '</td>';
13409
+ html += '<td style="padding:10px 12px;color:var(--text-muted)">' + fmtExpires(s.expiresAt) + '</td>';
13410
+ html += '<td style="padding:10px 12px;text-align:right">';
13411
+ html += '<button class="btn-sm" style="font-size:11px;color:#ef4444" onclick="revokeSession(\\'' + esc(s.id) + '\\',' + (s.current ? 'true' : 'false') + ')">Revoke</button>';
13412
+ html += '</td></tr>';
13413
+ });
13414
+ html += '</tbody></table>';
13415
+ el.innerHTML = html;
13416
+ } catch (ex) {
13417
+ el.innerHTML = '<div class="empty-state" style="padding:24px">Error: ' + esc(String(ex)) + '</div>';
13418
+ }
13419
+ }
13420
+
13421
+ async function revokeSession(id, isCurrent) {
13422
+ var msg = isCurrent
13423
+ ? 'Revoke this session? You will be signed out immediately.'
13424
+ : 'Revoke this session?';
13425
+ if (!confirm(msg)) return;
13426
+ try {
13427
+ var r = await fetch('/auth/sessions/' + encodeURIComponent(id), { method: 'DELETE', credentials: 'same-origin' });
13428
+ if (!r.ok) { alert('Revoke failed'); return; }
13429
+ if (isCurrent) {
13430
+ window.location.href = '/';
13431
+ return;
13432
+ }
13433
+ refreshAuthSessions();
13434
+ } catch (ex) {
13435
+ alert('Revoke failed: ' + ex);
13436
+ }
13437
+ }
13438
+
13158
13439
  // ── Dynamic Team Nav ─────────────────────
13159
13440
  function renderTeamNav(agents) {
13160
13441
  var container = document.getElementById('team-nav');
@@ -16618,6 +16899,144 @@ async function saveUserModelSlot(slot) {
16618
16899
  } catch (e) { toast('Save failed: ' + String(e), 'error'); }
16619
16900
  }
16620
16901
 
16902
+ async function seedUserModel() {
16903
+ var seedPanel = document.getElementById('user-model-seed-panel');
16904
+ if (!seedPanel) return;
16905
+ seedPanel.style.display = 'block';
16906
+ seedPanel.innerHTML =
16907
+ '<div class="card" style="padding:14px;border-left:3px solid var(--accent,#f59e0b)">'
16908
+ + '<div style="font-weight:600;margin-bottom:6px">✨ Analyzing your existing memory…</div>'
16909
+ + '<div style="font-size:12px;color:var(--text-muted)">Running Haiku over MEMORY.md, top-salience chunks, and recent session summaries. Usually 5–15 seconds.</div>'
16910
+ + '</div>';
16911
+ try {
16912
+ var r = await apiFetch('/api/user-model/seed', {
16913
+ method: 'POST',
16914
+ headers: { 'Content-Type': 'application/json' },
16915
+ body: '{}',
16916
+ });
16917
+ var d = await r.json();
16918
+ if (!d.ok || !d.proposals) {
16919
+ seedPanel.innerHTML =
16920
+ '<div class="card" style="padding:14px;border-left:3px solid var(--red,#ef4444)">'
16921
+ + '<div style="font-weight:600;margin-bottom:6px">Seed failed</div>'
16922
+ + '<div style="font-size:12px;color:var(--text-muted)">' + esc(d.error || 'Unknown error') + '</div>'
16923
+ + '<div style="margin-top:10px"><button class="btn" onclick="dismissSeedPanel()" style="font-size:12px">Dismiss</button></div>'
16924
+ + '</div>';
16925
+ return;
16926
+ }
16927
+ renderSeedProposals(d.proposals);
16928
+ } catch (e) {
16929
+ seedPanel.innerHTML =
16930
+ '<div class="card" style="padding:14px;border-left:3px solid var(--red,#ef4444)">'
16931
+ + '<div style="font-weight:600">Seed failed: ' + esc(String(e)) + '</div>'
16932
+ + '<div style="margin-top:10px"><button class="btn" onclick="dismissSeedPanel()" style="font-size:12px">Dismiss</button></div>'
16933
+ + '</div>';
16934
+ }
16935
+ }
16936
+
16937
+ function renderSeedProposals(p) {
16938
+ var seedPanel = document.getElementById('user-model-seed-panel');
16939
+ if (!seedPanel) return;
16940
+ var labelMap = {
16941
+ user_facts: 'User Facts',
16942
+ goals: 'Active Goals',
16943
+ relationships: 'Key Relationships',
16944
+ agent_persona: 'Agent Persona',
16945
+ };
16946
+ var slots = ['user_facts', 'goals', 'relationships', 'agent_persona'];
16947
+ var anyContent = slots.some(function(s) { return (p[s] || '').trim().length > 0; });
16948
+ if (!anyContent) {
16949
+ seedPanel.innerHTML =
16950
+ '<div class="card" style="padding:14px;border-left:3px solid var(--accent,#f59e0b)">'
16951
+ + '<div style="font-weight:600;margin-bottom:4px">No clear signal in memory yet</div>'
16952
+ + '<div style="font-size:12px;color:var(--text-muted)">Haiku analyzed ' + (p.sourceCount || 0) + ' source items but found no facts strong enough to seed slots. Have a few conversations and try again, or fill the slots manually below.</div>'
16953
+ + '<div style="margin-top:10px"><button class="btn" onclick="dismissSeedPanel()" style="font-size:12px">Dismiss</button></div>'
16954
+ + '</div>';
16955
+ return;
16956
+ }
16957
+ var html = '<div class="card" style="padding:14px;border-left:3px solid var(--accent,#f59e0b)">';
16958
+ html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;gap:8px;flex-wrap:wrap">';
16959
+ html += '<div><div style="font-weight:600">✨ Proposed values from memory</div>';
16960
+ html += '<div style="font-size:11px;color:var(--text-muted)">Reviewed ' + (p.sourceCount || 0) + ' source items. Edit any slot before applying. Skipped slots stay empty.</div></div>';
16961
+ html += '<div style="display:flex;gap:6px"><button class="btn-primary" onclick="applyAllSeedSlots()" style="font-size:12px">Apply all</button><button class="btn" onclick="dismissSeedPanel()" style="font-size:12px">Dismiss</button></div>';
16962
+ html += '</div>';
16963
+ for (var i = 0; i < slots.length; i++) {
16964
+ var slot = slots[i];
16965
+ var content = (p[slot] || '').trim();
16966
+ if (!content) continue;
16967
+ var label = labelMap[slot] || slot;
16968
+ html += '<div class="seed-slot-card" data-slot="' + esc(slot) + '" style="margin-top:10px;padding:10px;background:var(--bg-input);border-radius:6px">';
16969
+ html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">';
16970
+ html += '<div style="font-weight:600;font-size:13px">' + esc(label) + '</div>';
16971
+ html += '<div style="display:flex;gap:6px"><button class="btn-primary" onclick="applySeedSlot(\\'' + esc(slot) + '\\')" style="font-size:11px;padding:2px 10px">Apply</button><button class="btn" onclick="skipSeedSlot(\\'' + esc(slot) + '\\')" style="font-size:11px;padding:2px 10px">Skip</button></div>';
16972
+ html += '</div>';
16973
+ html += '<textarea id="seed-textarea-' + esc(slot) + '" style="width:100%;min-height:70px;padding:6px;border:1px solid var(--border);border-radius:4px;background:var(--bg);color:var(--text);font-family:inherit;font-size:12px;resize:vertical">' + esc(content) + '</textarea>';
16974
+ html += '</div>';
16975
+ }
16976
+ html += '</div>';
16977
+ seedPanel.innerHTML = html;
16978
+ }
16979
+
16980
+ async function applySeedSlot(slot) {
16981
+ var ta = document.getElementById('seed-textarea-' + slot);
16982
+ if (!ta) return;
16983
+ var content = ta.value;
16984
+ var scope = document.getElementById('user-model-scope');
16985
+ var agentSlug = scope && scope.value ? scope.value : null;
16986
+ try {
16987
+ var r = await apiFetch('/api/user-model/' + encodeURIComponent(slot), {
16988
+ method: 'PUT',
16989
+ headers: { 'Content-Type': 'application/json' },
16990
+ body: JSON.stringify({ content: content, agentSlug: agentSlug }),
16991
+ });
16992
+ var d = await r.json();
16993
+ if (d.ok) {
16994
+ toast('Applied ' + slot, 'success');
16995
+ var card = document.querySelector('.seed-slot-card[data-slot="' + slot + '"]');
16996
+ if (card) card.remove();
16997
+ loadUserModel();
16998
+ } else {
16999
+ toast('Apply failed: ' + (d.error || 'unknown'), 'error');
17000
+ }
17001
+ } catch (e) { toast('Apply failed: ' + String(e), 'error'); }
17002
+ }
17003
+
17004
+ function skipSeedSlot(slot) {
17005
+ var card = document.querySelector('.seed-slot-card[data-slot="' + slot + '"]');
17006
+ if (card) card.remove();
17007
+ }
17008
+
17009
+ async function applyAllSeedSlots() {
17010
+ var slots = ['user_facts', 'goals', 'relationships', 'agent_persona'];
17011
+ var applied = 0;
17012
+ for (var i = 0; i < slots.length; i++) {
17013
+ var ta = document.getElementById('seed-textarea-' + slots[i]);
17014
+ if (!ta || !ta.value.trim()) continue;
17015
+ var scope = document.getElementById('user-model-scope');
17016
+ var agentSlug = scope && scope.value ? scope.value : null;
17017
+ try {
17018
+ var r = await apiFetch('/api/user-model/' + encodeURIComponent(slots[i]), {
17019
+ method: 'PUT',
17020
+ headers: { 'Content-Type': 'application/json' },
17021
+ body: JSON.stringify({ content: ta.value, agentSlug: agentSlug }),
17022
+ });
17023
+ var d = await r.json();
17024
+ if (d.ok) applied++;
17025
+ } catch (e) { /* continue */ }
17026
+ }
17027
+ toast('Applied ' + applied + ' slot' + (applied === 1 ? '' : 's'), 'success');
17028
+ dismissSeedPanel();
17029
+ loadUserModel();
17030
+ }
17031
+
17032
+ function dismissSeedPanel() {
17033
+ var seedPanel = document.getElementById('user-model-seed-panel');
17034
+ if (seedPanel) {
17035
+ seedPanel.style.display = 'none';
17036
+ seedPanel.innerHTML = '';
17037
+ }
17038
+ }
17039
+
16621
17040
  async function clearUserModelSlot(slot) {
16622
17041
  if (!confirm('Clear the "' + slot + '" slot? This deletes everything currently stored there.')) return;
16623
17042
  var scope = document.getElementById('user-model-scope');
@@ -17413,6 +17832,192 @@ function formatTokens(n) {
17413
17832
  return String(n);
17414
17833
  }
17415
17834
 
17835
+ function formatBytes(n) {
17836
+ if (n == null) return '—';
17837
+ if (n < 1024) return n + ' B';
17838
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
17839
+ if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
17840
+ return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
17841
+ }
17842
+
17843
+ async function refreshMemoryHealth() {
17844
+ var el = document.getElementById('memory-health-content');
17845
+ if (!el) return;
17846
+ try {
17847
+ var r = await apiFetch('/api/memory/health');
17848
+ var d = await r.json();
17849
+ if (!d.ok || !d.health) {
17850
+ el.innerHTML = '<div class="empty-state">' + esc(d.error || 'No data') + '</div>';
17851
+ return;
17852
+ }
17853
+ var h = d.health;
17854
+ var consolidatedPct = h.chunks.total > 0
17855
+ ? ((h.chunks.consolidated / h.chunks.total) * 100).toFixed(1)
17856
+ : '0.0';
17857
+ var zombiePct = h.chunks.total > 0
17858
+ ? ((h.chunks.zombieCount / h.chunks.total) * 100).toFixed(1)
17859
+ : '0.0';
17860
+
17861
+ var html = '';
17862
+
17863
+ // Hero tiles row.
17864
+ html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:20px">';
17865
+ html += '<div class="metric-hero"><div class="metric-hero-value">' + (h.chunks.total || 0)
17866
+ + '</div><div class="metric-hero-label">Total Chunks</div>'
17867
+ + '<div class="metric-hero-sub">' + (h.chunks.pinned || 0) + ' pinned &middot; ' + (h.chunks.softDeleted || 0) + ' soft-deleted</div></div>';
17868
+ html += '<div class="metric-hero"><div class="metric-hero-value">' + consolidatedPct
17869
+ + '%</div><div class="metric-hero-label">Consolidated</div>'
17870
+ + '<div class="metric-hero-sub">' + (h.chunks.consolidated || 0) + ' of ' + (h.chunks.total || 0) + ' chunks</div></div>';
17871
+ html += '<div class="metric-hero"><div class="metric-hero-value">' + (h.chunks.zombieCount || 0)
17872
+ + '</div><div class="metric-hero-label">Zombies</div>'
17873
+ + '<div class="metric-hero-sub">' + zombiePct + '% of total &middot; eligible to expire</div></div>';
17874
+ html += '<div class="metric-hero"><div class="metric-hero-value">' + formatBytes(h.dbSizeBytes)
17875
+ + '</div><div class="metric-hero-label">DB File Size</div>'
17876
+ + '<div class="metric-hero-sub">last vacuum: ' + esc(h.lastVacuumAt || 'never') + '</div></div>';
17877
+ html += '</div>';
17878
+
17879
+ // Two-column layout: categories + table sizes on left, top cited on right.
17880
+ html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">';
17881
+
17882
+ // Left column: categories.
17883
+ html += '<div class="card"><div class="card-header"><h3>Chunks by Category</h3></div><div class="card-body">';
17884
+ if (!h.chunksByCategory || h.chunksByCategory.length === 0) {
17885
+ html += '<div class="empty-state">No chunks yet.</div>';
17886
+ } else {
17887
+ html += '<table class="data-table"><thead><tr><th>Category</th><th style="text-align:right">Count</th></tr></thead><tbody>';
17888
+ for (var i = 0; i < h.chunksByCategory.length; i++) {
17889
+ var c = h.chunksByCategory[i];
17890
+ html += '<tr><td>' + esc(c.category || '—') + '</td><td style="text-align:right">' + c.count + '</td></tr>';
17891
+ }
17892
+ html += '</tbody></table>';
17893
+ }
17894
+ html += '</div></div>';
17895
+
17896
+ // Right column: top cited.
17897
+ html += '<div class="card"><div class="card-header"><h3>Top Cited (last 30d)</h3></div><div class="card-body">';
17898
+ if (!h.topCitedLast30d || h.topCitedLast30d.length === 0) {
17899
+ html += '<div class="empty-state">No outcomes recorded in the last 30 days.</div>';
17900
+ } else {
17901
+ html += '<table class="data-table"><thead><tr><th>Source</th><th>Section</th><th style="text-align:right">Refs</th></tr></thead><tbody>';
17902
+ for (var j = 0; j < h.topCitedLast30d.length; j++) {
17903
+ var t = h.topCitedLast30d[j];
17904
+ html += '<tr><td>' + esc(t.sourceFile || '—') + '</td><td>' + esc(t.section || '—')
17905
+ + '</td><td style="text-align:right">' + t.refCount + '</td></tr>';
17906
+ }
17907
+ html += '</tbody></table>';
17908
+ }
17909
+ html += '</div></div>';
17910
+
17911
+ html += '</div>';
17912
+
17913
+ // Staleness section — high-salience drift + user-model age.
17914
+ var staleSlots = h.staleUserModelSlots || [];
17915
+ var staleChunks = h.staleHighSalienceChunks || [];
17916
+ if (staleSlots.length > 0 || staleChunks.length > 0) {
17917
+ html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">';
17918
+
17919
+ html += '<div class="card"><div class="card-header"><h3>Stale User-Model Slots</h3></div><div class="card-body">';
17920
+ if (staleSlots.length === 0) {
17921
+ html += '<div class="empty-state">All slots fresh.</div>';
17922
+ } else {
17923
+ html += '<table class="data-table"><thead><tr><th>Slot</th><th>Agent</th><th style="text-align:right">Age (days)</th></tr></thead><tbody>';
17924
+ for (var ss = 0; ss < staleSlots.length; ss++) {
17925
+ var s = staleSlots[ss];
17926
+ html += '<tr><td>' + esc(s.slot) + '</td><td>' + esc(s.agentSlug || 'global') + '</td><td style="text-align:right">' + s.ageDays + '</td></tr>';
17927
+ }
17928
+ html += '</tbody></table>';
17929
+ }
17930
+ html += '</div></div>';
17931
+
17932
+ html += '<div class="card"><div class="card-header"><h3>Stale High-Salience Chunks</h3><div style="font-size:11px;color:var(--text-muted)">High salience but EMA gone negative &mdash; ranked but not cited</div></div><div class="card-body">';
17933
+ if (staleChunks.length === 0) {
17934
+ html += '<div class="empty-state">No drift detected.</div>';
17935
+ } else {
17936
+ html += '<table class="data-table"><thead><tr><th>Source</th><th>Section</th><th style="text-align:right">Salience</th><th style="text-align:right">EMA</th></tr></thead><tbody>';
17937
+ for (var sc = 0; sc < staleChunks.length; sc++) {
17938
+ var sk = staleChunks[sc];
17939
+ html += '<tr><td>' + esc(sk.sourceFile || '') + '</td><td>' + esc(sk.section || '') + '</td><td style="text-align:right">' + sk.salience.toFixed(2) + '</td><td style="text-align:right">' + sk.lastOutcomeScore.toFixed(2) + '</td></tr>';
17940
+ }
17941
+ html += '</tbody></table>';
17942
+ }
17943
+ html += '</div></div>';
17944
+ html += '</div>';
17945
+ }
17946
+
17947
+ // Cache + write queue + integrity row.
17948
+ html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px;margin-top:16px">';
17949
+
17950
+ if (h.chunkCacheStats) {
17951
+ var cs = h.chunkCacheStats;
17952
+ var hitRatePct = (cs.hitRate * 100).toFixed(1);
17953
+ html += '<div class="card"><div class="card-header"><h3>Hot Chunk Cache</h3></div><div class="card-body">';
17954
+ html += '<div style="display:flex;gap:18px;flex-wrap:wrap;font-size:13px">';
17955
+ html += '<div><strong>' + hitRatePct + '%</strong> hit rate</div>';
17956
+ html += '<div><strong>' + cs.hits + '</strong> hits</div>';
17957
+ html += '<div><strong>' + cs.misses + '</strong> misses</div>';
17958
+ html += '<div><strong>' + cs.size + ' / ' + cs.capacity + '</strong> entries</div>';
17959
+ html += '<div><strong>' + cs.evictions + '</strong> evictions</div>';
17960
+ html += '</div></div></div>';
17961
+ }
17962
+
17963
+ // Async write queue status.
17964
+ var wq = h.writeQueue;
17965
+ html += '<div class="card"><div class="card-header"><h3>Async Write Queue</h3></div><div class="card-body">';
17966
+ if (!wq) {
17967
+ html += '<div style="font-size:13px;color:var(--text-muted)">Sync mode (queue disabled)</div>';
17968
+ } else {
17969
+ html += '<div style="display:flex;gap:18px;flex-wrap:wrap;font-size:13px">';
17970
+ html += '<div><strong>' + wq.size + '</strong> pending</div>';
17971
+ html += '<div><strong>' + wq.dropped + '</strong> dropped (back-pressure)</div>';
17972
+ html += '</div>';
17973
+ }
17974
+ html += '</div></div>';
17975
+
17976
+ // Integrity report.
17977
+ var ir = h.lastIntegrityReport;
17978
+ html += '<div class="card"><div class="card-header"><h3>Integrity</h3></div><div class="card-body">';
17979
+ if (!ir) {
17980
+ html += '<div style="font-size:13px;color:var(--text-muted)">No probes have run yet.</div>';
17981
+ } else {
17982
+ var ftsLabel = ir.ftsOk
17983
+ ? '<span style="color:var(--green,#3a3)">ok</span>'
17984
+ : (ir.ftsRebuilt ? '<span style="color:var(--orange,#f80)">rebuilt</span>' : '<span style="color:var(--red,#c33)">failing</span>');
17985
+ html += '<div style="display:flex;gap:18px;flex-wrap:wrap;font-size:13px">';
17986
+ html += '<div>FTS5: ' + ftsLabel + '</div>';
17987
+ html += '<div><strong>' + ir.orphanRefsNulled + '</strong> orphan refs nulled</div>';
17988
+ html += '<div><strong>' + ir.missingEmbeddings + '</strong> missing embeddings</div>';
17989
+ html += '<div style="color:var(--text-muted)">last: ' + esc(ir.ranAt || '') + '</div>';
17990
+ html += '</div>';
17991
+ }
17992
+ html += '</div></div>';
17993
+
17994
+ html += '</div>';
17995
+
17996
+ // Table row counts (full width).
17997
+ html += '<div class="card" style="margin-top:16px"><div class="card-header"><h3>Table Sizes</h3></div><div class="card-body">';
17998
+ var tableRows = Object.keys(h.tableRowCounts || {}).sort(function(a, b) {
17999
+ return (h.tableRowCounts[b] || 0) - (h.tableRowCounts[a] || 0);
18000
+ });
18001
+ if (tableRows.length === 0) {
18002
+ html += '<div class="empty-state">No table data.</div>';
18003
+ } else {
18004
+ html += '<table class="data-table"><thead><tr><th>Table</th><th style="text-align:right">Rows</th></tr></thead><tbody>';
18005
+ for (var k = 0; k < tableRows.length; k++) {
18006
+ var tn = tableRows[k];
18007
+ var rowCount = h.tableRowCounts[tn];
18008
+ var label = rowCount === -1 ? '<span style="color:var(--text-muted)">missing</span>' : String(rowCount);
18009
+ html += '<tr><td>' + esc(tn) + '</td><td style="text-align:right">' + label + '</td></tr>';
18010
+ }
18011
+ html += '</tbody></table>';
18012
+ }
18013
+ html += '</div></div>';
18014
+
18015
+ el.innerHTML = html;
18016
+ } catch (err) {
18017
+ el.innerHTML = '<div class="empty-state">Failed to load: ' + esc(String(err)) + '</div>';
18018
+ }
18019
+ }
18020
+
17416
18021
  async function refreshMetrics() {
17417
18022
  try {
17418
18023
  const [r, ur] = await Promise.all([apiFetch('/api/metrics'), apiFetch('/api/metrics/usage')]);
@@ -21435,6 +22040,14 @@ function getLoginPageHTML() {
21435
22040
  margin-top: 24px; font-size: 12px;
21436
22041
  color: #475569; text-align: center; line-height: 1.5;
21437
22042
  }
22043
+ .remember-row {
22044
+ display: flex; align-items: center; gap: 8px;
22045
+ margin: 16px 0; font-size: 13px; color: #94a3b8;
22046
+ cursor: pointer; user-select: none;
22047
+ }
22048
+ .remember-row input[type="checkbox"] {
22049
+ accent-color: #f97316; width: 14px; height: 14px; cursor: pointer;
22050
+ }
21438
22051
  </style>
21439
22052
  </head>
21440
22053
  <body>
@@ -21448,6 +22061,10 @@ function getLoginPageHTML() {
21448
22061
  <input type="password" class="form-input" id="token-input"
21449
22062
  placeholder="clem_XXXX-XXXX-XXXX" autocomplete="off" autofocus>
21450
22063
  </div>
22064
+ <label class="remember-row">
22065
+ <input type="checkbox" id="remember-input">
22066
+ <span>Remember me on this device for 30 days</span>
22067
+ </label>
21451
22068
  <button type="submit" class="btn-login" id="login-btn">Sign In</button>
21452
22069
  <div class="error-msg" id="error-msg"></div>
21453
22070
  </form>
@@ -21462,6 +22079,7 @@ function getLoginPageHTML() {
21462
22079
  var btn = document.getElementById('login-btn');
21463
22080
  var err = document.getElementById('error-msg');
21464
22081
  var token = document.getElementById('token-input').value.trim();
22082
+ var remember = document.getElementById('remember-input').checked;
21465
22083
  if (!token) return;
21466
22084
  btn.disabled = true;
21467
22085
  btn.textContent = 'Signing in...';
@@ -21470,7 +22088,7 @@ function getLoginPageHTML() {
21470
22088
  var r = await fetch('/auth/login', {
21471
22089
  method: 'POST',
21472
22090
  headers: { 'Content-Type': 'application/json' },
21473
- body: JSON.stringify({ token: token })
22091
+ body: JSON.stringify({ token: token, remember: remember })
21474
22092
  });
21475
22093
  var d = await r.json();
21476
22094
  if (d.ok) {