claude-memory-agent 2.1.0 → 2.2.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 (91) hide show
  1. package/bin/cli.js +11 -1
  2. package/bin/lib/banner.js +39 -0
  3. package/bin/lib/environment.js +166 -0
  4. package/bin/lib/installer.js +291 -0
  5. package/bin/lib/models.js +95 -0
  6. package/bin/lib/steps/advanced.js +101 -0
  7. package/bin/lib/steps/confirm.js +87 -0
  8. package/bin/lib/steps/model.js +57 -0
  9. package/bin/lib/steps/provider.js +65 -0
  10. package/bin/lib/steps/scope.js +59 -0
  11. package/bin/lib/steps/server.js +74 -0
  12. package/bin/lib/ui.js +75 -0
  13. package/bin/onboarding.js +164 -0
  14. package/bin/postinstall.js +22 -257
  15. package/config.py +103 -4
  16. package/dashboard.html +697 -27
  17. package/hooks/extract_memories.py +439 -0
  18. package/hooks/pre_compact_hook.py +76 -0
  19. package/hooks/session_end_hook.py +149 -0
  20. package/hooks/stop_hook.py +372 -0
  21. package/install.py +85 -32
  22. package/main.py +1636 -892
  23. package/mcp_server.py +451 -0
  24. package/package.json +14 -3
  25. package/requirements.txt +12 -8
  26. package/services/adaptive_ranker.py +272 -0
  27. package/services/agent_catalog.json +153 -0
  28. package/services/agent_registry.py +245 -730
  29. package/services/claude_md_sync.py +320 -4
  30. package/services/consolidation.py +417 -0
  31. package/services/database.py +586 -105
  32. package/services/embedding_pipeline.py +262 -0
  33. package/services/embeddings.py +493 -85
  34. package/services/memory_decay.py +408 -0
  35. package/services/native_memory_paths.py +86 -0
  36. package/services/native_memory_sync.py +496 -0
  37. package/services/response_manager.py +183 -0
  38. package/services/terminal_ui.py +199 -0
  39. package/services/tier_manager.py +235 -0
  40. package/services/websocket.py +26 -6
  41. package/skills/search.py +136 -61
  42. package/skills/session_review.py +210 -23
  43. package/skills/store.py +125 -18
  44. package/terminal_dashboard.py +474 -0
  45. package/hooks/__pycache__/auto-detect-response.cpython-312.pyc +0 -0
  46. package/hooks/__pycache__/auto_capture.cpython-312.pyc +0 -0
  47. package/hooks/__pycache__/grounding-hook.cpython-312.pyc +0 -0
  48. package/hooks/__pycache__/session_end.cpython-312.pyc +0 -0
  49. package/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
  50. package/services/__pycache__/__init__.cpython-312.pyc +0 -0
  51. package/services/__pycache__/agent_registry.cpython-312.pyc +0 -0
  52. package/services/__pycache__/auth.cpython-312.pyc +0 -0
  53. package/services/__pycache__/auto_inject.cpython-312.pyc +0 -0
  54. package/services/__pycache__/claude_md_sync.cpython-312.pyc +0 -0
  55. package/services/__pycache__/cleanup.cpython-312.pyc +0 -0
  56. package/services/__pycache__/compaction_flush.cpython-312.pyc +0 -0
  57. package/services/__pycache__/confidence.cpython-312.pyc +0 -0
  58. package/services/__pycache__/curator.cpython-312.pyc +0 -0
  59. package/services/__pycache__/daily_log.cpython-312.pyc +0 -0
  60. package/services/__pycache__/database.cpython-312.pyc +0 -0
  61. package/services/__pycache__/embeddings.cpython-312.pyc +0 -0
  62. package/services/__pycache__/insights.cpython-312.pyc +0 -0
  63. package/services/__pycache__/llm_analyzer.cpython-312.pyc +0 -0
  64. package/services/__pycache__/memory_md_sync.cpython-312.pyc +0 -0
  65. package/services/__pycache__/retry_queue.cpython-312.pyc +0 -0
  66. package/services/__pycache__/timeline.cpython-312.pyc +0 -0
  67. package/services/__pycache__/vector_index.cpython-312.pyc +0 -0
  68. package/services/__pycache__/websocket.cpython-312.pyc +0 -0
  69. package/skills/__pycache__/__init__.cpython-312.pyc +0 -0
  70. package/skills/__pycache__/admin.cpython-312.pyc +0 -0
  71. package/skills/__pycache__/checkpoint.cpython-312.pyc +0 -0
  72. package/skills/__pycache__/claude_md.cpython-312.pyc +0 -0
  73. package/skills/__pycache__/cleanup.cpython-312.pyc +0 -0
  74. package/skills/__pycache__/confidence_tracker.cpython-312.pyc +0 -0
  75. package/skills/__pycache__/context.cpython-312.pyc +0 -0
  76. package/skills/__pycache__/curator.cpython-312.pyc +0 -0
  77. package/skills/__pycache__/grounding.cpython-312.pyc +0 -0
  78. package/skills/__pycache__/insights.cpython-312.pyc +0 -0
  79. package/skills/__pycache__/natural_language.cpython-312.pyc +0 -0
  80. package/skills/__pycache__/retrieve.cpython-312.pyc +0 -0
  81. package/skills/__pycache__/search.cpython-312.pyc +0 -0
  82. package/skills/__pycache__/session_review.cpython-312.pyc +0 -0
  83. package/skills/__pycache__/state.cpython-312.pyc +0 -0
  84. package/skills/__pycache__/store.cpython-312.pyc +0 -0
  85. package/skills/__pycache__/summarize.cpython-312.pyc +0 -0
  86. package/skills/__pycache__/timeline.cpython-312.pyc +0 -0
  87. package/skills/__pycache__/verification.cpython-312.pyc +0 -0
  88. package/test_automation.py +0 -221
  89. package/test_complete.py +0 -338
  90. package/test_full.py +0 -322
  91. package/verify_db.py +0 -134
package/dashboard.html CHANGED
@@ -204,6 +204,27 @@
204
204
  border-radius: 50%;
205
205
  animation: pulse 2s infinite;
206
206
  }
