claude-memory-layer 1.0.41 → 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.
@@ -78,9 +78,11 @@ function setupEventListeners() {
78
78
  await loadKpiData();
79
79
  await loadMemoryUsefulnessData();
80
80
  await loadOperationsStatsData();
81
+ await loadPerspectiveStatsData();
81
82
  updateKpiCardsUI();
82
83
  updateMemoryUsefulnessUI();
83
84
  updateOperationsStatsUI();
85
+ updatePerspectiveStatsUI();
84
86
  renderKpiTrendChart();
85
87
  });
86
88
  });
@@ -153,6 +155,11 @@ function setupEventListeners() {
153
155
  refreshBtn.addEventListener('click', refreshData);
154
156
  }
155
157
 
158
+ const vectorHealthRecoverBtn = document.getElementById('vector-health-recover-btn');
159
+ if (vectorHealthRecoverBtn) {
160
+ vectorHealthRecoverBtn.addEventListener('click', recoverVectorHealth);
161
+ }
162
+
156
163
  // Stat cards
157
164
  document.querySelectorAll('.stat-card[data-stat]').forEach(card => {
158
165
  card.addEventListener('click', () => {
@@ -20,6 +20,18 @@ async function loadOperationsStatsData() {
20
20
  .catch(() => null);
21
21
  }
22
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
+
23
35
  async function refreshData() {
24
36
  const btn = document.getElementById('refresh-btn');
25
37
  if(btn) btn.classList.add('loading');
@@ -29,7 +41,7 @@ async function refreshData() {
29
41
  const kpiWindowAtStart = state.kpiWindow;
30
42
 
31
43
  try {
32
- const [stats, shared, mostAccessed, helpfulness, memoryUsefulness, retrievalTraces, retrievalReviewQueue, operationsStats, adherenceSummary] = await Promise.all([
44
+ const [stats, shared, mostAccessed, helpfulness, memoryUsefulness, retrievalTraces, retrievalReviewQueue, operationsStats, perspectiveStats, adherenceSummary, vectorHealth] = await Promise.all([
33
45
  fetch(apiUrl(`${API_BASE}/stats`)).then(r => r.json()).catch(() => null),
34
46
  fetch(apiUrl(`${API_BASE}/stats/shared`)).then(r => r.json()).catch(() => null),
35
47
  fetch(apiUrl(`${API_BASE}/stats/most-accessed`, { limit: 10 })).then(r => r.json()).catch(() => null),
@@ -38,7 +50,9 @@ async function refreshData() {
38
50
  fetch(apiUrl(`${API_BASE}/stats/retrieval-traces`, { limit: 20 })).then(r => r.json()).catch(() => null),
39
51
  fetch(apiUrl(`${API_BASE}/stats/retrieval-review-queue`, { limit: 10 })).then(r => r.json()).catch(() => null),
40
52
  fetch(apiUrl(`${API_BASE}/stats/operations`, { windowDays: operationStatsWindowDays() })).then(r => r.ok ? r.json() : null).catch(() => null),
41
- fetchAdherenceSummary().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)
42
56
  ]);
43
57
 
44
58
  if (
@@ -57,7 +71,9 @@ async function refreshData() {
57
71
  state.retrievalTraces = retrievalTraces;
58
72
  state.retrievalReviewQueue = retrievalReviewQueue;
59
73
  state.operationsStats = operationsStats;
74
+ state.perspectiveStats = perspectiveStats;
60
75
  state.adherenceSummary = adherenceSummary;
76
+ state.vectorHealth = vectorHealth;
61
77
 
62
78
  await loadKpiData();
63
79
  if (refreshRequestId !== state.refreshRequestId) return;
@@ -377,7 +393,199 @@ function updateMemoryUsageUI() {
377
393
  updateTopAccessedEventsUI();
378
394
  updateAdherenceSummaryUI();
379
395
  updateRetrievalTraceUI();
396
+ updateVectorHealthUI();
380
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
+ }
381
589
  }
382
590
 
383
591
  function operationCount(value) {
@@ -503,6 +711,207 @@ function updateOperationsStatsUI() {
503
711
  if (lessonsEl) lessonsEl.innerHTML = operationRows(payload.lessons?.confidenceBuckets, 'bucket', 'count', 'No lesson confidence data');
504
712
  }
505
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
+ }
913
+ }
914
+
506
915
  function adherenceWindowToMs(window) {
507
916
  if (window === '24h') return 24 * 60 * 60 * 1000;
508
917
  if (window === '7d') return 7 * 24 * 60 * 60 * 1000;
@@ -15,6 +15,11 @@ const state = {
15
15
  retrievalTraces: null,
16
16
  retrievalReviewQueue: null,
17
17
  operationsStats: null,
18
+ perspectiveStats: null,
19
+ vectorHealth: null,
20
+ vectorHealthRecovery: null,
21
+ vectorHealthRecoveryProject: null,
22
+ isVectorRecoveryRunning: false,
18
23
  adherenceSummary: null,
19
24
  adherenceWindow: '24h',
20
25
  userPromptSearchQuery: '',
@@ -292,6 +292,22 @@
292
292
  </div>
293
293
  </div>
294
294
 
295
+ <!-- Vector Health -->
296
+ <div class="card">
297
+ <div class="card-header" style="align-items:flex-start;">
298
+ <div class="card-title">
299
+ <i class="ri-pulse-line"></i>
300
+ <span>Vector Health</span>
301
+ </div>
302
+ <button id="vector-health-recover-btn" class="sort-btn" title="Recover stale processing and retryable failed outbox jobs">Recover</button>
303
+ </div>
304
+ <div id="vector-health-summary" style="padding:8px 0; font-size:13px; color:var(--text-muted);">Loading vector health...</div>
305
+ <div id="vector-health-queue-list" class="shared-list" style="margin-top:10px;">
306
+ <div style="padding:12px; text-align:center; color:var(--text-muted); font-size:13px;">Loading...</div>
307
+ </div>
308
+ <div id="vector-health-recovery-result" style="padding:8px 0 0; font-size:12px; color:var(--text-muted);">No recovery run in this dashboard session.</div>
309
+ </div>
310
+
295
311
  <!-- Shared Knowledge -->
296
312
  <div class="card">
297
313
  <div class="card-header">
@@ -453,6 +469,66 @@
453
469
  </div>
454
470
  </div>
455
471
 
472
+ <!-- Perspective Memory -->
473
+ <div class="card">
474
+ <div class="card-header">
475
+ <div class="card-title">
476
+ <i class="ri-team-line"></i>
477
+ <span>Perspective Memory</span>
478
+ </div>
479
+ </div>
480
+ <div id="perspective-stats-summary" style="padding:8px 0; font-size:13px; color:var(--text-muted);">Loading perspective aggregates...</div>
481
+
482
+ <div style="margin-top:16px;">
483
+ <div class="section-label">Actor Card Panel</div>
484
+ <div id="perspective-cards-list" class="shared-list">
485
+ <div style="padding:12px; text-align:center; color:var(--text-muted); font-size:13px;">Loading...</div>
486
+ </div>
487
+ </div>
488
+
489
+ <div style="margin-top:16px;">
490
+ <div class="section-label">Actors & Session Membership</div>
491
+ <div id="perspective-actors-list" class="shared-list">
492
+ <div style="padding:12px; text-align:center; color:var(--text-muted); font-size:13px;">Loading...</div>
493
+ </div>
494
+ </div>
495
+
496
+ <div style="margin-top:16px;">
497
+ <div class="section-label">Observation Timeline / Filters</div>
498
+ <div id="perspective-observations-list" class="shared-list">
499
+ <div style="padding:12px; text-align:center; color:var(--text-muted); font-size:13px;">Loading...</div>
500
+ </div>
501
+ </div>
502
+
503
+ <div style="margin-top:16px;">
504
+ <div class="section-label">Observer → Observed Graph</div>
505
+ <div id="perspective-graph-list" class="shared-list">
506
+ <div style="padding:12px; text-align:center; color:var(--text-muted); font-size:13px;">Loading...</div>
507
+ </div>
508
+ </div>
509
+
510
+ <div style="margin-top:16px;">
511
+ <div class="section-label">Source Evidence Coverage</div>
512
+ <div id="perspective-evidence-list" class="shared-list">
513
+ <div style="padding:12px; text-align:center; color:var(--text-muted); font-size:13px;">Loading...</div>
514
+ </div>
515
+ </div>
516
+
517
+ <div style="margin-top:16px;">
518
+ <div class="section-label">Contradiction Review Queue</div>
519
+ <div id="perspective-contradictions-list" class="shared-list">
520
+ <div style="padding:12px; text-align:center; color:var(--text-muted); font-size:13px;">Loading...</div>
521
+ </div>
522
+ </div>
523
+
524
+ <div style="margin-top:16px;">
525
+ <div class="section-label">What Changed After Consolidation?</div>
526
+ <div id="perspective-activity-list" class="shared-list">
527
+ <div style="padding:12px; text-align:center; color:var(--text-muted); font-size:13px;">Loading...</div>
528
+ </div>
529
+ </div>
530
+ </div>
531
+
456
532
  </div>
457
533
 
458
534
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-memory-layer",
3
- "version": "1.0.41",
3
+ "version": "1.0.42",
4
4
  "description": "Claude Code plugin that learns from conversations to provide personalized assistance",
5
5
  "main": "dist/index.js",
6
6
  "bin": {