memorix 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,921 @@
1
+ /**
2
+ * Memorix Dashboard — SPA Application
3
+ * Vanilla JS, zero dependencies, i18n support (EN/ZH)
4
+ */
5
+
6
+ // ============================================================
7
+ // i18n — Internationalization
8
+ // ============================================================
9
+
10
+ const i18n = {
11
+ en: {
12
+ // Dashboard
13
+ dashboard: 'Dashboard',
14
+ dashboardSubtitle: 'Overview of your project memory',
15
+ entities: 'Entities',
16
+ relations: 'Relations',
17
+ observations: 'Observations',
18
+ nextId: 'Next ID',
19
+ observationTypes: 'Observation Types',
20
+ recentActivity: 'Recent Activity',
21
+ noObservationsYet: 'No observations yet',
22
+ noRecentActivity: 'No recent activity',
23
+ noData: 'No Data',
24
+ noDataDesc: 'Start using Memorix to see your dashboard',
25
+
26
+ // Graph
27
+ knowledgeGraph: 'Knowledge Graph',
28
+ noGraphData: 'No Graph Data',
29
+ noGraphDataDesc: 'Create entities and relations to see your knowledge graph',
30
+ observation_s: 'observation(s)',
31
+ nodes: 'nodes',
32
+ edges: 'edges',
33
+ clickNodeToView: 'Click a node to view details',
34
+ noObservations: 'No observations',
35
+ noRelations: 'No relations',
36
+
37
+ // Observations
38
+ observationsStored: 'observations stored',
39
+ searchObservations: 'Search observations...',
40
+ all: 'All',
41
+ noMatchingObs: 'No matching observations',
42
+ noObsTitle: 'No Observations',
43
+ noObsDesc: 'Use memorix_store to create observations',
44
+ untitled: 'Untitled',
45
+
46
+ // Retention
47
+ memoryRetention: 'Memory Retention',
48
+ retentionSubtitle: 'Exponential decay scoring with immunity rules',
49
+ active: 'Active',
50
+ stale: 'Stale',
51
+ archiveCandidates: 'Archive Candidates',
52
+ immune: 'Immune',
53
+ allObsByScore: 'All Observations by Retention Score',
54
+ id: 'ID',
55
+ title: 'Title',
56
+ type: 'Type',
57
+ entity: 'Entity',
58
+ score: 'Score',
59
+ ageH: 'Age (h)',
60
+ access: 'Access',
61
+ status: 'Status',
62
+ noRetentionData: 'No Retention Data',
63
+ noRetentionDesc: 'Store observations to see memory retention scores',
64
+
65
+ // Nav tooltips
66
+ navDashboard: 'Dashboard',
67
+ navGraph: 'Knowledge Graph',
68
+ navObservations: 'Observations',
69
+ navRetention: 'Retention',
70
+ },
71
+ zh: {
72
+ // Dashboard
73
+ dashboard: '仪表盘',
74
+ dashboardSubtitle: '项目记忆概览',
75
+ entities: '实体',
76
+ relations: '关系',
77
+ observations: '观察记录',
78
+ nextId: '下一个 ID',
79
+ observationTypes: '观察类型分布',
80
+ recentActivity: '最近活动',
81
+ noObservationsYet: '暂无观察记录',
82
+ noRecentActivity: '暂无最近活动',
83
+ noData: '暂无数据',
84
+ noDataDesc: '开始使用 Memorix 来查看仪表盘',
85
+
86
+ // Graph
87
+ knowledgeGraph: '知识图谱',
88
+ noGraphData: '暂无图谱数据',
89
+ noGraphDataDesc: '创建实体和关系来查看知识图谱',
90
+ observation_s: '条观察',
91
+ nodes: '个节点',
92
+ edges: '条边',
93
+ clickNodeToView: '点击节点查看详情',
94
+ noObservations: '暂无观察',
95
+ noRelations: '暂无关系',
96
+
97
+ // Observations
98
+ observationsStored: '条观察已存储',
99
+ searchObservations: '搜索观察记录...',
100
+ all: '全部',
101
+ noMatchingObs: '没有匹配的观察记录',
102
+ noObsTitle: '暂无观察记录',
103
+ noObsDesc: '使用 memorix_store 创建观察记录',
104
+ untitled: '无标题',
105
+
106
+ // Retention
107
+ memoryRetention: '记忆衰减',
108
+ retentionSubtitle: '基于指数衰减的评分系统,支持免疫规则',
109
+ active: '活跃',
110
+ stale: '陈旧',
111
+ archiveCandidates: '归档候选',
112
+ immune: '免疫',
113
+ allObsByScore: '按衰减分数排列的所有观察',
114
+ id: 'ID',
115
+ title: '标题',
116
+ type: '类型',
117
+ entity: '实体',
118
+ score: '分数',
119
+ ageH: '年龄 (h)',
120
+ access: '访问次数',
121
+ status: '状态',
122
+ noRetentionData: '暂无衰减数据',
123
+ noRetentionDesc: '存储观察记录以查看记忆衰减分数',
124
+
125
+ // Nav tooltips
126
+ navDashboard: '仪表盘',
127
+ navGraph: '知识图谱',
128
+ navObservations: '观察记录',
129
+ navRetention: '记忆衰减',
130
+ },
131
+ };
132
+
133
+ let currentLang = localStorage.getItem('memorix-lang') || 'en';
134
+
135
+ function t(key) {
136
+ return (i18n[currentLang] && i18n[currentLang][key]) || i18n.en[key] || key;
137
+ }
138
+
139
+ function setLang(lang) {
140
+ currentLang = lang;
141
+ localStorage.setItem('memorix-lang', lang);
142
+
143
+ // Update button text
144
+ const btn = document.getElementById('lang-toggle');
145
+ if (btn) btn.textContent = lang === 'en' ? '中' : 'EN';
146
+
147
+ // Update nav tooltips
148
+ const tooltipMap = { dashboard: 'navDashboard', graph: 'navGraph', observations: 'navObservations', retention: 'navRetention' };
149
+ document.querySelectorAll('.nav-btn').forEach(b => {
150
+ const page = b.dataset.page;
151
+ if (page && tooltipMap[page]) b.title = t(tooltipMap[page]);
152
+ });
153
+
154
+ // Force reload all pages
155
+ Object.keys(loaded).forEach(k => delete loaded[k]);
156
+ loadPage(currentPage);
157
+ }
158
+
159
+ // Init lang toggle button
160
+ document.addEventListener('DOMContentLoaded', () => {
161
+ const btn = document.getElementById('lang-toggle');
162
+ if (btn) {
163
+ btn.textContent = currentLang === 'en' ? '中' : 'EN';
164
+ btn.addEventListener('click', () => {
165
+ setLang(currentLang === 'en' ? 'zh' : 'en');
166
+ });
167
+ }
168
+ });
169
+
170
+ // ============================================================
171
+ // Theme Toggle (Light / Dark)
172
+ // ============================================================
173
+
174
+ let currentTheme = localStorage.getItem('memorix-theme') || 'dark';
175
+
176
+ function applyTheme(theme) {
177
+ currentTheme = theme;
178
+ document.documentElement.setAttribute('data-theme', theme);
179
+ localStorage.setItem('memorix-theme', theme);
180
+
181
+ const sunIcon = document.getElementById('theme-icon-sun');
182
+ const moonIcon = document.getElementById('theme-icon-moon');
183
+ if (sunIcon && moonIcon) {
184
+ // Show sun icon in dark mode (click to go light), moon in light mode (click to go dark)
185
+ sunIcon.style.display = theme === 'dark' ? 'none' : 'block';
186
+ moonIcon.style.display = theme === 'dark' ? 'block' : 'none';
187
+ }
188
+
189
+ // Force reload current page so Canvas graph redraws with new colors
190
+ try {
191
+ if (typeof currentPage !== 'undefined' && loaded[currentPage]) {
192
+ delete loaded[currentPage];
193
+ loadPage(currentPage);
194
+ }
195
+ } catch { /* initial call before loaded is defined */ }
196
+ }
197
+
198
+ // Apply saved theme immediately
199
+ applyTheme(currentTheme);
200
+
201
+ document.addEventListener('DOMContentLoaded', () => {
202
+ const themeBtn = document.getElementById('theme-toggle');
203
+ if (themeBtn) {
204
+ themeBtn.addEventListener('click', () => {
205
+ applyTheme(currentTheme === 'dark' ? 'light' : 'dark');
206
+ });
207
+ }
208
+ });
209
+
210
+ // ============================================================
211
+ // Router & Navigation
212
+ // ============================================================
213
+
214
+ const pages = ['dashboard', 'graph', 'observations', 'retention'];
215
+ let currentPage = 'dashboard';
216
+
217
+ function navigate(page) {
218
+ if (!pages.includes(page)) return;
219
+ currentPage = page;
220
+
221
+ // Update nav
222
+ document.querySelectorAll('.nav-btn').forEach(btn => {
223
+ btn.classList.toggle('active', btn.dataset.page === page);
224
+ });
225
+
226
+ // Update pages
227
+ document.querySelectorAll('.page').forEach(p => {
228
+ p.classList.toggle('active', p.id === `page-${page}`);
229
+ });
230
+
231
+ // Load page data
232
+ loadPage(page);
233
+ }
234
+
235
+ // Nav click handlers
236
+ document.querySelectorAll('.nav-btn').forEach(btn => {
237
+ btn.addEventListener('click', () => navigate(btn.dataset.page));
238
+ });
239
+
240
+ // ============================================================
241
+ // API Client
242
+ // ============================================================
243
+
244
+ async function api(endpoint) {
245
+ try {
246
+ const res = await fetch(`/api/${endpoint}`);
247
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
248
+ return await res.json();
249
+ } catch (err) {
250
+ console.error(`API error (${endpoint}):`, err);
251
+ return null;
252
+ }
253
+ }
254
+
255
+ // ============================================================
256
+ // Page Loaders
257
+ // ============================================================
258
+
259
+ const loaded = {};
260
+
261
+ async function loadPage(page) {
262
+ if (loaded[page]) return;
263
+
264
+ switch (page) {
265
+ case 'dashboard': await loadDashboard(); break;
266
+ case 'graph': await loadGraph(); break;
267
+ case 'observations': await loadObservations(); break;
268
+ case 'retention': await loadRetention(); break;
269
+ }
270
+ loaded[page] = true;
271
+ }
272
+
273
+ // ============================================================
274
+ // Dashboard Page
275
+ // ============================================================
276
+
277
+ async function loadDashboard() {
278
+ const container = document.getElementById('page-dashboard');
279
+ container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
280
+
281
+ const [stats, project] = await Promise.all([api('stats'), api('project')]);
282
+ if (!stats) {
283
+ container.innerHTML = emptyState('📊', t('noData'), t('noDataDesc'));
284
+ return;
285
+ }
286
+
287
+ const projectLabel = project ? project.name : '';
288
+
289
+ const typeIcons = {
290
+ 'session-request': '🎯', gotcha: '🔴', 'problem-solution': '🟡',
291
+ 'how-it-works': '🔵', 'what-changed': '🟢', discovery: '🟣',
292
+ 'why-it-exists': '🟠', decision: '🟤', 'trade-off': '⚖️',
293
+ };
294
+
295
+ // Type distribution
296
+ const typeEntries = Object.entries(stats.typeCounts || {}).sort((a, b) => b[1] - a[1]);
297
+ const maxTypeCount = Math.max(...typeEntries.map(e => e[1]), 1);
298
+
299
+ container.innerHTML = `
300
+ <div class="page-header">
301
+ <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>
302
+ <p class="page-subtitle">${t('dashboardSubtitle')}</p>
303
+ </div>
304
+
305
+ <div class="stats-grid">
306
+ <div class="stat-card" data-accent="cyan">
307
+ <div class="stat-label">${t('entities')}</div>
308
+ <div class="stat-value">${stats.entities}</div>
309
+ </div>
310
+ <div class="stat-card" data-accent="purple">
311
+ <div class="stat-label">${t('relations')}</div>
312
+ <div class="stat-value">${stats.relations}</div>
313
+ </div>
314
+ <div class="stat-card" data-accent="amber">
315
+ <div class="stat-label">${t('observations')}</div>
316
+ <div class="stat-value">${stats.observations}</div>
317
+ </div>
318
+ <div class="stat-card" data-accent="green">
319
+ <div class="stat-label">${t('nextId')}</div>
320
+ <div class="stat-value">#${stats.nextId}</div>
321
+ </div>
322
+ </div>
323
+
324
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
325
+ <div class="panel">
326
+ <div class="panel-header">
327
+ <span class="panel-title">${t('observationTypes')}</span>
328
+ </div>
329
+ <div class="panel-body">
330
+ ${typeEntries.length > 0 ? typeEntries.map(([type, count]) => `
331
+ <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
332
+ <span style="width: 20px; text-align: center;">${typeIcons[type] || '❓'}</span>
333
+ <span style="width: 120px; font-size: 12px; color: var(--text-secondary);">${type}</span>
334
+ <div style="flex: 1; height: 6px; background: rgba(255,255,255,0.04); border-radius: 3px; overflow: hidden;">
335
+ <div style="width: ${(count / maxTypeCount) * 100}%; height: 100%; background: var(--type-${type}, var(--accent-cyan)); border-radius: 3px;"></div>
336
+ </div>
337
+ <span style="font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); min-width: 24px; text-align: right;">${count}</span>
338
+ </div>
339
+ `).join('') : `<p style="color: var(--text-muted); font-size: 13px;">${t('noObservationsYet')}</p>`}
340
+ </div>
341
+ </div>
342
+
343
+ <div class="panel">
344
+ <div class="panel-header">
345
+ <span class="panel-title">${t('recentActivity')}</span>
346
+ </div>
347
+ <div class="panel-body">
348
+ <ul class="activity-list">
349
+ ${(stats.recentObservations || []).map(obs => `
350
+ <li class="activity-item">
351
+ <span class="activity-id">#${obs.id}</span>
352
+ <span class="type-badge" data-type="${obs.type}">
353
+ <span class="type-icon" data-type="${obs.type}"></span>
354
+ ${obs.type}
355
+ </span>
356
+ <span class="activity-title">${escapeHtml(obs.title || t('untitled'))}</span>
357
+ <span class="activity-entity">${escapeHtml(obs.entityName || '')}</span>
358
+ </li>
359
+ `).join('')}
360
+ </ul>
361
+ ${(stats.recentObservations || []).length === 0 ? `<p style="color: var(--text-muted); font-size: 13px; padding: 12px 0;">${t('noRecentActivity')}</p>` : ''}
362
+ </div>
363
+ </div>
364
+ </div>
365
+ `;
366
+ }
367
+
368
+ // ============================================================
369
+ // Knowledge Graph Page
370
+ // ============================================================
371
+
372
+ async function loadGraph() {
373
+ const container = document.getElementById('page-graph');
374
+ container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
375
+
376
+ const graph = await api('graph');
377
+ if (!graph || (graph.entities.length === 0 && graph.relations.length === 0)) {
378
+ container.innerHTML = emptyState('🕸️', t('noGraphData'), t('noGraphDataDesc'));
379
+ return;
380
+ }
381
+
382
+ container.innerHTML = `
383
+ <div class="page-header">
384
+ <h1 class="page-title">${t('knowledgeGraph')}</h1>
385
+ <p class="page-subtitle">${graph.entities.length} ${t('entities').toLowerCase()}, ${graph.relations.length} ${t('relations').toLowerCase()}</p>
386
+ </div>
387
+ <div class="graph-layout">
388
+ <div id="graph-container">
389
+ <canvas id="graph-canvas"></canvas>
390
+ <div class="graph-tooltip" id="graph-tooltip">
391
+ <div class="graph-tooltip-name"></div>
392
+ <div class="graph-tooltip-type"></div>
393
+ </div>
394
+ </div>
395
+ <div id="graph-detail" class="graph-detail">
396
+ <div class="graph-detail-empty">${t('clickNodeToView') || 'Click a node to view details'}</div>
397
+ </div>
398
+ </div>
399
+ `;
400
+
401
+ renderGraph(graph);
402
+ }
403
+
404
+ // ============================================================
405
+ // Canvas-based Force-Directed Graph (Enhanced)
406
+ // ============================================================
407
+
408
+ function renderGraph(graph) {
409
+ const canvas = document.getElementById('graph-canvas');
410
+ const ctx = canvas.getContext('2d');
411
+ const container = document.getElementById('graph-container');
412
+
413
+ const rect = container.getBoundingClientRect();
414
+ const dpr = window.devicePixelRatio || 1;
415
+ canvas.width = rect.width * dpr;
416
+ canvas.height = rect.height * dpr;
417
+ canvas.style.width = rect.width + 'px';
418
+ canvas.style.height = rect.height + 'px';
419
+ ctx.scale(dpr, dpr);
420
+
421
+ const W = rect.width;
422
+ const H = rect.height;
423
+
424
+ const typeColors = {};
425
+ const palette = ['#00d4ff', '#a855f7', '#ec4899', '#10b981', '#f59e0b', '#3b82f6', '#ef4444', '#f97316'];
426
+ let colorIdx = 0;
427
+ function getTypeColor(type) {
428
+ if (!typeColors[type]) { typeColors[type] = palette[colorIdx % palette.length]; colorIdx++; }
429
+ return typeColors[type];
430
+ }
431
+
432
+ // Bigger nodes, wider spread
433
+ const nodes = graph.entities.map((e) => ({
434
+ id: e.name, type: e.entityType, observations: e.observations,
435
+ x: W / 2 + (Math.random() - 0.5) * W * 0.7,
436
+ y: H / 2 + (Math.random() - 0.5) * H * 0.7,
437
+ vx: 0, vy: 0,
438
+ radius: Math.min(10 + e.observations.length * 3, 32),
439
+ color: getTypeColor(e.entityType),
440
+ }));
441
+ const nodeMap = {};
442
+ nodes.forEach(n => nodeMap[n.id] = n);
443
+
444
+ const edges = graph.relations
445
+ .filter(r => nodeMap[r.from] && nodeMap[r.to])
446
+ .map(r => ({ source: nodeMap[r.from], target: nodeMap[r.to], type: r.relationType }));
447
+
448
+ // Stronger repulsion to fill the canvas
449
+ const REPULSION = 8000;
450
+ const ATTRACTION = 0.003;
451
+ const DAMPING = 0.82;
452
+ const CENTER_GRAVITY = 0.008;
453
+
454
+ let animating = true;
455
+ let hoveredNode = null;
456
+ let selectedNode = null;
457
+ let dragNode = null;
458
+ let pulsePhase = 0;
459
+
460
+ function simulate() {
461
+ for (let i = 0; i < nodes.length; i++) {
462
+ for (let j = i + 1; j < nodes.length; j++) {
463
+ const a = nodes[i], b = nodes[j];
464
+ let dx = b.x - a.x, dy = b.y - a.y;
465
+ let dist = Math.sqrt(dx * dx + dy * dy) || 1;
466
+ let force = REPULSION / (dist * dist);
467
+ let fx = (dx / dist) * force, fy = (dy / dist) * force;
468
+ a.vx -= fx; a.vy -= fy;
469
+ b.vx += fx; b.vy += fy;
470
+ }
471
+ }
472
+ for (const edge of edges) {
473
+ let dx = edge.target.x - edge.source.x, dy = edge.target.y - edge.source.y;
474
+ let dist = Math.sqrt(dx * dx + dy * dy) || 1;
475
+ let force = (dist - 150) * ATTRACTION;
476
+ let fx = (dx / dist) * force, fy = (dy / dist) * force;
477
+ edge.source.vx += fx; edge.source.vy += fy;
478
+ edge.target.vx -= fx; edge.target.vy -= fy;
479
+ }
480
+ for (const node of nodes) {
481
+ node.vx += (W / 2 - node.x) * CENTER_GRAVITY;
482
+ node.vy += (H / 2 - node.y) * CENTER_GRAVITY;
483
+ }
484
+ let totalMovement = 0;
485
+ for (const node of nodes) {
486
+ if (node === dragNode) continue;
487
+ node.vx *= DAMPING; node.vy *= DAMPING;
488
+ node.x += node.vx; node.y += node.vy;
489
+ node.x = Math.max(node.radius + 20, Math.min(W - node.radius - 20, node.x));
490
+ node.y = Math.max(node.radius + 20, Math.min(H - node.radius - 20, node.y));
491
+ totalMovement += Math.abs(node.vx) + Math.abs(node.vy);
492
+ }
493
+ return totalMovement;
494
+ }
495
+
496
+ function getGraphColors() {
497
+ const isLight = document.documentElement.getAttribute('data-theme') === 'light';
498
+ return {
499
+ edgeNormal: isLight ? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.06)',
500
+ edgeHighlight: isLight ? 'rgba(0,0,0,0.25)' : 'rgba(255,255,255,0.25)',
501
+ edgeLabel: isLight ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.45)',
502
+ labelNormal: isLight ? 'rgba(0,0,0,0.55)' : 'rgba(255,255,255,0.6)',
503
+ labelHover: isLight ? '#1a1a2e' : '#ffffff',
504
+ };
505
+ }
506
+
507
+ function draw() {
508
+ ctx.clearRect(0, 0, W, H);
509
+ const colors = getGraphColors();
510
+ pulsePhase += 0.02;
511
+
512
+ // Edges with curve + arrow
513
+ for (const edge of edges) {
514
+ const isActive = (hoveredNode && (edge.source === hoveredNode || edge.target === hoveredNode))
515
+ || (selectedNode && (edge.source === selectedNode || edge.target === selectedNode));
516
+ const mx = (edge.source.x + edge.target.x) / 2;
517
+ const my = (edge.source.y + edge.target.y) / 2;
518
+ const dx = edge.target.x - edge.source.x;
519
+ const dy = edge.target.y - edge.source.y;
520
+ const ox = -dy * 0.08, oy = dx * 0.08;
521
+
522
+ ctx.beginPath();
523
+ ctx.moveTo(edge.source.x, edge.source.y);
524
+ ctx.quadraticCurveTo(mx + ox, my + oy, edge.target.x, edge.target.y);
525
+ ctx.strokeStyle = isActive ? colors.edgeHighlight : colors.edgeNormal;
526
+ ctx.lineWidth = isActive ? 2 : 1;
527
+ ctx.stroke();
528
+
529
+ // Arrow
530
+ const as = isActive ? 8 : 5;
531
+ const angle = Math.atan2(edge.target.y - (my + oy), edge.target.x - (mx + ox));
532
+ const tx = edge.target.x - Math.cos(angle) * edge.target.radius;
533
+ const ty = edge.target.y - Math.sin(angle) * edge.target.radius;
534
+ ctx.beginPath();
535
+ ctx.moveTo(tx, ty);
536
+ ctx.lineTo(tx - as * Math.cos(angle - 0.3), ty - as * Math.sin(angle - 0.3));
537
+ ctx.lineTo(tx - as * Math.cos(angle + 0.3), ty - as * Math.sin(angle + 0.3));
538
+ ctx.closePath();
539
+ ctx.fillStyle = isActive ? colors.edgeHighlight : colors.edgeNormal;
540
+ ctx.fill();
541
+
542
+ if (isActive) {
543
+ ctx.font = '10px Inter, sans-serif';
544
+ ctx.fillStyle = colors.edgeLabel;
545
+ ctx.textAlign = 'center';
546
+ ctx.fillText(edge.type, mx + ox, my + oy - 6);
547
+ }
548
+ }
549
+
550
+ // Nodes
551
+ for (const node of nodes) {
552
+ const active = node === hoveredNode || node === selectedNode;
553
+
554
+ // Glow + dashed ring
555
+ if (active) {
556
+ const pr = node.radius + 12 + Math.sin(pulsePhase * 3) * 3;
557
+ ctx.beginPath();
558
+ ctx.arc(node.x, node.y, pr, 0, Math.PI * 2);
559
+ const g = ctx.createRadialGradient(node.x, node.y, node.radius, node.x, node.y, pr);
560
+ g.addColorStop(0, node.color + '30');
561
+ g.addColorStop(1, node.color + '00');
562
+ ctx.fillStyle = g;
563
+ ctx.fill();
564
+ ctx.beginPath();
565
+ ctx.arc(node.x, node.y, node.radius + 6, 0, Math.PI * 2);
566
+ ctx.setLineDash([3, 3]);
567
+ ctx.strokeStyle = node.color + '50';
568
+ ctx.lineWidth = 1;
569
+ ctx.stroke();
570
+ ctx.setLineDash([]);
571
+ }
572
+
573
+ // Main sphere with gradient
574
+ ctx.beginPath();
575
+ ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
576
+ const ng = ctx.createRadialGradient(node.x - node.radius * 0.3, node.y - node.radius * 0.3, 0, node.x, node.y, node.radius);
577
+ ng.addColorStop(0, node.color);
578
+ ng.addColorStop(1, node.color + (active ? 'cc' : '99'));
579
+ ctx.fillStyle = ng;
580
+ ctx.fill();
581
+
582
+ // Inner highlight
583
+ ctx.beginPath();
584
+ ctx.arc(node.x - node.radius * 0.2, node.y - node.radius * 0.2, node.radius * 0.3, 0, Math.PI * 2);
585
+ ctx.fillStyle = 'rgba(255,255,255,0.15)';
586
+ ctx.fill();
587
+
588
+ // Label
589
+ ctx.font = `${active ? '600' : '400'} ${active ? 13 : 11}px Inter, sans-serif`;
590
+ ctx.fillStyle = active ? colors.labelHover : colors.labelNormal;
591
+ ctx.textAlign = 'center';
592
+ ctx.fillText(node.id, node.x, node.y + node.radius + 18);
593
+
594
+ // Obs count badge
595
+ if (node.observations.length > 0) {
596
+ const bx = node.x + node.radius * 0.6, by = node.y - node.radius * 0.6;
597
+ ctx.beginPath();
598
+ ctx.arc(bx, by, 8, 0, Math.PI * 2);
599
+ ctx.fillStyle = node.color;
600
+ ctx.fill();
601
+ ctx.font = 'bold 8px Inter, sans-serif';
602
+ ctx.fillStyle = '#fff';
603
+ ctx.textAlign = 'center';
604
+ ctx.textBaseline = 'middle';
605
+ ctx.fillText(String(node.observations.length), bx, by);
606
+ ctx.textBaseline = 'alphabetic';
607
+ }
608
+ }
609
+
610
+ if (selectedNode && !animating) requestAnimationFrame(draw);
611
+ }
612
+
613
+ function showDetail(node) {
614
+ const panel = document.getElementById('graph-detail');
615
+ if (!node) {
616
+ panel.innerHTML = `<div class="graph-detail-empty">${t('clickNodeToView') || 'Click a node to view details'}</div>`;
617
+ return;
618
+ }
619
+ const related = edges.filter(e => e.source === node || e.target === node);
620
+ const obsHtml = node.observations.length > 0
621
+ ? node.observations.map(o => `<div class="graph-obs-item">${escapeHtml(o)}</div>`).join('')
622
+ : `<div class="graph-detail-muted">${t('noObservations') || 'No observations'}</div>`;
623
+ const relHtml = related.length > 0
624
+ ? related.map(e => {
625
+ const dir = e.source === node;
626
+ const other = dir ? e.target : e.source;
627
+ return `<div class="graph-rel-item"><span class="graph-rel-arrow">${dir ? '→' : '←'}</span> <span class="graph-rel-type">${escapeHtml(e.type)}</span> <strong>${escapeHtml(other.id)}</strong></div>`;
628
+ }).join('')
629
+ : `<div class="graph-detail-muted">${t('noRelations') || 'No relations'}</div>`;
630
+
631
+ panel.innerHTML = `
632
+ <div class="graph-detail-header">
633
+ <div class="graph-detail-dot" style="background:${node.color}"></div>
634
+ <div>
635
+ <div class="graph-detail-name">${escapeHtml(node.id)}</div>
636
+ <div class="graph-detail-type">${escapeHtml(node.type)}</div>
637
+ </div>
638
+ </div>
639
+ <div class="graph-detail-section">
640
+ <h3>${t('observations')} <span class="graph-detail-count">${node.observations.length}</span></h3>
641
+ ${obsHtml}
642
+ </div>
643
+ <div class="graph-detail-section">
644
+ <h3>${t('relations')} <span class="graph-detail-count">${related.length}</span></h3>
645
+ ${relHtml}
646
+ </div>
647
+ `;
648
+ }
649
+
650
+ function tick() {
651
+ const movement = simulate();
652
+ draw();
653
+ if (animating && movement > 0.1) requestAnimationFrame(tick);
654
+ }
655
+
656
+ canvas.addEventListener('mousemove', (e) => {
657
+ const r = canvas.getBoundingClientRect();
658
+ const mx = e.clientX - r.left, my = e.clientY - r.top;
659
+ if (dragNode) { dragNode.x = mx; dragNode.y = my; dragNode.vx = 0; dragNode.vy = 0; draw(); return; }
660
+ let found = null;
661
+ for (const node of nodes) {
662
+ const dx = mx - node.x, dy = my - node.y;
663
+ if (dx * dx + dy * dy < (node.radius + 6) * (node.radius + 6)) { found = node; break; }
664
+ }
665
+ if (found !== hoveredNode) {
666
+ hoveredNode = found;
667
+ canvas.style.cursor = found ? 'pointer' : 'default';
668
+ if (found) {
669
+ const tt = document.getElementById('graph-tooltip');
670
+ tt.querySelector('.graph-tooltip-name').textContent = found.id;
671
+ tt.querySelector('.graph-tooltip-type').textContent = `${found.type} · ${found.observations.length} ${t('observation_s')}`;
672
+ tt.style.left = (mx + 16) + 'px';
673
+ tt.style.top = (my - 20) + 'px';
674
+ tt.classList.add('visible');
675
+ } else {
676
+ document.getElementById('graph-tooltip').classList.remove('visible');
677
+ }
678
+ draw();
679
+ }
680
+ });
681
+ canvas.addEventListener('mousedown', () => { if (hoveredNode) { dragNode = hoveredNode; canvas.style.cursor = 'grabbing'; } });
682
+ canvas.addEventListener('mouseup', () => { if (dragNode) { dragNode = null; canvas.style.cursor = hoveredNode ? 'pointer' : 'default'; animating = true; tick(); } });
683
+ canvas.addEventListener('click', () => { if (hoveredNode) { selectedNode = hoveredNode; showDetail(selectedNode); draw(); } });
684
+ canvas.addEventListener('mouseleave', () => { hoveredNode = null; dragNode = null; document.getElementById('graph-tooltip').classList.remove('visible'); draw(); });
685
+
686
+ tick();
687
+ setTimeout(() => { animating = false; }, 8000);
688
+ }
689
+
690
+ // ============================================================
691
+ // Observations Page
692
+ // ============================================================
693
+
694
+ let allObservations = [];
695
+ let obsFilter = '';
696
+ let obsTypeFilter = '';
697
+
698
+ async function loadObservations() {
699
+ const container = document.getElementById('page-observations');
700
+ container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
701
+
702
+ allObservations = await api('observations') || [];
703
+
704
+ if (allObservations.length === 0) {
705
+ container.innerHTML = emptyState('🔍', t('noObsTitle'), t('noObsDesc'));
706
+ return;
707
+ }
708
+
709
+ allObservations.sort((a, b) => (b.id || 0) - (a.id || 0));
710
+
711
+ const types = [...new Set(allObservations.map(o => o.type).filter(Boolean))];
712
+
713
+ container.innerHTML = `
714
+ <div class="page-header">
715
+ <h1 class="page-title">${t('observations')}</h1>
716
+ <p class="page-subtitle">${allObservations.length} ${t('observationsStored')}</p>
717
+ </div>
718
+
719
+ <div class="search-bar">
720
+ <input class="search-input" id="obs-search" type="text" placeholder="${t('searchObservations')}" />
721
+ <button class="filter-btn active" data-type="" id="filter-all">${t('all')}</button>
722
+ ${types.map(tp => `<button class="filter-btn" data-type="${tp}">${tp}</button>`).join('')}
723
+ </div>
724
+
725
+ <div class="obs-grid" id="obs-list"></div>
726
+ `;
727
+
728
+ document.getElementById('obs-search').addEventListener('input', (e) => {
729
+ obsFilter = e.target.value.toLowerCase();
730
+ renderObsList();
731
+ });
732
+
733
+ container.querySelectorAll('.filter-btn').forEach(btn => {
734
+ btn.addEventListener('click', () => {
735
+ container.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
736
+ btn.classList.add('active');
737
+ obsTypeFilter = btn.dataset.type;
738
+ renderObsList();
739
+ });
740
+ });
741
+
742
+ renderObsList();
743
+ }
744
+
745
+ function renderObsList() {
746
+ const list = document.getElementById('obs-list');
747
+ if (!list) return;
748
+
749
+ const typeIcons = {
750
+ 'session-request': '🎯', gotcha: '🔴', 'problem-solution': '🟡',
751
+ 'how-it-works': '🔵', 'what-changed': '🟢', discovery: '🟣',
752
+ 'why-it-exists': '🟠', decision: '🟤', 'trade-off': '⚖️',
753
+ };
754
+
755
+ let filtered = allObservations;
756
+
757
+ if (obsTypeFilter) {
758
+ filtered = filtered.filter(o => o.type === obsTypeFilter);
759
+ }
760
+
761
+ if (obsFilter) {
762
+ filtered = filtered.filter(o =>
763
+ (o.title || '').toLowerCase().includes(obsFilter) ||
764
+ (o.narrative || '').toLowerCase().includes(obsFilter) ||
765
+ (o.entityName || '').toLowerCase().includes(obsFilter) ||
766
+ (o.facts || []).some(f => f.toLowerCase().includes(obsFilter))
767
+ );
768
+ }
769
+
770
+ if (filtered.length === 0) {
771
+ list.innerHTML = `<div style="padding: 40px; text-align: center; color: var(--text-muted);">${t('noMatchingObs')}</div>`;
772
+ return;
773
+ }
774
+
775
+ list.innerHTML = filtered.map(obs => `
776
+ <div class="obs-card">
777
+ <div class="obs-card-header">
778
+ <span class="obs-card-id">#${obs.id}</span>
779
+ <span class="type-badge" data-type="${obs.type || 'unknown'}">
780
+ ${typeIcons[obs.type] || '❓'} ${obs.type || 'unknown'}
781
+ </span>
782
+ <span class="obs-card-title">${escapeHtml(obs.title || t('untitled'))}</span>
783
+ </div>
784
+ <div class="obs-card-meta">
785
+ <span>📁 ${escapeHtml(obs.entityName || 'unknown')}</span>
786
+ ${obs.createdAt ? `<span>🕐 ${formatTime(obs.createdAt)}</span>` : ''}
787
+ ${obs.accessCount ? `<span>👁 ${obs.accessCount}</span>` : ''}
788
+ </div>
789
+ ${obs.narrative ? `<div class="obs-card-narrative">${escapeHtml(obs.narrative)}</div>` : ''}
790
+ ${obs.facts && obs.facts.length > 0 ? `
791
+ <div class="obs-card-facts">
792
+ ${obs.facts.map(f => `<span class="fact-tag">${escapeHtml(f)}</span>`).join('')}
793
+ </div>
794
+ ` : ''}
795
+ </div>
796
+ `).join('');
797
+ }
798
+
799
+ // ============================================================
800
+ // Retention Page
801
+ // ============================================================
802
+
803
+ async function loadRetention() {
804
+ const container = document.getElementById('page-retention');
805
+ container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
806
+
807
+ const data = await api('retention');
808
+ if (!data || data.items.length === 0) {
809
+ container.innerHTML = emptyState('📉', t('noRetentionData'), t('noRetentionDesc'));
810
+ return;
811
+ }
812
+
813
+ const { summary, items } = data;
814
+
815
+ container.innerHTML = `
816
+ <div class="page-header">
817
+ <h1 class="page-title">${t('memoryRetention')}</h1>
818
+ <p class="page-subtitle">${t('retentionSubtitle')}</p>
819
+ </div>
820
+
821
+ <div class="retention-summary">
822
+ <div class="stat-card" data-accent="green">
823
+ <div class="stat-label">${t('active')}</div>
824
+ <div class="stat-value">${summary.active}</div>
825
+ </div>
826
+ <div class="stat-card" data-accent="amber">
827
+ <div class="stat-label">${t('stale')}</div>
828
+ <div class="stat-value">${summary.stale}</div>
829
+ </div>
830
+ <div class="stat-card" data-accent="cyan">
831
+ <div class="stat-label">${t('archiveCandidates')}</div>
832
+ <div class="stat-value">${summary.archive}</div>
833
+ </div>
834
+ <div class="stat-card" data-accent="purple">
835
+ <div class="stat-label">${t('immune')}</div>
836
+ <div class="stat-value">${summary.immune}</div>
837
+ </div>
838
+ </div>
839
+
840
+ <div class="panel">
841
+ <div class="panel-header">
842
+ <span class="panel-title">${t('allObsByScore')}</span>
843
+ </div>
844
+ <div class="panel-body" style="padding: 0;">
845
+ <table class="retention-table">
846
+ <thead>
847
+ <tr>
848
+ <th>${t('id')}</th>
849
+ <th>${t('title')}</th>
850
+ <th>${t('type')}</th>
851
+ <th>${t('entity')}</th>
852
+ <th>${t('score')}</th>
853
+ <th>${t('ageH')}</th>
854
+ <th>${t('access')}</th>
855
+ <th>${t('status')}</th>
856
+ </tr>
857
+ </thead>
858
+ <tbody>
859
+ ${items.map(item => {
860
+ const scorePercent = Math.min(item.score / 10 * 100, 100);
861
+ const scoreColor = item.score >= 5 ? 'var(--accent-green)' : item.score >= 3 ? 'var(--accent-amber)' : item.score >= 1 ? 'var(--accent-red)' : 'var(--text-muted)';
862
+ return `
863
+ <tr>
864
+ <td style="font-family: var(--font-mono); color: var(--text-muted);">#${item.id}</td>
865
+ <td style="color: var(--text-primary); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(item.title || t('untitled'))}</td>
866
+ <td><span class="type-badge" data-type="${item.type}">${item.type}</span></td>
867
+ <td style="font-family: var(--font-mono); color: var(--text-muted); font-size: 12px;">${escapeHtml(item.entityName || '')}</td>
868
+ <td>
869
+ <div class="score-bar"><div class="score-bar-fill" style="width: ${scorePercent}%; background: ${scoreColor};"></div></div>
870
+ <span style="font-family: var(--font-mono); font-size: 12px; color: ${scoreColor};">${item.score}</span>
871
+ </td>
872
+ <td style="font-family: var(--font-mono); color: var(--text-muted); font-size: 12px;">${item.ageHours}h</td>
873
+ <td style="font-family: var(--font-mono); color: var(--text-muted); font-size: 12px;">${item.accessCount}</td>
874
+ <td>${item.isImmune ? `<span class="immune-badge">🛡️ ${t('immune')}</span>` : ''}</td>
875
+ </tr>
876
+ `;
877
+ }).join('')}
878
+ </tbody>
879
+ </table>
880
+ </div>
881
+ </div>
882
+ `;
883
+ }
884
+
885
+ // ============================================================
886
+ // Utilities
887
+ // ============================================================
888
+
889
+ function escapeHtml(text) {
890
+ const div = document.createElement('div');
891
+ div.textContent = text;
892
+ return div.innerHTML;
893
+ }
894
+
895
+ function formatTime(isoString) {
896
+ try {
897
+ const d = new Date(isoString);
898
+ return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
899
+ } catch {
900
+ return isoString;
901
+ }
902
+ }
903
+
904
+ function emptyState(icon, title, desc) {
905
+ return `
906
+ <div class="empty-state">
907
+ <div class="empty-state-icon">${icon}</div>
908
+ <div class="empty-state-title">${title}</div>
909
+ <div class="empty-state-desc">${desc}</div>
910
+ </div>
911
+ `;
912
+ }
913
+
914
+ // ============================================================
915
+ // Init
916
+ // ============================================================
917
+
918
+ // Apply initial language to nav tooltips
919
+ setLang(currentLang);
920
+
921
+ loadPage('dashboard');