memorix 1.0.3 → 1.0.4

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.
@@ -96,7 +96,8 @@ const i18n = {
96
96
  // Team
97
97
  teamTitle: 'Team',
98
98
  teamSubtitle: 'Multi-agent collaboration overview',
99
- teamNoData: 'Team features available when using HTTP transport (memorix serve-http)',
99
+ teamNoData: 'Team features require HTTP transport',
100
+ teamNoDataHint: 'Team collaboration (agents, file locks, tasks) requires the HTTP transport. Start it with:',
100
101
  teamActiveAgents: 'Active Agents',
101
102
  teamLockedFiles: 'Locked Files',
102
103
  teamTasks: 'Tasks',
@@ -105,11 +106,81 @@ const i18n = {
105
106
  teamLocks: 'File Locks',
106
107
  teamTaskBoard: 'Task Board',
107
108
 
109
+ // Overview (new)
110
+ memoryControlPlane: 'Memory Control Plane',
111
+ memoriesAcross: 'memories across',
112
+ entitiesUnit: 'entities',
113
+ gitMemories: 'Git Memories',
114
+ agentMemories: 'Agent Memories',
115
+ thisWeek: 'this week',
116
+ hooksAndMcp: 'hooks + MCP',
117
+ memorySources: 'Memory Sources',
118
+ retentionHealth: 'Retention Health',
119
+ sourceGit: 'Git',
120
+ sourceAgent: 'Agent',
121
+ sourceManual: 'Manual',
122
+
123
+ // Git Memory
124
+ gitMemoryTitle: 'Git Memory',
125
+ gitMemorySubtitle: 'memories from git commits — ground truth, immutable',
126
+ totalGitMemories: 'Total Git Memories',
127
+ uniqueCommits: 'Unique Commits',
128
+ typeCoverage: 'Type Coverage',
129
+ noGitMemory: 'No Git Memory',
130
+ noGitMemoryDesc: 'Install the post-commit hook with: memorix git-hook-install',
131
+ noGitMemoriesYet: 'No Git Memories Yet',
132
+ noGitMemoriesHint: 'Install the post-commit hook to automatically capture git memories:',
133
+ recentGitMemories: 'Recent Git Memories',
134
+ commit: 'Commit',
135
+ created: 'Created',
136
+
137
+ // Config
138
+ configTitle: 'Config Provenance',
139
+ configSubtitle: 'Where every configuration value comes from — two files, two roles',
140
+ configSourceMatrix: 'Config Source Matrix',
141
+ configHint: '= behavior config',
142
+ configHintEnv: '= secrets only',
143
+ valueProvenance: 'Value Provenance',
144
+ trackedValues: 'tracked values',
145
+ configKey: 'Key',
146
+ configValue: 'Value',
147
+ configSource: 'Source',
148
+ configStatus: 'Status',
149
+ moveToEnv: 'Move to .env',
150
+ configUnavailable: 'Config Unavailable',
151
+ configUnavailableDesc: 'Could not load configuration data',
152
+
153
+ // Identity
154
+ identityTitle: 'Project Identity Health',
155
+ identitySubtitle: 'Project ID stability, aliases, and cross-agent consistency',
156
+ healthStatus: 'Health Status',
157
+ healthy: '✓ Healthy',
158
+ unhealthy: '⚠ Issues',
159
+ knownProjectIds: 'Known Project IDs',
160
+ aliasGroups: 'Alias Groups',
161
+ dirtyIds: 'Dirty IDs',
162
+ currentIdentity: 'Current Identity',
163
+ currentProjectId: 'Current Project ID',
164
+ canonicalId: 'Canonical ID',
165
+ aliases: 'Aliases',
166
+ healthIssues: 'Health Issues',
167
+ noIssues: 'No issues detected. Project identity is clean.',
168
+ dirtyProjectIds: 'Dirty Project IDs',
169
+ allKnownProjectIds: 'All Known Project IDs',
170
+ tagCurrent: 'current',
171
+ tagCanonical: 'canonical',
172
+ tagDirty: 'dirty',
173
+ identityUnavailable: 'Identity Unavailable',
174
+ identityUnavailableDesc: 'Could not load project identity data',
175
+
108
176
  // Nav tooltips
109
177
  navDashboard: 'Dashboard',
178
+ navGitMemory: 'Git Memory',
110
179
  navGraph: 'Knowledge Graph',
111
180
  navObservations: 'Observations',
112
181
  navRetention: 'Retention',
182
+ navConfig: 'Config',
183
+ navIdentity: 'Identity',
113
184
  navSessions: 'Sessions',
114
185
  navTeam: 'Team',
115
186
  },
@@ -201,7 +272,8 @@ const i18n = {
201
272
  // Team
202
273
  teamTitle: '团队',
203
274
  teamSubtitle: '多 Agent 协作概览',
204
- teamNoData: '使用 HTTP 传输时可用团队功能 (memorix serve-http)',
275
+ teamNoData: '团队功能需要 HTTP 传输',
276
+ teamNoDataHint: '团队协作(Agent 注册、文件锁、任务看板)需要 HTTP 传输模式。使用以下命令启动:',
205
277
  teamActiveAgents: '活跃 Agent',
206
278
  teamLockedFiles: '锁定文件',
207
279
  teamTasks: '任务',
@@ -210,11 +282,81 @@ const i18n = {
210
282
  teamLocks: '文件锁',
211
283
  teamTaskBoard: '任务看板',
212
284
 
285
+ // Overview (new)
286
+ memoryControlPlane: '记忆控制台',
287
+ memoriesAcross: '条记忆,分布于',
288
+ entitiesUnit: '个实体',
289
+ gitMemories: 'Git 记忆',
290
+ agentMemories: 'Agent 记忆',
291
+ thisWeek: '本周新增',
292
+ hooksAndMcp: 'hooks + MCP',
293
+ memorySources: '记忆来源',
294
+ retentionHealth: '衰减健康度',
295
+ sourceGit: 'Git',
296
+ sourceAgent: 'Agent',
297
+ sourceManual: '手动',
298
+
299
+ // Git Memory
300
+ gitMemoryTitle: 'Git 记忆',
301
+ gitMemorySubtitle: '来自 git 提交的记忆 — 不可变的事实源',
302
+ totalGitMemories: 'Git 记忆总数',
303
+ uniqueCommits: '唯一提交数',
304
+ typeCoverage: '类型覆盖',
305
+ noGitMemory: '暂无 Git 记忆',
306
+ noGitMemoryDesc: '使用以下命令安装 post-commit hook: memorix git-hook-install',
307
+ noGitMemoriesYet: '暂无 Git 记忆',
308
+ noGitMemoriesHint: '安装 post-commit hook 以自动捕获 git 记忆:',
309
+ recentGitMemories: '最近的 Git 记忆',
310
+ commit: '提交',
311
+ created: '创建时间',
312
+
313
+ // Config
314
+ configTitle: '配置溯源',
315
+ configSubtitle: '每个配置值的来源 — 两个文件,两种角色',
316
+ configSourceMatrix: '配置源矩阵',
317
+ configHint: '= 行为配置',
318
+ configHintEnv: '= 仅存放密钥',
319
+ valueProvenance: '值的溯源',
320
+ trackedValues: '个追踪值',
321
+ configKey: '键',
322
+ configValue: '值',
323
+ configSource: '来源',
324
+ configStatus: '状态',
325
+ moveToEnv: '应移至 .env',
326
+ configUnavailable: '配置不可用',
327
+ configUnavailableDesc: '无法加载配置数据',
328
+
329
+ // Identity
330
+ identityTitle: '项目身份健康度',
331
+ identitySubtitle: '项目 ID 稳定性、别名和跨 Agent 一致性',
332
+ healthStatus: '健康状态',
333
+ healthy: '✓ 健康',
334
+ unhealthy: '⚠ 存在问题',
335
+ knownProjectIds: '已知项目 ID',
336
+ aliasGroups: '别名组',
337
+ dirtyIds: '脏 ID',
338
+ currentIdentity: '当前身份',
339
+ currentProjectId: '当前项目 ID',
340
+ canonicalId: '标准 ID',
341
+ aliases: '别名',
342
+ healthIssues: '健康问题',
343
+ noIssues: '未检测到问题。项目身份状态良好。',
344
+ dirtyProjectIds: '脏项目 ID',
345
+ allKnownProjectIds: '所有已知项目 ID',
346
+ tagCurrent: '当前',
347
+ tagCanonical: '标准',
348
+ tagDirty: '脏',
349
+ identityUnavailable: '身份信息不可用',
350
+ identityUnavailableDesc: '无法加载项目身份数据',
351
+
213
352
  // Nav tooltips
214
353
  navDashboard: '仪表盘',
354
+ navGitMemory: 'Git 记忆',
215
355
  navGraph: '知识图谱',
216
356
  navObservations: '观察记录',
217
357
  navRetention: '记忆衰减',
358
+ navConfig: '配置溯源',
359
+ navIdentity: '身份健康',
218
360
  navSessions: '会话',
219
361
  navTeam: '团队',
220
362
  },
@@ -303,7 +445,7 @@ document.addEventListener('DOMContentLoaded', () => {
303
445
  // Router & Navigation
304
446
  // ============================================================
305
447
 
306
- const pages = ['dashboard', 'graph', 'observations', 'retention', 'sessions', 'team'];
448
+ const pages = ['dashboard', 'git-memory', 'graph', 'observations', 'retention', 'config', 'identity', 'sessions', 'team'];
307
449
  let currentPage = 'dashboard';
308
450
 
309
451
  function navigate(page) {
@@ -485,9 +627,12 @@ async function loadPage(page) {
485
627
 
486
628
  switch (page) {
487
629
  case 'dashboard': await loadDashboard(); break;
630
+ case 'git-memory': await loadGitMemory(); break;
488
631
  case 'graph': await loadGraph(); break;
489
632
  case 'observations': await loadObservations(); break;
490
633
  case 'retention': await loadRetention(); break;
634
+ case 'config': await loadConfig(); break;
635
+ case 'identity': await loadIdentity(); break;
491
636
  case 'sessions': await loadSessions(); break;
492
637
  case 'team': await loadTeam(); break;
493
638
  }
@@ -509,6 +654,10 @@ async function loadDashboard() {
509
654
  }
510
655
 
511
656
  const projectLabel = project ? project.name : '';
657
+ const sc = stats.sourceCounts || { git: 0, agent: 0, manual: 0 };
658
+ const totalObs = stats.observations || 0;
659
+ const gs = stats.gitSummary || { total: 0, recentWeek: 0, recentMemories: [] };
660
+ const rs = stats.retentionSummary || { active: 0, stale: 0, archive: 0, immune: 0 };
512
661
 
513
662
  const typeIcons = {
514
663
  'session-request': '🎯', gotcha: '🔴', 'problem-solution': '🟡',
@@ -516,45 +665,81 @@ async function loadDashboard() {
516
665
  'why-it-exists': '🟠', decision: '🟤', 'trade-off': '⚖️',
517
666
  };
518
667
 
519
- // Type distribution
520
668
  const typeEntries = Object.entries(stats.typeCounts || {}).sort((a, b) => b[1] - a[1]);
521
669
  const maxTypeCount = Math.max(...typeEntries.map(e => e[1]), 1);
522
670
 
671
+ // Source bar percentages
672
+ const srcTotal = Math.max(sc.git + sc.agent + sc.manual, 1);
673
+ const gitPct = Math.round(sc.git / srcTotal * 100);
674
+ const agentPct = Math.round(sc.agent / srcTotal * 100);
675
+ const manualPct = 100 - gitPct - agentPct;
676
+
523
677
  container.innerHTML = `
524
678
  <div class="page-header">
525
- <h1 class="page-title">${t('dashboard')} ${projectLabel ? `<span style="font-size: 14px; font-weight: 400; color: var(--text-muted); margin-left: 8px; padding: 2px 10px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: 6px; vertical-align: middle;">${escapeHtml(projectLabel)}</span>` : ''}</h1>
526
- <p class="page-subtitle">${t('dashboardSubtitle')}</p>
679
+ <h1 class="page-title">${t('memoryControlPlane')} ${projectLabel ? `<span class="overview-project-badge">${escapeHtml(projectLabel)}</span>` : ''}</h1>
680
+ <p class="page-subtitle">${totalObs} ${t('memoriesAcross')} ${stats.entities} ${t('entitiesUnit')}</p>
527
681
  </div>
528
682
 
529
683
  <div class="stats-grid">
530
- <div class="stat-card" data-accent="cyan">
531
- <div class="stat-label">${t('entities')}</div>
532
- <div class="stat-value">${stats.entities}</div>
684
+ <div class="stat-card" data-accent="green">
685
+ <div class="stat-label">${t('gitMemories')}</div>
686
+ <div class="stat-value">${sc.git}</div>
687
+ <div class="stat-sub">${gs.recentWeek} ${t('thisWeek')}</div>
533
688
  </div>
534
689
  <div class="stat-card" data-accent="purple">
535
- <div class="stat-label">${t('relations')}</div>
536
- <div class="stat-value">${stats.relations}</div>
537
- </div>
538
- <div class="stat-card" data-accent="amber">
539
- <div class="stat-label">${t('observations')}</div>
540
- <div class="stat-value">${stats.observations}</div>
690
+ <div class="stat-label">${t('agentMemories')}</div>
691
+ <div class="stat-value">${sc.agent}</div>
692
+ <div class="stat-sub">${t('hooksAndMcp')}</div>
541
693
  </div>
542
- <div class="stat-card" data-accent="green">
543
- <div class="stat-label">${t('nextId')}</div>
544
- <div class="stat-value">#${stats.nextId}</div>
694
+ <div class="stat-card" data-accent="cyan">
695
+ <div class="stat-label">${t('entities')}</div>
696
+ <div class="stat-value">${stats.entities}</div>
697
+ <div class="stat-sub">${stats.relations} ${t('relations')}</div>
545
698
  </div>
546
- <div class="stat-card" data-accent="${stats.embedding?.enabled ? 'cyan' : 'amber'}">
699
+ <div class="stat-card" data-accent="${stats.embedding?.enabled ? 'blue' : 'amber'}">
547
700
  <div class="stat-label">${t('vectorSearch')}</div>
548
701
  <div class="stat-value" style="font-size: 18px;">${stats.embedding?.enabled ? '✓ ' + t('enabled') : t('fulltextOnly')}</div>
549
- ${stats.embedding?.provider ? `<div style="font-size: 10px; color: var(--text-muted); margin-top: 4px; font-family: var(--font-mono);">${stats.embedding.provider} (${stats.embedding.dimensions}d)</div>` : ''}
702
+ ${stats.embedding?.provider ? `<div class="stat-sub">${stats.embedding.provider} (${stats.embedding.dimensions}d)</div>` : ''}
550
703
  </div>
551
704
  </div>
552
705
 
553
- <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
554
- <div class="panel">
555
- <div class="panel-header">
556
- <span class="panel-title">${t('observationTypes')}</span>
706
+ <!-- Source Breakdown -->
707
+ <div class="overview-row">
708
+ <div class="panel" style="flex:1;">
709
+ <div class="panel-header"><span class="panel-title">${t('memorySources')}</span></div>
710
+ <div class="panel-body">
711
+ <div class="source-bar-container">
712
+ <div class="source-bar">
713
+ ${gitPct > 0 ? `<div class="source-bar-seg" style="width:${gitPct}%;background:var(--accent-green);" title="Git ${gitPct}%"></div>` : ''}
714
+ ${agentPct > 0 ? `<div class="source-bar-seg" style="width:${agentPct}%;background:var(--accent-purple);" title="Agent ${agentPct}%"></div>` : ''}
715
+ ${manualPct > 0 ? `<div class="source-bar-seg" style="width:${manualPct}%;background:var(--accent-amber);" title="Manual ${manualPct}%"></div>` : ''}
716
+ </div>
717
+ <div class="source-legend">
718
+ <span class="source-legend-item"><span class="source-dot" style="background:var(--accent-green)"></span> ${t('sourceGit')} <strong>${sc.git}</strong></span>
719
+ <span class="source-legend-item"><span class="source-dot" style="background:var(--accent-purple)"></span> ${t('sourceAgent')} <strong>${sc.agent}</strong></span>
720
+ <span class="source-legend-item"><span class="source-dot" style="background:var(--accent-amber)"></span> ${t('sourceManual')} <strong>${sc.manual}</strong></span>
721
+ </div>
722
+ </div>
557
723
  </div>
724
+ </div>
725
+
726
+ <div class="panel" style="flex:1;">
727
+ <div class="panel-header"><span class="panel-title">${t('retentionHealth')}</span></div>
728
+ <div class="panel-body">
729
+ <div class="retention-mini-grid">
730
+ <div class="retention-mini-item"><span class="retention-mini-value" style="color:var(--accent-green)">${rs.active}</span><span class="retention-mini-label">${t('active')}</span></div>
731
+ <div class="retention-mini-item"><span class="retention-mini-value" style="color:var(--accent-amber)">${rs.stale}</span><span class="retention-mini-label">${t('stale')}</span></div>
732
+ <div class="retention-mini-item"><span class="retention-mini-value" style="color:var(--accent-red)">${rs.archive}</span><span class="retention-mini-label">${t('archiveCandidates')}</span></div>
733
+ <div class="retention-mini-item"><span class="retention-mini-value" style="color:var(--accent-purple)">${rs.immune}</span><span class="retention-mini-label">${t('immune')}</span></div>
734
+ </div>
735
+ </div>
736
+ </div>
737
+ </div>
738
+
739
+ <!-- Type Distribution + Recent Activity -->
740
+ <div class="overview-row">
741
+ <div class="panel" style="flex:1;">
742
+ <div class="panel-header"><span class="panel-title">${t('observationTypes')}</span></div>
558
743
  <div class="panel-body">
559
744
  ${typeEntries.length > 0 ? `
560
745
  <div style="display: flex; gap: 20px; align-items: flex-start;">
@@ -576,10 +761,8 @@ async function loadDashboard() {
576
761
  </div>
577
762
  </div>
578
763
 
579
- <div class="panel">
580
- <div class="panel-header">
581
- <span class="panel-title">${t('recentActivity')}</span>
582
- </div>
764
+ <div class="panel" style="flex:1;">
765
+ <div class="panel-header"><span class="panel-title">${t('recentActivity')}</span></div>
583
766
  <div class="panel-body">
584
767
  <ul class="activity-list">
585
768
  ${(stats.recentObservations || []).map(obs => `
@@ -600,7 +783,6 @@ async function loadDashboard() {
600
783
  </div>
601
784
  `;
602
785
 
603
- // Render pie chart if data exists
604
786
  if (typeEntries.length > 0) {
605
787
  requestAnimationFrame(() => renderPieChart('type-pie-chart', typeEntries, typeIcons));
606
788
  }
@@ -807,6 +989,10 @@ function renderGraph(graph) {
807
989
  let dragNode = null;
808
990
  let panStart = null;
809
991
  let simTick = 0;
992
+ let isSettled = false;
993
+ let stableMode = nodes.length > 15; // Auto-stable for larger graphs
994
+ const SETTLE_THRESHOLD = 0.15;
995
+ let settleCountdown = 0; // frames below threshold before declaring settled
810
996
 
811
997
  // Group nodes by color → separate galaxies
812
998
  const colorGroups = {};
@@ -855,13 +1041,17 @@ function renderGraph(graph) {
855
1041
  }
856
1042
  }
857
1043
 
858
- // --- Node-to-node repulsion ---
1044
+ // --- Warmup factor (ramp all forces gradually to prevent explosive start) ---
1045
+ const warmup = Math.min(1, simTick / 100);
1046
+
1047
+ // --- Node-to-node repulsion (also ramped) ---
1048
+ const curRepulsion = REPULSION * (0.1 + 0.9 * warmup);
859
1049
  for (let i = 0; i < nodes.length; i++) {
860
1050
  for (let j = i + 1; j < nodes.length; j++) {
861
1051
  const a = nodes[i], b = nodes[j];
862
1052
  let dx = b.x - a.x, dy = b.y - a.y;
863
1053
  let dist = Math.sqrt(dx * dx + dy * dy) || 1;
864
- let force = REPULSION / (dist * dist);
1054
+ let force = curRepulsion / (dist * dist);
865
1055
  // Same-color nodes repel less (stay in galaxy)
866
1056
  if (a.color === b.color) force *= 0.4;
867
1057
  let fx = (dx / dist) * force, fy = (dy / dist) * force;
@@ -870,8 +1060,7 @@ function renderGraph(graph) {
870
1060
  }
871
1061
  }
872
1062
 
873
- // --- Edge attraction (gradual warmup to prevent violent bouncing) ---
874
- const warmup = Math.min(1, simTick / 120);
1063
+ // --- Edge attraction (also ramped) ---
875
1064
  const curAttraction = ATTRACTION * warmup;
876
1065
  for (const edge of edges) {
877
1066
  let dx = edge.target.x - edge.source.x, dy = edge.target.y - edge.source.y;
@@ -890,21 +1079,34 @@ function renderGraph(graph) {
890
1079
  node.vy += (cc.y - node.y) * CLUSTER_PULL;
891
1080
  }
892
1081
 
893
- // --- Continuous breathing jitter (subtle early, gentler forever) ---
894
- const jitter = simTick < 80 ? 0.15 : 0.03;
895
- const maxV = 3.0; // Cap velocity to prevent wild bouncing
1082
+ // --- Gentle jitter only during warmup, then pure physics ---
1083
+ const jitter = simTick < 60 ? 0.06 * (1 - simTick / 60) : 0;
1084
+ const maxV = simTick < 40 ? 1.2 : 3.0; // Strict speed limit during early frames
896
1085
  let totalMovement = 0;
897
1086
  for (const node of nodes) {
898
1087
  if (node === dragNode) continue;
899
1088
  node.vx *= DAMPING; node.vy *= DAMPING;
900
- node.vx += (Math.random() - 0.5) * jitter;
901
- node.vy += (Math.random() - 0.5) * jitter;
1089
+ if (jitter > 0) {
1090
+ node.vx += (Math.random() - 0.5) * jitter;
1091
+ node.vy += (Math.random() - 0.5) * jitter;
1092
+ }
902
1093
  // Clamp velocity
903
1094
  const speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy);
904
1095
  if (speed > maxV) { node.vx *= maxV / speed; node.vy *= maxV / speed; }
905
1096
  node.x += node.vx; node.y += node.vy;
906
1097
  totalMovement += Math.abs(node.vx) + Math.abs(node.vy);
907
1098
  }
1099
+
1100
+ // Settle detection — stop physics when stable
1101
+ if (stableMode && simTick > 120) {
1102
+ const avgMovement = totalMovement / Math.max(1, nodes.length);
1103
+ if (avgMovement < SETTLE_THRESHOLD) {
1104
+ settleCountdown++;
1105
+ if (settleCountdown > 30) isSettled = true;
1106
+ } else {
1107
+ settleCountdown = 0;
1108
+ }
1109
+ }
908
1110
  return totalMovement;
909
1111
  }
910
1112
 
@@ -1292,14 +1494,19 @@ function renderGraph(graph) {
1292
1494
  drawer.classList.add('open');
1293
1495
  }
1294
1496
 
1295
- // --- Animation loop (always dynamic continuous breathing) ---
1497
+ // --- Animation loop stops physics when settled, breathing is visual-only ---
1498
+ let animFrame = null;
1296
1499
  function tick() {
1297
- simulate();
1500
+ if (!isSettled) {
1501
+ simulate();
1502
+ }
1298
1503
  draw();
1299
- requestAnimationFrame(tick);
1504
+ animFrame = requestAnimationFrame(tick);
1300
1505
  }
1301
1506
 
1302
1507
  function wakeUp() {
1508
+ isSettled = false;
1509
+ settleCountdown = 0;
1303
1510
  nodes.forEach(n => {
1304
1511
  n.vx += (Math.random() - 0.5) * 0.5;
1305
1512
  n.vy += (Math.random() - 0.5) * 0.5;
@@ -1824,6 +2031,295 @@ async function deleteObs(id, event) {
1824
2031
  window.toggleObsDetail = toggleObsDetail;
1825
2032
  window.deleteObs = deleteObs;
1826
2033
 
2034
+ // ============================================================
2035
+ // Git Memory Page
2036
+ // ============================================================
2037
+
2038
+ async function loadGitMemory() {
2039
+ const container = document.getElementById('page-git-memory');
2040
+ container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
2041
+
2042
+ const [stats, allObs] = await Promise.all([api('stats'), api('observations')]);
2043
+ if (!stats || !allObs) {
2044
+ container.innerHTML = emptyState('🔀', t('noGitMemory'), t('noGitMemoryDesc'));
2045
+ return;
2046
+ }
2047
+
2048
+ const gitObs = (allObs || []).filter(o => o.source === 'git').sort((a, b) => (b.id || 0) - (a.id || 0));
2049
+ const gs = stats.gitSummary || { total: 0, recentWeek: 0, recentMemories: [] };
2050
+ const sc = stats.sourceCounts || {};
2051
+
2052
+ // Type breakdown of git memories
2053
+ const gitTypes = {};
2054
+ gitObs.forEach(o => { gitTypes[o.type || 'unknown'] = (gitTypes[o.type || 'unknown'] || 0) + 1; });
2055
+ const gitTypeEntries = Object.entries(gitTypes).sort((a, b) => b[1] - a[1]);
2056
+
2057
+ container.innerHTML = `
2058
+ <div class="page-header">
2059
+ <h1 class="page-title">${t('gitMemoryTitle')}</h1>
2060
+ <p class="page-subtitle">${gitObs.length} ${t('gitMemorySubtitle')}</p>
2061
+ </div>
2062
+
2063
+ <div class="stats-grid">
2064
+ <div class="stat-card" data-accent="green">
2065
+ <div class="stat-label">${t('totalGitMemories')}</div>
2066
+ <div class="stat-value">${gitObs.length}</div>
2067
+ </div>
2068
+ <div class="stat-card" data-accent="cyan">
2069
+ <div class="stat-label">${t('thisWeek')}</div>
2070
+ <div class="stat-value">${gs.recentWeek}</div>
2071
+ </div>
2072
+ <div class="stat-card" data-accent="purple">
2073
+ <div class="stat-label">${t('uniqueCommits')}</div>
2074
+ <div class="stat-value">${new Set(gitObs.map(o => o.commitHash).filter(Boolean)).size}</div>
2075
+ </div>
2076
+ <div class="stat-card" data-accent="amber">
2077
+ <div class="stat-label">${t('typeCoverage')}</div>
2078
+ <div class="stat-value">${gitTypeEntries.length}</div>
2079
+ <div class="stat-sub">${gitTypeEntries.slice(0, 3).map(([t]) => t).join(', ')}</div>
2080
+ </div>
2081
+ </div>
2082
+
2083
+ ${gitObs.length === 0 ? `
2084
+ <div class="panel">
2085
+ <div class="panel-body" style="text-align:center;padding:48px;">
2086
+ <div style="font-size:36px;margin-bottom:12px;">🔀</div>
2087
+ <div style="font-size:16px;font-weight:600;color:var(--text-primary);margin-bottom:8px;">${t('noGitMemoriesYet')}</div>
2088
+ <div style="font-size:13px;color:var(--text-muted);max-width:400px;margin:0 auto;">
2089
+ ${t('noGitMemoriesHint')}<br>
2090
+ <code style="background:var(--bg-surface);padding:4px 10px;border-radius:6px;margin-top:8px;display:inline-block;font-size:12px;">memorix git-hook-install</code>
2091
+ </div>
2092
+ </div>
2093
+ </div>
2094
+ ` : `
2095
+ <div class="panel">
2096
+ <div class="panel-header">
2097
+ <span class="panel-title">${t('recentGitMemories')}</span>
2098
+ <span style="font-size:11px;color:var(--text-muted);">${gitObs.length} total</span>
2099
+ </div>
2100
+ <div class="panel-body" style="padding:0;">
2101
+ <table class="retention-table">
2102
+ <thead>
2103
+ <tr>
2104
+ <th>${t('id')}</th>
2105
+ <th>${t('commit')}</th>
2106
+ <th>${t('title')}</th>
2107
+ <th>${t('type')}</th>
2108
+ <th>${t('entity')}</th>
2109
+ <th>${t('files')}</th>
2110
+ <th>${t('created')}</th>
2111
+ </tr>
2112
+ </thead>
2113
+ <tbody>
2114
+ ${gitObs.slice(0, 50).map(obs => `
2115
+ <tr>
2116
+ <td style="font-family:var(--font-mono);color:var(--text-muted);">#${obs.id}</td>
2117
+ <td><code class="git-hash">${obs.commitHash ? escapeHtml(obs.commitHash.slice(0, 7)) : '—'}</code></td>
2118
+ <td style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(obs.title || 'Untitled')}</td>
2119
+ <td><span class="type-badge" data-type="${obs.type || 'unknown'}">${obs.type || 'unknown'}</span></td>
2120
+ <td style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted);">${escapeHtml(obs.entityName || '')}</td>
2121
+ <td style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);">${(obs.filesModified || []).length || '—'}</td>
2122
+ <td style="font-size:11px;color:var(--text-muted);">${obs.createdAt ? formatTime(obs.createdAt) : '—'}</td>
2123
+ </tr>
2124
+ `).join('')}
2125
+ </tbody>
2126
+ </table>
2127
+ </div>
2128
+ </div>
2129
+ `}
2130
+ `;
2131
+ }
2132
+
2133
+ // ============================================================
2134
+ // Config Provenance Page
2135
+ // ============================================================
2136
+
2137
+ async function loadConfig() {
2138
+ const container = document.getElementById('page-config');
2139
+ container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
2140
+
2141
+ const data = await api('config');
2142
+ if (!data) {
2143
+ container.innerHTML = emptyState('⚙️', t('configUnavailable'), t('configUnavailableDesc'));
2144
+ return;
2145
+ }
2146
+
2147
+ const fileEntries = Object.entries(data.files || {});
2148
+ const values = data.values || [];
2149
+
2150
+ container.innerHTML = `
2151
+ <div class="page-header">
2152
+ <h1 class="page-title">${t('configTitle')}</h1>
2153
+ <p class="page-subtitle">${t('configSubtitle')}</p>
2154
+ </div>
2155
+
2156
+ <div class="overview-row">
2157
+ <div class="panel" style="flex:1;">
2158
+ <div class="panel-header"><span class="panel-title">${t('configSourceMatrix')}</span></div>
2159
+ <div class="panel-body">
2160
+ <div class="config-matrix">
2161
+ ${fileEntries.map(([name, info]) => `
2162
+ <div class="config-file-row">
2163
+ <span class="config-file-status ${info.exists ? 'exists' : 'missing'}">${info.exists ? '✓' : '✗'}</span>
2164
+ <span class="config-file-name">${escapeHtml(name)}</span>
2165
+ <span class="config-file-path">${info.path ? escapeHtml(info.path) : ''}</span>
2166
+ </div>
2167
+ `).join('')}
2168
+ </div>
2169
+ <div class="config-hint">
2170
+ <strong>memorix.yml</strong> ${t('configHint')} &nbsp;|&nbsp; <strong>.env</strong> ${t('configHintEnv')}
2171
+ </div>
2172
+ </div>
2173
+ </div>
2174
+ </div>
2175
+
2176
+ <div class="panel">
2177
+ <div class="panel-header">
2178
+ <span class="panel-title">${t('valueProvenance')}</span>
2179
+ <span style="font-size:11px;color:var(--text-muted);">${values.length} ${t('trackedValues')}</span>
2180
+ </div>
2181
+ <div class="panel-body" style="padding:0;">
2182
+ <table class="retention-table">
2183
+ <thead>
2184
+ <tr>
2185
+ <th>${t('configKey')}</th>
2186
+ <th>${t('configValue')}</th>
2187
+ <th>${t('configSource')}</th>
2188
+ <th>${t('configStatus')}</th>
2189
+ </tr>
2190
+ </thead>
2191
+ <tbody>
2192
+ ${values.map(v => {
2193
+ const isWarn = v.source && v.source.includes('move to .env');
2194
+ const isSensitive = v.sensitive;
2195
+ return `
2196
+ <tr>
2197
+ <td><code class="config-key">${escapeHtml(v.key)}</code></td>
2198
+ <td style="font-family:var(--font-mono);font-size:12px;">${isSensitive ? '<span class="config-masked">' + escapeHtml(v.value) + '</span>' : escapeHtml(v.value)}</td>
2199
+ <td><span class="config-source-badge ${isWarn ? 'warn' : ''}">${escapeHtml(v.source)}</span></td>
2200
+ <td>${isWarn ? '<span class="config-warn-badge">⚠ ' + t('moveToEnv') + '</span>' : ''}</td>
2201
+ </tr>
2202
+ `;
2203
+ }).join('')}
2204
+ </tbody>
2205
+ </table>
2206
+ </div>
2207
+ </div>
2208
+ `;
2209
+ }
2210
+
2211
+ // ============================================================
2212
+ // Identity Health Page
2213
+ // ============================================================
2214
+
2215
+ async function loadIdentity() {
2216
+ const container = document.getElementById('page-identity');
2217
+ container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
2218
+
2219
+ const data = await api('identity');
2220
+ if (!data) {
2221
+ container.innerHTML = emptyState('🛡️', t('identityUnavailable'), t('identityUnavailableDesc'));
2222
+ return;
2223
+ }
2224
+
2225
+ const healthColor = data.isHealthy ? 'var(--accent-green)' : 'var(--accent-red)';
2226
+ const healthIcon = data.isHealthy ? t('healthy') : t('unhealthy');
2227
+
2228
+ container.innerHTML = `
2229
+ <div class="page-header">
2230
+ <h1 class="page-title">${t('identityTitle')}</h1>
2231
+ <p class="page-subtitle">${t('identitySubtitle')}</p>
2232
+ </div>
2233
+
2234
+ <div class="stats-grid">
2235
+ <div class="stat-card" data-accent="${data.isHealthy ? 'green' : 'red'}">
2236
+ <div class="stat-label">${t('healthStatus')}</div>
2237
+ <div class="stat-value" style="font-size:20px;color:${healthColor}">${healthIcon}</div>
2238
+ </div>
2239
+ <div class="stat-card" data-accent="cyan">
2240
+ <div class="stat-label">${t('knownProjectIds')}</div>
2241
+ <div class="stat-value">${data.allProjectIds?.length || 0}</div>
2242
+ </div>
2243
+ <div class="stat-card" data-accent="purple">
2244
+ <div class="stat-label">${t('aliasGroups')}</div>
2245
+ <div class="stat-value">${data.aliasGroups || 0}</div>
2246
+ </div>
2247
+ <div class="stat-card" data-accent="amber">
2248
+ <div class="stat-label">${t('dirtyIds')}</div>
2249
+ <div class="stat-value">${data.dirtyIds?.length || 0}</div>
2250
+ </div>
2251
+ </div>
2252
+
2253
+ <div class="overview-row">
2254
+ <div class="panel" style="flex:1;">
2255
+ <div class="panel-header"><span class="panel-title">${t('currentIdentity')}</span></div>
2256
+ <div class="panel-body">
2257
+ <div class="identity-row">
2258
+ <span class="identity-label">${t('currentProjectId')}</span>
2259
+ <code class="identity-value">${escapeHtml(data.currentProjectId || '—')}</code>
2260
+ </div>
2261
+ <div class="identity-row">
2262
+ <span class="identity-label">${t('canonicalId')}</span>
2263
+ <code class="identity-value">${escapeHtml(data.canonicalId || '—')}</code>
2264
+ </div>
2265
+ <div class="identity-row">
2266
+ <span class="identity-label">${t('aliases')}</span>
2267
+ <div>${(data.aliases || []).map(a => `<code class="identity-alias">${escapeHtml(a)}</code>`).join(' ')}</div>
2268
+ </div>
2269
+ </div>
2270
+ </div>
2271
+
2272
+ <div class="panel" style="flex:1;">
2273
+ <div class="panel-header"><span class="panel-title">${t('healthIssues')}</span></div>
2274
+ <div class="panel-body">
2275
+ ${(data.healthIssues || []).length === 0
2276
+ ? '<div style="color:var(--accent-green);font-size:13px;">' + t('noIssues') + '</div>'
2277
+ : (data.healthIssues || []).map(issue => `
2278
+ <div class="identity-issue">
2279
+ <span style="color:var(--accent-red);">⚠</span>
2280
+ <span>${escapeHtml(issue)}</span>
2281
+ </div>
2282
+ `).join('')
2283
+ }
2284
+ </div>
2285
+ </div>
2286
+ </div>
2287
+
2288
+ ${(data.dirtyIds || []).length > 0 ? `
2289
+ <div class="panel">
2290
+ <div class="panel-header"><span class="panel-title">${t('dirtyProjectIds')}</span></div>
2291
+ <div class="panel-body">
2292
+ <div style="display:flex;flex-wrap:wrap;gap:8px;">
2293
+ ${data.dirtyIds.map(id => `<code class="identity-dirty">${escapeHtml(id)}</code>`).join('')}
2294
+ </div>
2295
+ </div>
2296
+ </div>
2297
+ ` : ''}
2298
+
2299
+ <div class="panel">
2300
+ <div class="panel-header">
2301
+ <span class="panel-title">${t('allKnownProjectIds')}</span>
2302
+ <span style="font-size:11px;color:var(--text-muted);">${data.allProjectIds?.length || 0} total</span>
2303
+ </div>
2304
+ <div class="panel-body">
2305
+ <div style="display:flex;flex-direction:column;gap:6px;">
2306
+ ${(data.allProjectIds || []).map(id => {
2307
+ const isDirty = (data.dirtyIds || []).includes(id);
2308
+ const isCurrent = id === data.currentProjectId;
2309
+ const isCanonical = id === data.canonicalId;
2310
+ return `<div class="identity-id-row">
2311
+ <code class="identity-id ${isDirty ? 'dirty' : ''}">${escapeHtml(id)}</code>
2312
+ ${isCurrent ? '<span class="identity-tag current">' + t('tagCurrent') + '</span>' : ''}
2313
+ ${isCanonical ? '<span class="identity-tag canonical">' + t('tagCanonical') + '</span>' : ''}
2314
+ ${isDirty ? '<span class="identity-tag dirty">' + t('tagDirty') + '</span>' : ''}
2315
+ </div>`;
2316
+ }).join('')}
2317
+ </div>
2318
+ </div>
2319
+ </div>
2320
+ `;
2321
+ }
2322
+
1827
2323
  // ============================================================
1828
2324
  // Utilities
1829
2325
  // ============================================================
@@ -1972,8 +2468,23 @@ async function loadTeam() {
1972
2468
  }
1973
2469
 
1974
2470
  const data = await api('team');
1975
- if (!data) {
1976
- container.innerHTML = emptyState('', t('teamTitle'), t('teamNoData'));
2471
+ if (!data || data.unavailable) {
2472
+ container.innerHTML = `
2473
+ <div class="page-header">
2474
+ <h1 class="page-title">${t('teamTitle')}</h1>
2475
+ <p class="page-subtitle">${t('teamSubtitle')}</p>
2476
+ </div>
2477
+ <div class="panel">
2478
+ <div class="panel-body" style="text-align:center;padding:48px;">
2479
+ <div style="font-size:36px;margin-bottom:12px;">👥</div>
2480
+ <div style="font-size:16px;font-weight:600;color:var(--text-primary);margin-bottom:8px;">${t('teamNoData')}</div>
2481
+ <div style="font-size:13px;color:var(--text-muted);max-width:480px;margin:0 auto;line-height:1.6;">
2482
+ ${t('teamNoDataHint')}<br>
2483
+ <code style="background:var(--bg-surface);padding:4px 10px;border-radius:6px;margin-top:8px;display:inline-block;font-size:12px;">memorix serve-http --port 3211</code>
2484
+ </div>
2485
+ </div>
2486
+ </div>
2487
+ `;
1977
2488
  return;
1978
2489
  }
1979
2490