feat: Add core memory clustering visualization and hooks configuration

- Implemented core memory clustering visualization in core-memory-clusters.js
- Added functions for loading, rendering, and managing clusters and their members
- Created example hooks configuration in hooks-config-example.json for session management
- Developed test script for hooks integration in test-hooks.js
- Included error handling and notifications for user interactions
This commit is contained in:
catlog22
2025-12-18 23:06:58 +08:00
parent 68f9de0c69
commit 9f6e6852da
24 changed files with 4543 additions and 590 deletions

View File

@@ -102,6 +102,70 @@
padding: 1.5rem;
}
/* Tab Navigation */
.core-memory-tabs {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
border-bottom: 1px solid hsl(var(--border));
padding-bottom: 1rem;
}
.tab-nav {
display: flex;
gap: 0;
background: hsl(var(--muted) / 0.5);
border-radius: 8px;
padding: 4px;
}
.tab-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
}
.tab-btn i {
width: 18px;
height: 18px;
}
.tab-btn:hover {
color: hsl(var(--foreground));
}
.tab-btn.active {
background: hsl(var(--card));
color: hsl(var(--primary));
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.tab-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.cm-tab-panel {
animation: fadeIn 0.2s ease-out;
}
.cm-tab-panel .memory-stats {
margin-bottom: 1rem;
}
.core-memory-header {
display: flex;
justify-content: space-between;
@@ -829,3 +893,609 @@
[data-theme="dark"] .version-content-preview {
background: #0f172a;
}
/* ============================================
Session Clustering Styles
============================================ */
.clusters-container {
margin-top: 0;
height: calc(100vh - 200px);
min-height: 500px;
}
.clusters-layout {
display: grid;
grid-template-columns: 320px 1fr;
gap: 1.5rem;
height: 100%;
}
@media (max-width: 900px) {
.clusters-layout {
grid-template-columns: 1fr;
}
}
/* Clusters Sidebar */
.clusters-sidebar {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.clusters-sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.3);
}
.clusters-sidebar-header h4 {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.cluster-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
/* Cluster Item */
.cluster-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 0.5rem;
border: 1px solid transparent;
}
.cluster-item:hover {
background: hsl(var(--muted) / 0.5);
}
.cluster-item.active {
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary) / 0.3);
}
.cluster-icon {
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--muted));
border-radius: 6px;
color: hsl(var(--muted-foreground));
}
.cluster-icon i {
width: 18px;
height: 18px;
}
.cluster-item.active .cluster-icon {
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
}
.cluster-info {
flex: 1;
min-width: 0;
}
.cluster-name {
font-weight: 500;
font-size: 0.9375rem;
color: hsl(var(--foreground));
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cluster-meta {
display: flex;
gap: 0.75rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
/* Cluster Status Badges */
.badge-active {
background: hsl(142 76% 36% / 0.15);
color: hsl(142 76% 36%);
}
.badge-archived {
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
}
.badge-pending {
background: hsl(38 92% 50% / 0.15);
color: hsl(38 92% 40%);
}
/* Clusters Detail Panel */
.clusters-detail {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.cluster-detail-content {
padding: 1.5rem;
flex: 1;
overflow-y: auto;
min-height: 0; /* Enable flexbox scrolling */
}
.cluster-detail-content .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 300px;
}
/* Cluster Detail View */
.cluster-detail {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.cluster-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.cluster-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.cluster-actions {
display: flex;
gap: 0.5rem;
}
.cluster-description {
color: hsl(var(--muted-foreground));
font-size: 0.9375rem;
line-height: 1.6;
margin: 0;
}
.cluster-intent {
padding: 0.75rem 1rem;
background: hsl(var(--muted) / 0.5);
border-radius: 6px;
font-size: 0.875rem;
color: hsl(var(--foreground));
}
.cluster-intent strong {
color: hsl(var(--primary));
}
/* Cluster Sections */
.cluster-timeline,
.cluster-relations {
padding-top: 1rem;
border-top: 1px solid hsl(var(--border));
}
.cluster-timeline h4,
.cluster-relations h4 {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.cluster-timeline h4 i,
.cluster-relations h4 i {
width: 18px;
height: 18px;
color: hsl(var(--primary));
}
/* Session Timeline */
.timeline {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 100%;
}
/* Override conflicting timeline-item styles from other CSS files */
.cluster-timeline .timeline .timeline-item {
display: flex;
gap: 1rem;
position: relative;
/* Reset card-like appearance from other CSS */
background: transparent;
border: none;
border-radius: 0;
padding: 0;
margin-bottom: 0;
/* Remove height constraints */
min-height: auto;
max-height: none;
overflow: visible;
cursor: default;
}
.cluster-timeline .timeline .timeline-item::before {
content: '';
position: absolute;
left: 0.6875rem;
top: 2rem;
bottom: -1rem;
width: 2px;
background: hsl(var(--border));
}
.cluster-timeline .timeline .timeline-item:last-child::before {
display: none;
}
.timeline-marker {
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.timeline-number {
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--primary));
color: white;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
}
.timeline-content {
flex: 1;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 8px;
padding: 1rem;
transition: all 0.2s ease;
cursor: pointer;
}
.timeline-content:hover {
border-color: hsl(var(--primary) / 0.3);
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.05);
}
.timeline-content.expanded {
border-color: hsl(var(--primary) / 0.5);
background: hsl(var(--primary) / 0.02);
}
.timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid hsl(var(--border));
}
.session-id {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted));
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
/* Session Type Badges */
.badge-core_memory {
background: hsl(260 70% 50% / 0.15);
color: hsl(260 70% 50%);
}
.badge-workflow {
background: hsl(200 80% 50% / 0.15);
color: hsl(200 80% 45%);
}
.badge-cli_history {
background: hsl(30 80% 50% / 0.15);
color: hsl(30 80% 40%);
}
.session-title {
font-weight: 600;
font-size: 0.9375rem;
color: hsl(var(--foreground));
line-height: 1.4;
margin-bottom: 0.5rem;
}
.session-summary {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
line-height: 1.5;
margin-bottom: 0.5rem;
}
.session-tokens {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
padding: 0.25rem 0.5rem;
background: hsl(var(--muted));
border-radius: 4px;
margin-bottom: 0.75rem;
}
.session-tokens i {
width: 12px;
height: 12px;
}
/* Expandable timeline card - DEPRECATED, use clickable instead */
.timeline-content.expandable {
position: relative;
}
/* Clickable timeline card */
.timeline-content.clickable {
position: relative;
}
.timeline-content.clickable:hover {
border-color: hsl(var(--primary) / 0.5);
background: hsl(var(--primary) / 0.02);
}
.timeline-content.clickable:active {
transform: scale(0.995);
}
/* Timeline card footer with preview hint */
.timeline-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid hsl(var(--border));
}
.preview-hint {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
transition: color 0.2s;
}
.preview-hint i {
width: 14px;
height: 14px;
}
.timeline-content:hover .preview-hint {
color: hsl(var(--primary));
}
/* CSS Arrow for expand hint - DEPRECATED */
.expand-arrow {
display: flex;
justify-content: center;
padding: 0.75rem 0 0.25rem;
opacity: 0.5;
transition: opacity 0.2s;
}
.timeline-content:hover .expand-arrow {
opacity: 1;
}
.expand-arrow::after {
content: '';
width: 10px;
height: 10px;
border-right: 2px solid hsl(var(--primary));
border-bottom: 2px solid hsl(var(--primary));
transform: rotate(45deg);
transition: transform 0.2s ease;
}
.timeline-content.expanded .expand-arrow::after {
transform: rotate(-135deg) translateY(3px);
}
/* Expanded detail section */
.session-detail-expand {
display: none;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid hsl(var(--border));
animation: fadeIn 0.2s ease-out;
}
.timeline-content.expanded .session-detail-expand {
display: block;
}
.full-summary {
font-size: 0.875rem;
color: hsl(var(--foreground));
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 1rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.3);
border-radius: 6px;
}
.session-detail-expand .session-tokens {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin-bottom: 1rem;
}
.timeline-actions {
display: flex;
gap: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid hsl(var(--border));
}
.btn-xs {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
display: inline-flex;
align-items: center;
gap: 0.375rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.btn-xs i {
width: 14px;
height: 14px;
}
.btn-ghost {
background: transparent;
border: 1px solid hsl(var(--border));
color: hsl(var(--muted-foreground));
}
.btn-ghost:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
border-color: hsl(var(--muted-foreground));
}
.btn-ghost.btn-danger:hover {
background: hsl(var(--destructive) / 0.1);
color: hsl(var(--destructive));
border-color: hsl(var(--destructive) / 0.3);
}
/* Relations List */
.relations-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.relation-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.875rem;
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 6px;
font-size: 0.875rem;
}
.relation-item i {
width: 14px;
height: 14px;
color: hsl(var(--muted-foreground));
}
.relation-type {
padding: 0.125rem 0.5rem;
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
.relation-item a {
color: hsl(var(--primary));
text-decoration: none;
}
.relation-item a:hover {
text-decoration: underline;
}
/* Dark Mode for Clusters */
[data-theme="dark"] .clusters-sidebar,
[data-theme="dark"] .clusters-detail {
background: #1e293b;
border-color: #334155;
}
[data-theme="dark"] .tab-nav {
background: rgba(51, 65, 85, 0.5);
}
[data-theme="dark"] .tab-btn.active {
background: #1e293b;
}
[data-theme="dark"] .clusters-sidebar-header {
background: rgba(15, 23, 42, 0.5);
border-color: #334155;
}
[data-theme="dark"] .cluster-item:hover {
background: rgba(51, 65, 85, 0.5);
}
[data-theme="dark"] .cluster-item.active {
background: rgba(59, 130, 246, 0.15);
border-color: rgba(59, 130, 246, 0.3);
}
[data-theme="dark"] .timeline-content,
[data-theme="dark"] .cluster-intent,
[data-theme="dark"] .relation-item {
background: rgba(15, 23, 42, 0.5);
border-color: #334155;
}

View File

@@ -124,6 +124,26 @@ const HOOK_TEMPLATES = {
description: 'Record user prompts for pattern analysis',
category: 'memory',
timeout: 5000
},
// Session Context - Progressive Disclosure (session start - recent sessions)
'session-context': {
event: 'UserPromptSubmit',
matcher: '',
command: 'bash',
args: ['-c', 'curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"session-start\\",\\"sessionId\\":\\"$CLAUDE_SESSION_ID\\"}" http://localhost:3456/api/hook 2>/dev/null | jq -r ".content // empty"'],
description: 'Load recent sessions at session start (time-sorted)',
category: 'context',
timeout: 5000
},
// Session Context - Continuous Disclosure (intent matching on every prompt)
'session-context-continuous': {
event: 'UserPromptSubmit',
matcher: '',
command: 'bash',
args: ['-c', 'PROMPT=$(cat | jq -r ".prompt // empty"); curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"context\\",\\"sessionId\\":\\"$CLAUDE_SESSION_ID\\",\\"prompt\\":\\"$PROMPT\\"}" http://localhost:3456/api/hook 2>/dev/null | jq -r ".content // empty"'],
description: 'Load intent-matched sessions on every prompt (similarity-based)',
category: 'context',
timeout: 5000
}
};