207
+ .retry-btn {
208
+ background: transparent;
209
+ border: 1px solid currentColor;
210
+ color: inherit;
211
+ padding: 4px 8px;
212
+ border-radius: 4px;
213
+ cursor: pointer;
214
+ font-size: 11px;
215
+ margin-left: 8px;
216
+ transition: all 0.2s ease;
217
+ }
218
+ .retry-btn:hover {
219
+ background: rgba(255,255,255,0.1);
220
+ }
221
+ .retry-btn.spinning i {
222
+ animation: spin 1s linear infinite;
223
+ }
224
+ @keyframes spin {
225
+ from { transform: rotate(0deg); }
226
+ to { transform: rotate(360deg); }
227
+ }
207
228
  /* Main Content */
208
229
  .main-content {
209
230
  max-width: 1800px;
@@ -1489,6 +1510,243 @@
1489
1510
  .toast.success { border-left: 4px solid var(--accent-green); }
1490
1511
  .toast.error { border-left: 4px solid var(--accent-red); }
1491
1512
  .toast.info { border-left: 4px solid var(--accent-blue); }
1513
+ /* Maintenance Tab */
1514
+ .maintenance-grid {
1515
+ display: grid;
1516
+ grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
1517
+ gap: 24px;
1518
+ }
1519
+ .maintenance-card {
1520
+ background: var(--bg-card);
1521
+ border: 1px solid var(--border-color);
1522
+ border-radius: 16px;
1523
+ padding: 24px;
1524
+ position: relative;
1525
+ overflow: hidden;
1526
+ transition: all 0.3s;
1527
+ }
1528
+ .maintenance-card:hover {
1529
+ border-color: var(--accent-blue);
1530
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
1531
+ }
1532
+ .maintenance-card::before {
1533
+ content: '';
1534
+ position: absolute;
1535
+ top: 0; left: 0; right: 0;
1536
+ height: 3px;
1537
+ }
1538
+ .maintenance-card.decay-card::before { background: var(--gradient-4); }
1539
+ .maintenance-card.dedup-card::before { background: var(--gradient-3); }
1540
+ .maintenance-card.tier1-card::before { background: var(--gradient-1); }
1541
+ .maintenance-card-header {
1542
+ display: flex;
1543
+ align-items: center;
1544
+ gap: 14px;
1545
+ margin-bottom: 20px;
1546
+ }
1547
+ .maintenance-card-icon {
1548
+ width: 48px; height: 48px;
1549
+ border-radius: 12px;
1550
+ display: flex;
1551
+ align-items: center;
1552
+ justify-content: center;
1553
+ font-size: 20px;
1554
+ flex-shrink: 0;
1555
+ }
1556
+ .maintenance-card-title {
1557
+ font-size: 17px;
1558
+ font-weight: 600;
1559
+ }
1560
+ .maintenance-card-subtitle {
1561
+ font-size: 12px;
1562
+ color: var(--text-secondary);
1563
+ margin-top: 2px;
1564
+ }
1565
+ .maintenance-stats {
1566
+ display: grid;
1567
+ grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
1568
+ gap: 12px;
1569
+ margin-bottom: 20px;
1570
+ }
1571
+ .maintenance-stat {
1572
+ text-align: center;
1573
+ padding: 12px 8px;
1574
+ background: var(--bg-tertiary);
1575
+ border-radius: 10px;
1576
+ transition: all 0.2s;
1577
+ }
1578
+ .maintenance-stat:hover {
1579
+ background: var(--bg-secondary);
1580
+ }
1581
+ .maintenance-stat-value {
1582
+ font-size: 22px;
1583
+ font-weight: 700;
1584
+ color: var(--text-primary);
1585
+ }
1586
+ .maintenance-stat-value.green { color: var(--accent-green); }
1587
+ .maintenance-stat-value.yellow { color: var(--accent-yellow); }
1588
+ .maintenance-stat-value.red { color: var(--accent-red); }
1589
+ .maintenance-stat-value.blue { color: var(--accent-blue); }
1590
+ .maintenance-stat-value.purple { color: var(--accent-purple); }
1591
+ .maintenance-stat-value.cyan { color: var(--accent-cyan); }
1592
+ .maintenance-stat-label {
1593
+ font-size: 11px;
1594
+ color: var(--text-secondary);
1595
+ margin-top: 4px;
1596
+ text-transform: uppercase;
1597
+ letter-spacing: 0.5px;
1598
+ }
1599
+ .maintenance-actions {
1600
+ display: flex;
1601
+ gap: 10px;
1602
+ flex-wrap: wrap;
1603
+ }
1604
+ .maintenance-btn {
1605
+ padding: 10px 18px;
1606
+ border-radius: 10px;
1607
+ font-size: 13px;
1608
+ font-weight: 500;
1609
+ cursor: pointer;
1610
+ border: 1px solid var(--border-color);
1611
+ background: var(--bg-tertiary);
1612
+ color: var(--text-secondary);
1613
+ transition: all 0.2s;
1614
+ display: flex;
1615
+ align-items: center;
1616
+ gap: 8px;
1617
+ }
1618
+ .maintenance-btn:hover {
1619
+ color: var(--text-primary);
1620
+ border-color: var(--accent-blue);
1621
+ background: rgba(88, 166, 255, 0.1);
1622
+ }
1623
+ .maintenance-btn:disabled {
1624
+ opacity: 0.5;
1625
+ cursor: not-allowed;
1626
+ }
1627
+ .maintenance-btn.primary {
1628
+ background: var(--accent-blue);
1629
+ color: white;
1630
+ border-color: var(--accent-blue);
1631
+ }
1632
+ .maintenance-btn.primary:hover {
1633
+ background: #4a9aef;
1634
+ box-shadow: 0 4px 15px rgba(88, 166, 255, 0.3);
1635
+ }
1636
+ .maintenance-btn.success {
1637
+ background: var(--accent-green);
1638
+ color: white;
1639
+ border-color: var(--accent-green);
1640
+ }
1641
+ .maintenance-btn.success:hover {
1642
+ background: #36a446;
1643
+ box-shadow: 0 4px 15px rgba(63, 185, 80, 0.3);
1644
+ }
1645
+ .maintenance-btn .spinner-sm {
1646
+ width: 14px; height: 14px;
1647
+ border: 2px solid rgba(255,255,255,0.3);
1648
+ border-top: 2px solid currentColor;
1649
+ border-radius: 50%;
1650
+ animation: spin 0.8s linear infinite;
1651
+ display: none;
1652
+ }
1653
+ .maintenance-btn.loading .spinner-sm { display: inline-block; }
1654
+ .maintenance-btn.loading { pointer-events: none; opacity: 0.8; }
1655
+ .maintenance-last-run {
1656
+ margin-top: 16px;
1657
+ padding-top: 12px;
1658
+ border-top: 1px solid var(--border-color);
1659
+ font-size: 12px;
1660
+ color: var(--text-muted);
1661
+ display: flex;
1662
+ align-items: center;
1663
+ gap: 6px;
1664
+ }
1665
+ .maintenance-last-run i { font-size: 11px; }
1666
+ .dedup-results {
1667
+ margin-top: 16px;
1668
+ max-height: 250px;
1669
+ overflow-y: auto;
1670
+ }
1671
+ .dedup-cluster {
1672
+ background: var(--bg-tertiary);
1673
+ border-radius: 10px;
1674
+ padding: 12px 14px;
1675
+ margin-bottom: 10px;
1676
+ border: 1px solid transparent;
1677
+ transition: all 0.2s;
1678
+ }
1679
+ .dedup-cluster:hover {
1680
+ border-color: var(--border-color);
1681
+ }
1682
+ .dedup-cluster-header {
1683
+ display: flex;
1684
+ justify-content: space-between;
1685
+ align-items: center;
1686
+ margin-bottom: 8px;
1687
+ }
1688
+ .dedup-cluster-score {
1689
+ font-size: 12px;
1690
+ font-weight: 600;
1691
+ padding: 3px 10px;
1692
+ border-radius: 12px;
1693
+ }
1694
+ .dedup-cluster-score.high {
1695
+ background: rgba(248, 81, 73, 0.15);
1696
+ color: var(--accent-red);
1697
+ }
1698
+ .dedup-cluster-score.medium {
1699
+ background: rgba(210, 153, 34, 0.15);
1700
+ color: var(--accent-yellow);
1701
+ }
1702
+ .dedup-cluster-item {
1703
+ font-size: 12px;
1704
+ color: var(--text-secondary);
1705
+ padding: 4px 0;
1706
+ white-space: nowrap;
1707
+ overflow: hidden;
1708
+ text-overflow: ellipsis;
1709
+ }
1710
+ .dedup-cluster-item i {
1711
+ margin-right: 6px;
1712
+ font-size: 10px;
1713
+ }
1714
+ .tier1-preview {
1715
+ margin-top: 16px;
1716
+ background: var(--bg-tertiary);
1717
+ border-radius: 10px;
1718
+ padding: 16px;
1719
+ max-height: 300px;
1720
+ overflow-y: auto;
1721
+ font-size: 13px;
1722
+ line-height: 1.6;
1723
+ white-space: pre-wrap;
1724
+ font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
1725
+ color: var(--text-secondary);
1726
+ display: none;
1727
+ }
1728
+ .tier1-preview.visible { display: block; }
1729
+ .tier1-preview h4 {
1730
+ color: var(--text-primary);
1731
+ margin-bottom: 8px;
1732
+ font-family: inherit;
1733
+ }
1734
+ .health-ring {
1735
+ position: relative;
1736
+ width: 80px;
1737
+ height: 80px;
1738
+ margin: 0 auto 12px;
1739
+ }
1740
+ .health-ring svg {
1741
+ transform: rotate(-90deg);
1742
+ }
1743
+ .health-ring-text {
1744
+ position: absolute;
1745
+ top: 50%; left: 50%;
1746
+ transform: translate(-50%, -50%);
1747
+ font-size: 16px;
1748
+ font-weight: 700;
1749
+ }
1492
1750
  /* Responsive */
1493
1751
  @media (max-width: 768px) {
1494
1752
  .header-content { flex-direction: column; gap: 16px; }
@@ -1851,6 +2109,9 @@
1851
2109
  <div class="status-indicator" id="statusIndicator">
1852
2110
  <span class="status-dot"></span>
1853
2111
  <span id="statusText">Connected</span>
2112
+ <button id="retryBtn" class="retry-btn" style="display: none;" onclick="retryConnection()" title="Click to retry Ollama connection">
2113
+ <i class="fas fa-sync-alt"></i>
2114
+ </button>
1854
2115
  </div>
1855
2116
  <div class="ws-status disconnected" id="wsStatus" title="Connecting...">
1856
2117
  <i class="fas fa-bolt"></i>
@@ -1897,6 +2158,12 @@
1897
2158
  <div class="stat-value" id="eventsCount">0</div>
1898
2159
  <div class="stat-label">Timeline Events</div>
1899
2160
  </div>
2161
+ <div class="stat-card" style="--card-accent: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)">
2162
+ <div class="stat-icon" style="background: rgba(63, 185, 80, 0.15); color: var(--accent-green);"><i class="fas fa-heartbeat"></i></div>
2163
+ <div class="stat-value" id="healthScore">--</div>
2164
+ <div class="stat-label">Health Score</div>
2165
+ <div class="stat-change positive" id="healthAtRisk">0 at risk</div>
2166
+ </div>
1900
2167
  </div>
1901
2168
 
1902
2169
  <!-- Tabs -->
@@ -1931,6 +2198,9 @@
1931
2198
  <div class="tab" data-tab="graph" onclick="switchTab('graph')">
1932
2199
  <i class="fas fa-project-diagram"></i> Graph
1933
2200
  </div>
2201
+ <div class="tab" data-tab="maintenance" onclick="switchTab('maintenance')">
2202
+ <i class="fas fa-tools"></i> Maintenance
2203
+ </div>
1934
2204
  </div>
1935
2205
 
1936
2206
  <!-- Agents Tab -->
@@ -2275,6 +2545,81 @@
2275
2545
  </div>
2276
2546
  </div>
2277
2547
  </div>
2548
+
2549
+ <!-- Maintenance Tab -->
2550
+ <div class="tab-content" id="maintenance-tab">
2551
+ <div class="section-header">
2552
+ <div class="section-title"><i class="fas fa-tools"></i> System Maintenance</div>
2553
+ <div class="section-actions">
2554
+ <button class="btn btn-secondary" onclick="loadMaintenanceData()"><i class="fas fa-sync"></i> Refresh All</button>
2555
+ </div>
2556
+ </div>
2557
+ <div class="maintenance-grid">
2558
+ <div class="maintenance-card decay-card">
2559
+ <div class="maintenance-card-header">
2560
+ <div class="maintenance-card-icon" style="background: rgba(63, 185, 80, 0.15); color: var(--accent-green);"><i class="fas fa-hourglass-half"></i></div>
2561
+ <div>
2562
+ <div class="maintenance-card-title">Memory Decay</div>
2563
+ <div class="maintenance-card-subtitle">Type-based memory lifecycle management</div>
2564
+ </div>
2565
+ </div>
2566
+ <div class="maintenance-stats" id="decayStats">
2567
+ <div class="maintenance-stat"><div class="maintenance-stat-value green" id="decayPermanent">--</div><div class="maintenance-stat-label">Permanent</div></div>
2568
+ <div class="maintenance-stat"><div class="maintenance-stat-value blue" id="decayDecayable">--</div><div class="maintenance-stat-label">Decayable</div></div>
2569
+ <div class="maintenance-stat"><div class="maintenance-stat-value yellow" id="decayAtRisk">--</div><div class="maintenance-stat-label">At Risk</div></div>
2570
+ <div class="maintenance-stat"><div class="maintenance-stat-value red" id="decayArchived">--</div><div class="maintenance-stat-label">Archived</div></div>
2571
+ </div>
2572
+ <div id="decayTypeBreakdown" style="margin-bottom: 16px;"></div>
2573
+ <div class="maintenance-actions">
2574
+ <button class="maintenance-btn primary" id="runDecayBtn" onclick="runDecayMaintenance()"><span class="spinner-sm"></span><i class="fas fa-play"></i> Run Decay Maintenance</button>
2575
+ </div>
2576
+ <div class="maintenance-last-run" id="decayLastRun"><i class="fas fa-clock"></i> <span>Last run: Never</span></div>
2577
+ </div>
2578
+ <div class="maintenance-card dedup-card">
2579
+ <div class="maintenance-card-header">
2580
+ <div class="maintenance-card-icon" style="background: rgba(88, 166, 255, 0.15); color: var(--accent-blue);"><i class="fas fa-clone"></i></div>
2581
+ <div>
2582
+ <div class="maintenance-card-title">Deduplication</div>
2583
+ <div class="maintenance-card-subtitle">Find and merge duplicate memories</div>
2584
+ </div>
2585
+ </div>
2586
+ <div class="maintenance-stats" id="dedupStats">
2587
+ <div class="maintenance-stat"><div class="maintenance-stat-value cyan" id="dedupClusters">--</div><div class="maintenance-stat-label">Clusters</div></div>
2588
+ <div class="maintenance-stat"><div class="maintenance-stat-value purple" id="dedupPairs">--</div><div class="maintenance-stat-label">Pairs Found</div></div>
2589
+ <div class="maintenance-stat"><div class="maintenance-stat-value green" id="dedupAutoMerged">--</div><div class="maintenance-stat-label">Auto-Merged</div></div>
2590
+ </div>
2591
+ <div class="maintenance-actions">
2592
+ <button class="maintenance-btn primary" id="scanDedupBtn" onclick="scanDuplicates()"><span class="spinner-sm"></span><i class="fas fa-search"></i> Scan Duplicates</button>
2593
+ <div style="display: flex; align-items: center; gap: 8px; margin-left: auto;">
2594
+ <label style="font-size: 12px; color: var(--text-secondary);">Threshold:</label>
2595
+ <input type="range" id="dedupThreshold" min="85" max="99" value="92" style="width: 80px; accent-color: var(--accent-blue);" oninput="document.getElementById('dedupThresholdVal').textContent = this.value + '%'">
2596
+ <span id="dedupThresholdVal" style="font-size: 12px; color: var(--accent-blue); font-weight: 600; min-width: 32px;">92%</span>
2597
+ </div>
2598
+ </div>
2599
+ <div class="dedup-results" id="dedupResults"></div>
2600
+ </div>
2601
+ <div class="maintenance-card tier1-card">
2602
+ <div class="maintenance-card-header">
2603
+ <div class="maintenance-card-icon" style="background: rgba(163, 113, 247, 0.15); color: var(--accent-purple);"><i class="fas fa-file-code"></i></div>
2604
+ <div>
2605
+ <div class="maintenance-card-title">Tier 1 Context (CLAUDE.md)</div>
2606
+ <div class="maintenance-card-subtitle">Auto-generate startup context from top memories</div>
2607
+ </div>
2608
+ </div>
2609
+ <div class="maintenance-stats" id="tier1Stats">
2610
+ <div class="maintenance-stat"><div class="maintenance-stat-value purple" id="tier1Entries">--</div><div class="maintenance-stat-label">Entries</div></div>
2611
+ <div class="maintenance-stat"><div class="maintenance-stat-value blue" id="tier1Categories">--</div><div class="maintenance-stat-label">Categories</div></div>
2612
+ <div class="maintenance-stat"><div class="maintenance-stat-value cyan" id="tier1CharCount">--</div><div class="maintenance-stat-label">Characters</div></div>
2613
+ </div>
2614
+ <div class="maintenance-actions">
2615
+ <button class="maintenance-btn" id="previewTier1Btn" onclick="previewTier1()"><span class="spinner-sm"></span><i class="fas fa-eye"></i> Preview</button>
2616
+ <button class="maintenance-btn success" id="generateTier1Btn" onclick="generateTier1()"><span class="spinner-sm"></span><i class="fas fa-file-export"></i> Generate & Write</button>
2617
+ </div>
2618
+ <div class="tier1-preview" id="tier1Preview"></div>
2619
+ <div class="maintenance-last-run" id="tier1LastRun"><i class="fas fa-clock"></i> <span>Last generated: Never</span></div>
2620
+ </div>
2621
+ </div>
2622
+ </div>
2278
2623
  </main>
2279
2624
 
2280
2625
  <div class="toast-container" id="toastContainer"></div>
@@ -2433,10 +2778,84 @@
2433
2778
  }
2434
2779
 
2435
2780
  function getActivityIcon(type) {
2436
- const icons = { context: 'clock', embed: 'microchip', store: 'save', search: 'search' };
2781
+ const icons = { context: 'clock', embed: 'microchip', store: 'save', search: 'search', timeline: 'stream', decision: 'lightbulb', observation: 'eye', action: 'cog', error: 'exclamation-triangle', user_request: 'user', checkpoint: 'flag' };
2437
2782
  return icons[type] || 'circle';
2438
2783
  }
2439
2784
 
2785
+ let activityLoaded = false;
2786
+
2787
+ async function loadActivity() {
2788
+ // Only load historical data once; after that, WebSocket keeps it updated
2789
+ if (activityLoaded && activityItems.length > 0) {
2790
+ renderActivityFeed();
2791
+ return;
2792
+ }
2793
+ const container = document.getElementById('activityItems');
2794
+ container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
2795
+ try {
2796
+ let url = `${API_URL}/api/timeline?limit=30`;
2797
+ if (currentProject) {
2798
+ url += `&project_path=${encodeURIComponent(currentProject)}`;
2799
+ }
2800
+ const response = await fetch(url);
2801
+ const data = await response.json();
2802
+ if (data.success && data.events && data.events.length > 0) {
2803
+ // Map timeline events to activity items (without duplicating existing live items)
2804
+ const existingTitles = new Set(activityItems.map(a => a.title + a.detail));
2805
+ const historicalItems = data.events
2806
+ .filter(e => !existingTitles.has((e.event_type === 'decision' ? 'Decision' : e.event_type === 'observation' ? 'Observation' : e.event_type === 'action' ? 'Action' : e.event_type === 'error' ? 'Error' : 'Timeline Event') + (e.summary || '')))
2807
+ .map(e => {
2808
+ const typeMap = {
2809
+ 'decision': { actType: 'context', title: 'Decision' },
2810
+ 'observation': { actType: 'search', title: 'Observation' },
2811
+ 'action': { actType: 'store', title: 'Action' },
2812
+ 'user_request': { actType: 'context', title: 'User Request' },
2813
+ 'error': { actType: 'embed', title: 'Error' },
2814
+ 'checkpoint': { actType: 'context', title: 'Checkpoint' },
2815
+ 'outcome': { actType: 'store', title: 'Outcome' },
2816
+ 'thinking': { actType: 'search', title: 'Thinking' }
2817
+ };
2818
+ const mapped = typeMap[e.event_type] || { actType: 'context', title: e.event_type || 'Event' };
2819
+ return {
2820
+ type: mapped.actType,
2821
+ title: mapped.title,
2822
+ detail: (e.summary || '').substring(0, 100),
2823
+ time: new Date(e.created_at)
2824
+ };
2825
+ });
2826
+ // Merge: live items first, then historical (avoid duplicates)
2827
+ const merged = [...activityItems, ...historicalItems];
2828
+ // Sort by time descending
2829
+ merged.sort((a, b) => b.time - a.time);
2830
+ // Deduplicate by title+detail
2831
+ const seen = new Set();
2832
+ activityItems = merged.filter(item => {
2833
+ const key = item.title + item.detail;
2834
+ if (seen.has(key)) return false;
2835
+ seen.add(key);
2836
+ return true;
2837
+ }).slice(0, 50);
2838
+ activityLoaded = true;
2839
+ renderActivityFeed();
2840
+ } else {
2841
+ activityLoaded = true;
2842
+ renderActivityFeed();
2843
+ }
2844
+ } catch (e) {
2845
+ console.error('Error loading activity:', e);
2846
+ container.innerHTML = `<div style="padding: 40px; text-align: center; color: var(--text-secondary);">
2847
+ <i class="fas fa-exclamation-triangle" style="font-size: 32px; opacity: 0.3; margin-bottom: 12px;"></i>
2848
+ <div>Failed to load activity</div>
2849
+ </div>`;
2850
+ }
2851
+ }
2852
+
2853
+ async function loadActivityIfActive() {
2854
+ if (document.getElementById('activity-tab').classList.contains('active')) {
2855
+ await loadActivity();
2856
+ }
2857
+ }
2858
+
2440
2859
  function formatRelativeTime(date) {
2441
2860
  const seconds = Math.floor((new Date() - date) / 1000);
2442
2861
  if (seconds < 60) return 'just now';
@@ -2493,6 +2912,8 @@
2493
2912
  initWebSocket();
2494
2913
  setInterval(sendHeartbeat, 30000);
2495
2914
  renderVectorPreview(Array.from({ length: 100 }, () => Math.random() * 0.5));
2915
+ // Load health stats for top bar (non-blocking)
2916
+ loadDecayStats().catch(() => {});
2496
2917
  });
