claude-memory-layer 1.0.40 → 1.0.42

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.
@@ -77,8 +77,12 @@ function setupEventListeners() {
77
77
  });
78
78
  await loadKpiData();
79
79
  await loadMemoryUsefulnessData();
80
+ await loadOperationsStatsData();
81
+ await loadPerspectiveStatsData();
80
82
  updateKpiCardsUI();
81
83
  updateMemoryUsefulnessUI();
84
+ updateOperationsStatsUI();
85
+ updatePerspectiveStatsUI();
82
86
  renderKpiTrendChart();
83
87
  });
84
88
  });
@@ -151,6 +155,11 @@ function setupEventListeners() {
151
155
  refreshBtn.addEventListener('click', refreshData);
152
156
  }
153
157
 
158
+ const vectorHealthRecoverBtn = document.getElementById('vector-health-recover-btn');
159
+ if (vectorHealthRecoverBtn) {
160
+ vectorHealthRecoverBtn.addEventListener('click', recoverVectorHealth);
161
+ }
162
+
154
163
  // Stat cards
155
164
  document.querySelectorAll('.stat-card[data-stat]').forEach(card => {
156
165
  card.addEventListener('click', () => {
@@ -10,6 +10,28 @@ async function loadMemoryUsefulnessData() {
10
10
  .catch(() => null);
11
11
  }
12
12
 
13
+ function operationStatsWindowDays() {
14
+ return state.kpiWindow === '30d' ? 30 : 7;
15
+ }
16
+
17
+ async function loadOperationsStatsData() {
18
+ state.operationsStats = await fetch(apiUrl(`${API_BASE}/stats/operations`, { windowDays: operationStatsWindowDays() }))
19
+ .then(r => r.ok ? r.json() : null)
20
+ .catch(() => null);
21
+ }
22
+
23
+ async function loadPerspectiveStatsData() {
24
+ state.perspectiveStats = await fetch(apiUrl(`${API_BASE}/stats/perspective`, { windowDays: operationStatsWindowDays() }))
25
+ .then(r => r.ok ? r.json() : null)
26
+ .catch(() => null);
27
+ }
28
+
29
+ async function loadVectorHealthData() {
30
+ state.vectorHealth = await fetch(apiUrl(`${API_BASE}/health`))
31
+ .then(r => r.ok ? r.json() : null)
32
+ .catch(() => null);
33
+ }
34
+
13
35
  async function refreshData() {
14
36
  const btn = document.getElementById('refresh-btn');
15
37
  if(btn) btn.classList.add('loading');
@@ -19,7 +41,7 @@ async function refreshData() {
19
41
  const kpiWindowAtStart = state.kpiWindow;
20
42
 
21
43
  try {
22
- const [stats, shared, mostAccessed, helpfulness, memoryUsefulness, retrievalTraces, retrievalReviewQueue, adherenceSummary] = await Promise.all([
44
+ const [stats, shared, mostAccessed, helpfulness, memoryUsefulness, retrievalTraces, retrievalReviewQueue, operationsStats, perspectiveStats, adherenceSummary, vectorHealth] = await Promise.all([
23
45
  fetch(apiUrl(`${API_BASE}/stats`)).then(r => r.json()).catch(() => null),
24
46
  fetch(apiUrl(`${API_BASE}/stats/shared`)).then(r => r.json()).catch(() => null),
25
47
  fetch(apiUrl(`${API_BASE}/stats/most-accessed`, { limit: 10 })).then(r => r.json()).catch(() => null),
@@ -27,7 +49,10 @@ async function refreshData() {
27
49
  fetch(apiUrl(`${API_BASE}/stats/usefulness`, { window: state.kpiWindow })).then(r => r.json()).catch(() => null),
28
50
  fetch(apiUrl(`${API_BASE}/stats/retrieval-traces`, { limit: 20 })).then(r => r.json()).catch(() => null),
29
51
  fetch(apiUrl(`${API_BASE}/stats/retrieval-review-queue`, { limit: 10 })).then(r => r.json()).catch(() => null),
30
- fetchAdherenceSummary().catch(() => null)
52
+ fetch(apiUrl(`${API_BASE}/stats/operations`, { windowDays: operationStatsWindowDays() })).then(r => r.ok ? r.json() : null).catch(() => null),
53
+ fetch(apiUrl(`${API_BASE}/stats/perspective`, { windowDays: operationStatsWindowDays() })).then(r => r.ok ? r.json() : null).catch(() => null),
54
+ fetchAdherenceSummary().catch(() => null),
55
+ fetch(apiUrl(`${API_BASE}/health`)).then(r => r.ok ? r.json() : null).catch(() => null)
31
56
  ]);
32
57
 
33
58
  if (
@@ -45,7 +70,10 @@ async function refreshData() {
45
70
  state.memoryUsefulness = memoryUsefulness;
46
71
  state.retrievalTraces = retrievalTraces;
47
72
  state.retrievalReviewQueue = retrievalReviewQueue;
73
+ state.operationsStats = operationsStats;
74
+ state.perspectiveStats = perspectiveStats;
48
75
  state.adherenceSummary = adherenceSummary;
76
+ state.vectorHealth = vectorHealth;
49
77
 
50
78
  await loadKpiData();
51
79
  if (refreshRequestId !== state.refreshRequestId) return;
@@ -365,6 +393,523 @@ function updateMemoryUsageUI() {
365
393
  updateTopAccessedEventsUI();
366
394
  updateAdherenceSummaryUI();
367
395
  updateRetrievalTraceUI();
396
+ updateVectorHealthUI();
397
+ updateOperationsStatsUI();
398
+ updatePerspectiveStatsUI();
399
+ }
400
+
401
+ function vectorHealthCount(value) {
402
+ const count = Number(value || 0);
403
+ return Number.isFinite(count) && count > 0 ? count : 0;
404
+ }
405
+
406
+ function vectorHealthEmpty(message) {
407
+ return `<div style="padding:12px; text-align:center; color:var(--text-muted); font-size:13px;">${escapeHtml(message)}</div>`;
408
+ }
409
+
410
+ function vectorOutboxTotals(outbox) {
411
+ const embedding = outbox?.embedding || {};
412
+ const vector = outbox?.vector || {};
413
+ const providedTotals = outbox?.totals || null;
414
+ return {
415
+ pending: vectorHealthCount(providedTotals?.pending ?? (vectorHealthCount(embedding.pending) + vectorHealthCount(vector.pending))),
416
+ processing: vectorHealthCount(providedTotals?.processing ?? (vectorHealthCount(embedding.processing) + vectorHealthCount(vector.processing))),
417
+ failed: vectorHealthCount(providedTotals?.failed ?? (vectorHealthCount(embedding.failed) + vectorHealthCount(vector.failed))),
418
+ stuckProcessing: vectorHealthCount(providedTotals?.stuckProcessing ?? (vectorHealthCount(embedding.stuckProcessing) + vectorHealthCount(vector.stuckProcessing))),
419
+ oldestProcessingAgeMs: providedTotals?.oldestProcessingAgeMs ?? maxNullableHealthAge(embedding.oldestProcessingAgeMs, vector.oldestProcessingAgeMs)
420
+ };
421
+ }
422
+
423
+ function maxNullableHealthAge(a, b) {
424
+ const values = [a, b]
425
+ .map(value => Number(value))
426
+ .filter(value => Number.isFinite(value) && value >= 0);
427
+ return values.length > 0 ? Math.max(...values) : null;
428
+ }
429
+
430
+ function formatVectorHealthAge(ms) {
431
+ const value = Number(ms);
432
+ if (!Number.isFinite(value) || value <= 0) return 'none';
433
+ const seconds = Math.floor(value / 1000);
434
+ if (seconds < 60) return `${seconds}s`;
435
+ const minutes = Math.floor(seconds / 60);
436
+ if (minutes < 60) return `${minutes}m`;
437
+ const hours = Math.floor(minutes / 60);
438
+ if (hours < 48) return `${hours}h`;
439
+ return `${Math.floor(hours / 24)}d`;
440
+ }
441
+
442
+ function vectorOutboxQueueRow(label, stats) {
443
+ const pending = vectorHealthCount(stats?.pending);
444
+ const processing = vectorHealthCount(stats?.processing);
445
+ const failed = vectorHealthCount(stats?.failed);
446
+ const stuck = vectorHealthCount(stats?.stuckProcessing);
447
+ const total = vectorHealthCount(stats?.total);
448
+ const age = formatVectorHealthAge(stats?.oldestProcessingAgeMs);
449
+ const statusColor = failed > 0 || stuck > 0 ? 'var(--warning)' : 'var(--success)';
450
+ return `
451
+ <div class="shared-item">
452
+ <div class="shared-info" style="flex-direction:column; align-items:flex-start; gap:2px; min-width:0;">
453
+ <span style="font-size:12px; color:var(--text-secondary);">${escapeHtml(label)}</span>
454
+ <span style="font-size:10px; color:var(--text-muted);">pending ${formatNumber(pending)} · processing ${formatNumber(processing)} · failed ${formatNumber(failed)} · stuck ${formatNumber(stuck)} · oldest ${age}</span>
455
+ </div>
456
+ <div style="display:flex; flex-direction:column; align-items:flex-end; gap:2px; min-width:48px;">
457
+ <span style="font-size:15px; font-weight:700; color:${statusColor};">${formatNumber(total)}</span>
458
+ <span style="font-size:10px; color:var(--text-muted);">total</span>
459
+ </div>
460
+ </div>
461
+ `;
462
+ }
463
+
464
+ function recoveryBucketTotal(bucket) {
465
+ return vectorHealthCount(bucket?.recoveredProcessing) + vectorHealthCount(bucket?.retriedFailed);
466
+ }
467
+
468
+ function renderVectorHealthRecovery() {
469
+ const recoveryEl = document.getElementById('vector-health-recovery-result');
470
+ if (!recoveryEl) return;
471
+
472
+ const recovery = state.vectorHealthRecovery;
473
+ const recoveryProject = state.vectorHealthRecoveryProject;
474
+ if (recovery && recoveryProject !== null && recoveryProject !== (state.currentProject || '')) {
475
+ recoveryEl.innerHTML = '<span style="color:var(--text-muted);">No recovery run in this dashboard session.</span>';
476
+ return;
477
+ }
478
+ if (!recovery) {
479
+ recoveryEl.innerHTML = '<span style="color:var(--text-muted);">No recovery run in this dashboard session.</span>';
480
+ return;
481
+ }
482
+
483
+ if (recovery.status && recovery.status !== 'ok') {
484
+ recoveryEl.innerHTML = '<span style="color:var(--warning);">Last recovery request failed. No private error details are shown.</span>';
485
+ return;
486
+ }
487
+
488
+ const embeddingTotal = recoveryBucketTotal(recovery.recovered?.embedding);
489
+ const vectorTotal = recoveryBucketTotal(recovery.recovered?.vector);
490
+ const timestamp = recovery.timestamp ? new Date(recovery.timestamp).toLocaleString() : 'just now';
491
+ recoveryEl.innerHTML = `
492
+ <span style="color:var(--text-secondary);">Last recovery ${escapeHtml(timestamp)}</span>
493
+ <span style="color:var(--text-muted); margin-left:6px;">embedding=${formatNumber(embeddingTotal)} · vector=${formatNumber(vectorTotal)} · total=${formatNumber(embeddingTotal + vectorTotal)}</span>
494
+ `;
495
+ }
496
+
497
+ function updateVectorHealthUI() {
498
+ const payload = state.vectorHealth;
499
+ const summaryEl = document.getElementById('vector-health-summary');
500
+ const queueEl = document.getElementById('vector-health-queue-list');
501
+ const recoveryEl = document.getElementById('vector-health-recovery-result');
502
+ if (!summaryEl && !queueEl && !recoveryEl) return;
503
+
504
+ if (!payload) {
505
+ if (summaryEl) summaryEl.innerHTML = '<span style="color:var(--text-muted);">Vector health unavailable</span>';
506
+ if (queueEl) queueEl.innerHTML = vectorHealthEmpty('Vector health aggregate data unavailable');
507
+ renderVectorHealthRecovery();
508
+ return;
509
+ }
510
+
511
+ const status = payload.status || 'unknown';
512
+ const outbox = payload.outbox || {};
513
+ const totals = vectorOutboxTotals(outbox);
514
+ const vectorCount = vectorHealthCount(payload.storage?.vectorCount);
515
+ const statusColor = status === 'ok' ? 'var(--success)' : (status === 'needs-attention' ? 'var(--warning)' : 'var(--text-muted)');
516
+
517
+ if (summaryEl) {
518
+ summaryEl.innerHTML = `
519
+ <div style="display:flex; gap:10px; flex-wrap:wrap; font-size:13px; color:var(--text-secondary);">
520
+ <span><strong style="color:${statusColor};">${escapeHtml(status)}</strong></span>
521
+ <span><strong>${formatNumber(vectorCount)} vectors</strong></span>
522
+ <span><strong>${formatNumber(totals.pending)} pending</strong></span>
523
+ <span><strong>${formatNumber(totals.processing)} processing</strong></span>
524
+ <span><strong>${formatNumber(totals.failed)} failed</strong></span>
525
+ <span><strong>${formatNumber(totals.stuckProcessing)} stuck</strong></span>
526
+ <span><strong>${formatVectorHealthAge(totals.oldestProcessingAgeMs)} oldest processing</strong></span>
527
+ </div>
528
+ `;
529
+ }
530
+
531
+ if (queueEl) {
532
+ queueEl.innerHTML = [
533
+ vectorOutboxQueueRow('Embedding Outbox', outbox.embedding),
534
+ vectorOutboxQueueRow('Vector Outbox', outbox.vector)
535
+ ].join('');
536
+ }
537
+
538
+ renderVectorHealthRecovery();
539
+ }
540
+
541
+ function healthPayloadFromRecovery(payload) {
542
+ const after = payload?.after || {};
543
+ if (!after.storage && !after.outbox) return null;
544
+ const totals = vectorOutboxTotals(after.outbox || {});
545
+ const status = totals.failed > 0 || totals.stuckProcessing > 0 ? 'needs-attention' : (payload.status || 'ok');
546
+ return {
547
+ status,
548
+ timestamp: payload.timestamp || new Date().toISOString(),
549
+ storage: after.storage || {},
550
+ outbox: after.outbox || {},
551
+ levelStats: []
552
+ };
553
+ }
554
+
555
+ async function recoverVectorHealth() {
556
+ const button = document.getElementById('vector-health-recover-btn');
557
+ if (state.isVectorRecoveryRunning) return;
558
+ state.isVectorRecoveryRunning = true;
559
+ if (button) button.disabled = true;
560
+
561
+ try {
562
+ const response = await fetch(apiUrl(`${API_BASE}/health/recover`), {
563
+ method: 'POST',
564
+ headers: { 'Content-Type': 'application/json' },
565
+ body: JSON.stringify({})
566
+ });
567
+ if (!response.ok) throw new Error('recovery failed');
568
+ const payload = await response.json();
569
+ state.vectorHealthRecovery = payload;
570
+ state.vectorHealthRecoveryProject = state.currentProject || '';
571
+ const health = healthPayloadFromRecovery(payload);
572
+ if (health) state.vectorHealth = health;
573
+ updateVectorHealthUI();
574
+ } catch {
575
+ state.vectorHealthRecovery = {
576
+ status: 'error',
577
+ timestamp: new Date().toISOString(),
578
+ recovered: {
579
+ embedding: { recoveredProcessing: 0, retriedFailed: 0 },
580
+ vector: { recoveredProcessing: 0, retriedFailed: 0 }
581
+ }
582
+ };
583
+ state.vectorHealthRecoveryProject = state.currentProject || '';
584
+ updateVectorHealthUI();
585
+ } finally {
586
+ state.isVectorRecoveryRunning = false;
587
+ if (button) button.disabled = false;
588
+ }
589
+ }
590
+
591
+ function operationCount(value) {
592
+ const count = Number(value || 0);
593
+ return Number.isFinite(count) && count > 0 ? count : 0;
594
+ }
595
+
596
+ function operationEmpty(message) {
597
+ return `<div style="padding:12px; text-align:center; color:var(--text-muted); font-size:13px;">${escapeHtml(message)}</div>`;
598
+ }
599
+
600
+ function operationRows(rows, labelKey, countKey, emptyMessage, labelFormatter) {
601
+ const safeRows = Array.isArray(rows) ? rows : [];
602
+ if (safeRows.length === 0) return operationEmpty(emptyMessage);
603
+ return safeRows.map(row => {
604
+ const label = labelFormatter ? labelFormatter(row) : row?.[labelKey];
605
+ return `
606
+ <div class="shared-item">
607
+ <div class="shared-info" style="min-width:0;">
608
+ <span style="font-size:12px; color:var(--text-secondary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(label || 'unknown')}</span>
609
+ </div>
610
+ <div class="shared-count">${formatNumber(operationCount(row?.[countKey]))}</div>
611
+ </div>
612
+ `;
613
+ }).join('');
614
+ }
615
+
616
+ function updateOperationsStatsUI() {
617
+ const payload = state.operationsStats;
618
+ const summaryEl = document.getElementById('operations-stats-summary');
619
+ const facetsEl = document.getElementById('operations-facets-list');
620
+ const actionsEl = document.getElementById('operations-actions-list');
621
+ const leasesEl = document.getElementById('operations-leases-list');
622
+ const retentionEl = document.getElementById('operations-retention-list');
623
+ const governanceEl = document.getElementById('operations-governance-list');
624
+ const lessonsEl = document.getElementById('operations-lessons-list');
625
+
626
+ if (!summaryEl && !facetsEl && !actionsEl && !leasesEl && !retentionEl && !governanceEl && !lessonsEl) return;
627
+
628
+ if (!payload) {
629
+ if (summaryEl) summaryEl.innerHTML = '<span style="color:var(--text-muted);">Operation aggregates unavailable</span>';
630
+ if (facetsEl) facetsEl.innerHTML = operationEmpty('Operation aggregate data unavailable');
631
+ if (actionsEl) actionsEl.innerHTML = operationEmpty('Operation aggregate data unavailable');
632
+ if (leasesEl) leasesEl.innerHTML = operationEmpty('Operation aggregate data unavailable');
633
+ if (retentionEl) retentionEl.innerHTML = operationEmpty('Operation aggregate data unavailable');
634
+ if (governanceEl) governanceEl.innerHTML = operationEmpty('Operation aggregate data unavailable');
635
+ if (lessonsEl) lessonsEl.innerHTML = operationEmpty('Operation aggregate data unavailable');
636
+ return;
637
+ }
638
+
639
+ const available = payload.projection?.available !== false && payload.projection?.databaseExists !== false;
640
+ if (summaryEl) {
641
+ if (!available) {
642
+ summaryEl.innerHTML = '<span style="color:var(--text-muted);">Operation projections unavailable</span>';
643
+ } else {
644
+ summaryEl.innerHTML = `
645
+ <div style="display:flex; gap:10px; flex-wrap:wrap; font-size:13px; color:var(--text-secondary);">
646
+ <span><strong>${formatNumber(operationCount(payload.facets?.totalAssignments))} facets</strong></span>
647
+ <span><strong>${formatNumber(operationCount(payload.actions?.total))} actions</strong></span>
648
+ <span><strong>${formatNumber(operationCount(payload.leases?.totalActive))} active leases</strong></span>
649
+ <span><strong>${formatNumber(operationCount(payload.retention?.total))} retention decisions</strong></span>
650
+ <span><strong>${formatNumber(operationCount(payload.governanceAudit?.total))} audits</strong></span>
651
+ <span><strong>${formatNumber(operationCount(payload.lessons?.total))} lessons</strong></span>
652
+ </div>
653
+ `;
654
+ }
655
+ }
656
+
657
+ if (!available) {
658
+ if (facetsEl) facetsEl.innerHTML = operationEmpty('No facet aggregates');
659
+ if (actionsEl) actionsEl.innerHTML = operationEmpty('No action status data');
660
+ if (leasesEl) leasesEl.innerHTML = operationEmpty('No active leases');
661
+ if (retentionEl) retentionEl.innerHTML = operationEmpty('No retention decisions');
662
+ if (governanceEl) governanceEl.innerHTML = operationEmpty('No governance audit activity');
663
+ if (lessonsEl) lessonsEl.innerHTML = operationEmpty('No lesson confidence data');
664
+ return;
665
+ }
666
+
667
+ if (facetsEl) {
668
+ const distributions = Array.isArray(payload.facets?.distribution) ? payload.facets.distribution : [];
669
+ facetsEl.innerHTML = distributions.length === 0
670
+ ? operationEmpty('No facet aggregates')
671
+ : distributions.map(item => {
672
+ const valueCount = Array.isArray(item?.values) ? item.values.reduce((sum, row) => sum + operationCount(row?.count), 0) : 0;
673
+ const otherCount = operationCount(item?.other);
674
+ const bucketCount = (Array.isArray(item?.values) ? item.values.length : 0) + (otherCount > 0 ? 1 : 0);
675
+ return `
676
+ <div class="shared-item">
677
+ <div class="shared-info" style="min-width:0;">
678
+ <span style="font-size:12px; color:var(--text-secondary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(item?.dimension || 'unknown')}</span>
679
+ </div>
680
+ <div style="display:flex; flex-direction:column; align-items:flex-end; gap:2px; min-width:72px;">
681
+ <span style="font-size:15px; font-weight:700; color:var(--accent-primary);">${formatNumber(valueCount + otherCount)}</span>
682
+ <span style="font-size:10px; color:var(--text-muted);">${bucketCount} value buckets</span>
683
+ </div>
684
+ </div>
685
+ `;
686
+ }).join('');
687
+ }
688
+
689
+ if (actionsEl) actionsEl.innerHTML = operationRows(payload.actions?.byStatus, 'status', 'count', 'No action status data');
690
+ if (leasesEl) leasesEl.innerHTML = operationRows(payload.leases?.activeByTargetType, 'targetType', 'count', 'No active leases');
691
+ if (retentionEl) retentionEl.innerHTML = operationRows(payload.retention?.byDecision, 'decision', 'count', 'No retention decisions');
692
+ if (governanceEl) {
693
+ const days = Array.isArray(payload.governanceAudit?.operationsByDay) ? payload.governanceAudit.operationsByDay : [];
694
+ governanceEl.innerHTML = days.length === 0
695
+ ? operationEmpty('No governance audit activity')
696
+ : days.map(day => {
697
+ const operations = (Array.isArray(day?.operations) ? day.operations : [])
698
+ .map(op => `${escapeHtml(op?.operation || 'unknown')}: ${formatNumber(operationCount(op?.count))}`)
699
+ .join(' · ');
700
+ return `
701
+ <div class="shared-item">
702
+ <div class="shared-info" style="flex-direction:column; align-items:flex-start; gap:2px; min-width:0;">
703
+ <span style="font-size:12px; color:var(--text-secondary);">${escapeHtml(day?.date || 'unknown')}</span>
704
+ <span style="font-size:10px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:220px;">${operations || 'no operations'}</span>
705
+ </div>
706
+ <div class="shared-count">${formatNumber(operationCount(day?.total))}</div>
707
+ </div>
708
+ `;
709
+ }).join('');
710
+ }
711
+ if (lessonsEl) lessonsEl.innerHTML = operationRows(payload.lessons?.confidenceBuckets, 'bucket', 'count', 'No lesson confidence data');
712
+ }
713
+
714
+ function perspectiveLevelRows(rows, emptyMessage) {
715
+ return operationRows(rows, 'level', 'count', emptyMessage);
716
+ }
717
+
718
+ function perspectiveGraphRows(edges) {
719
+ const safeEdges = Array.isArray(edges) ? edges : [];
720
+ if (safeEdges.length === 0) return operationEmpty('No perspective graph edges');
721
+ return safeEdges.map(edge => {
722
+ const confidence = `${(Number(edge?.averageConfidence || 0) * 100).toFixed(0)}% avg confidence`;
723
+ const levels = (Array.isArray(edge?.levelCounts) ? edge.levelCounts : [])
724
+ .map(level => `${escapeHtml(level?.level || 'unknown')}: ${formatNumber(operationCount(level?.count))}`)
725
+ .join(' · ');
726
+ return `
727
+ <div class="shared-item">
728
+ <div class="shared-info" style="flex-direction:column; align-items:flex-start; gap:2px; min-width:0;">
729
+ <span style="font-size:12px; color:var(--text-secondary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:240px;">${escapeHtml(edge?.observerActorId || 'unknown')} → ${escapeHtml(edge?.observedActorId || 'unknown')}</span>
730
+ <span style="font-size:10px; color:var(--text-muted);">${formatNumber(operationCount(edge?.observationCount))} observations · ${formatNumber(operationCount(edge?.actorCardCount))} actor cards · ${confidence}</span>
731
+ <span style="font-size:10px; color:var(--text-muted);">sources: ${formatNumber(operationCount(edge?.sourceEventCount))} events · ${formatNumber(operationCount(edge?.sourceObservationCount))} observations</span>
732
+ <span style="font-size:10px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:240px;">${levels || 'no levels'}</span>
733
+ </div>
734
+ <div class="shared-count">${formatNumber(operationCount(edge?.observationCount))}</div>
735
+ </div>
736
+ `;
737
+ }).join('');
738
+ }
739
+
740
+ function perspectiveSourceEvidenceRows(sourceEvidence) {
741
+ const rows = Array.isArray(sourceEvidence?.byLevel) ? sourceEvidence.byLevel : [];
742
+ const summary = sourceEvidence?.summary || {};
743
+ if (rows.length === 0) return operationEmpty('No source evidence aggregates');
744
+ return `
745
+ <div class="shared-item">
746
+ <div class="shared-info" style="flex-direction:column; align-items:flex-start; gap:2px; min-width:0;">
747
+ <span style="font-size:12px; color:var(--text-secondary);">Evidence coverage</span>
748
+ <span style="font-size:10px; color:var(--text-muted);">${formatNumber(operationCount(summary.totalSourceEvents))} source events · ${formatNumber(operationCount(summary.totalSourceObservations))} source observations · ${formatNumber(operationCount(summary.observationsMissingEvidence))} missing evidence</span>
749
+ </div>
750
+ <div class="shared-count">${formatNumber(operationCount(summary.totalObservations))}</div>
751
+ </div>
752
+ ${rows.map(row => `
753
+ <div class="shared-item">
754
+ <div class="shared-info" style="flex-direction:column; align-items:flex-start; gap:2px; min-width:0;">
755
+ <span style="font-size:12px; color:var(--text-secondary);">${escapeHtml(row?.level || 'unknown')}</span>
756
+ <span style="font-size:10px; color:var(--text-muted);">sources: ${formatNumber(operationCount(row?.sourceEventCount))} events · ${formatNumber(operationCount(row?.sourceObservationCount))} observations · missing: ${formatNumber(operationCount(row?.missingEvidenceCount))}</span>
757
+ </div>
758
+ <div class="shared-count">${formatNumber(operationCount(row?.count))}</div>
759
+ </div>
760
+ `).join('')}
761
+ `;
762
+ }
763
+
764
+ function updatePerspectiveStatsUI() {
765
+ const payload = state.perspectiveStats;
766
+ const summaryEl = document.getElementById('perspective-stats-summary');
767
+ const actorsEl = document.getElementById('perspective-actors-list');
768
+ const cardsEl = document.getElementById('perspective-cards-list');
769
+ const observationsEl = document.getElementById('perspective-observations-list');
770
+ const graphEl = document.getElementById('perspective-graph-list');
771
+ const evidenceEl = document.getElementById('perspective-evidence-list');
772
+ const contradictionsEl = document.getElementById('perspective-contradictions-list');
773
+ const activityEl = document.getElementById('perspective-activity-list');
774
+
775
+ if (!summaryEl && !actorsEl && !cardsEl && !observationsEl && !graphEl && !evidenceEl && !contradictionsEl && !activityEl) return;
776
+
777
+ if (!payload) {
778
+ if (summaryEl) summaryEl.innerHTML = '<span style="color:var(--text-muted);">Perspective aggregates unavailable</span>';
779
+ if (actorsEl) actorsEl.innerHTML = operationEmpty('Perspective aggregate data unavailable');
780
+ if (cardsEl) cardsEl.innerHTML = operationEmpty('Perspective aggregate data unavailable');
781
+ if (observationsEl) observationsEl.innerHTML = operationEmpty('Perspective aggregate data unavailable');
782
+ if (graphEl) graphEl.innerHTML = operationEmpty('Perspective aggregate data unavailable');
783
+ if (evidenceEl) evidenceEl.innerHTML = operationEmpty('Perspective aggregate data unavailable');
784
+ if (contradictionsEl) contradictionsEl.innerHTML = operationEmpty('Perspective aggregate data unavailable');
785
+ if (activityEl) activityEl.innerHTML = operationEmpty('Perspective aggregate data unavailable');
786
+ return;
787
+ }
788
+
789
+ const available = payload.projection?.available !== false && payload.projection?.databaseExists !== false;
790
+ if (summaryEl) {
791
+ if (!available) {
792
+ summaryEl.innerHTML = '<span style="color:var(--text-muted);">Perspective projections unavailable</span>';
793
+ } else {
794
+ summaryEl.innerHTML = `
795
+ <div style="display:flex; gap:10px; flex-wrap:wrap; font-size:13px; color:var(--text-secondary);">
796
+ <span><strong>${formatNumber(operationCount(payload.actors?.total))} actors</strong></span>
797
+ <span><strong>${formatNumber(operationCount(payload.sessionActors?.total))} session actors</strong></span>
798
+ <span><strong>${formatNumber(operationCount(payload.actorCards?.total))} actor cards</strong></span>
799
+ <span><strong>${formatNumber(operationCount(payload.observations?.total))} observations</strong></span>
800
+ <span><strong>${formatNumber(operationCount(payload.perspectiveGraph?.summary?.totalEdges))} perspective edges</strong></span>
801
+ <span><strong>${formatNumber(operationCount(payload.contradictions?.summary?.total))} contradictions</strong></span>
802
+ </div>
803
+ `;
804
+ }
805
+ }
806
+
807
+ if (!available) {
808
+ if (actorsEl) actorsEl.innerHTML = operationEmpty('No actor kind data');
809
+ if (cardsEl) cardsEl.innerHTML = operationEmpty('No actor card aggregates');
810
+ if (observationsEl) observationsEl.innerHTML = operationEmpty('No perspective observation data');
811
+ if (graphEl) graphEl.innerHTML = operationEmpty('No perspective graph edges');
812
+ if (evidenceEl) evidenceEl.innerHTML = operationEmpty('No source evidence aggregates');
813
+ if (contradictionsEl) contradictionsEl.innerHTML = operationEmpty('No contradictions queued');
814
+ if (activityEl) activityEl.innerHTML = operationEmpty('No perspective activity');
815
+ return;
816
+ }
817
+
818
+ if (actorsEl) {
819
+ const kindRows = operationRows(payload.actors?.byKind, 'kind', 'count', 'No actor kind data');
820
+ const roleRows = operationRows(payload.sessionActors?.byRole, 'role', 'count', 'No session actor roles');
821
+ actorsEl.innerHTML = `
822
+ <div class="section-label" style="font-size:11px; margin-bottom:6px;">Actor Kinds</div>
823
+ ${kindRows}
824
+ <div class="section-label" style="font-size:11px; margin:12px 0 6px;">Session Roles</div>
825
+ ${roleRows}
826
+ <div style="font-size:10px; color:var(--text-muted); padding:8px 0;">observe self: ${formatNumber(operationCount(payload.sessionActors?.observeSelfEnabled))} · observe others: ${formatNumber(operationCount(payload.sessionActors?.observeOthersEnabled))}</div>
827
+ `;
828
+ }
829
+
830
+ if (cardsEl) {
831
+ const totalCards = operationCount(payload.actorCards?.total);
832
+ if (totalCards === 0) {
833
+ cardsEl.innerHTML = operationEmpty('No actor card aggregates');
834
+ } else {
835
+ cardsEl.innerHTML = `
836
+ <div class="shared-item">
837
+ <div class="shared-info"><span style="font-size:12px; color:var(--text-secondary);">Total cards</span></div>
838
+ <div class="shared-count">${formatNumber(totalCards)}</div>
839
+ </div>
840
+ <div class="shared-item">
841
+ <div class="shared-info"><span style="font-size:12px; color:var(--text-secondary);">Card entries</span></div>
842
+ <div class="shared-count">${formatNumber(operationCount(payload.actorCards?.totalEntries))} entries</div>
843
+ </div>
844
+ <div class="shared-item">
845
+ <div class="shared-info"><span style="font-size:12px; color:var(--text-secondary);">Average entries</span></div>
846
+ <div class="shared-count">${Number(payload.actorCards?.averageEntries || 0).toFixed(2)}</div>
847
+ </div>
848
+ <div class="shared-item">
849
+ <div class="shared-info"><span style="font-size:12px; color:var(--text-secondary);">Full cards</span></div>
850
+ <div class="shared-count">${formatNumber(operationCount(payload.actorCards?.fullCards))} full cards</div>
851
+ </div>
852
+ `;
853
+ }
854
+ }
855
+
856
+ if (observationsEl) {
857
+ const byLevel = perspectiveLevelRows(payload.observations?.byLevel, 'No perspective observation data');
858
+ const byCreatedBy = operationRows(payload.observations?.byCreatedBy, 'createdBy', 'count', 'No observation creator data');
859
+ observationsEl.innerHTML = `
860
+ <div class="section-label" style="font-size:11px; margin-bottom:6px;">Timeline / Levels</div>
861
+ ${byLevel}
862
+ <div class="section-label" style="font-size:11px; margin:12px 0 6px;">Created By</div>
863
+ ${byCreatedBy}
864
+ `;
865
+ }
866
+
867
+ if (graphEl) {
868
+ graphEl.innerHTML = perspectiveGraphRows(payload.perspectiveGraph?.edges);
869
+ }
870
+
871
+ if (evidenceEl) {
872
+ evidenceEl.innerHTML = perspectiveSourceEvidenceRows(payload.sourceEvidence);
873
+ }
874
+
875
+ if (contradictionsEl) {
876
+ const items = Array.isArray(payload.contradictions?.items) ? payload.contradictions.items : [];
877
+ contradictionsEl.innerHTML = items.length === 0
878
+ ? operationEmpty('No contradictions queued')
879
+ : items.map(item => {
880
+ const confidence = `${(Number(item?.confidence || 0) * 100).toFixed(0)}%`;
881
+ return `
882
+ <div class="shared-item">
883
+ <div class="shared-info" style="flex-direction:column; align-items:flex-start; gap:2px; min-width:0;">
884
+ <span style="font-size:12px; color:var(--text-secondary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:220px;">${escapeHtml(item?.observationId || 'unknown')}</span>
885
+ <span style="font-size:10px; color:var(--text-muted);">${escapeHtml(item?.observerActorId || 'unknown')} → ${escapeHtml(item?.observedActorId || 'unknown')}</span>
886
+ <span style="font-size:10px; color:var(--text-muted);">sources: ${formatNumber(operationCount(item?.sourceEventCount))} events · ${formatNumber(operationCount(item?.sourceObservationCount))} observations</span>
887
+ </div>
888
+ <div class="shared-count">${confidence}</div>
889
+ </div>
890
+ `;
891
+ }).join('');
892
+ }
893
+
894
+ if (activityEl) {
895
+ const days = Array.isArray(payload.recentActivity?.byDay) ? payload.recentActivity.byDay : [];
896
+ activityEl.innerHTML = days.length === 0
897
+ ? operationEmpty('No perspective activity')
898
+ : days.map(day => {
899
+ const levels = (Array.isArray(day?.levels) ? day.levels : [])
900
+ .map(level => `${escapeHtml(level?.level || 'unknown')}: ${formatNumber(operationCount(level?.count))}`)
901
+ .join(' · ');
902
+ return `
903
+ <div class="shared-item">
904
+ <div class="shared-info" style="flex-direction:column; align-items:flex-start; gap:2px; min-width:0;">
905
+ <span style="font-size:12px; color:var(--text-secondary);">${escapeHtml(day?.date || 'unknown')}</span>
906
+ <span style="font-size:10px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:220px;">${levels || 'no levels'}</span>
907
+ </div>
908
+ <div class="shared-count">${formatNumber(operationCount(day?.total))}</div>
909
+ </div>
910
+ `;
911
+ }).join('');
912
+ }
368
913
  }
369
914
 
370
915
  function adherenceWindowToMs(window) {
@@ -14,6 +14,12 @@ const state = {
14
14
  memoryUsefulness: null,
15
15
  retrievalTraces: null,
16
16
  retrievalReviewQueue: null,
17
+ operationsStats: null,
18
+ perspectiveStats: null,
19
+ vectorHealth: null,
20
+ vectorHealthRecovery: null,
21
+ vectorHealthRecoveryProject: null,
22
+ isVectorRecoveryRunning: false,
17
23
  adherenceSummary: null,
18
24
  adherenceWindow: '24h',
19
25
  userPromptSearchQuery: '',