View File

@@ -12,7 +12,18 @@ const i18n = {
// App title and brand
'app.title': 'CCW Dashboard',
'app.brand': 'Claude Code Workflow',
// Common
'common.view': 'View',
'common.edit': 'Edit',
'common.delete': 'Delete',
'common.cancel': 'Cancel',
'common.save': 'Save',
'common.close': 'Close',
'common.loading': 'Loading...',
'common.error': 'Error',
'common.success': 'Success',
// Header
'header.project': 'Project:',
'header.recentProjects': 'Recent Projects',
@@ -685,6 +696,10 @@ const i18n = {
'hook.template.gitAddDesc': 'Auto stage written files',
// Hook Quick Install Templates
'hook.tpl.sessionContext': 'Session Context (Start)',
'hook.tpl.sessionContextDesc': 'Load recent sessions at session start (time-sorted)',
'hook.tpl.sessionContextContinuous': 'Session Context (Continuous)',
'hook.tpl.sessionContextContinuousDesc': 'Load intent-matched sessions on every prompt (similarity-based)',
'hook.tpl.codexlensSync': 'CodexLens Auto-Sync',
'hook.tpl.codexlensSyncDesc': 'Auto-update code index when files are written or edited',
'hook.tpl.ccwDashboardNotify': 'CCW Dashboard Notify',
@@ -704,6 +719,7 @@ const i18n = {
'hook.category.git': 'git',
'hook.category.memory': 'memory',
'hook.category.skill': 'skill',
'hook.category.context': 'context',
// Hook Wizard Templates
'hook.wizard.memoryUpdate': 'Memory Update Hook',
@@ -1164,6 +1180,8 @@ const i18n = {
'common.edit': 'Edit',
'common.close': 'Close',
'common.refresh': 'Refresh',
'common.refreshed': 'Refreshed',
'common.refreshing': 'Refreshing...',
'common.loading': 'Loading...',
'common.error': 'Error',
'common.success': 'Success',
@@ -1226,12 +1244,56 @@ const i18n = {
'coreMemory.evolutionError': 'Failed to load evolution history',
'coreMemory.created': 'Created',
'coreMemory.updated': 'Updated',
// View toggle
'coreMemory.memories': 'Memories',
'coreMemory.clusters': 'Clusters',
'coreMemory.clustersList': 'Cluster List',
'coreMemory.selectCluster': 'Select a cluster to view details',
'coreMemory.openSession': 'Open Session',
'coreMemory.clickToPreview': 'Click to preview',
'coreMemory.previewError': 'Failed to load preview',
'coreMemory.unknownSessionType': 'Unknown session type',
// Clustering features
'coreMemory.noClusters': 'No clusters yet',
'coreMemory.autoCluster': 'Auto Cluster',
'coreMemory.clusterLoadError': 'Failed to load clusters',
'coreMemory.clusterDetailError': 'Failed to load cluster details',
'coreMemory.intent': 'Intent',
'coreMemory.sessionTimeline': 'Session Timeline',
'coreMemory.relatedClusters': 'Related Clusters',
'coreMemory.noSessions': 'No sessions in this cluster',
'coreMemory.clusteringInProgress': 'Clustering in progress...',
'coreMemory.clusteringComplete': 'Created {created} clusters with {sessions} sessions',
'coreMemory.clusteringError': 'Auto-clustering failed',
'coreMemory.enterClusterName': 'Enter cluster name:',
'coreMemory.clusterCreated': 'Cluster created',
'coreMemory.clusterCreateError': 'Failed to create cluster',
'coreMemory.confirmDeleteCluster': 'Delete this cluster?',
'coreMemory.clusterDeleted': 'Cluster deleted',
'coreMemory.clusterDeleteError': 'Failed to delete cluster',
'coreMemory.clusterUpdated': 'Cluster updated',
'coreMemory.clusterUpdateError': 'Failed to update cluster',
'coreMemory.memberRemoved': 'Member removed',
'coreMemory.memberRemoveError': 'Failed to remove member',
},
zh: {
// App title and brand
'app.title': 'CCW 控制面板',
'app.brand': 'Claude Code Workflow',
// Common
'common.view': '查看',
'common.edit': '编辑',
'common.delete': '删除',
'common.cancel': '取消',
'common.save': '保存',
'common.close': '关闭',
'common.loading': '加载中...',
'common.error': '错误',
'common.success': '成功',
// Header
'header.project': '项目:',
@@ -1883,6 +1945,10 @@ const i18n = {
'hook.template.gitAddDesc': '自动暂存写入的文件',
// Hook Quick Install Templates
'hook.tpl.sessionContext': 'Session 上下文(启动)',
'hook.tpl.sessionContextDesc': '会话启动时加载最近会话(按时间排序)',
'hook.tpl.sessionContextContinuous': 'Session 上下文(持续)',
'hook.tpl.sessionContextContinuousDesc': '每次提示词时加载意图匹配会话(相似度排序)',
'hook.tpl.codexlensSync': 'CodexLens 自动同步',
'hook.tpl.codexlensSyncDesc': '文件写入或编辑时自动更新代码索引',
'hook.tpl.ccwDashboardNotify': 'CCW 控制面板通知',
@@ -1902,6 +1968,7 @@ const i18n = {
'hook.category.git': 'Git',
'hook.category.memory': '记忆',
'hook.category.skill': '技能',
'hook.category.context': '上下文',
// Hook Wizard Templates
'hook.wizard.memoryUpdate': '记忆更新钩子',
@@ -2393,6 +2460,8 @@ const i18n = {
'common.edit': '编辑',
'common.close': '关闭',
'common.refresh': '刷新',
'common.refreshed': '已刷新',
'common.refreshing': '刷新中...',
'common.loading': '加载中...',
'common.error': '错误',
'common.success': '成功',
@@ -2455,6 +2524,39 @@ const i18n = {
'coreMemory.evolutionError': '加载演化历史失败',
'coreMemory.created': '创建时间',
'coreMemory.updated': '更新时间',
// View toggle
'coreMemory.memories': '记忆',
'coreMemory.clusters': '聚类',
'coreMemory.clustersList': '聚类列表',
'coreMemory.selectCluster': '选择聚类查看详情',
'coreMemory.openSession': '打开 Session',
'coreMemory.clickToPreview': '点击预览',
'coreMemory.previewError': '加载预览失败',
'coreMemory.unknownSessionType': '未知的会话类型',
// Clustering features
'coreMemory.noClusters': '暂无聚类',
'coreMemory.autoCluster': '自动聚类',
'coreMemory.clusterLoadError': '加载聚类失败',
'coreMemory.clusterDetailError': '加载聚类详情失败',
'coreMemory.intent': '意图',
'coreMemory.sessionTimeline': 'Session 时间线',
'coreMemory.relatedClusters': '关联聚类',
'coreMemory.noSessions': '此聚类暂无 session',
'coreMemory.clusteringInProgress': '聚类进行中...',
'coreMemory.clusteringComplete': '创建了 {created} 个聚类,包含 {sessions} 个 session',
'coreMemory.clusteringError': '自动聚类失败',
'coreMemory.enterClusterName': '请输入聚类名称:',
'coreMemory.clusterCreated': '聚类已创建',
'coreMemory.clusterCreateError': '创建聚类失败',
'coreMemory.confirmDeleteCluster': '确定删除此聚类?',
'coreMemory.clusterDeleted': '聚类已删除',
'coreMemory.clusterDeleteError': '删除聚类失败',
'coreMemory.clusterUpdated': '聚类已更新',
'coreMemory.clusterUpdateError': '更新聚类失败',
'coreMemory.memberRemoved': '成员已移除',
'coreMemory.memberRemoveError': '移除成员失败',
}
};

View File

@@ -0,0 +1,388 @@
// Session Clustering visualization for Core Memory
// Dependencies: This file requires core-memory.js to be loaded first
// - Uses: viewMemoryDetail(), fetchMemoryById(), showNotification(), t(), escapeHtml(), projectPath
// Global state
var clusterList = [];
var selectedCluster = null;
/**
* Fetch and render cluster list
*/
async function loadClusters() {
try {
const response = await fetch(`/api/core-memory/clusters?path=${encodeURIComponent(projectPath)}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
clusterList = result.clusters || [];
renderClusterList();
} catch (error) {
console.error('Failed to load clusters:', error);
showNotification(t('coreMemory.clusterLoadError'), 'error');
}
}
/**
* Render cluster list in sidebar
*/
function renderClusterList() {
const container = document.getElementById('clusterListContainer');
if (!container) return;
if (clusterList.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i data-lucide="folder-tree"></i>
<p>${t('coreMemory.noClusters')}</p>
<button class="btn btn-primary btn-sm" onclick="triggerAutoClustering()">
<i data-lucide="sparkles"></i>
${t('coreMemory.autoCluster')}
</button>
</div>
`;
lucide.createIcons();
return;
}
container.innerHTML = clusterList.map(cluster => `
<div class="cluster-item ${selectedCluster?.id === cluster.id ? 'active' : ''}"
onclick="selectCluster('${cluster.id}')">
<div class="cluster-icon">
<i data-lucide="${cluster.status === 'active' ? 'folder-open' : 'folder'}"></i>
</div>
<div class="cluster-info">
<div class="cluster-name">${escapeHtml(cluster.name)}</div>
<div class="cluster-meta">
<span>${cluster.memberCount} sessions</span>
<span>${formatDate(cluster.updated_at)}</span>
</div>
</div>
<span class="badge badge-${cluster.status}">${cluster.status}</span>
</div>
`).join('');
lucide.createIcons();
}
/**
* Select and load cluster details
*/
async function selectCluster(clusterId) {
try {
const response = await fetch(`/api/core-memory/clusters/${clusterId}?path=${encodeURIComponent(projectPath)}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
selectedCluster = result.cluster;
renderClusterDetail(result.cluster, result.members, result.relations);
// Update list to show selection
renderClusterList();
} catch (error) {
console.error('Failed to load cluster:', error);
showNotification(t('coreMemory.clusterDetailError'), 'error');
}
}
/**
* Render cluster detail view
*/
function renderClusterDetail(cluster, members, relations) {
const container = document.getElementById('clusterDetailContainer');
if (!container) return;
container.innerHTML = `
<div class="cluster-detail">
<div class="cluster-header">
<h3>${escapeHtml(cluster.name)}</h3>
<div class="cluster-actions">
<button class="btn btn-sm" onclick="editCluster('${cluster.id}')" title="${t('common.edit')}">
<i data-lucide="edit-2"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteCluster('${cluster.id}')" title="${t('common.delete')}">
<i data-lucide="trash-2"></i>
</button>
</div>
</div>
${cluster.description ? `<p class="cluster-description">${escapeHtml(cluster.description)}</p>` : ''}
${cluster.intent ? `<div class="cluster-intent"><strong>${t('coreMemory.intent')}:</strong> ${escapeHtml(cluster.intent)}</div>` : ''}
<div class="cluster-timeline">
<h4><i data-lucide="git-branch"></i> ${t('coreMemory.sessionTimeline')}</h4>
${renderTimeline(members)}
</div>
${relations && relations.length > 0 ? `
<div class="cluster-relations">
<h4><i data-lucide="link"></i> ${t('coreMemory.relatedClusters')}</h4>
${renderRelations(relations)}
</div>
` : ''}
</div>
`;
lucide.createIcons();
}
/**
* Render session timeline
*/
function renderTimeline(members) {
if (!members || members.length === 0) {
return `<p class="text-muted">${t('coreMemory.noSessions')}</p>`;
}
// Sort by sequence order
const sorted = [...members].sort((a, b) => a.sequence_order - b.sequence_order);
return `
<div class="timeline">
${sorted.map((member, index) => {
const meta = member.metadata || {};
// Get display text - prefer title, fallback to summary
const displayTitle = meta.title || meta.summary || '';
// Truncate for display
const truncatedTitle = displayTitle.length > 120
? displayTitle.substring(0, 120) + '...'
: displayTitle;
return `
<div class="timeline-item">
<div class="timeline-marker">
<span class="timeline-number">${index + 1}</span>
</div>
<div class="timeline-content clickable" onclick="previewSession('${member.session_id}', '${member.session_type}')">
<div class="timeline-header">
<span class="session-id">${escapeHtml(member.session_id)}</span>
<span class="badge badge-${member.session_type}">${member.session_type}</span>
</div>
${truncatedTitle ? `<div class="session-title">${escapeHtml(truncatedTitle)}</div>` : ''}
${meta.token_estimate ? `<div class="session-tokens">~${meta.token_estimate} tokens</div>` : ''}
<div class="timeline-card-footer">
<span class="preview-hint"><i data-lucide="eye"></i> ${t('coreMemory.clickToPreview')}</span>
<button class="btn btn-xs btn-ghost btn-danger" onclick="event.stopPropagation(); removeMember('${selectedCluster.id}', '${member.session_id}')" title="${t('common.delete')}">
<i data-lucide="trash-2"></i>
</button>
</div>
</div>
</div>
`}).join('')}
</div>
`;
}
/**
* Preview session in modal based on type
*/
async function previewSession(sessionId, sessionType) {
try {
if (sessionType === 'cli_history') {
// Use CLI history preview modal
if (typeof showExecutionDetail === 'function') {
await showExecutionDetail(sessionId);
} else {
console.error('showExecutionDetail is not available. Make sure cli-history.js is loaded.');
showNotification(t('coreMemory.previewError'), 'error');
}
} else if (sessionType === 'core_memory') {
// Use memory preview modal
await viewMemoryContent(sessionId);
} else if (sessionType === 'workflow') {
// Navigate to workflow view for now
window.location.hash = `#workflow/${sessionId}`;
} else {
showNotification(t('coreMemory.unknownSessionType'), 'warning');
}
} catch (error) {
console.error('Failed to preview session:', error);
showNotification(t('coreMemory.previewError'), 'error');
}
}
/**
* Render cluster relations
*/
function renderRelations(relations) {
return `
<div class="relations-list">
${relations.map(rel => `
<div class="relation-item">
<i data-lucide="arrow-right"></i>
<span class="relation-type">${rel.relation_type}</span>
<a href="#" onclick="selectCluster('${rel.target_cluster_id}'); return false;">
${rel.target_cluster_id}
</a>
</div>
`).join('')}
</div>
`;
}
/**
* Trigger auto-clustering
*/
async function triggerAutoClustering(scope = 'recent') {
try {
showNotification(t('coreMemory.clusteringInProgress'), 'info');
const response = await fetch(`/api/core-memory/clusters/auto?path=${encodeURIComponent(projectPath)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scope })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
showNotification(
t('coreMemory.clusteringComplete', {
created: result.clustersCreated,
sessions: result.sessionsClustered
}),
'success'
);
// Reload clusters
await loadClusters();
} catch (error) {
console.error('Auto-clustering failed:', error);
showNotification(t('coreMemory.clusteringError'), 'error');
}
}
/**
* Create new cluster
*/
async function createCluster() {
const name = prompt(t('coreMemory.enterClusterName'));
if (!name) return;
try {
const response = await fetch(`/api/core-memory/clusters?path=${encodeURIComponent(projectPath)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
showNotification(t('coreMemory.clusterCreated'), 'success');
await loadClusters();
} catch (error) {
console.error('Failed to create cluster:', error);
showNotification(t('coreMemory.clusterCreateError'), 'error');
}
}
/**
* Edit cluster (placeholder)
*/
function editCluster(clusterId) {
const cluster = selectedCluster;
if (!cluster) return;
const newName = prompt(t('coreMemory.enterClusterName'), cluster.name);
if (!newName || newName === cluster.name) return;
updateCluster(clusterId, { name: newName });
}
/**
* Update cluster
*/
async function updateCluster(clusterId, updates) {
try {
const response = await fetch(`/api/core-memory/clusters/${clusterId}?path=${encodeURIComponent(projectPath)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
showNotification(t('coreMemory.clusterUpdated'), 'success');
await loadClusters();
if (selectedCluster?.id === clusterId) {
await selectCluster(clusterId);
}
} catch (error) {
console.error('Failed to update cluster:', error);
showNotification(t('coreMemory.clusterUpdateError'), 'error');
}
}
/**
* Delete cluster
*/
async function deleteCluster(clusterId) {
if (!confirm(t('coreMemory.confirmDeleteCluster'))) return;
try {
const response = await fetch(`/api/core-memory/clusters/${clusterId}?path=${encodeURIComponent(projectPath)}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
showNotification(t('coreMemory.clusterDeleted'), 'success');
selectedCluster = null;
await loadClusters();
// Clear detail view
const container = document.getElementById('clusterDetailContainer');
if (container) container.innerHTML = '';
} catch (error) {
console.error('Failed to delete cluster:', error);
showNotification(t('coreMemory.clusterDeleteError'), 'error');
}
}
/**
* Remove member from cluster
*/
async function removeMember(clusterId, sessionId) {
try {
const response = await fetch(
`/api/core-memory/clusters/${clusterId}/members/${sessionId}?path=${encodeURIComponent(projectPath)}`,
{ method: 'DELETE' }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
showNotification(t('coreMemory.memberRemoved'), 'success');
await selectCluster(clusterId); // Refresh detail
} catch (error) {
console.error('Failed to remove member:', error);
showNotification(t('coreMemory.memberRemoveError'), 'error');
}
}
/**
* View memory content in modal
* Requires: viewMemoryDetail from core-memory.js
*/
async function viewMemoryContent(memoryId) {
try {
// Check if required functions exist (from core-memory.js)
if (typeof viewMemoryDetail === 'function') {
await viewMemoryDetail(memoryId);
} else {
console.error('viewMemoryDetail is not available. Make sure core-memory.js is loaded.');
showNotification(t('coreMemory.fetchError'), 'error');
}
} catch (error) {
console.error('Failed to load memory content:', error);
showNotification(t('coreMemory.fetchError'), 'error');
}
}
/**
* Format date for display
*/
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString();
}

View File

@@ -1,293 +0,0 @@
// Knowledge Graph and Evolution visualization functions for Core Memory
async function viewKnowledgeGraph(memoryId) {
try {
const response = await fetch(`/api/core-memory/memories/${memoryId}/knowledge-graph?path=${encodeURIComponent(projectPath)}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const graph = await response.json();
const modal = document.getElementById('memoryDetailModal');
document.getElementById('memoryDetailTitle').textContent = `${t('coreMemory.knowledgeGraph')} - ${memoryId}`;
const body = document.getElementById('memoryDetailBody');
body.innerHTML = `
<div class="knowledge-graph">
<div id="knowledgeGraphContainer" class="knowledge-graph-container"></div>
</div>
`;
modal.style.display = 'flex';
lucide.createIcons();
// Render D3 graph after modal is visible
setTimeout(() => {
renderKnowledgeGraphD3(graph);
}, 100);
} catch (error) {
console.error('Failed to fetch knowledge graph:', error);
showNotification(t('coreMemory.graphError'), 'error');
}
}
function renderKnowledgeGraphD3(graph) {
// Check if D3 is available
if (typeof d3 === 'undefined') {
const container = document.getElementById('knowledgeGraphContainer');
if (container) {
container.innerHTML = `
<div class="graph-error">
<i data-lucide="alert-triangle"></i>
<p>D3.js not loaded</p>
</div>
`;
lucide.createIcons();
}
return;
}
if (!graph || !graph.entities || graph.entities.length === 0) {
const container = document.getElementById('knowledgeGraphContainer');
if (container) {
container.innerHTML = `
<div class="graph-empty-state">
<i data-lucide="network"></i>
<p>${t('coreMemory.noEntities')}</p>
</div>
`;
lucide.createIcons();
}
return;
}
const container = document.getElementById('knowledgeGraphContainer');
if (!container) return;
const width = container.clientWidth || 800;
const height = 400;
// Clear existing
container.innerHTML = '';
// Transform data to D3 format
const nodes = graph.entities.map(entity => ({
id: entity.name,
name: entity.name,
type: entity.type || 'entity',
displayName: entity.name.length > 25 ? entity.name.substring(0, 22) + '...' : entity.name
}));
const nodeIds = new Set(nodes.map(n => n.id));
const edges = (graph.relationships || []).filter(rel =>
nodeIds.has(rel.source) && nodeIds.has(rel.target)
).map(rel => ({
source: rel.source,
target: rel.target,
type: rel.type || 'related'
}));
// Create SVG with zoom support
coreMemGraphSvg = d3.select('#knowledgeGraphContainer')
.append('svg')
.attr('width', width)
.attr('height', height)
.attr('class', 'knowledge-graph-svg')
.attr('viewBox', [0, 0, width, height]);
// Create a group for zoom/pan transformations
coreMemGraphGroup = coreMemGraphSvg.append('g').attr('class', 'graph-content');
// Setup zoom behavior
coreMemGraphZoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
coreMemGraphGroup.attr('transform', event.transform);
});
coreMemGraphSvg.call(coreMemGraphZoom);
// Add arrowhead marker
coreMemGraphSvg.append('defs').append('marker')
.attr('id', 'arrowhead-core')
.attr('viewBox', '-0 -5 10 10')
.attr('refX', 20)
.attr('refY', 0)
.attr('orient', 'auto')
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('xoverflow', 'visible')
.append('svg:path')
.attr('d', 'M 0,-5 L 10 ,0 L 0,5')
.attr('fill', '#999')
.style('stroke', 'none');
// Create force simulation
coreMemGraphSimulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(edges).id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(20))
.force('x', d3.forceX(width / 2).strength(0.05))
.force('y', d3.forceY(height / 2).strength(0.05));
// Draw edges
const link = coreMemGraphGroup.append('g')
.attr('class', 'graph-links')
.selectAll('line')
.data(edges)
.enter()
.append('line')
.attr('class', 'graph-edge')
.attr('stroke', '#999')
.attr('stroke-width', 2)
.attr('marker-end', 'url(#arrowhead-core)');
// Draw nodes
const node = coreMemGraphGroup.append('g')
.attr('class', 'graph-nodes')
.selectAll('g')
.data(nodes)
.enter()
.append('g')
.attr('class', d => 'graph-node-group ' + (d.type || 'entity'))
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended))
.on('click', (event, d) => {
event.stopPropagation();
showNodeDetail(d);
});
// Add circles to nodes (color by type)
node.append('circle')
.attr('class', d => 'graph-node ' + (d.type || 'entity'))
.attr('r', 10)
.attr('fill', d => {
if (d.type === 'file') return '#3b82f6'; // blue
if (d.type === 'function') return '#10b981'; // green
if (d.type === 'module') return '#8b5cf6'; // purple
return '#6b7280'; // gray
})
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.attr('data-id', d => d.id);
// Add labels to nodes
node.append('text')
.attr('class', 'graph-label')
.text(d => d.displayName)
.attr('x', 14)
.attr('y', 4)
.attr('font-size', '11px')
.attr('fill', '#333');
// Update positions on simulation tick
coreMemGraphSimulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
node.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
});
// Drag functions
function dragstarted(event, d) {
if (!event.active) coreMemGraphSimulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) coreMemGraphSimulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
}
function showNodeDetail(node) {
showNotification(`${node.name} (${node.type})`, 'info');
}
async function viewEvolutionHistory(memoryId) {
try {
const response = await fetch(`/api/core-memory/memories/${memoryId}/evolution?path=${encodeURIComponent(projectPath)}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const versions = await response.json();
const modal = document.getElementById('memoryDetailModal');
document.getElementById('memoryDetailTitle').textContent = `${t('coreMemory.evolutionHistory')} - ${memoryId}`;
const body = document.getElementById('memoryDetailBody');
body.innerHTML = `
<div class="evolution-timeline">
${versions && versions.length > 0
? versions.map((version, index) => renderEvolutionVersion(version, index)).join('')
: `<div class="evolution-empty-state">
<i data-lucide="git-branch"></i>
<p>${t('coreMemory.noHistory')}</p>
</div>`
}
</div>
`;
modal.style.display = 'flex';
lucide.createIcons();
} catch (error) {
console.error('Failed to fetch evolution history:', error);
showNotification(t('coreMemory.evolutionError'), 'error');
}
}
function renderEvolutionVersion(version, index) {
const timestamp = new Date(version.timestamp).toLocaleString();
const contentPreview = version.content
? (version.content.substring(0, 150) + (version.content.length > 150 ? '...' : ''))
: '';
// Parse diff stats
const diffStats = version.diff_stats || {};
const added = diffStats.added || 0;
const modified = diffStats.modified || 0;
const deleted = diffStats.deleted || 0;
return `
<div class="version-card">
<div class="version-header">
<div class="version-info">
<span class="version-number">v${version.version}</span>
<span class="version-date">${timestamp}</span>
${index === 0 ? `<span class="badge badge-current">${t('coreMemory.current')}</span>` : ''}
</div>
</div>
${contentPreview ? `
<div class="version-content-preview">
${escapeHtml(contentPreview)}
</div>
` : ''}
${(added > 0 || modified > 0 || deleted > 0) ? `
<div class="version-diff-stats">
${added > 0 ? `<span class="diff-stat diff-added"><i data-lucide="plus"></i> ${added} added</span>` : ''}
${modified > 0 ? `<span class="diff-stat diff-modified"><i data-lucide="edit-3"></i> ${modified} modified</span>` : ''}
${deleted > 0 ? `<span class="diff-stat diff-deleted"><i data-lucide="minus"></i> ${deleted} deleted</span>` : ''}
</div>
` : ''}
${version.reason ? `
<div class="version-reason">
<strong>Reason:</strong> ${escapeHtml(version.reason)}
</div>
` : ''}
</div>
`;
}

View File

@@ -49,12 +49,6 @@ function showNotification(message, type = 'info') {
}, 3000);
}
// State for visualization (prefixed to avoid collision with memory.js)
var coreMemGraphSvg = null;
var coreMemGraphGroup = null;
var coreMemGraphZoom = null;
var coreMemGraphSimulation = null;
async function renderCoreMemoryView() {
const content = document.getElementById('mainContent');
hideStatsAndCarousel();
@@ -65,9 +59,19 @@ async function renderCoreMemoryView() {
content.innerHTML = `
<div class="core-memory-container">
<!-- Header Actions -->
<div class="core-memory-header">
<div class="header-actions">
<!-- Tab Navigation -->
<div class="core-memory-tabs">
<div class="tab-nav">
<button class="tab-btn active" id="memoriesViewBtn" onclick="showMemoriesView()">
<i data-lucide="brain"></i>
${t('coreMemory.memories')}
</button>
<button class="tab-btn" id="clustersViewBtn" onclick="showClustersView()">
<i data-lucide="folder-tree"></i>
${t('coreMemory.clusters')}
</button>
</div>
<div class="tab-actions">
<button class="btn btn-primary" onclick="showCreateMemoryModal()">
<i data-lucide="plus"></i>
${t('coreMemory.createNew')}
@@ -81,23 +85,51 @@ async function renderCoreMemoryView() {
${t('common.refresh')}
</button>
</div>
</div>
<!-- Memories Tab Content (default view) -->
<div class="cm-tab-panel" id="memoriesGrid">
<div class="memory-stats">
<div class="stat-item">
<span class="stat-label">${t('coreMemory.totalMemories')}</span>
<span class="stat-value" id="totalMemoriesCount">${memories.length}</span>
</div>
</div>
<div class="memories-grid">
${memories.length === 0
? `<div class="empty-state">
<i data-lucide="brain"></i>
<p>${t('coreMemory.noMemories')}</p>
</div>`
: memories.map(memory => renderMemoryCard(memory)).join('')
}
</div>
</div>
<!-- Memories Grid -->
<div class="memories-grid" id="memoriesGrid">
${memories.length === 0
? `<div class="empty-state">
<i data-lucide="brain"></i>
<p>${t('coreMemory.noMemories')}</p>
</div>`
: memories.map(memory => renderMemoryCard(memory)).join('')
}
<!-- Clusters Tab Content (hidden by default) -->
<div class="cm-tab-panel clusters-container" id="clustersContainer" style="display: none;">
<div class="clusters-layout">
<div class="clusters-sidebar">
<div class="clusters-sidebar-header">
<h4>${t('coreMemory.clustersList')}</h4>
<button class="btn btn-sm btn-primary" onclick="triggerAutoClustering()">
<i data-lucide="sparkles"></i>
${t('coreMemory.autoCluster')}
</button>
</div>
<div id="clusterListContainer" class="cluster-list">
<!-- Clusters will be loaded here -->
</div>
</div>
<div class="clusters-detail">
<div id="clusterDetailContainer" class="cluster-detail-content">
<div class="empty-state">
<i data-lucide="folder-tree"></i>
<p>${t('coreMemory.selectCluster')}</p>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -243,14 +275,6 @@ function renderMemoryCard(memory) {
<i data-lucide="sparkles"></i>
${t('coreMemory.summary')}
</button>
<button class="feature-btn" onclick="viewKnowledgeGraph('${memory.id}')" title="${t('coreMemory.knowledgeGraph')}">
<i data-lucide="network"></i>
${t('coreMemory.graph')}
</button>
<button class="feature-btn" onclick="viewEvolutionHistory('${memory.id}')" title="${t('coreMemory.evolution')}">
<i data-lucide="git-branch"></i>
${t('coreMemory.evolution')}
</button>
</div>
</div>
</div>
@@ -448,97 +472,6 @@ async function generateMemorySummary(memoryId) {
}
}
async function viewKnowledgeGraph(memoryId) {
try {
const response = await fetch(`/api/core-memory/memories/${memoryId}/knowledge-graph?path=${encodeURIComponent(projectPath)}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const graph = await response.json();
const modal = document.getElementById('memoryDetailModal');
document.getElementById('memoryDetailTitle').textContent = `${t('coreMemory.knowledgeGraph')} - ${memoryId}`;
const body = document.getElementById('memoryDetailBody');
body.innerHTML = `
<div class="knowledge-graph">
<div class="graph-section">
<h3>${t('coreMemory.entities')}</h3>
<div class="entities-list">
${graph.entities && graph.entities.length > 0
? graph.entities.map(entity => `
<div class="entity-item">
<span class="entity-name">${escapeHtml(entity.name)}</span>
<span class="entity-type">${escapeHtml(entity.type)}</span>
</div>
`).join('')
: `<p class="empty-text">${t('coreMemory.noEntities')}</p>`
}
</div>
</div>
<div class="graph-section">
<h3>${t('coreMemory.relationships')}</h3>
<div class="relationships-list">
${graph.relationships && graph.relationships.length > 0
? graph.relationships.map(rel => `
<div class="relationship-item">
<span class="rel-source">${escapeHtml(rel.source)}</span>
<span class="rel-type">${escapeHtml(rel.type)}</span>
<span class="rel-target">${escapeHtml(rel.target)}</span>
</div>
`).join('')
: `<p class="empty-text">${t('coreMemory.noRelationships')}</p>`
}
</div>
</div>
</div>
`;
modal.style.display = 'flex';
lucide.createIcons();
} catch (error) {
console.error('Failed to fetch knowledge graph:', error);
showNotification(t('coreMemory.graphError'), 'error');
}
}
async function viewEvolutionHistory(memoryId) {
try {
const response = await fetch(`/api/core-memory/memories/${memoryId}/evolution?path=${encodeURIComponent(projectPath)}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const versions = await response.json();
const modal = document.getElementById('memoryDetailModal');
document.getElementById('memoryDetailTitle').textContent = `${t('coreMemory.evolutionHistory')} - ${memoryId}`;
const body = document.getElementById('memoryDetailBody');
body.innerHTML = `
<div class="evolution-timeline">
${versions && versions.length > 0
? versions.map((version, index) => `
<div class="evolution-version">
<div class="version-header">
<span class="version-number">v${version.version}</span>
<span class="version-date">${new Date(version.timestamp).toLocaleString()}</span>
</div>
<div class="version-reason">${escapeHtml(version.reason || t('coreMemory.noReason'))}</div>
${index === 0 ? `<span class="badge badge-current">${t('coreMemory.current')}</span>` : ''}
</div>
`).join('')
: `<p class="empty-text">${t('coreMemory.noHistory')}</p>`
}
</div>
`;
modal.style.display = 'flex';
lucide.createIcons();
} catch (error) {
console.error('Failed to fetch evolution history:', error);
showNotification(t('coreMemory.evolutionError'), 'error');
}
}
async function viewMemoryDetail(memoryId) {
const memory = await fetchMemoryById(memoryId);
if (!memory) return;
@@ -603,20 +536,23 @@ async function toggleArchivedMemories() {
async function refreshCoreMemories() {
const memories = await fetchCoreMemories(showingArchivedMemories);
const grid = document.getElementById('memoriesGrid');
const container = document.getElementById('memoriesGrid');
const grid = container.querySelector('.memories-grid');
const countEl = document.getElementById('totalMemoriesCount');
if (countEl) countEl.textContent = memories.length;
if (memories.length === 0) {
grid.innerHTML = `
<div class="empty-state">
<i data-lucide="brain"></i>
<p>${showingArchivedMemories ? t('coreMemory.noArchivedMemories') : t('coreMemory.noMemories')}</p>
</div>
`;
} else {
grid.innerHTML = memories.map(memory => renderMemoryCard(memory)).join('');
if (grid) {
if (memories.length === 0) {
grid.innerHTML = `
<div class="empty-state">
<i data-lucide="brain"></i>
<p>${showingArchivedMemories ? t('coreMemory.noArchivedMemories') : t('coreMemory.noMemories')}</p>
</div>
`;
} else {
grid.innerHTML = memories.map(memory => renderMemoryCard(memory)).join('');
}
}
lucide.createIcons();
@@ -628,3 +564,25 @@ function escapeHtml(text) {
div.textContent = text;
return div.innerHTML;
}
// View Toggle Functions
function showMemoriesView() {
document.getElementById('memoriesGrid').style.display = '';
document.getElementById('clustersContainer').style.display = 'none';
document.getElementById('memoriesViewBtn').classList.add('active');
document.getElementById('clustersViewBtn').classList.remove('active');
}
function showClustersView() {
document.getElementById('memoriesGrid').style.display = 'none';
document.getElementById('clustersContainer').style.display = '';
document.getElementById('memoriesViewBtn').classList.remove('active');
document.getElementById('clustersViewBtn').classList.add('active');
// Load clusters from core-memory-clusters.js
if (typeof loadClusters === 'function') {
loadClusters();
} else {
console.error('loadClusters is not available. Make sure core-memory-clusters.js is loaded.');
}
}

View File

@@ -11,7 +11,7 @@ var nodeFilters = {
CLASS: true,
FUNCTION: true,
METHOD: true,
VARIABLE: false
VARIABLE: true
};
var edgeFilters = {
CALLS: true,
@@ -85,8 +85,17 @@ async function loadGraphData() {
queryParams.set('module', selectedModule);
}
var nodesUrl = '/api/graph/nodes' + (queryParams.toString() ? '?' + queryParams.toString() : '');
var edgesUrl = '/api/graph/edges' + (queryParams.toString() ? '?' + queryParams.toString() : '');
var queryString = queryParams.toString();
var nodesUrl = '/api/graph/nodes' + (queryString ? '?' + queryString : '');
var edgesUrl = '/api/graph/edges' + (queryString ? '?' + queryString : '');
console.log('[Graph] Loading data with filter:', {
mode: filterMode,
file: selectedFile,
module: selectedModule,
nodesUrl: nodesUrl,
edgesUrl: edgesUrl
});
var nodesResp = await fetch(nodesUrl);
if (!nodesResp.ok) throw new Error('Failed to load graph nodes');
@@ -100,6 +109,13 @@ async function loadGraphData() {
nodes: nodesData.nodes || [],
edges: edgesData.edges || []
};
console.log('[Graph] Loaded data:', {
nodes: graphData.nodes.length,
edges: graphData.edges.length,
filters: nodesData.filters
});
return graphData;
} catch (err) {
console.error('Failed to load graph data:', err);
@@ -449,6 +465,38 @@ function initializeCytoscape() {
}
});
// Mouse hover events for nodes
cyInstance.on('mouseover', 'node', function(evt) {
var node = evt.target;
node.addClass('hover');
// Highlight connected edges
node.connectedEdges().addClass('hover');
});
cyInstance.on('mouseout', 'node', function(evt) {
var node = evt.target;
node.removeClass('hover');
// Remove edge highlights (unless they are highlighted due to selection)
node.connectedEdges().removeClass('hover');
});
// Mouse hover events for edges
cyInstance.on('mouseover', 'edge', function(evt) {
var edge = evt.target;
edge.addClass('hover');
// Also highlight connected nodes
edge.source().addClass('hover');
edge.target().addClass('hover');
});
cyInstance.on('mouseout', 'edge', function(evt) {
var edge = evt.target;
edge.removeClass('hover');
// Remove node highlights
edge.source().removeClass('hover');
edge.target().removeClass('hover');
});
// Fit view after layout
setTimeout(function() {
fitCytoscape();
@@ -464,6 +512,22 @@ function transformDataForCytoscape() {
return nodeFilters[type];
});
// Create node ID set and name-to-id mapping for edge resolution
var nodeIdSet = new Set();
var nodeNameToIds = {}; // Map symbol names to their node IDs
filteredNodes.forEach(function(node) {
var nodeId = node.id;
nodeIdSet.add(nodeId);
// Extract symbol name for matching
var name = node.name || '';
if (!nodeNameToIds[name]) {
nodeNameToIds[name] = [];
}
nodeNameToIds[name].push(nodeId);
});
// Add nodes
filteredNodes.forEach(function(node) {
elements.push({
@@ -473,8 +537,8 @@ function transformDataForCytoscape() {
label: node.name || node.id,
type: node.type || 'MODULE',
symbolType: node.symbolType,
path: node.path,
lineNumber: node.lineNumber,
path: node.path || node.file,
lineNumber: node.lineNumber || node.line,
imports: node.imports || 0,
exports: node.exports || 0,
references: node.references || 0
@@ -482,29 +546,76 @@ function transformDataForCytoscape() {
});
});
// Create node ID set for filtering edges
var nodeIdSet = new Set(filteredNodes.map(function(n) { return n.id; }));
// Filter edges
// Filter and resolve edges
var filteredEdges = graphData.edges.filter(function(edge) {
var type = edge.type || 'CALLS';
return edgeFilters[type] &&
nodeIdSet.has(edge.source) &&
nodeIdSet.has(edge.target);
return edgeFilters[type];
});
// Add edges
filteredEdges.forEach(function(edge, index) {
elements.push({
group: 'edges',
data: {
id: 'edge-' + index,
source: edge.source,
target: edge.target,
type: edge.type || 'CALLS',
weight: edge.weight || 1
// Process edges with target resolution
var edgeCount = 0;
filteredEdges.forEach(function(edge) {
var sourceId = edge.source;
var targetId = edge.target;
// Check if source exists
if (!nodeIdSet.has(sourceId)) {
return; // Skip if source node doesn't exist
}
// Try to resolve target
var resolvedTargetId = null;
// 1. Direct match
if (nodeIdSet.has(targetId)) {
resolvedTargetId = targetId;
}
// 2. Try to match by qualified name (extract symbol name)
else if (targetId) {
// Try to extract symbol name from qualified name
var targetName = targetId;
// Handle qualified names like "module.ClassName.methodName" or "file:name:line"
if (targetId.includes('.')) {
var parts = targetId.split('.');
targetName = parts[parts.length - 1]; // Get last part
} else if (targetId.includes(':')) {
var colonParts = targetId.split(':');
if (colonParts.length >= 2) {
targetName = colonParts[1]; // file:name:line format
}
}
});
// Look up in name-to-id mapping
if (nodeNameToIds[targetName] && nodeNameToIds[targetName].length > 0) {
// If multiple matches, prefer one in the same file
var sourceFile = edge.sourceFile || '';
var matchInSameFile = nodeNameToIds[targetName].find(function(id) {
return id.startsWith(sourceFile);
});
resolvedTargetId = matchInSameFile || nodeNameToIds[targetName][0];
}
}
// Only add edge if both source and target are resolved
if (resolvedTargetId && sourceId !== resolvedTargetId) {
elements.push({
group: 'edges',
data: {
id: 'edge-' + edgeCount++,
source: sourceId,
target: resolvedTargetId,
type: edge.type || 'CALLS',
weight: edge.weight || 1
}
});
}
});
console.log('[Graph] Transformed elements:', {
nodes: filteredNodes.length,
edges: edgeCount,
totalRawEdges: filteredEdges.length
});
return elements;
@@ -512,47 +623,80 @@ function transformDataForCytoscape() {
function getCytoscapeStyles() {
var styles = [
// Node styles by type
// Node styles by type - no label by default
{
selector: 'node',
style: {
'background-color': function(ele) {
return NODE_COLORS[ele.data('type')] || '#6B7280';
},
'label': 'data(label)',
'label': '', // No label by default
'width': function(ele) {
var refs = ele.data('references') || 0;
return Math.max(16, Math.min(48, 16 + refs * 1.5));
return Math.max(20, Math.min(48, 20 + refs * 1.5));
},
'height': function(ele) {
var refs = ele.data('references') || 0;
return Math.max(16, Math.min(48, 16 + refs * 1.5));
return Math.max(20, Math.min(48, 20 + refs * 1.5));
},
'border-width': 2,
'border-color': function(ele) {
var color = NODE_COLORS[ele.data('type')] || '#6B7280';
return darkenColor(color, 20);
},
'text-valign': 'center',
'text-halign': 'center',
'font-size': '8px',
'color': '#000',
'text-outline-color': '#fff',
'text-outline-width': 1.5,
'overlay-padding': 6
}
},
// Selected node
// Hovered node - show label
{
selector: 'node.hover',
style: {
'label': 'data(label)',
'text-valign': 'top',
'text-halign': 'center',
'text-margin-y': -8,
'font-size': '11px',
'font-weight': 'bold',
'color': '#1f2937',
'text-outline-color': '#fff',
'text-outline-width': 2,
'text-background-color': '#fff',
'text-background-opacity': 0.9,
'text-background-padding': '4px',
'text-background-shape': 'roundrectangle',
'z-index': 999
}
},
// Selected node - show label
{
selector: 'node:selected',
style: {
'border-width': 3,
'label': 'data(label)',
'border-width': 4,
'border-color': '#000',
'text-valign': 'top',
'text-halign': 'center',
'text-margin-y': -8,
'font-size': '11px',
'font-weight': 'bold',
'color': '#1f2937',
'text-outline-color': '#fff',
'text-outline-width': 2,
'text-background-color': '#fff',
'text-background-opacity': 0.9,
'text-background-padding': '4px',
'text-background-shape': 'roundrectangle',
'overlay-color': '#000',
'overlay-opacity': 0.2
'overlay-opacity': 0.2,
'z-index': 999
}
},
// Edge styles by type
// Edge styles by type - enhanced visibility
{
selector: 'edge',
style: {
'width': function(ele) {
return Math.max(1, ele.data('weight') || 1);
return Math.max(2, (ele.data('weight') || 1) * 1.5);
},
'line-color': function(ele) {
return EDGE_COLORS[ele.data('type')] || '#6B7280';
@@ -562,8 +706,27 @@ function getCytoscapeStyles() {
},
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'arrow-scale': 1.2,
'opacity': 0.6
'arrow-scale': 1.5,
'opacity': 0.8,
'z-index': 1
}
},
// Hovered edge
{
selector: 'edge.hover',
style: {
'width': 4,
'opacity': 1,
'z-index': 100
}
},
// Highlighted edge (connected to selected node)
{
selector: 'edge.highlighted',
style: {
'width': 3,
'opacity': 1,
'z-index': 50
}
},
// Selected edge
@@ -572,8 +735,9 @@ function getCytoscapeStyles() {
style: {
'line-color': '#000',
'target-arrow-color': '#000',
'width': 3,
'opacity': 1
'width': 4,
'opacity': 1,
'z-index': 100
}
}
];
@@ -581,6 +745,16 @@ function getCytoscapeStyles() {
return styles;
}
// Helper function to darken a color
function darkenColor(hex, percent) {
var num = parseInt(hex.replace('#', ''), 16);
var amt = Math.round(2.55 * percent);
var R = Math.max(0, (num >> 16) - amt);
var G = Math.max(0, ((num >> 8) & 0x00FF) - amt);
var B = Math.max(0, (num & 0x0000FF) - amt);
return '#' + (0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1);
}
// ========== Node Selection ==========
function selectNode(nodeData) {
selectedNode = nodeData;
@@ -847,21 +1021,14 @@ async function switchDataSource(source) {
}
// Update stats display
var statsSpans = document.querySelectorAll('.graph-stats');
if (statsSpans.length >= 2) {
statsSpans[0].innerHTML = '<i data-lucide="circle" class="w-3 h-3"></i> ' +
graphData.nodes.length + ' ' + t('graph.nodes');
statsSpans[1].innerHTML = '<i data-lucide="arrow-right" class="w-3 h-3"></i> ' +
graphData.edges.length + ' ' + t('graph.edges');
if (window.lucide) lucide.createIcons();
}
updateGraphStats();
// Refresh Cytoscape with new data
// Reinitialize Cytoscape with new data
if (cyInstance) {
refreshCytoscape();
} else {
initializeCytoscape();
cyInstance.destroy();
cyInstance = null;
}
initializeCytoscape();
// Show toast notification
if (window.showToast) {
@@ -876,14 +1043,34 @@ async function refreshGraphData() {
showToast(t('common.refreshing'), 'info');
}
// Show loading state in container
var container = document.getElementById('cytoscapeContainer');
if (container) {
container.innerHTML = '<div class="cytoscape-empty">' +
'<i data-lucide="loader-2" class="w-8 h-8 animate-spin"></i>' +
'<p>' + t('common.loading') + '</p>' +
'</div>';
if (window.lucide) lucide.createIcons();
}
// Load data based on source
if (activeDataSource === 'memory') {
await loadCoreMemoryGraphData();
} else {
await loadGraphData();
}
if (activeTab === 'graph' && cyInstance) {
refreshCytoscape();
// Update stats display
updateGraphStats();
// Reinitialize Cytoscape with new data
if (cyInstance) {
cyInstance.destroy();
cyInstance = null;
}
if (activeTab === 'graph') {
initializeCytoscape();
}
if (window.showToast) {
@@ -891,6 +1078,18 @@ async function refreshGraphData() {
}
}
// Update graph statistics display
function updateGraphStats() {
var statsSpans = document.querySelectorAll('.graph-stats');
if (statsSpans.length >= 2) {
statsSpans[0].innerHTML = '<i data-lucide="circle" class="w-3 h-3"></i> ' +
graphData.nodes.length + ' ' + t('graph.nodes');
statsSpans[1].innerHTML = '<i data-lucide="arrow-right" class="w-3 h-3"></i> ' +
graphData.edges.length + ' ' + t('graph.edges');
if (window.lucide) lucide.createIcons();
}
}
// ========== Utility ==========
function hideStatsAndCarousel() {
var statsGrid = document.getElementById('statsGrid');
@@ -915,6 +1114,7 @@ function cleanupGraphExplorer() {
// ========== Scope Filter Actions ==========
async function changeScopeMode(mode) {
console.log('[Graph] Changing scope mode to:', mode);
filterMode = mode;
selectedFile = null;
selectedModule = null;
@@ -936,6 +1136,7 @@ async function changeScopeMode(mode) {
}
async function selectModule(modulePath) {
console.log('[Graph] Selecting module:', modulePath);
selectedModule = modulePath;
if (modulePath) {
await refreshGraphData();
@@ -943,6 +1144,7 @@ async function selectModule(modulePath) {
}
async function selectFile(filePath) {
console.log('[Graph] Selecting file:', filePath);
selectedFile = filePath;
if (filePath) {
await refreshGraphData();

View File

@@ -100,6 +100,8 @@ async function renderHookManager() {
</div>
<div class="hook-templates-grid grid grid-cols-1 md:grid-cols-2 gap-4">
${renderQuickInstallCard('session-context', t('hook.tpl.sessionContext'), t('hook.tpl.sessionContextDesc'), 'UserPromptSubmit', '')}
${renderQuickInstallCard('session-context-continuous', t('hook.tpl.sessionContextContinuous'), t('hook.tpl.sessionContextContinuousDesc'), 'UserPromptSubmit', '')}
${renderQuickInstallCard('codexlens-update', t('hook.tpl.codexlensSync'), t('hook.tpl.codexlensSyncDesc'), 'PostToolUse', 'Write|Edit')}
${renderQuickInstallCard('ccw-notify', t('hook.tpl.ccwDashboardNotify'), t('hook.tpl.ccwDashboardNotifyDesc'), 'PostToolUse', 'Write')}
${renderQuickInstallCard('log-tool', t('hook.tpl.toolLogger'), t('hook.tpl.toolLoggerDesc'), 'PostToolUse', 'All')}

View File

@@ -0,0 +1,60 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"description": "Example hooks configuration for CCW. Place in .claude/settings.json under 'hooks' key.",
"hooks": {
"session-start": [
{
"name": "Progressive Disclosure",
"description": "Injects progressive disclosure index at session start",
"enabled": true,
"handler": "internal:context",
"timeout": 5000,
"failMode": "silent"
}
],
"session-end": [
{
"name": "Update Cluster Metadata",
"description": "Updates cluster metadata after session ends",
"enabled": true,
"command": "ccw core-memory update-cluster --session $SESSION_ID",
"timeout": 30000,
"async": true,
"failMode": "log"
}
],
"file-modified": [
{
"name": "Auto Commit Checkpoint",
"description": "Creates git checkpoint on file modifications",
"enabled": false,
"command": "git add . && git commit -m \"[Auto] Checkpoint: $FILE_PATH\"",
"timeout": 10000,
"async": true,
"failMode": "log"
}
],
"context-request": [
{
"name": "Dynamic Context",
"description": "Provides context based on current session cluster",
"enabled": true,
"handler": "internal:context",
"timeout": 5000,
"failMode": "silent"
}
]
},
"hookSettings": {
"globalTimeout": 60000,
"defaultFailMode": "silent",
"allowAsync": true,
"enableLogging": true
},
"notes": {
"handler": "Use 'internal:context' for built-in context generation, or 'command' for external commands",
"failMode": "Options: 'silent' (ignore errors), 'log' (log errors), 'fail' (abort on error)",
"variables": "Available: $SESSION_ID, $FILE_PATH, $PROJECT_PATH, $CLUSTER_ID",
"async": "Async hooks run in background and don't block the main flow"
}
}