2497
2918
 
2498
2919
  // Show/hide loading overlay
@@ -2583,12 +3004,15 @@
2583
3004
 
2584
3005
  // Hot reload all data for the project
2585
3006
  try {
3007
+ // Reset activity cache when project changes so it reloads
3008
+ activityLoaded = false;
2586
3009
  await Promise.all([
2587
3010
  loadProjectConfig(),
2588
3011
  loadStats(),
2589
3012
  loadMemoriesIfActive(),
2590
3013
  loadPatternsIfActive(),
2591
- loadTimelineIfActive()
3014
+ loadTimelineIfActive(),
3015
+ loadActivityIfActive()
2592
3016
  ]);
2593
3017
  renderAgents();
2594
3018
  renderMcps();
@@ -2870,6 +3294,7 @@
2870
3294
  if (tabId === 'timeline') loadTimeline();
2871
3295
  if (tabId === 'memories') loadMemories();
2872
3296
  if (tabId === 'patterns') loadPatterns();
3297
+ if (tabId === 'activity') loadActivity();
2873
3298
  }
2874
3299
 
2875
3300
  // Timeline
@@ -2880,30 +3305,31 @@
2880
3305
  const container = document.getElementById('timelineContainer');
2881
3306
  container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
2882
3307
  try {
2883
- const sessionRes = await fetch(`${API_URL}/api/sessions/${encodeURIComponent(currentProject)}`);
2884
- const sessionData = await sessionRes.json();
2885
- if (!sessionData.sessions?.length) {
2886
- container.innerHTML = `<div class="timeline-empty"><i class="fas fa-stream"></i><div>No timeline events yet</div><div style="font-size: 12px; margin-top: 8px;">Events will appear here as you work</div></div>`;
2887
- return;
3308
+ // Use /api/timeline directly - queries the DB with optional filters.
3309
+ // This replaces the broken session-lookup -> A2A -> skill chain which
3310
+ // failed when currentProject was null ("All Projects") or when
3311
+ // session_state rows had no matching project_path.
3312
+ let url = `${API_URL}/api/timeline?limit=100`;
3313
+ if (currentProject) {
3314
+ url += `&project_path=${encodeURIComponent(currentProject)}`;
2888
3315
  }
2889
- const sessionId = sessionData.sessions[0].session_id;
2890
- const response = await fetch(`${API_URL}/a2a`, {
2891
- method: 'POST',
2892
- headers: { 'Content-Type': 'application/json' },
2893
- body: JSON.stringify({
2894
- jsonrpc: '2.0',
2895
- id: Date.now(),
2896
- method: 'tasks/send',
2897
- params: { metadata: { skill_id: 'timeline_get', params: { session_id: sessionId, limit: 50 } } }
2898
- })
2899
- });
3316
+ if (timelineFilter && timelineFilter !== 'all') {
3317
+ url += `&event_type=${encodeURIComponent(timelineFilter)}`;
3318
+ }
3319
+ const response = await fetch(url);
2900
3320
  const data = await response.json();
2901
- const result = JSON.parse(data.result?.artifacts?.[0]?.parts?.[0]?.text || '{}');
2902
- if (!result.events?.length) {
2903
- container.innerHTML = `<div class="timeline-empty"><i class="fas fa-stream"></i><div>No timeline events yet</div></div>`;
3321
+ const events = data.events || [];
3322
+ if (!events.length) {
3323
+ container.innerHTML = `<div class="timeline-empty"><i class="fas fa-stream"></i><div>No timeline events yet</div><div style="font-size: 12px; margin-top: 8px;">Events will appear here as you work</div></div>`;
2904
3324
  return;
2905
3325
  }
2906
- const chains = buildCausalChains(result.events);
3326
+ // Parse details field if stored as JSON string
3327
+ events.forEach(e => {
3328
+ if (typeof e.details === 'string') {
3329
+ try { e.details = JSON.parse(e.details); } catch (_) {}
3330
+ }
3331
+ });
3332
+ const chains = buildCausalChains(events);
2907
3333
  const filterHtml = `
2908
3334
  <div class="timeline-filters">
2909
3335
  <div class="timeline-filter-chip ${timelineFilter === 'all' ? 'active' : ''}" onclick="filterTimeline('all')"><i class="fas fa-layer-group"></i> All</div>
@@ -3222,32 +3648,81 @@
3222
3648
 
3223
3649
  // Health check
3224
3650
  async function checkHealth() {
3651
+ const indicator = document.getElementById('statusIndicator');
3652
+ const text = document.getElementById('statusText');
3653
+ const retryBtn = document.getElementById('retryBtn');
3654
+
3225
3655
  try {
3226
3656
  const response = await fetch(`${API_URL}/health`);
3227
3657
  const data = await response.json();
3228
- const indicator = document.getElementById('statusIndicator');
3229
- const text = document.getElementById('statusText');
3658
+
3230
3659
  if (data.status === 'healthy') {
3231
3660
  indicator.style.borderColor = 'rgba(63, 185, 80, 0.3)';
3232
3661
  indicator.style.background = 'rgba(63, 185, 80, 0.1)';
3233
3662
  indicator.style.color = 'var(--accent-green)';
3234
3663
  text.textContent = 'Connected';
3664
+ retryBtn.style.display = 'none';
3665
+ } else if (data.status === 'degraded') {
3666
+ // Degraded mode - server is connected but some features limited
3667
+ indicator.style.borderColor = 'rgba(227, 179, 65, 0.3)';
3668
+ indicator.style.background = 'rgba(227, 179, 65, 0.1)';
3669
+ indicator.style.color = 'var(--accent-yellow)';
3670
+ text.textContent = 'Degraded';
3671
+ text.title = 'Server connected but Ollama is unavailable. Click retry to reconnect.';
3672
+ retryBtn.style.display = 'inline-block';
3235
3673
  } else {
3236
3674
  throw new Error('Unhealthy');
3237
3675
  }
3238
3676
  } catch (e) {
3239
- const indicator = document.getElementById('statusIndicator');
3240
- const text = document.getElementById('statusText');
3241
3677
  indicator.style.borderColor = 'rgba(248, 81, 73, 0.3)';
3242
3678
  indicator.style.background = 'rgba(248, 81, 73, 0.1)';
3243
3679
  indicator.style.color = 'var(--accent-red)';
3244
3680
  text.textContent = 'Disconnected';
3681
+ retryBtn.style.display = 'inline-block';
3682
+ }
3683
+ }
3684
+
3685
+ async function retryConnection() {
3686
+ const retryBtn = document.getElementById('retryBtn');
3687
+ const text = document.getElementById('statusText');
3688
+
3689
+ // Show spinning animation
3690
+ retryBtn.classList.add('spinning');
3691
+ text.textContent = 'Retrying...';
3692
+
3693
+ try {
3694
+ const response = await fetch(`${API_URL}/health/retry`, { method: 'POST' });
3695
+ const data = await response.json();
3696
+
3697
+ if (data.success) {
3698
+ showToast('Connection restored!', 'success');
3699
+ } else {
3700
+ showToast(data.message || 'Still degraded', 'warning');
3701
+ }
3702
+ } catch (e) {
3703
+ showToast('Retry failed: ' + e.message, 'error');
3245
3704
  }
3705
+
3706
+ retryBtn.classList.remove('spinning');
3707
+ // Refresh health status
3708
+ await checkHealth();
3246
3709
  }
3247
3710
 
3248
3711
  setInterval(checkHealth, 10000);
3249
3712
  checkHealth();
3250
3713
 
3714
+ // Auto-retry in background every 30 seconds when degraded
3715
+ setInterval(async () => {
3716
+ const text = document.getElementById('statusText');
3717
+ if (text && (text.textContent === 'Degraded' || text.textContent === 'Disconnected')) {
3718
+ // Silent background retry
3719
+ try {
3720
+ await fetch(`${API_URL}/health/retry`, { method: 'POST' });
3721
+ await checkHealth();
3722
+ } catch (e) { /* ignore */ }
3723
+ }
3724
+ }, 30000);
3725
+
3251
3726
  // Memory Detail Modal
3252
3727
  function showMemoryDetail(index) {
3253
3728
  const memory = currentMemories[index];
@@ -3309,7 +3784,10 @@
3309
3784
  const time = new Date(s.ended_at || s.started_at).toLocaleTimeString();
3310
3785
  const pending = s.pending_count || 0;
3311
3786
  const project = s.project_name || s.project_path?.split(/[/\\]/).pop() || 'Unknown';
3312
- return `<option value="${s.session_id}">[${date} ${time}] ${project} - ${s.memory_count} memories (${pending} pending)</option>`;
3787
+ const rawGoal = (s.current_goal || '').replace(/<[^>]*>/g, '').trim();
3788
+ const goal = rawGoal ? ' - ' + rawGoal.substring(0, 60) + (rawGoal.length > 60 ? '...' : '') : '';
3789
+ const memLabel = s.memory_count > 0 ? `${s.memory_count} memories` : 'no memories yet';
3790
+ return `<option value="${s.session_id}">[${date} ${time}] ${project} - ${memLabel}${pending > 0 ? ` (${pending} pending)` : ''}${goal}</option>`;
3313
3791
  }).join('');
3314
3792
  } catch (e) {
3315
3793
  console.error('Error loading sessions:', e);
@@ -4168,6 +4646,195 @@
4168
4646
 
4169
4647
  // ===== End Knowledge Graph =====
4170
4648
 
4649
+ // ===== Maintenance Tab Functions =====
4650
+ let maintenanceDataLoaded = false;
4651
+
4652
+ async function loadMaintenanceData() {
4653
+ await Promise.all([loadDecayStats()]);
4654
+ maintenanceDataLoaded = true;
4655
+ }
4656
+
4657
+ async function loadDecayStats() {
4658
+ try {
4659
+ const response = await fetch(`${API_URL}/api/decay/stats`);
4660
+ const data = await response.json();
4661
+ if (data.success || data.permanent !== undefined) {
4662
+ const stats = data.data || data;
4663
+ document.getElementById('decayPermanent').textContent = stats.permanent ?? 0;
4664
+ document.getElementById('decayDecayable').textContent = stats.decayable ?? 0;
4665
+ document.getElementById('decayAtRisk').textContent = stats.at_risk ?? 0;
4666
+ document.getElementById('decayArchived').textContent = stats.archived ?? 0;
4667
+ const total = (stats.permanent || 0) + (stats.decayable || 0);
4668
+ const atRisk = stats.at_risk || 0;
4669
+ if (total > 0) {
4670
+ const healthPct = Math.round(((total - atRisk) / total) * 100);
4671
+ document.getElementById('healthScore').textContent = healthPct + '%';
4672
+ const healthEl = document.getElementById('healthAtRisk');
4673
+ healthEl.textContent = atRisk + ' at risk';
4674
+ if (atRisk > 0) {
4675
+ healthEl.className = 'stat-change';
4676
+ healthEl.style.background = 'rgba(210, 153, 34, 0.15)';
4677
+ healthEl.style.color = 'var(--accent-yellow)';
4678
+ } else {
4679
+ healthEl.className = 'stat-change positive';
4680
+ healthEl.style.background = '';
4681
+ healthEl.style.color = '';
4682
+ }
4683
+ }
4684
+ if (stats.by_type) { renderDecayTypeBreakdown(stats.by_type); }
4685
+ if (stats.last_run) {
4686
+ document.getElementById('decayLastRun').querySelector('span').textContent = 'Last run: ' + formatTimeAgo(stats.last_run);
4687
+ }
4688
+ }
4689
+ } catch (e) { console.error('Failed to load decay stats:', e); }
4690
+ }
4691
+
4692
+ function renderDecayTypeBreakdown(byType) {
4693
+ const container = document.getElementById('decayTypeBreakdown');
4694
+ if (!byType || Object.keys(byType).length === 0) { container.innerHTML = ''; return; }
4695
+ const typeColors = { decision: 'var(--accent-blue)', preference: 'var(--accent-pink)', code: 'var(--accent-green)', error: 'var(--accent-red)', session: 'var(--accent-cyan)', chunk: 'var(--accent-yellow)' };
4696
+ const typeIcons = { decision: 'lightbulb', preference: 'sliders-h', code: 'code', error: 'bug', session: 'clock', chunk: 'puzzle-piece' };
4697
+ let html = '<div style="display: flex; flex-wrap: wrap; gap: 8px;">';
4698
+ for (const [type, count] of Object.entries(byType)) {
4699
+ const color = typeColors[type] || 'var(--text-secondary)';
4700
+ const icon = typeIcons[type] || 'circle';
4701
+ html += `<div style="display: flex; align-items: center; gap: 6px; padding: 4px 10px; background: var(--bg-tertiary); border-radius: 8px; font-size: 12px;">
4702
+ <i class="fas fa-${icon}" style="color: ${color}; font-size: 10px;"></i>
4703
+ <span style="color: var(--text-secondary);">${type}</span>
4704
+ <span style="color: ${color}; font-weight: 600;">${count}</span>
4705
+ </div>`;
4706
+ }
4707
+ container.innerHTML = html + '</div>';
4708
+ }
4709
+
4710
+ async function runDecayMaintenance() {
4711
+ const btn = document.getElementById('runDecayBtn');
4712
+ btn.classList.add('loading');
4713
+ try {
4714
+ const response = await fetch(`${API_URL}/api/decay/run`, { method: 'POST' });
4715
+ const data = await response.json();
4716
+ if (response.ok) {
4717
+ const result = data.data || data;
4718
+ showToast(`Decay complete: ${result.updated_count || result.updated || 0} updated, ${result.archived_count || result.archived || 0} archived`, 'success');
4719
+ await loadDecayStats();
4720
+ } else { showToast('Decay failed: ' + (data.error || 'Unknown error'), 'error'); }
4721
+ } catch (e) {
4722
+ console.error('Decay maintenance failed:', e);
4723
+ showToast('Failed to run decay maintenance', 'error');
4724
+ } finally { btn.classList.remove('loading'); }
4725
+ }
4726
+
4727
+ async function scanDuplicates() {
4728
+ const btn = document.getElementById('scanDedupBtn');
4729
+ btn.classList.add('loading');
4730
+ const threshold = (document.getElementById('dedupThreshold').value / 100).toFixed(2);
4731
+ try {
4732
+ let url = `${API_URL}/api/curator/duplicates?similarity_threshold=${threshold}`;
4733
+ if (currentProject) { url += `&project_path=${encodeURIComponent(currentProject)}`; }
4734
+ const response = await fetch(url);
4735
+ const data = await response.json();
4736
+ const results = data.data || data;
4737
+ const duplicates = results.duplicates || results.clusters || results || [];
4738
+ const container = document.getElementById('dedupResults');
4739
+ document.getElementById('dedupClusters').textContent = duplicates.length || 0;
4740
+ let totalPairs = 0;
4741
+ if (Array.isArray(duplicates)) { duplicates.forEach(d => { totalPairs += (d.pairs || d.memories || []).length || 1; }); }
4742
+ document.getElementById('dedupPairs').textContent = totalPairs;
4743
+ if (!duplicates.length) {
4744
+ container.innerHTML = `<div style="text-align: center; padding: 24px; color: var(--text-secondary);"><i class="fas fa-check-circle" style="color: var(--accent-green); font-size: 24px; margin-bottom: 8px;"></i><div>No duplicates found at ${Math.round(threshold * 100)}% threshold</div></div>`;
4745
+ showToast('No duplicates found', 'success');
4746
+ return;
4747
+ }
4748
+ let html = '';
4749
+ duplicates.forEach((cluster, i) => {
4750
+ const similarity = cluster.similarity || cluster.score || 0;
4751
+ const simPct = Math.round(similarity * 100);
4752
+ const scoreClass = simPct >= 95 ? 'high' : 'medium';
4753
+ const memories = cluster.memories || cluster.pairs || [cluster];
4754
+ const content1 = (cluster.content_1 || memories[0]?.content || '').substring(0, 80);
4755
+ const content2 = (cluster.content_2 || memories[1]?.content || '').substring(0, 80);
4756
+ html += `<div class="dedup-cluster"><div class="dedup-cluster-header"><span style="font-size: 12px; color: var(--text-secondary);">Cluster ${i + 1}</span><span class="dedup-cluster-score ${scoreClass}">${simPct}% similar</span></div><div class="dedup-cluster-item"><i class="fas fa-file-alt" style="color: var(--accent-blue);"></i> ${escapeHtml(content1)}...</div><div class="dedup-cluster-item"><i class="fas fa-copy" style="color: var(--accent-yellow);"></i> ${escapeHtml(content2)}...</div></div>`;
4757
+ });
4758
+ container.innerHTML = html;
4759
+ showToast(`Found ${duplicates.length} duplicate cluster(s)`, 'info');
4760
+ } catch (e) {
4761
+ console.error('Duplicate scan failed:', e);
4762
+ showToast('Failed to scan duplicates', 'error');
4763
+ } finally { btn.classList.remove('loading'); }
4764
+ }
4765
+
4766
+ function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }
4767
+
4768
+ async function previewTier1() {
4769
+ const btn = document.getElementById('previewTier1Btn');
4770
+ btn.classList.add('loading');
4771
+ try {
4772
+ const body = { dry_run: true };
4773
+ if (currentProject) body.project_path = currentProject;
4774
+ const response = await fetch(`${API_URL}/api/tier1/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
4775
+ const data = await response.json();
4776
+ const result = data.data || data;
4777
+ const preview = document.getElementById('tier1Preview');
4778
+ if (result.content || result.tier1_content) {
4779
+ const content = result.content || result.tier1_content || '';
4780
+ preview.textContent = content;
4781
+ preview.classList.add('visible');
4782
+ const lines = content.split('\n').filter(l => l.trim());
4783
+ const categories = content.match(/^## /gm) || content.match(/^### /gm) || [];
4784
+ document.getElementById('tier1Entries').textContent = result.entry_count || lines.filter(l => l.startsWith('- ')).length || 0;
4785
+ document.getElementById('tier1Categories').textContent = result.category_count || categories.length || 0;
4786
+ document.getElementById('tier1CharCount').textContent = content.length;
4787
+ showToast('Preview generated', 'success');
4788
+ } else {
4789
+ preview.textContent = 'No content generated. Store more high-importance memories first.';
4790
+ preview.classList.add('visible');
4791
+ showToast('No tier 1 content available', 'info');
4792
+ }
4793
+ } catch (e) {
4794
+ console.error('Tier 1 preview failed:', e);
4795
+ showToast('Failed to generate preview', 'error');
4796
+ } finally { btn.classList.remove('loading'); }
4797
+ }
4798
+
4799
+ async function generateTier1() {
4800
+ const btn = document.getElementById('generateTier1Btn');
4801
+ btn.classList.add('loading');
4802
+ try {
4803
+ const body = { dry_run: false };
4804
+ if (currentProject) body.project_path = currentProject;
4805
+ const response = await fetch(`${API_URL}/api/tier1/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
4806
+ const data = await response.json();
4807
+ const result = data.data || data;
4808
+ if (result.success !== false) {
4809
+ const content = result.content || result.tier1_content || '';
4810
+ const entries = content.split('\n').filter(l => l.startsWith('- ')).length;
4811
+ document.getElementById('tier1Entries').textContent = result.entry_count || entries;
4812
+ document.getElementById('tier1CharCount').textContent = content.length;
4813
+ document.getElementById('tier1LastRun').querySelector('span').textContent = 'Last generated: Just now';
4814
+ showToast(`CLAUDE.md updated with ${entries} entries`, 'success');
4815
+ } else { showToast('Generation failed: ' + (result.error || 'Unknown error'), 'error'); }
4816
+ } catch (e) {
4817
+ console.error('Tier 1 generation failed:', e);
4818
+ showToast('Failed to generate CLAUDE.md content', 'error');
4819
+ } finally { btn.classList.remove('loading'); }
4820
+ }
4821
+
4822
+ function formatTimeAgo(dateStr) {
4823
+ if (!dateStr) return 'Never';
4824
+ try {
4825
+ const date = new Date(dateStr);
4826
+ const now = new Date();
4827
+ const diffMs = now - date;
4828
+ const diffMins = Math.floor(diffMs / 60000);
4829
+ if (diffMins < 1) return 'Just now';
4830
+ if (diffMins < 60) return diffMins + 'm ago';
4831
+ const diffHours = Math.floor(diffMins / 60);
4832
+ if (diffHours < 24) return diffHours + 'h ago';
4833
+ return Math.floor(diffHours / 24) + 'd ago';
4834
+ } catch (e) { return dateStr; }
4835
+ }
4836
+ // ===== End Maintenance Tab =====
4837
+
4171
4838
  // Hook into tab switching
4172
4839
  const originalSwitchTab = window.switchTab;
4173
4840
  window.switchTab = function(tab) {
@@ -4182,6 +4849,9 @@
4182
4849
  loadCuratorStatus();
4183
4850
  }, 100);
4184
4851
  }
4852
+ if (tab === 'maintenance') {
4853
+ loadMaintenanceData();
4854
+ }
4185
4855
  };
4186
4856
 
4187
4857
  // Check URL hash for deep linking to review