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

@@ -0,0 +1,321 @@
---
name: debug
description: Interactive hypothesis-driven debugging with NDJSON logging, iterative until resolved
argument-hint: "\"bug description or error message\""
allowed-tools: TodoWrite(*), Task(*), AskUserQuestion(*), Read(*), Grep(*), Glob(*), Bash(*), Edit(*), Write(*)
---
# Workflow Debug Command (/workflow:debug)
## Overview
Evidence-based interactive debugging command. Systematically identifies root causes through hypothesis-driven logging and iterative verification.
**Core workflow**: Explore → Add Logging → Reproduce → Analyze Log → Fix → Verify
## Usage
```bash
/workflow:debug <BUG_DESCRIPTION>
# Arguments
<bug-description> Bug description, error message, or stack trace (required)
```
## Execution Process
```
Session Detection:
├─ Check if debug session exists for this bug
├─ EXISTS + debug.log has content → Analyze mode
└─ NOT_FOUND or empty log → Explore mode
Explore Mode:
├─ Locate error source in codebase
├─ Generate testable hypotheses (dynamic count)
├─ Add NDJSON logging instrumentation
└─ Output: Hypothesis list + await user reproduction
Analyze Mode:
├─ Parse debug.log, validate each hypothesis
└─ Decision:
├─ Confirmed → Fix root cause
├─ Inconclusive → Add more logging, iterate
└─ All rejected → Generate new hypotheses
Fix & Cleanup:
├─ Apply fix based on confirmed hypothesis
├─ User verifies
├─ Remove debug instrumentation
└─ If not fixed → Return to Analyze mode
```
## Implementation
### Session Setup & Mode Detection
```javascript
const getUtc8ISOString = () => new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString()
const bugSlug = bug_description.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 30)
const dateStr = getUtc8ISOString().substring(0, 10)
const sessionId = `DBG-${bugSlug}-${dateStr}`
const sessionFolder = `.workflow/.debug/${sessionId}`
const debugLogPath = `${sessionFolder}/debug.log`
// Auto-detect mode
const sessionExists = fs.existsSync(sessionFolder)
const logHasContent = sessionExists && fs.existsSync(debugLogPath) && fs.statSync(debugLogPath).size > 0
const mode = logHasContent ? 'analyze' : 'explore'
if (!sessionExists) {
bash(`mkdir -p ${sessionFolder}`)
}
```
---
### Explore Mode
**Step 1.1: Locate Error Source**
```javascript
// Extract keywords from bug description
const keywords = extractErrorKeywords(bug_description)
// e.g., ['Stack Length', '未找到', 'registered 0']
// Search codebase for error locations
for (const keyword of keywords) {
Grep({ pattern: keyword, path: ".", output_mode: "content", "-C": 3 })
}
// Identify affected files and functions
const affectedLocations = [...] // from search results
```
**Step 1.2: Generate Hypotheses (Dynamic)**
```javascript
// Hypothesis categories based on error pattern
const HYPOTHESIS_PATTERNS = {
"not found|missing|undefined|未找到": "data_mismatch",
"0|empty|zero|registered 0": "logic_error",
"timeout|connection|sync": "integration_issue",
"type|format|parse": "type_mismatch"
}
// Generate hypotheses based on actual issue (NOT fixed count)
function generateHypotheses(bugDescription, affectedLocations) {
const hypotheses = []
// Analyze bug and create targeted hypotheses
// Each hypothesis has:
// - id: H1, H2, ... (dynamic count)
// - description: What might be wrong
// - testable_condition: What to log
// - logging_point: Where to add instrumentation
return hypotheses // Could be 1, 3, 5, or more
}
const hypotheses = generateHypotheses(bug_description, affectedLocations)
```
**Step 1.3: Add NDJSON Instrumentation**
For each hypothesis, add logging at the relevant location:
**Python template**:
```python
# region debug [H{n}]
try:
import json, time
_dbg = {
"sid": "{sessionId}",
"hid": "H{n}",
"loc": "{file}:{line}",
"msg": "{testable_condition}",
"data": {
# Capture relevant values here
},
"ts": int(time.time() * 1000)
}
with open(r"{debugLogPath}", "a", encoding="utf-8") as _f:
_f.write(json.dumps(_dbg, ensure_ascii=False) + "\n")
except: pass
# endregion
```
**JavaScript/TypeScript template**:
```javascript
// region debug [H{n}]
try {
require('fs').appendFileSync("{debugLogPath}", JSON.stringify({
sid: "{sessionId}",
hid: "H{n}",
loc: "{file}:{line}",
msg: "{testable_condition}",
data: { /* Capture relevant values */ },
ts: Date.now()
}) + "\n");
} catch(_) {}
// endregion
```
**Output to user**:
```
## Hypotheses Generated
Based on error "{bug_description}", generated {n} hypotheses:
{hypotheses.map(h => `
### ${h.id}: ${h.description}
- Logging at: ${h.logging_point}
- Testing: ${h.testable_condition}
`).join('')}
**Debug log**: ${debugLogPath}
**Next**: Run reproduction steps, then come back for analysis.
```
---
### Analyze Mode
```javascript
// Parse NDJSON log
const entries = Read(debugLogPath).split('\n')
.filter(l => l.trim())
.map(l => JSON.parse(l))
// Group by hypothesis
const byHypothesis = groupBy(entries, 'hid')
// Validate each hypothesis
for (const [hid, logs] of Object.entries(byHypothesis)) {
const hypothesis = hypotheses.find(h => h.id === hid)
const latestLog = logs[logs.length - 1]
// Check if evidence confirms or rejects hypothesis
const verdict = evaluateEvidence(hypothesis, latestLog.data)
// Returns: 'confirmed' | 'rejected' | 'inconclusive'
}
```
**Output**:
```
## Evidence Analysis
Analyzed ${entries.length} log entries.
${results.map(r => `
### ${r.id}: ${r.description}
- **Status**: ${r.verdict}
- **Evidence**: ${JSON.stringify(r.evidence)}
- **Reason**: ${r.reason}
`).join('')}
${confirmedHypothesis ? `
## Root Cause Identified
**${confirmedHypothesis.id}**: ${confirmedHypothesis.description}
Ready to fix.
` : `
## Need More Evidence
Add more logging or refine hypotheses.
`}
```
---
### Fix & Cleanup
```javascript
// Apply fix based on confirmed hypothesis
// ... Edit affected files
// After user verifies fix works:
// Remove debug instrumentation (search for region markers)
const instrumentedFiles = Grep({
pattern: "# region debug|// region debug",
output_mode: "files_with_matches"
})
for (const file of instrumentedFiles) {
// Remove content between region markers
removeDebugRegions(file)
}
console.log(`
## Debug Complete
- Root cause: ${confirmedHypothesis.description}
- Fix applied to: ${modifiedFiles.join(', ')}
- Debug instrumentation removed
`)
```
---
## Debug Log Format (NDJSON)
Each line is a JSON object:
```json
{"sid":"DBG-xxx-2025-12-18","hid":"H1","loc":"file.py:func:42","msg":"Check dict keys","data":{"keys":["a","b"],"target":"c","found":false},"ts":1734567890123}
```
| Field | Description |
|-------|-------------|
| `sid` | Session ID |
| `hid` | Hypothesis ID (H1, H2, ...) |
| `loc` | Code location |
| `msg` | What's being tested |
| `data` | Captured values |
| `ts` | Timestamp (ms) |
## Session Folder
```
.workflow/.debug/DBG-{slug}-{date}/
├── debug.log # NDJSON log (main artifact)
└── resolution.md # Summary after fix (optional)
```
## Iteration Flow
```
First Call (/workflow:debug "error"):
├─ No session exists → Explore mode
├─ Extract error keywords, search codebase
├─ Generate hypotheses, add logging
└─ Await user reproduction
After Reproduction (/workflow:debug "error"):
├─ Session exists + debug.log has content → Analyze mode
├─ Parse log, evaluate hypotheses
└─ Decision:
├─ Confirmed → Fix → User verify
│ ├─ Fixed → Cleanup → Done
│ └─ Not fixed → Add logging → Iterate
├─ Inconclusive → Add logging → Iterate
└─ All rejected → New hypotheses → Iterate
Output:
└─ .workflow/.debug/DBG-{slug}-{date}/debug.log
```
## Error Handling
| Situation | Action |
|-----------|--------|
| Empty debug.log | Verify reproduction triggered the code path |
| All hypotheses rejected | Generate new hypotheses with broader scope |
| Fix doesn't work | Iterate with more granular logging |
| >5 iterations | Escalate to `/workflow:lite-fix` with evidence |

View File

@@ -0,0 +1,127 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Debug Log Entry Schema",
"description": "NDJSON log entry for hypothesis-driven debugging workflow",
"type": "object",
"required": [
"sessionId",
"runId",
"hypothesisId",
"location",
"message",
"data",
"timestamp"
],
"properties": {
"sessionId": {
"type": "string",
"pattern": "^DBG-[a-z0-9-]+-\\d{4}-\\d{2}-\\d{2}$",
"description": "Debug session identifier (e.g., 'DBG-stack-length-not-found-2025-12-18')"
},
"runId": {
"type": "string",
"pattern": "^run-\\d+$",
"description": "Reproduction run number (e.g., 'run-1', 'run-2')"
},
"hypothesisId": {
"type": "string",
"pattern": "^H\\d+$",
"description": "Hypothesis identifier being tested (e.g., 'H1', 'H2')"
},
"location": {
"type": "string",
"description": "Code location in format 'file:function:line' or 'file:line'"
},
"message": {
"type": "string",
"description": "Human-readable description of what's being logged"
},
"data": {
"type": "object",
"additionalProperties": true,
"description": "Captured values for hypothesis validation",
"properties": {
"keys_sample": {
"type": "array",
"items": {"type": "string"},
"description": "Sample of dictionary/object keys (first 30)"
},
"value": {
"description": "Captured value (any type)"
},
"expected_value": {
"description": "Expected value for comparison"
},
"actual_type": {
"type": "string",
"description": "Actual type of captured value"
},
"count": {
"type": "integer",
"description": "Count of items (for arrays/collections)"
},
"is_null": {
"type": "boolean",
"description": "Whether value is null/None"
},
"is_empty": {
"type": "boolean",
"description": "Whether collection is empty"
},
"comparison_result": {
"type": "string",
"enum": ["match", "mismatch", "partial_match"],
"description": "Result of value comparison"
}
}
},
"timestamp": {
"type": "integer",
"description": "Unix timestamp in milliseconds"
},
"severity": {
"type": "string",
"enum": ["debug", "info", "warning", "error"],
"default": "info",
"description": "Log severity level"
},
"stack_trace": {
"type": "string",
"description": "Stack trace if capturing exception context"
},
"parent_entry_id": {
"type": "string",
"description": "Reference to parent log entry for nested contexts"
}
},
"examples": [
{
"sessionId": "DBG-stack-length-not-found-2025-12-18",
"runId": "run-1",
"hypothesisId": "H1",
"location": "rmxprt_api/core/rmxprt_parameter.py:sync_from_machine:642",
"message": "Inspect stator keys from machine.to_dict and compare Stack Length vs Length",
"data": {
"keys_sample": ["Length", "Outer Diameter", "Inner Diameter", "Slot"],
"stack_length_value": "未找到",
"length_value": "120mm",
"comparison_result": "mismatch"
},
"timestamp": 1734523456789
},
{
"sessionId": "DBG-registered-zero-2025-12-18",
"runId": "run-1",
"hypothesisId": "H2",
"location": "rmxprt_api/utils/param_core.py:update_variables_from_result_model:670",
"message": "Check result parameters count and sample keys",
"data": {
"count": 0,
"is_empty": true,
"sections_parsed": ["Stator", "Rotor", "General"],
"expected_count": 145
},
"timestamp": 1734523457123
}
]
}

View File

@@ -1,3 +1,12 @@
{
"mcpServers": {}
"mcpServers": {
"chrome-devtools": {
"type": "stdio",
"command": "npx",
"args": [
"chrome-devtools-mcp@latest"
],
"env": {}
}
}
}

View File

@@ -0,0 +1,297 @@
# Hook 集成实现总结
## 实现概览
已成功实现 Hook 系统与 session-start 渐进式披露索引的集成。
## 修改的文件
### 1. `ccw/src/core/routes/hooks-routes.ts`
**修改内容**:
-`/api/hook` POST 端点中添加了 `session-start``context` hook 类型的处理逻辑
- 集成 `SessionClusteringService` 以生成渐进式披露索引
- 实现失败静默处理机制fail silently
**关键代码**:
```typescript
// Handle context hooks (session-start, context)
if (type === 'session-start' || type === 'context') {
try {
const projectPath = url.searchParams.get('path') || initialPath;
const { SessionClusteringService } = await import('../session-clustering-service.js');
const clusteringService = new SessionClusteringService(projectPath);
const format = url.searchParams.get('format') || 'markdown';
const index = await clusteringService.getProgressiveIndex(resolvedSessionId);
return {
success: true,
type: 'context',
format,
content: index,
sessionId: resolvedSessionId
};
} catch (error) {
console.error('[Hooks] Failed to generate context:', error);
return {
success: true,
type: 'context',
format: 'markdown',
content: '',
sessionId: resolvedSessionId,
error: (error as Error).message
};
}
}
```
### 2. `ccw/src/core/session-clustering-service.ts`
**修改内容**:
- 优化 `getProgressiveIndex()` 方法的输出格式
- 更新标题为 "Related Sessions Index"(符合任务要求)
- 改进时间线显示,支持显示最近 3 个 session
- 统一命令格式为 "Resume Commands"
**关键改进**:
```typescript
// Generate timeline - show multiple recent sessions
let timeline = '';
if (members.length > 0) {
const timelineEntries: string[] = [];
const displayCount = Math.min(members.length, 3); // Show last 3 sessions
for (let i = members.length - displayCount; i < members.length; i++) {
const member = members[i];
const date = member.created_at ? new Date(member.created_at).toLocaleDateString() : '';
const title = member.title?.substring(0, 30) || 'Untitled';
const isCurrent = i === members.length - 1;
const marker = isCurrent ? ' ← Current' : '';
timelineEntries.push(`${date} ─●─ ${member.session_id} (${title})${marker}`);
}
timeline = `\`\`\`\n${timelineEntries.join('\n │\n')}\n\`\`\``;
}
```
### 3. `ccw/src/commands/core-memory.ts`
**修改内容**:
- 修复 TypeScript 类型错误
-`scope` 变量添加明确的类型注解 `'all' | 'recent' | 'unclustered'`
## 新增文件
### 1. `ccw/src/templates/hooks-config-example.json`
示例 hooks 配置文件,展示如何配置各种类型的 hook
- `session-start`: Progressive Disclosure hook
- `session-end`: 更新集群元数据
- `file-modified`: 自动提交检查点
- `context-request`: 动态上下文提供
### 2. `ccw/docs/hooks-integration.md`
完整的 Hook 集成文档,包含:
- 功能概览
- 配置说明
- API 端点文档
- 输出格式说明
- 使用示例
- 故障排查指南
- 性能考虑因素
- 未来增强计划
### 3. `ccw/test-hooks.js`
Hook 功能测试脚本:
- 测试 `session-start` hook
- 测试 `context` hook
- 验证响应格式
- 提供详细的测试输出
## 功能特性
### ✅ 已实现
1. **Context Hook 处理**
- 支持 `session-start``context` 两种 hook 类型
- 调用 `SessionClusteringService.getProgressiveIndex()` 生成上下文
- 返回结构化的 Markdown 格式索引
2. **失败静默处理**
- 所有错误都被捕获并记录
- 失败时返回空内容,不阻塞 session 启动
- 超时时间 < 5 秒
3. **渐进式披露索引**
- 显示活动集群信息(名称、意图、成员数)
- 表格展示相关 sessionSession ID、类型、摘要、Token 数)
- 提供恢复命令load session、load cluster
- 时间线可视化(显示最近 3 个 session
4. **灵活配置**
- 支持通过 `.claude/settings.json` 配置 hook
- 支持多种 hook 类型和处理器
- 支持超时配置、失败模式配置
### 📋 配置格式
```json
{
"hooks": {
"session-start": [
{
"name": "Progressive Disclosure",
"description": "Injects progressive disclosure index at session start",
"enabled": true,
"handler": "internal:context",
"timeout": 5000,
"failMode": "silent"
}
]
}
}
```
### 📊 输出示例
```markdown
<ccw-session-context>
## 📋 Related Sessions Index
### 🔗 Active Cluster: auth-implementation (3 sessions)
**Intent**: Implement authentication system
| # | Session | Type | Summary | Tokens |
|---|---------|------|---------|--------|
| 1 | WFS-001 | Workflow | Create auth module | ~1200 |
| 2 | CLI-002 | CLI | Add JWT validation | ~800 |
| 3 | WFS-003 | Workflow | OAuth2 integration | ~1500 |
**Resume Commands**:
```bash
# Load specific session
ccw core-memory load WFS-003
# Load entire cluster context
ccw core-memory load-cluster cluster-001
```
### 📊 Timeline
```
2024-12-16 ─●─ CLI-002 (Add JWT validation)
2024-12-17 ─●─ WFS-003 (OAuth2 integration) ← Current
```
---
**Tip**: Use `ccw core-memory search <keyword>` to find more sessions
</ccw-session-context>
```
## API 使用
### 触发 Hook
```bash
POST http://localhost:3456/api/hook
Content-Type: application/json
{
"type": "session-start",
"sessionId": "WFS-20241218-001"
}
```
### 响应格式
```json
{
"success": true,
"type": "context",
"format": "markdown",
"content": "<ccw-session-context>...</ccw-session-context>",
"sessionId": "WFS-20241218-001"
}
```
## 测试
### 运行测试
```bash
# 启动 CCW 服务器
ccw server
# 在另一个终端运行测试
node ccw/test-hooks.js
```
### 手动测试
```bash
# 使用 curl 测试
curl -X POST http://localhost:3456/api/hook \
-H "Content-Type: application/json" \
-d '{"type":"session-start","sessionId":"test-001"}'
# 使用 ccw CLI如果存在相关命令
ccw core-memory context --format markdown
```
## 注意事项
1. **超时时间**: Hook 必须在 5 秒内完成,否则会被终止
2. **失败模式**: 默认使用 `silent` 模式,确保 hook 失败不影响主流程
3. **性能**: 使用缓存的 metadata 避免完整 session 解析
4. **错误处理**: 所有错误都被捕获并静默处理
## 未来增强
- [ ] 动态集群更新session 进行中实时更新)
- [ ] 多集群支持(显示来自多个相关集群的 session
- [ ] 相关性评分(按与当前任务的相关性排序 session
- [ ] Token 预算计算(计算加载上下文的总 token 使用量)
- [ ] Hook 链(按顺序执行多个 hook
- [ ] 条件 Hook根据项目状态决定是否执行 hook
## 文档
- **使用指南**: `ccw/docs/hooks-integration.md`
- **配置示例**: `ccw/src/templates/hooks-config-example.json`
- **测试脚本**: `ccw/test-hooks.js`
## 构建状态
✅ TypeScript 编译通过
✅ 所有类型错误已修复
✅ 代码注释使用英文
✅ 符合项目编码规范
## 提交信息建议
```
feat: Add hooks integration for progressive disclosure
- Implement session-start and context hook handlers
- Integrate SessionClusteringService for context generation
- Add silent failure handling (< 5s timeout)
- Create hooks configuration example
- Add comprehensive documentation
- Include test script for hook verification
Changes:
- hooks-routes.ts: Add context hook processing
- session-clustering-service.ts: Enhance getProgressiveIndex output
- core-memory.ts: Fix TypeScript type error
New files:
- docs/hooks-integration.md: Complete integration guide
- src/templates/hooks-config-example.json: Configuration template
- test-hooks.js: Hook testing script
```

View File

@@ -0,0 +1,294 @@
# Hooks Integration for Progressive Disclosure
This document describes how to integrate session hooks with CCW's progressive disclosure system.
## Overview
CCW now supports automatic context injection via hooks. When a session starts, the system can automatically provide a progressive disclosure index showing related sessions from the same cluster.
## Features
- **Automatic Context Injection**: Session start hooks inject cluster context
- **Progressive Disclosure**: Shows related sessions, their summaries, and recovery commands
- **Silent Failure**: Hook failures don't block session start (< 5 seconds timeout)
- **Multiple Hook Types**: Supports `session-start`, `context`, and custom hooks
## Hook Configuration
### Location
Place hook configurations in `.claude/settings.json`:
```json
{
"hooks": {
"session-start": [
{
"name": "Progressive Disclosure",
"description": "Injects progressive disclosure index at session start",
"enabled": true,
"handler": "internal:context",
"timeout": 5000,
"failMode": "silent"
}
]
}
}
```
### Hook Types
#### `session-start`
Triggered when a new session begins. Ideal for injecting context.
#### `context`
Triggered on explicit context requests. Same handler as `session-start`.
#### `session-end`
Triggered when a session ends. Useful for updating cluster metadata.
#### `file-modified`
Triggered when files are modified. Can be used for auto-commits or notifications.
### Hook Properties
- **`name`**: Human-readable hook name
- **`description`**: What the hook does
- **`enabled`**: Boolean to enable/disable the hook
- **`handler`**: `internal:context` for built-in context generation, or use `command` field
- **`command`**: Shell command to execute (alternative to `handler`)
- **`timeout`**: Maximum execution time in milliseconds (default: 5000)
- **`failMode`**: How to handle failures
- `silent`: Ignore errors, don't log
- `log`: Log errors but continue
- `fail`: Abort on error
- **`async`**: Run in background without blocking (default: false)
### Available Variables
In `command` fields, use these variables:
- `$SESSION_ID`: Current session ID
- `$FILE_PATH`: File path (for file-modified hooks)
- `$PROJECT_PATH`: Current project path
- `$CLUSTER_ID`: Active cluster ID (if available)
## API Endpoint
### Trigger Hook
```bash
POST http://localhost:3456/api/hook
Content-Type: application/json
{
"type": "session-start",
"sessionId": "WFS-20241218-001",
"projectPath": "/path/to/project"
}
```
### Response Format
```json
{
"success": true,
"type": "context",
"format": "markdown",
"content": "<ccw-session-context>...</ccw-session-context>",
"sessionId": "WFS-20241218-001"
}
```
### Query Parameters
- `?path=/project/path`: Override project path
- `?format=markdown|json`: Response format (default: markdown)
## Progressive Disclosure Output Format
The hook returns a structured Markdown document:
```markdown
<ccw-session-context>
## 📋 Related Sessions Index
### 🔗 Active Cluster: {cluster_name} ({member_count} sessions)
**Intent**: {cluster_intent}
| # | Session | Type | Summary | Tokens |
|---|---------|------|---------|--------|
| 1 | WFS-001 | Workflow | Implement auth | ~1200 |
| 2 | CLI-002 | CLI | Fix login bug | ~800 |
**Resume Commands**:
```bash
# Load specific session
ccw core-memory load {session_id}
# Load entire cluster context
ccw core-memory load-cluster {cluster_id}
```
### 📊 Timeline
```
2024-12-15 ─●─ WFS-001 (Implement auth)
2024-12-16 ─●─ CLI-002 (Fix login bug) ← Current
```
---
**Tip**: Use `ccw core-memory search <keyword>` to find more sessions
</ccw-session-context>
```
## Examples
### Example 1: Basic Session Start Hook
```json
{
"hooks": {
"session-start": [
{
"name": "Progressive Disclosure",
"enabled": true,
"handler": "internal:context",
"timeout": 5000,
"failMode": "silent"
}
]
}
}
```
### Example 2: Custom Command Hook
```json
{
"hooks": {
"session-end": [
{
"name": "Update Cluster",
"enabled": true,
"command": "ccw core-memory update-cluster --session $SESSION_ID",
"timeout": 30000,
"async": true,
"failMode": "log"
}
]
}
}
```
### Example 3: File Modification Hook
```json
{
"hooks": {
"file-modified": [
{
"name": "Auto Commit",
"enabled": false,
"command": "git add $FILE_PATH && git commit -m '[Auto] Save: $FILE_PATH'",
"timeout": 10000,
"async": true,
"failMode": "log"
}
]
}
}
```
## Implementation Details
### Handler: `internal:context`
The built-in context handler:
1. Determines the current session ID
2. Queries `SessionClusteringService` for related clusters
3. Retrieves cluster members and their metadata
4. Generates a progressive disclosure index
5. Returns formatted Markdown within `<ccw-session-context>` tags
### Timeout Behavior
- Hooks have a maximum execution time (default: 5 seconds)
- If timeout is exceeded, the hook is terminated
- Behavior depends on `failMode`:
- `silent`: Continues without notification
- `log`: Logs timeout error
- `fail`: Aborts session start (not recommended)
### Error Handling
All errors are caught and handled according to `failMode`. The system ensures that hook failures never block the main workflow.
## Testing
### Test Hook Trigger
```bash
# Using curl
curl -X POST http://localhost:3456/api/hook \
-H "Content-Type: application/json" \
-d '{"type":"session-start","sessionId":"test-001"}'
# Using ccw (if CLI command exists)
ccw core-memory context --format markdown
```
### Expected Output
If a cluster exists:
- Table of related sessions
- Resume commands
- Timeline visualization
If no cluster exists:
- Message indicating no cluster found
- Commands to search or trigger clustering
## Troubleshooting
### Hook Not Triggering
1. Check that hooks are enabled in `.claude/settings.json`
2. Verify the hook type matches the event
3. Ensure the server is running on the correct port
### Timeout Issues
1. Increase `timeout` value for slow operations
2. Use `async: true` for long-running commands
3. Check logs for performance issues
### Empty Context
1. Ensure clustering has been run: `ccw core-memory cluster --auto`
2. Verify session metadata exists
3. Check that the session has been added to a cluster
## Performance Considerations
- Progressive disclosure index generation is fast (< 1 second typical)
- Uses cached metadata to avoid full session parsing
- Timeout enforced to prevent blocking
- Failures return empty content instead of errors
## Future Enhancements
- **Dynamic Clustering**: Real-time cluster updates during session
- **Multi-Cluster Support**: Show sessions from multiple related clusters
- **Relevance Scoring**: Sort sessions by relevance to current task
- **Token Budget**: Calculate total token usage for context loading
- **Hook Chains**: Execute multiple hooks in sequence
- **Conditional Hooks**: Execute hooks based on project state
## References
- **Session Clustering**: See `session-clustering-service.ts`
- **Core Memory Store**: See `core-memory-store.ts`
- **Hook Routes**: See `routes/hooks-routes.ts`
- **Example Configuration**: See `hooks-config-example.json`

View File

@@ -10,6 +10,16 @@ import { notifyRefreshRequired } from '../tools/notifier.js';
interface CommandOptions {
id?: string;
tool?: 'gemini' | 'qwen';
status?: string;
json?: boolean;
auto?: boolean;
scope?: string;
create?: boolean;
name?: string;
members?: string;
format?: string;
level?: string;
type?: string;
}
/**
@@ -147,6 +157,297 @@ async function summaryAction(options: CommandOptions): Promise<void> {
}
}
/**
* List all clusters
*/
async function clustersAction(options: CommandOptions): Promise<void> {
try {
const store = getCoreMemoryStore(getProjectPath());
const clusters = store.listClusters(options.status);
if (options.json) {
console.log(JSON.stringify(clusters, null, 2));
return;
}
if (clusters.length === 0) {
console.log(chalk.yellow('\n No clusters found. Run "ccw core-memory cluster --auto" to create clusters.\n'));
return;
}
console.log(chalk.bold.cyan('\n 📦 Session Clusters\n'));
console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────'));
for (const cluster of clusters) {
const members = store.getClusterMembers(cluster.id);
console.log(chalk.cyan(`${cluster.name}`) + chalk.gray(` (${cluster.id})`));
console.log(chalk.white(` Status: ${cluster.status} | Sessions: ${members.length}`));
console.log(chalk.gray(` Updated: ${cluster.updated_at}`));
if (cluster.intent) console.log(chalk.white(` Intent: ${cluster.intent}`));
console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────'));
}
console.log(chalk.gray(`\n Total: ${clusters.length}\n`));
} catch (error) {
console.error(chalk.red(`Error: ${(error as Error).message}`));
process.exit(1);
}
}
/**
* View cluster details or create new cluster
*/
async function clusterAction(clusterId: string | undefined, options: CommandOptions): Promise<void> {
try {
const store = getCoreMemoryStore(getProjectPath());
// Auto clustering
if (options.auto) {
const { SessionClusteringService } = await import('../core/session-clustering-service.js');
const service = new SessionClusteringService(getProjectPath());
console.log(chalk.cyan('🔄 Running auto-clustering...'));
const scope: 'all' | 'recent' | 'unclustered' =
options.scope === 'all' || options.scope === 'recent' || options.scope === 'unclustered'
? options.scope
: 'recent';
const result = await service.autocluster({ scope });
console.log(chalk.green(`✓ Created ${result.clustersCreated} clusters`));
console.log(chalk.white(` Processed ${result.sessionsProcessed} sessions`));
console.log(chalk.white(` Clustered ${result.sessionsClustered} sessions`));
// Notify dashboard
notifyRefreshRequired('memory').catch(() => { /* ignore */ });
return;
}
// Create new cluster
if (options.create) {
if (!options.name) {
console.error(chalk.red('Error: --name is required for --create'));
process.exit(1);
}
const cluster = store.createCluster({ name: options.name });
console.log(chalk.green(`✓ Created cluster: ${cluster.id}`));
// Add members if specified
if (options.members) {
const memberIds = options.members.split(',').map(s => s.trim());
for (const memberId of memberIds) {
// Detect session type from ID
let sessionType = 'core_memory';
if (memberId.startsWith('WFS-')) sessionType = 'workflow';
else if (memberId.includes('-gemini') || memberId.includes('-qwen') || memberId.includes('-codex')) {
sessionType = 'cli_history';
}
store.addClusterMember({
cluster_id: cluster.id,
session_id: memberId,
session_type: sessionType as any,
sequence_order: memberIds.indexOf(memberId) + 1,
relevance_score: 1.0
});
}
console.log(chalk.white(` Added ${memberIds.length} members`));
}
// Notify dashboard
notifyRefreshRequired('memory').catch(() => { /* ignore */ });
return;
}
// View cluster details
if (clusterId) {
const cluster = store.getCluster(clusterId);
if (!cluster) {
console.error(chalk.red(`Cluster not found: ${clusterId}`));
process.exit(1);
}
const members = store.getClusterMembers(clusterId);
const relations = store.getClusterRelations(clusterId);
console.log(chalk.bold.cyan(`\n 📦 Cluster: ${cluster.name}\n`));
console.log(chalk.white(` ID: ${cluster.id}`));
console.log(chalk.white(` Status: ${cluster.status}`));
if (cluster.description) console.log(chalk.white(` Description: ${cluster.description}`));
if (cluster.intent) console.log(chalk.white(` Intent: ${cluster.intent}`));
if (members.length > 0) {
console.log(chalk.bold.white('\n 📋 Sessions:'));
for (const member of members) {
const meta = store.getSessionMetadata(member.session_id);
console.log(chalk.cyan(` ${member.sequence_order}. ${member.session_id}`) + chalk.gray(` (${member.session_type})`));
if (meta?.title) console.log(chalk.white(` ${meta.title}`));
if (meta?.token_estimate) console.log(chalk.gray(` ~${meta.token_estimate} tokens`));
}
}
if (relations.length > 0) {
console.log(chalk.bold.white('\n 🔗 Relations:'));
for (const rel of relations) {
console.log(chalk.white(`${rel.relation_type} ${rel.target_cluster_id}`));
}
}
console.log();
return;
}
// No action specified - show usage
console.log(chalk.yellow('Usage: ccw core-memory cluster <id> or --auto or --create --name <name>'));
} catch (error) {
console.error(chalk.red(`Error: ${(error as Error).message}`));
process.exit(1);
}
}
/**
* Get progressive disclosure context
*/
async function contextAction(options: CommandOptions): Promise<void> {
try {
const { SessionClusteringService } = await import('../core/session-clustering-service.js');
const service = new SessionClusteringService(getProjectPath());
const index = await service.getProgressiveIndex();
if (options.format === 'json') {
console.log(JSON.stringify({ index }, null, 2));
} else {
console.log(index);
}
} catch (error) {
console.error(chalk.red(`Error: ${(error as Error).message}`));
process.exit(1);
}
}
/**
* Load cluster context
*/
async function loadClusterAction(clusterId: string, options: CommandOptions): Promise<void> {
if (!clusterId) {
console.error(chalk.red('Error: Cluster ID is required'));
console.error(chalk.gray('Usage: ccw core-memory load-cluster <id> [--level metadata|keyFiles|full]'));
process.exit(1);
}
try {
const store = getCoreMemoryStore(getProjectPath());
const cluster = store.getCluster(clusterId);
if (!cluster) {
console.error(chalk.red(`Cluster not found: ${clusterId}`));
process.exit(1);
}
const members = store.getClusterMembers(clusterId);
const level = options.level || 'metadata';
console.log(chalk.bold.cyan(`\n# Cluster: ${cluster.name}\n`));
if (cluster.intent) console.log(chalk.white(`Intent: ${cluster.intent}\n`));
console.log(chalk.bold.white('## Sessions\n'));
for (const member of members) {
const meta = store.getSessionMetadata(member.session_id);
console.log(chalk.bold.cyan(`### ${member.sequence_order}. ${member.session_id}`));
console.log(chalk.white(`Type: ${member.session_type}`));
if (meta) {
if (meta.title) console.log(chalk.white(`Title: ${meta.title}`));
if (level === 'metadata') {
if (meta.summary) console.log(chalk.white(`Summary: ${meta.summary}`));
} else if (level === 'keyFiles' || level === 'full') {
if (meta.summary) console.log(chalk.white(`Summary: ${meta.summary}`));
if (meta.file_patterns) {
const patterns = JSON.parse(meta.file_patterns as any);
console.log(chalk.white(`Files: ${patterns.join(', ')}`));
}
if (meta.keywords) {
const keywords = JSON.parse(meta.keywords as any);
console.log(chalk.white(`Keywords: ${keywords.join(', ')}`));
}
}
if (level === 'full') {
// Load full content based on session type
if (member.session_type === 'core_memory') {
const memory = store.getMemory(member.session_id);
if (memory) {
console.log(chalk.white('\nContent:'));
console.log(chalk.gray(memory.content));
}
}
}
}
console.log();
}
} catch (error) {
console.error(chalk.red(`Error: ${(error as Error).message}`));
process.exit(1);
}
}
/**
* Search sessions by keyword
*/
async function searchAction(keyword: string, options: CommandOptions): Promise<void> {
if (!keyword || keyword.trim() === '') {
console.error(chalk.red('Error: Keyword is required'));
console.error(chalk.gray('Usage: ccw core-memory search <keyword> [--type core|workflow|cli|all]'));
process.exit(1);
}
try {
const store = getCoreMemoryStore(getProjectPath());
const results = store.searchSessionsByKeyword(keyword);
if (results.length === 0) {
console.log(chalk.yellow(`\n No sessions found for: "${keyword}"\n`));
return;
}
// Filter by type if specified
let filtered = results;
if (options.type && options.type !== 'all') {
const typeMap: Record<string, string> = {
core: 'core_memory',
workflow: 'workflow',
cli: 'cli_history'
};
filtered = results.filter(r => r.session_type === typeMap[options.type!]);
}
console.log(chalk.bold.cyan(`\n 🔍 Found ${filtered.length} sessions for "${keyword}"\n`));
console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────'));
for (const result of filtered) {
console.log(chalk.cyan(`${result.session_id}`) + chalk.gray(` (${result.session_type})`));
if (result.title) console.log(chalk.white(` ${result.title}`));
if (result.token_estimate) console.log(chalk.gray(` ~${result.token_estimate} tokens`));
console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────'));
}
console.log();
} catch (error) {
console.error(chalk.red(`Error: ${(error as Error).message}`));
process.exit(1);
}
}
/**
* Core Memory command entry point
*/
@@ -175,24 +476,69 @@ export async function coreMemoryCommand(
await summaryAction(options);
break;
case 'clusters':
await clustersAction(options);
break;
case 'cluster':
await clusterAction(argsArray[0], options);
break;
case 'context':
await contextAction(options);
break;
case 'load-cluster':
await loadClusterAction(textArg, options);
break;
case 'search':
await searchAction(textArg, options);
break;
default:
console.log(chalk.bold.cyan('\n CCW Core Memory\n'));
console.log(' Manage core memory entries.\n');
console.log(' Commands:');
console.log(' Manage core memory entries and session clusters.\n');
console.log(chalk.bold(' Basic Commands:'));
console.log(chalk.white(' list ') + chalk.gray('List all memories'));
console.log(chalk.white(' import "<text>" ') + chalk.gray('Import text as new memory'));
console.log(chalk.white(' export --id <id> ') + chalk.gray('Export memory as plain text'));
console.log(chalk.white(' summary --id <id> ') + chalk.gray('Generate AI summary'));
console.log();
console.log(' Options:');
console.log(chalk.gray(' --id <id> Memory ID (for export/summary)'));
console.log(chalk.gray(' --tool gemini|qwen AI tool for summary (default: gemini)'));
console.log(chalk.bold(' Clustering Commands:'));
console.log(chalk.white(' clusters [--status] ') + chalk.gray('List all clusters'));
console.log(chalk.white(' cluster [id] ') + chalk.gray('View cluster details'));
console.log(chalk.white(' cluster --auto ') + chalk.gray('Run auto-clustering'));
console.log(chalk.white(' cluster --create --name ') + chalk.gray('Create new cluster'));
console.log(chalk.white(' context ') + chalk.gray('Get progressive index'));
console.log(chalk.white(' load-cluster <id> ') + chalk.gray('Load cluster context'));
console.log(chalk.white(' search <keyword> ') + chalk.gray('Search sessions'));
console.log();
console.log(' Examples:');
console.log(chalk.bold(' Options:'));
console.log(chalk.gray(' --id <id> Memory ID (for export/summary)'));
console.log(chalk.gray(' --tool gemini|qwen AI tool for summary (default: gemini)'));
console.log(chalk.gray(' --status <status> Filter by status (active/archived/merged)'));
console.log(chalk.gray(' --json Output as JSON'));
console.log(chalk.gray(' --scope <scope> Auto-cluster scope (all/recent/unclustered)'));
console.log(chalk.gray(' --name <name> Cluster name (for --create)'));
console.log(chalk.gray(' --members <ids> Comma-separated session IDs (for --create)'));
console.log(chalk.gray(' --format <format> Output format (markdown/json)'));
console.log(chalk.gray(' --level <level> Detail level (metadata/keyFiles/full)'));
console.log(chalk.gray(' --type <type> Filter by type (core/workflow/cli/all)'));
console.log();
console.log(chalk.bold(' Examples:'));
console.log(chalk.gray(' # Basic commands'));
console.log(chalk.gray(' ccw core-memory list'));
console.log(chalk.gray(' ccw core-memory import "This is important context about the auth module"'));
console.log(chalk.gray(' ccw core-memory import "Important context"'));
console.log(chalk.gray(' ccw core-memory export --id CMEM-20251217-143022'));
console.log(chalk.gray(' ccw core-memory summary --id CMEM-20251217-143022'));
console.log();
console.log(chalk.gray(' # Clustering commands'));
console.log(chalk.gray(' ccw core-memory clusters'));
console.log(chalk.gray(' ccw core-memory cluster --auto'));
console.log(chalk.gray(' ccw core-memory cluster CLU-001'));
console.log(chalk.gray(' ccw core-memory cluster --create --name "Auth Module"'));
console.log(chalk.gray(' ccw core-memory load-cluster CLU-001 --level full'));
console.log(chalk.gray(' ccw core-memory search authentication --type workflow'));
console.log();
}
}

View File

@@ -164,15 +164,58 @@ export class CoreMemoryStore {
}
/**
* Migrate database by removing old tables
* Migrate database by removing old tables, views, and triggers
*/
private migrateDatabase(): void {
const oldTables = ['knowledge_graph', 'knowledge_graph_edges', 'evolution_history'];
for (const table of oldTables) {
try {
// Disable foreign key constraints during migration
this.db.pragma('foreign_keys = OFF');
// Drop any triggers that might reference old tables
const triggers = this.db.prepare(
`SELECT name FROM sqlite_master WHERE type='trigger'`
).all() as { name: string }[];
for (const trigger of triggers) {
try {
this.db.exec(`DROP TRIGGER IF EXISTS "${trigger.name}"`);
} catch (e) {
// Ignore trigger drop errors
}
}
// Drop any views that might reference old tables
const views = this.db.prepare(
`SELECT name FROM sqlite_master WHERE type='view'`
).all() as { name: string }[];
for (const view of views) {
try {
this.db.exec(`DROP VIEW IF EXISTS "${view.name}"`);
} catch (e) {
// Ignore view drop errors
}
}
// Now drop the old tables
for (const table of oldTables) {
try {
this.db.exec(`DROP TABLE IF EXISTS "${table}"`);
} catch (e) {
// Ignore if table doesn't exist
}
}
// Re-enable foreign key constraints
this.db.pragma('foreign_keys = ON');
} catch (e) {
// If migration fails, continue - tables may not exist
try {
this.db.exec(`DROP TABLE IF EXISTS ${table}`);
} catch (e) {
// Ignore if table doesn't exist
this.db.pragma('foreign_keys = ON');
} catch (_) {
// Ignore
}
}
}
@@ -202,7 +245,10 @@ export class CoreMemoryStore {
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `CLST-${year}${month}${day}-${hours}${minutes}${seconds}`;
const ms = String(now.getMilliseconds()).padStart(3, '0');
// Add random 2-digit suffix to ensure uniqueness
const random = String(Math.floor(Math.random() * 100)).padStart(2, '0');
return `CLST-${year}${month}${day}-${hours}${minutes}${seconds}${ms}${random}`;
}
/**

View File

@@ -90,6 +90,7 @@ const MODULE_FILES = [
'views/memory.js',
'views/core-memory.js',
'views/core-memory-graph.js',
'views/core-memory-clusters.js',
'views/prompt-history.js',
'views/skills-manager.js',
'views/rules-manager.js',

View File

@@ -77,7 +77,7 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
// API: CodexLens Index List - Get all indexed projects with details
if (pathname === '/api/codexlens/indexes') {
try {
// First get config to find index directory
// Get config for index directory path
const configResult = await executeCodexLens(['config', '--json']);
let indexDir = '';
@@ -85,109 +85,127 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
try {
const config = extractJSON(configResult.output);
if (config.success && config.result) {
indexDir = config.result.index_root || '';
// CLI returns index_dir (not index_root)
indexDir = config.result.index_dir || config.result.index_root || '';
}
} catch (e) {
console.error('[CodexLens] Failed to parse config for index list:', e.message);
}
}
// Get detailed status including projects
const statusResult = await executeCodexLens(['status', '--json']);
// Get project list using 'projects list' command
const projectsResult = await executeCodexLens(['projects', 'list', '--json']);
let indexes: any[] = [];
let totalSize = 0;
let vectorIndexCount = 0;
let normalIndexCount = 0;
if (projectsResult.success) {
try {
const projectsData = extractJSON(projectsResult.output);
if (projectsData.success && Array.isArray(projectsData.result)) {
const { statSync, existsSync } = await import('fs');
const { basename, join } = await import('path');
for (const project of projectsData.result) {
// Skip test/temp projects
if (project.source_root && (
project.source_root.includes('\\Temp\\') ||
project.source_root.includes('/tmp/') ||
project.total_files === 0
)) {
continue;
}
let projectSize = 0;
let hasVectorIndex = false;
let hasNormalIndex = true; // All projects have FTS index
let lastModified = null;
// Try to get actual index size from index_root
if (project.index_root && existsSync(project.index_root)) {
try {
const { readdirSync } = await import('fs');
const files = readdirSync(project.index_root);
for (const file of files) {
try {
const filePath = join(project.index_root, file);
const stat = statSync(filePath);
projectSize += stat.size;
if (!lastModified || stat.mtime > lastModified) {
lastModified = stat.mtime;
}
// Check for vector/embedding files
if (file.includes('vector') || file.includes('embedding') ||
file.endsWith('.faiss') || file.endsWith('.npy') ||
file.includes('semantic_chunks')) {
hasVectorIndex = true;
}
} catch (e) {
// Skip files we can't stat
}
}
} catch (e) {
// Can't read index directory
}
}
if (hasVectorIndex) vectorIndexCount++;
if (hasNormalIndex) normalIndexCount++;
totalSize += projectSize;
// Use source_root as the display name
const displayName = project.source_root ? basename(project.source_root) : `project_${project.id}`;
indexes.push({
id: displayName,
path: project.source_root || '',
indexPath: project.index_root || '',
size: projectSize,
sizeFormatted: formatSize(projectSize),
fileCount: project.total_files || 0,
dirCount: project.total_dirs || 0,
hasVectorIndex,
hasNormalIndex,
status: project.status || 'active',
lastModified: lastModified ? lastModified.toISOString() : null
});
}
// Sort by file count (most files first), then by name
indexes.sort((a, b) => {
if (b.fileCount !== a.fileCount) return b.fileCount - a.fileCount;
return a.id.localeCompare(b.id);
});
}
} catch (e) {
console.error('[CodexLens] Failed to parse projects list:', e.message);
}
}
// Also get summary stats from status command
const statusResult = await executeCodexLens(['status', '--json']);
let statusSummary: any = {};
if (statusResult.success) {
try {
const status = extractJSON(statusResult.output);
if (status.success && status.result) {
const projectsCount = status.result.projects_count || 0;
// Try to get project list from index directory
if (indexDir) {
const { readdirSync, statSync, existsSync } = await import('fs');
const { join } = await import('path');
const { homedir } = await import('os');
// Expand ~ in path
const expandedDir = indexDir.startsWith('~')
? join(homedir(), indexDir.slice(1))
: indexDir;
if (existsSync(expandedDir)) {
try {
const entries = readdirSync(expandedDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const projectDir = join(expandedDir, entry.name);
let projectSize = 0;
let hasVectorIndex = false;
let hasNormalIndex = false;
let fileCount = 0;
let lastModified = null;
try {
// Check for index files
const projectFiles = readdirSync(projectDir);
for (const file of projectFiles) {
const filePath = join(projectDir, file);
try {
const stat = statSync(filePath);
projectSize += stat.size;
fileCount++;
if (!lastModified || stat.mtime > lastModified) {
lastModified = stat.mtime;
}
// Check index type
if (file.includes('vector') || file.includes('embedding') || file.endsWith('.faiss') || file.endsWith('.npy')) {
hasVectorIndex = true;
}
if (file.includes('fts') || file.endsWith('.db') || file.endsWith('.sqlite')) {
hasNormalIndex = true;
}
} catch (e) {
// Skip files we can't stat
}
}
} catch (e) {
// Can't read project directory
}
if (hasVectorIndex) vectorIndexCount++;
if (hasNormalIndex) normalIndexCount++;
totalSize += projectSize;
indexes.push({
id: entry.name,
path: projectDir,
size: projectSize,
sizeFormatted: formatSize(projectSize),
fileCount,
hasVectorIndex,
hasNormalIndex,
lastModified: lastModified ? lastModified.toISOString() : null
});
}
}
// Sort by last modified (most recent first)
indexes.sort((a, b) => {
if (!a.lastModified) return 1;
if (!b.lastModified) return -1;
return new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime();
});
} catch (e) {
console.error('[CodexLens] Failed to read index directory:', e.message);
}
}
statusSummary = {
totalProjects: status.result.projects_count || indexes.length,
totalFiles: status.result.total_files || 0,
totalDirs: status.result.total_dirs || 0,
indexSizeBytes: status.result.index_size_bytes || totalSize,
indexSizeMb: status.result.index_size_mb || 0,
embeddings: status.result.embeddings || {}
};
// Use status total size if available
if (status.result.index_size_bytes) {
totalSize = status.result.index_size_bytes;
}
}
} catch (e) {
console.error('[CodexLens] Failed to parse status for index list:', e.message);
console.error('[CodexLens] Failed to parse status:', e.message);
}
}
@@ -201,7 +219,8 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
totalSize,
totalSizeFormatted: formatSize(totalSize),
vectorIndexCount,
normalIndexCount
normalIndexCount,
...statusSummary
}
}));
} catch (err) {
@@ -280,7 +299,8 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
try {
const config = extractJSON(configResult.output);
if (config.success && config.result) {
responseData.index_dir = config.result.index_root || responseData.index_dir;
// CLI returns index_dir (not index_root)
responseData.index_dir = config.result.index_dir || config.result.index_root || responseData.index_dir;
}
} catch (e) {
console.error('[CodexLens] Failed to parse config:', e.message);

View File

@@ -1,7 +1,7 @@
import * as http from 'http';
import { URL } from 'url';
import { getCoreMemoryStore } from '../core-memory-store.js';
import type { CoreMemory } from '../core-memory-store.js';
import type { CoreMemory, SessionCluster, ClusterMember, ClusterRelation } from '../core-memory-store.js';
/**
* Route context interface
@@ -197,5 +197,329 @@ export async function handleCoreMemoryRoutes(ctx: RouteContext): Promise<boolean
return true;
}
// ============================================================
// Session Clustering API Endpoints
// ============================================================
// API: Get all clusters
if (pathname === '/api/core-memory/clusters' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
const status = url.searchParams.get('status') || undefined;
try {
const store = getCoreMemoryStore(projectPath);
const clusters = store.listClusters(status);
// Add member count to each cluster
const clustersWithCount = clusters.map(c => ({
...c,
memberCount: store.getClusterMembers(c.id).length
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, clusters: clustersWithCount }));
} catch (error: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
}
return true;
}
// API: Get cluster detail with members
if (pathname.match(/^\/api\/core-memory\/clusters\/[^\/]+$/) && req.method === 'GET') {
const clusterId = pathname.split('/').pop()!;
const projectPath = url.searchParams.get('path') || initialPath;
try {
const store = getCoreMemoryStore(projectPath);
const cluster = store.getCluster(clusterId);
if (!cluster) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Cluster not found' }));
return true;
}
const members = store.getClusterMembers(clusterId);
const relations = store.getClusterRelations(clusterId);
// Get metadata for each member
const membersWithMetadata = members.map(m => ({
...m,
metadata: store.getSessionMetadata(m.session_id)
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
cluster,
members: membersWithMetadata,
relations
}));
} catch (error: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
}
return true;
}
// API: Auto-cluster sessions
if (pathname === '/api/core-memory/clusters/auto' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { scope = 'recent', minClusterSize = 2, path: projectPath } = body;
const basePath = projectPath || initialPath;
try {
const { SessionClusteringService } = await import('../session-clustering-service.js');
const service = new SessionClusteringService(basePath);
const validScope: 'all' | 'recent' | 'unclustered' =
scope === 'all' || scope === 'recent' || scope === 'unclustered' ? scope : 'recent';
const result = await service.autocluster({
scope: validScope,
minClusterSize
});
// Broadcast update event
broadcastToClients({
type: 'CLUSTERS_UPDATED',
payload: {
...result,
timestamp: new Date().toISOString()
}
});
return {
success: true,
...result
};
} catch (error: unknown) {
return { error: (error as Error).message, status: 500 };
}
});
return true;
}
// API: Create new cluster
if (pathname === '/api/core-memory/clusters' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { name, description, intent, metadata, path: projectPath } = body;
if (!name) {
return { error: 'name is required', status: 400 };
}
const basePath = projectPath || initialPath;
try {
const store = getCoreMemoryStore(basePath);
const cluster = store.createCluster({
name,
description,
intent,
metadata: metadata ? JSON.stringify(metadata) : undefined
});
// Broadcast update event
broadcastToClients({
type: 'CLUSTER_UPDATED',
payload: {
cluster,
timestamp: new Date().toISOString()
}
});
return {
success: true,
cluster
};
} catch (error: unknown) {
return { error: (error as Error).message, status: 500 };
}
});
return true;
}
// API: Update cluster (supports both PUT and PATCH)
if (pathname.match(/^\/api\/core-memory\/clusters\/[^\/]+$/) && (req.method === 'PUT' || req.method === 'PATCH')) {
const clusterId = pathname.split('/').pop()!;
handlePostRequest(req, res, async (body) => {
const { name, description, intent, status, metadata, path: projectPath } = body;
const basePath = projectPath || initialPath;
try {
const store = getCoreMemoryStore(basePath);
const cluster = store.updateCluster(clusterId, {
name,
description,
intent,
status,
metadata: metadata ? JSON.stringify(metadata) : undefined
});
if (!cluster) {
return { error: 'Cluster not found', status: 404 };
}
// Broadcast update event
broadcastToClients({
type: 'CLUSTER_UPDATED',
payload: {
cluster,
timestamp: new Date().toISOString()
}
});
return {
success: true,
cluster
};
} catch (error: unknown) {
return { error: (error as Error).message, status: 500 };
}
});
return true;
}
// API: Delete cluster
if (pathname.match(/^\/api\/core-memory\/clusters\/[^\/]+$/) && req.method === 'DELETE') {
const clusterId = pathname.split('/').pop()!;
const projectPath = url.searchParams.get('path') || initialPath;
try {
const store = getCoreMemoryStore(projectPath);
const deleted = store.deleteCluster(clusterId);
if (!deleted) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Cluster not found' }));
return true;
}
// Broadcast update event
broadcastToClients({
type: 'CLUSTER_UPDATED',
payload: {
clusterId,
deleted: true,
timestamp: new Date().toISOString()
}
});
res.writeHead(204, { 'Content-Type': 'application/json' });
res.end();
} catch (error: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
}
return true;
}
// API: Add member to cluster
if (pathname.match(/^\/api\/core-memory\/clusters\/[^\/]+\/members$/) && req.method === 'POST') {
const clusterId = pathname.split('/')[4];
handlePostRequest(req, res, async (body) => {
const { session_id, session_type, sequence_order, relevance_score, path: projectPath } = body;
if (!session_id || !session_type) {
return { error: 'session_id and session_type are required', status: 400 };
}
const basePath = projectPath || initialPath;
try {
const store = getCoreMemoryStore(basePath);
const member = store.addClusterMember({
cluster_id: clusterId,
session_id,
session_type,
sequence_order: sequence_order ?? 0,
relevance_score: relevance_score ?? 1.0
});
// Broadcast update event
broadcastToClients({
type: 'CLUSTER_UPDATED',
payload: {
clusterId,
member,
timestamp: new Date().toISOString()
}
});
return {
success: true,
member
};
} catch (error: unknown) {
return { error: (error as Error).message, status: 500 };
}
});
return true;
}
// API: Remove member from cluster
if (pathname.match(/^\/api\/core-memory\/clusters\/[^\/]+\/members\/[^\/]+$/) && req.method === 'DELETE') {
const parts = pathname.split('/');
const clusterId = parts[4];
const sessionId = parts[6];
const projectPath = url.searchParams.get('path') || initialPath;
try {
const store = getCoreMemoryStore(projectPath);
const removed = store.removeClusterMember(clusterId, sessionId);
if (!removed) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Member not found' }));
return true;
}
// Broadcast update event
broadcastToClients({
type: 'CLUSTER_UPDATED',
payload: {
clusterId,
removedSessionId: sessionId,
timestamp: new Date().toISOString()
}
});
res.writeHead(204, { 'Content-Type': 'application/json' });
res.end();
} catch (error: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
}
return true;
}
// API: Search sessions by keyword
if (pathname === '/api/core-memory/sessions/search' && req.method === 'GET') {
const keyword = url.searchParams.get('q') || '';
const projectPath = url.searchParams.get('path') || initialPath;
if (!keyword) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Query parameter q is required' }));
return true;
}
try {
const store = getCoreMemoryStore(projectPath);
const results = store.searchSessionsByKeyword(keyword);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, results }));
} catch (error: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
}
return true;
}
return false;
}

View File

@@ -138,8 +138,16 @@ function findAllIndexDbs(dir: string): string[] {
/**
* Map codex-lens symbol kinds to graph node types
* Returns null for non-code symbols (markdown headings, etc.)
*/
function mapSymbolKind(kind: string): string {
function mapSymbolKind(kind: string): string | null {
const kindLower = kind.toLowerCase();
// Exclude markdown headings
if (/^h[1-6]$/.test(kindLower)) {
return null;
}
const kindMap: Record<string, string> = {
'function': 'FUNCTION',
'class': 'CLASS',
@@ -148,8 +156,13 @@ function mapSymbolKind(kind: string): string {
'module': 'MODULE',
'interface': 'CLASS', // TypeScript interfaces as CLASS
'type': 'CLASS', // Type aliases as CLASS
'constant': 'VARIABLE',
'property': 'VARIABLE',
'parameter': 'VARIABLE',
'import': 'MODULE',
'export': 'MODULE',
};
return kindMap[kind.toLowerCase()] || 'VARIABLE';
return kindMap[kindLower] || 'VARIABLE';
}
/**
@@ -224,13 +237,19 @@ async function querySymbols(projectPath: string, fileFilter?: string, moduleFilt
db.close();
allNodes.push(...rows.map((row: any) => ({
id: `${row.file}:${row.name}:${row.start_line}`,
name: row.name,
type: mapSymbolKind(row.kind),
file: row.file,
line: row.start_line,
})));
// Filter out non-code symbols (markdown headings, etc.)
rows.forEach((row: any) => {
const type = mapSymbolKind(row.kind);
if (type !== null) {
allNodes.push({
id: `${row.file}:${row.name}:${row.start_line}`,
name: row.name,
type,
file: row.file,
line: row.start_line,
});
}
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[Graph] Failed to query symbols from ${dbPath}: ${message}`);

View File

@@ -202,6 +202,46 @@ export async function handleHooksRoutes(ctx: RouteContext): Promise<boolean> {
resolvedSessionId = extractSessionIdFromPath(filePath);
}
// Handle context hooks (session-start, context)
if (type === 'session-start' || type === 'context') {
try {
const projectPath = url.searchParams.get('path') || initialPath;
const { SessionClusteringService } = await import('../session-clustering-service.js');
const clusteringService = new SessionClusteringService(projectPath);
const format = url.searchParams.get('format') || 'markdown';
// Pass type and prompt to getProgressiveIndex
// session-start: returns recent sessions by time
// context: returns intent-matched sessions based on prompt
const index = await clusteringService.getProgressiveIndex({
type: type as 'session-start' | 'context',
sessionId: resolvedSessionId,
prompt: extraData.prompt // Pass user prompt for intent matching
});
// Return context directly
return {
success: true,
type: 'context',
format,
content: index,
sessionId: resolvedSessionId
};
} catch (error) {
console.error('[Hooks] Failed to generate context:', error);
// Return empty content on failure (fail silently)
return {
success: true,
type: 'context',
format: 'markdown',
content: '',
sessionId: resolvedSessionId,
error: (error as Error).message
};
}
}
// Broadcast to all connected WebSocket clients
const notification = {
type: type || 'session_updated',

View File

@@ -132,6 +132,7 @@ const MODULE_FILES = [
'views/memory.js',
'views/core-memory.js',
'views/core-memory-graph.js',
'views/core-memory-clusters.js',
'views/prompt-history.js',
'views/skills-manager.js',
'views/rules-manager.js',

View File

@@ -0,0 +1,842 @@
/**
* Session Clustering Service
* Intelligently groups related sessions into clusters using multi-dimensional similarity analysis
*/
import { CoreMemoryStore, SessionCluster, ClusterMember, SessionMetadataCache } from './core-memory-store.js';
import { CliHistoryStore } from '../tools/cli-history-store.js';
import { StoragePaths } from '../config/storage-paths.js';
import { readdirSync, readFileSync, statSync, existsSync } from 'fs';
import { join } from 'path';
// Clustering dimension weights
const WEIGHTS = {
fileOverlap: 0.3,
temporalProximity: 0.2,
semanticSimilarity: 0.3,
intentAlignment: 0.2,
};
// Clustering threshold
const CLUSTER_THRESHOLD = 0.6;
export interface ClusteringOptions {
scope?: 'all' | 'recent' | 'unclustered';
timeRange?: { start: string; end: string };
minClusterSize?: number;
}
export interface ClusteringResult {
clustersCreated: number;
sessionsProcessed: number;
sessionsClustered: number;
}
export class SessionClusteringService {
private coreMemoryStore: CoreMemoryStore;
private cliHistoryStore: CliHistoryStore;
private projectPath: string;
constructor(projectPath: string) {
this.projectPath = projectPath;
this.coreMemoryStore = new CoreMemoryStore(projectPath);
this.cliHistoryStore = new CliHistoryStore(projectPath);
}
/**
* Collect all session sources
*/
async collectSessions(options?: ClusteringOptions): Promise<SessionMetadataCache[]> {
const sessions: SessionMetadataCache[] = [];
// 1. Core Memories
const memories = this.coreMemoryStore.getMemories({ archived: false, limit: 1000 });
for (const memory of memories) {
const cached = this.coreMemoryStore.getSessionMetadata(memory.id);
if (cached) {
sessions.push(cached);
} else {
const metadata = this.extractMetadata(memory, 'core_memory');
sessions.push(metadata);
}
}
// 2. CLI History
const history = this.cliHistoryStore.getHistory({ limit: 1000 });
for (const exec of history.executions) {
const cached = this.coreMemoryStore.getSessionMetadata(exec.id);
if (cached) {
sessions.push(cached);
} else {
const conversation = this.cliHistoryStore.getConversation(exec.id);
if (conversation) {
const metadata = this.extractMetadata(conversation, 'cli_history');
sessions.push(metadata);
}
}
}
// 3. Workflow Sessions (WFS-*)
const workflowSessions = await this.parseWorkflowSessions();
sessions.push(...workflowSessions);
// Apply scope filter
if (options?.scope === 'recent') {
// Last 30 days
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 30);
const cutoffStr = cutoff.toISOString();
return sessions.filter(s => (s.created_at || '') >= cutoffStr);
} else if (options?.scope === 'unclustered') {
// Only sessions not in any cluster
return sessions.filter(s => {
const clusters = this.coreMemoryStore.getSessionClusters(s.session_id);
return clusters.length === 0;
});
}
return sessions;
}
/**
* Extract metadata from a session
*/
extractMetadata(session: any, type: 'core_memory' | 'workflow' | 'cli_history' | 'native'): SessionMetadataCache {
let content = '';
let title = '';
let created_at = '';
if (type === 'core_memory') {
content = session.content || '';
created_at = session.created_at;
// Extract title from first line
const lines = content.split('\n');
title = lines[0].replace(/^#+\s*/, '').trim().substring(0, 100);
} else if (type === 'cli_history') {
// Extract from conversation turns
const turns = session.turns || [];
if (turns.length > 0) {
content = turns.map((t: any) => t.prompt).join('\n');
title = turns[0].prompt.substring(0, 100);
created_at = session.created_at || turns[0].timestamp;
}
} else if (type === 'workflow') {
content = session.content || '';
title = session.title || 'Workflow Session';
created_at = session.created_at || '';
}
const summary = content.substring(0, 200).trim();
const keywords = this.extractKeywords(content);
const file_patterns = this.extractFilePatterns(content);
const token_estimate = Math.ceil(content.length / 4);
return {
session_id: session.id,
session_type: type,
title,
summary,
keywords,
token_estimate,
file_patterns,
created_at,
last_accessed: new Date().toISOString(),
access_count: 0
};
}
/**
* Extract keywords from content
*/
private extractKeywords(content: string): string[] {
const keywords = new Set<string>();
// 1. File paths (src/xxx, .ts, .js, etc)
const filePathRegex = /(?:^|\s|["'`])((?:\.\/|\.\.\/|\/)?[\w-]+(?:\/[\w-]+)*\.[\w]+)(?:\s|["'`]|$)/g;
let match;
while ((match = filePathRegex.exec(content)) !== null) {
keywords.add(match[1]);
}
// 2. Function/Class names (camelCase, PascalCase)
const camelCaseRegex = /\b([A-Z][a-z]+(?:[A-Z][a-z]+)+|[a-z]+[A-Z][a-z]+(?:[A-Z][a-z]+)*)\b/g;
while ((match = camelCaseRegex.exec(content)) !== null) {
keywords.add(match[1]);
}
// 3. Technical terms (common frameworks/libraries)
const techTerms = [
'react', 'vue', 'angular', 'typescript', 'javascript', 'node', 'express',
'auth', 'authentication', 'jwt', 'oauth', 'session', 'token',
'api', 'rest', 'graphql', 'database', 'sql', 'mongodb', 'redis',
'test', 'testing', 'jest', 'mocha', 'vitest',
'refactor', 'refactoring', 'optimization', 'performance',
'bug', 'fix', 'error', 'issue', 'debug'
];
const lowerContent = content.toLowerCase();
for (const term of techTerms) {
if (lowerContent.includes(term)) {
keywords.add(term);
}
}
// Return top 20 keywords
return Array.from(keywords).slice(0, 20);
}
/**
* Extract file patterns from content
*/
private extractFilePatterns(content: string): string[] {
const patterns = new Set<string>();
// Extract directory patterns (src/xxx/, lib/xxx/)
const dirRegex = /\b((?:src|lib|test|dist|build|public|components|utils|services|config|core|tools)(?:\/[\w-]+)*)\//g;
let match;
while ((match = dirRegex.exec(content)) !== null) {
patterns.add(match[1] + '/**');
}
// Extract file extension patterns
const extRegex = /\.(\w+)(?:\s|$|["'`])/g;
const extensions = new Set<string>();
while ((match = extRegex.exec(content)) !== null) {
extensions.add(match[1]);
}
// Add extension patterns
if (extensions.size > 0) {
patterns.add(`**/*.{${Array.from(extensions).join(',')}}`);
}
return Array.from(patterns).slice(0, 10);
}
/**
* Calculate relevance score between two sessions
*/
calculateRelevance(session1: SessionMetadataCache, session2: SessionMetadataCache): number {
const fileScore = this.calculateFileOverlap(session1, session2);
const temporalScore = this.calculateTemporalProximity(session1, session2);
const semanticScore = this.calculateSemanticSimilarity(session1, session2);
const intentScore = this.calculateIntentAlignment(session1, session2);
return (
fileScore * WEIGHTS.fileOverlap +
temporalScore * WEIGHTS.temporalProximity +
semanticScore * WEIGHTS.semanticSimilarity +
intentScore * WEIGHTS.intentAlignment
);
}
/**
* Calculate file path overlap score (Jaccard similarity)
*/
private calculateFileOverlap(s1: SessionMetadataCache, s2: SessionMetadataCache): number {
const files1 = new Set(s1.file_patterns || []);
const files2 = new Set(s2.file_patterns || []);
if (files1.size === 0 || files2.size === 0) return 0;
const intersection = new Set([...files1].filter(f => files2.has(f)));
const union = new Set([...files1, ...files2]);
return intersection.size / union.size;
}
/**
* Calculate temporal proximity score
* 24h: 1.0, 7d: 0.7, 30d: 0.4, >30d: 0.1
*/
private calculateTemporalProximity(s1: SessionMetadataCache, s2: SessionMetadataCache): number {
if (!s1.created_at || !s2.created_at) return 0.1;
const t1 = new Date(s1.created_at).getTime();
const t2 = new Date(s2.created_at).getTime();
const diffMs = Math.abs(t1 - t2);
const diffHours = diffMs / (1000 * 60 * 60);
if (diffHours <= 24) return 1.0;
if (diffHours <= 24 * 7) return 0.7;
if (diffHours <= 24 * 30) return 0.4;
return 0.1;
}
/**
* Calculate semantic similarity using keyword overlap (Jaccard similarity)
*/
private calculateSemanticSimilarity(s1: SessionMetadataCache, s2: SessionMetadataCache): number {
const kw1 = new Set(s1.keywords || []);
const kw2 = new Set(s2.keywords || []);
if (kw1.size === 0 || kw2.size === 0) return 0;
const intersection = new Set([...kw1].filter(k => kw2.has(k)));
const union = new Set([...kw1, ...kw2]);
return intersection.size / union.size;
}
/**
* Calculate intent alignment score
* Based on title/summary keyword matching
*/
private calculateIntentAlignment(s1: SessionMetadataCache, s2: SessionMetadataCache): number {
const text1 = ((s1.title || '') + ' ' + (s1.summary || '')).toLowerCase();
const text2 = ((s2.title || '') + ' ' + (s2.summary || '')).toLowerCase();
if (!text1 || !text2) return 0;
// Simple word-based TF-IDF approximation
const words1 = text1.split(/\s+/).filter(w => w.length > 3);
const words2 = text2.split(/\s+/).filter(w => w.length > 3);
const set1 = new Set(words1);
const set2 = new Set(words2);
const intersection = new Set([...set1].filter(w => set2.has(w)));
const union = new Set([...set1, ...set2]);
return intersection.size / union.size;
}
/**
* Run auto-clustering algorithm
*/
async autocluster(options?: ClusteringOptions): Promise<ClusteringResult> {
// 1. Collect sessions
const sessions = await this.collectSessions(options);
console.log(`[Clustering] Collected ${sessions.length} sessions`);
// 2. Update metadata cache
for (const session of sessions) {
this.coreMemoryStore.upsertSessionMetadata(session);
}
// 3. Calculate relevance matrix
const n = sessions.length;
const relevanceMatrix: number[][] = Array(n).fill(0).map(() => Array(n).fill(0));
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const score = this.calculateRelevance(sessions[i], sessions[j]);
relevanceMatrix[i][j] = score;
relevanceMatrix[j][i] = score;
}
}
// 4. Agglomerative clustering
const clusters = this.agglomerativeClustering(sessions, relevanceMatrix, CLUSTER_THRESHOLD);
console.log(`[Clustering] Generated ${clusters.length} clusters`);
// 5. Create session_clusters
let clustersCreated = 0;
let sessionsClustered = 0;
for (const cluster of clusters) {
if (cluster.length < (options?.minClusterSize || 2)) {
continue; // Skip small clusters
}
const clusterName = this.generateClusterName(cluster);
const clusterIntent = this.generateClusterIntent(cluster);
const clusterRecord = this.coreMemoryStore.createCluster({
name: clusterName,
description: `Auto-generated cluster with ${cluster.length} sessions`,
intent: clusterIntent,
status: 'active'
});
// Add members
cluster.forEach((session, index) => {
this.coreMemoryStore.addClusterMember({
cluster_id: clusterRecord.id,
session_id: session.session_id,
session_type: session.session_type as 'core_memory' | 'workflow' | 'cli_history' | 'native',
sequence_order: index + 1,
relevance_score: 1.0 // TODO: Calculate based on centrality
});
});
clustersCreated++;
sessionsClustered += cluster.length;
}
return {
clustersCreated,
sessionsProcessed: sessions.length,
sessionsClustered
};
}
/**
* Agglomerative clustering algorithm
* Returns array of clusters (each cluster is array of sessions)
*/
private agglomerativeClustering(
sessions: SessionMetadataCache[],
relevanceMatrix: number[][],
threshold: number
): SessionMetadataCache[][] {
const n = sessions.length;
// Initialize: each session is its own cluster
const clusters: Set<number>[] = sessions.map((_, i) => new Set([i]));
while (true) {
let maxScore = -1;
let mergeI = -1;
let mergeJ = -1;
// Find pair of clusters with highest average linkage
for (let i = 0; i < clusters.length; i++) {
for (let j = i + 1; j < clusters.length; j++) {
const score = this.averageLinkage(clusters[i], clusters[j], relevanceMatrix);
if (score > maxScore) {
maxScore = score;
mergeI = i;
mergeJ = j;
}
}
}
// Stop if no pair exceeds threshold
if (maxScore < threshold) break;
// Merge clusters
const merged = new Set([...clusters[mergeI], ...clusters[mergeJ]]);
clusters.splice(mergeJ, 1); // Remove j first (higher index)
clusters.splice(mergeI, 1);
clusters.push(merged);
}
// Convert cluster indices to sessions
return clusters.map(cluster =>
Array.from(cluster).map(i => sessions[i])
);
}
/**
* Calculate average linkage between two clusters
*/
private averageLinkage(
cluster1: Set<number>,
cluster2: Set<number>,
relevanceMatrix: number[][]
): number {
let sum = 0;
let count = 0;
for (const i of cluster1) {
for (const j of cluster2) {
sum += relevanceMatrix[i][j];
count++;
}
}
return count > 0 ? sum / count : 0;
}
/**
* Generate cluster name from members
*/
private generateClusterName(members: SessionMetadataCache[]): string {
// Count keyword frequency
const keywordFreq = new Map<string, number>();
for (const member of members) {
for (const keyword of member.keywords || []) {
keywordFreq.set(keyword, (keywordFreq.get(keyword) || 0) + 1);
}
}
// Get top 2 keywords
const sorted = Array.from(keywordFreq.entries())
.sort((a, b) => b[1] - a[1])
.map(([kw]) => kw);
if (sorted.length >= 2) {
return `${sorted[0]}-${sorted[1]}`;
} else if (sorted.length === 1) {
return sorted[0];
} else {
return 'unnamed-cluster';
}
}
/**
* Generate cluster intent from members
*/
private generateClusterIntent(members: SessionMetadataCache[]): string {
// Extract common action words from titles
const actionWords = ['implement', 'refactor', 'fix', 'add', 'create', 'update', 'optimize'];
const titles = members.map(m => (m.title || '').toLowerCase());
for (const action of actionWords) {
const count = titles.filter(t => t.includes(action)).length;
if (count >= members.length / 2) {
const topic = this.generateClusterName(members);
return `${action.charAt(0).toUpperCase() + action.slice(1)} ${topic}`;
}
}
return `Work on ${this.generateClusterName(members)}`;
}
/**
* Get progressive disclosure index for hook
* @param options - Configuration options
* @param options.type - 'session-start' returns recent sessions, 'context' returns intent-matched sessions
* @param options.sessionId - Current session ID (optional)
* @param options.prompt - User prompt for intent matching (required for 'context' type)
*/
async getProgressiveIndex(options: {
type: 'session-start' | 'context';
sessionId?: string;
prompt?: string;
}): Promise<string> {
const { type, sessionId, prompt } = options;
// For session-start: return recent sessions by time
if (type === 'session-start') {
return this.getRecentSessionsIndex();
}
// For context: return intent-matched sessions based on prompt
if (type === 'context' && prompt) {
return this.getIntentMatchedIndex(prompt, sessionId);
}
// Fallback to recent sessions
return this.getRecentSessionsIndex();
}
/**
* Get recent sessions index (for session-start)
*/
private async getRecentSessionsIndex(): Promise<string> {
const sessions = await this.collectSessions({ scope: 'recent' });
// Sort by created_at descending (most recent first)
const sortedSessions = sessions
.filter(s => s.created_at)
.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''))
.slice(0, 10); // Top 10 recent sessions
if (sortedSessions.length === 0) {
return `<ccw-session-context>
## 📋 Recent Sessions
No recent sessions found. Start a new workflow to begin tracking.
**MCP Tools**:
\`\`\`
# Search sessions
Use tool: mcp__ccw-tools__core_memory
Parameters: { "action": "search", "query": "<keyword>" }
# Create new session
Parameters: { "action": "save", "content": "<context>" }
\`\`\`
</ccw-session-context>`;
}
// Generate table
let table = `| # | Session | Type | Title | Date |\n`;
table += `|---|---------|------|-------|------|\n`;
sortedSessions.forEach((s, idx) => {
const type = s.session_type === 'core_memory' ? 'Core' :
s.session_type === 'workflow' ? 'Workflow' : 'CLI';
const title = (s.title || '').substring(0, 40);
const date = s.created_at ? new Date(s.created_at).toLocaleDateString() : '';
table += `| ${idx + 1} | ${s.session_id} | ${type} | ${title} | ${date} |\n`;
});
return `<ccw-session-context>
## 📋 Recent Sessions (Last 30 days)
${table}
**Resume via MCP**:
\`\`\`
Use tool: mcp__ccw-tools__core_memory
Parameters: { "action": "load", "id": "${sortedSessions[0].session_id}" }
\`\`\`
---
**Tip**: Sessions are sorted by most recent. Use \`search\` action to find specific topics.
</ccw-session-context>`;
}
/**
* Get intent-matched sessions index (for context with prompt)
*/
private async getIntentMatchedIndex(prompt: string, sessionId?: string): Promise<string> {
const sessions = await this.collectSessions({ scope: 'all' });
if (sessions.length === 0) {
return `<ccw-session-context>
## 📋 Related Sessions
No sessions available for intent matching.
</ccw-session-context>`;
}
// Create a virtual session from the prompt for similarity calculation
const promptSession: SessionMetadataCache = {
session_id: 'prompt-virtual',
session_type: 'native',
title: prompt.substring(0, 100),
summary: prompt.substring(0, 200),
keywords: this.extractKeywords(prompt),
token_estimate: Math.ceil(prompt.length / 4),
file_patterns: this.extractFilePatterns(prompt),
created_at: new Date().toISOString(),
last_accessed: new Date().toISOString(),
access_count: 0
};
// Calculate relevance scores for all sessions
const scoredSessions = sessions
.filter(s => s.session_id !== sessionId) // Exclude current session
.map(s => ({
session: s,
score: this.calculateRelevance(promptSession, s)
}))
.filter(item => item.score >= 0.3) // Minimum relevance threshold
.sort((a, b) => b.score - a.score)
.slice(0, 8); // Top 8 relevant sessions
if (scoredSessions.length === 0) {
return `<ccw-session-context>
## 📋 Related Sessions
No sessions match current intent. Consider:
- Starting fresh with a new approach
- Using \`search\` to find sessions by keyword
**MCP Tools**:
\`\`\`
Use tool: mcp__ccw-tools__core_memory
Parameters: { "action": "search", "query": "<keyword>" }
\`\`\`
</ccw-session-context>`;
}
// Group by relevance tier
const highRelevance = scoredSessions.filter(s => s.score >= 0.6);
const mediumRelevance = scoredSessions.filter(s => s.score >= 0.4 && s.score < 0.6);
const lowRelevance = scoredSessions.filter(s => s.score < 0.4);
// Generate output
let output = `<ccw-session-context>
## 📋 Intent-Matched Sessions
**Detected Intent**: ${promptSession.keywords.slice(0, 5).join(', ') || 'General'}
`;
if (highRelevance.length > 0) {
output += `### 🔥 Highly Relevant (${highRelevance.length})\n`;
output += `| Session | Type | Match | Summary |\n`;
output += `|---------|------|-------|--------|\n`;
for (const item of highRelevance) {
const type = item.session.session_type === 'core_memory' ? 'Core' :
item.session.session_type === 'workflow' ? 'Workflow' : 'CLI';
const matchPct = Math.round(item.score * 100);
const summary = (item.session.title || item.session.summary || '').substring(0, 35);
output += `| ${item.session.session_id} | ${type} | ${matchPct}% | ${summary} |\n`;
}
output += `\n`;
}
if (mediumRelevance.length > 0) {
output += `### 📌 Related (${mediumRelevance.length})\n`;
output += `| Session | Type | Match | Summary |\n`;
output += `|---------|------|-------|--------|\n`;
for (const item of mediumRelevance) {
const type = item.session.session_type === 'core_memory' ? 'Core' :
item.session.session_type === 'workflow' ? 'Workflow' : 'CLI';
const matchPct = Math.round(item.score * 100);
const summary = (item.session.title || item.session.summary || '').substring(0, 35);
output += `| ${item.session.session_id} | ${type} | ${matchPct}% | ${summary} |\n`;
}
output += `\n`;
}
if (lowRelevance.length > 0) {
output += `### 💡 May Be Useful (${lowRelevance.length})\n`;
const sessionList = lowRelevance.map(s => s.session.session_id).join(', ');
output += `${sessionList}\n\n`;
}
// Add resume command for top match
const topMatch = scoredSessions[0];
output += `**Resume Top Match**:
\`\`\`
Use tool: mcp__ccw-tools__core_memory
Parameters: { "action": "load", "id": "${topMatch.session.session_id}" }
\`\`\`
---
**Tip**: Sessions ranked by semantic similarity to your prompt.
</ccw-session-context>`;
return output;
}
/**
* Legacy method for backward compatibility
* @deprecated Use getProgressiveIndex({ type, sessionId, prompt }) instead
*/
async getProgressiveIndexLegacy(sessionId?: string): Promise<string> {
let activeCluster: SessionCluster | null = null;
let members: SessionMetadataCache[] = [];
if (sessionId) {
const clusters = this.coreMemoryStore.getSessionClusters(sessionId);
if (clusters.length > 0) {
activeCluster = clusters[0];
const clusterMembers = this.coreMemoryStore.getClusterMembers(activeCluster.id);
members = clusterMembers
.map(m => this.coreMemoryStore.getSessionMetadata(m.session_id))
.filter((m): m is SessionMetadataCache => m !== null)
.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
}
}
if (!activeCluster || members.length === 0) {
return `<ccw-session-context>
## 📋 Related Sessions Index
No active cluster found. Start a new workflow or continue from recent sessions.
**MCP Tools**:
\`\`\`
# Search sessions
Use tool: mcp__ccw-tools__core_memory
Parameters: { "action": "search", "query": "<keyword>" }
# Trigger clustering
Parameters: { "action": "cluster", "scope": "auto" }
\`\`\`
</ccw-session-context>`;
}
// Generate table
let table = `| # | Session | Type | Summary | Tokens |\n`;
table += `|---|---------|------|---------|--------|\n`;
members.forEach((m, idx) => {
const type = m.session_type === 'core_memory' ? 'Core' :
m.session_type === 'workflow' ? 'Workflow' : 'CLI';
const summary = (m.summary || '').substring(0, 40);
const token = `~${m.token_estimate || 0}`;
table += `| ${idx + 1} | ${m.session_id} | ${type} | ${summary} | ${token} |\n`;
});
// Generate timeline - show multiple recent sessions
let timeline = '';
if (members.length > 0) {
const timelineEntries: string[] = [];
const displayCount = Math.min(members.length, 3); // Show last 3 sessions
for (let i = members.length - displayCount; i < members.length; i++) {
const member = members[i];
const date = member.created_at ? new Date(member.created_at).toLocaleDateString() : '';
const title = member.title?.substring(0, 30) || 'Untitled';
const isCurrent = i === members.length - 1;
const marker = isCurrent ? ' ← Current' : '';
timelineEntries.push(`${date} ─●─ ${member.session_id} (${title})${marker}`);
}
timeline = `\`\`\`\n${timelineEntries.join('\n │\n')}\n\`\`\``;
}
return `<ccw-session-context>
## 📋 Related Sessions Index
### 🔗 Active Cluster: ${activeCluster.name} (${members.length} sessions)
**Intent**: ${activeCluster.intent || 'No intent specified'}
${table}
**Resume via MCP**:
\`\`\`
Use tool: mcp__ccw-tools__core_memory
Parameters: { "action": "load", "id": "${members[members.length - 1].session_id}" }
Or load entire cluster:
{ "action": "load-cluster", "clusterId": "${activeCluster.id}" }
\`\`\`
### 📊 Timeline
${timeline}
---
**Tip**: Use \`mcp__ccw-tools__core_memory({ action: "search", query: "<keyword>" })\` to find more sessions
</ccw-session-context>`;
}
/**
* Parse workflow session files
*/
private async parseWorkflowSessions(): Promise<SessionMetadataCache[]> {
const sessions: SessionMetadataCache[] = [];
const workflowDir = join(this.projectPath, '.workflow', 'sessions');
if (!existsSync(workflowDir)) {
return sessions;
}
try {
const sessionDirs = readdirSync(workflowDir).filter(d => d.startsWith('WFS-'));
for (const sessionDir of sessionDirs) {
const sessionFile = join(workflowDir, sessionDir, 'session.json');
if (!existsSync(sessionFile)) continue;
try {
const content = readFileSync(sessionFile, 'utf8');
const sessionData = JSON.parse(content);
const metadata: SessionMetadataCache = {
session_id: sessionDir,
session_type: 'workflow',
title: sessionData.title || sessionDir,
summary: (sessionData.description || '').substring(0, 200),
keywords: this.extractKeywords(JSON.stringify(sessionData)),
token_estimate: Math.ceil(JSON.stringify(sessionData).length / 4),
file_patterns: this.extractFilePatterns(JSON.stringify(sessionData)),
created_at: sessionData.created_at || statSync(sessionFile).mtime.toISOString(),
last_accessed: new Date().toISOString(),
access_count: 0
};
sessions.push(metadata);
} catch (err) {
console.warn(`[Clustering] Failed to parse ${sessionFile}:`, err);
}
}
} catch (err) {
console.warn('[Clustering] Failed to read workflow sessions:', err);
}
return sessions;
}
/**
* Update metadata cache for all sessions
*/
async refreshMetadataCache(): Promise<number> {
const sessions = await this.collectSessions({ scope: 'all' });
for (const session of sessions) {
this.coreMemoryStore.upsertSessionMetadata(session);
}
return sessions.length;
}
}

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"
}
}

157
ccw/test-hooks.js Normal file
View File

@@ -0,0 +1,157 @@
#!/usr/bin/env node
/**
* Test script for hooks integration
* Tests the session-start hook with progressive disclosure
*/
import http from 'http';
const DASHBOARD_PORT = process.env.DASHBOARD_PORT || '3456';
async function testSessionStartHook() {
console.log('🧪 Testing session-start hook...\n');
const payload = JSON.stringify({
type: 'session-start',
sessionId: 'test-session-001',
projectPath: process.cwd()
});
const options = {
hostname: 'localhost',
port: Number(DASHBOARD_PORT),
path: '/api/hook?format=markdown',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
return new Promise((resolve, reject) => {
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const result = JSON.parse(data);
console.log('✅ Hook Response:');
console.log('─'.repeat(80));
console.log(`Status: ${res.statusCode}`);
console.log(`Success: ${result.success}`);
console.log(`Type: ${result.type}`);
console.log(`Format: ${result.format}`);
console.log(`Session ID: ${result.sessionId}`);
console.log('\nContent Preview:');
console.log('─'.repeat(80));
if (result.content) {
// Show first 500 characters
const preview = result.content.substring(0, 500);
console.log(preview);
if (result.content.length > 500) {
console.log(`\n... (${result.content.length - 500} more characters)`);
}
} else {
console.log('(Empty content)');
}
console.log('─'.repeat(80));
if (result.error) {
console.log(`\n⚠️ Error: ${result.error}`);
}
resolve(result);
} catch (error) {
console.error('❌ Failed to parse response:', error);
console.log('Raw response:', data);
reject(error);
}
});
});
req.on('error', (error) => {
console.error('❌ Request failed:', error.message);
console.log('\n💡 Make sure the CCW server is running:');
console.log(' ccw server');
reject(error);
});
req.write(payload);
req.end();
});
}
async function testContextHook() {
console.log('\n🧪 Testing context hook...\n');
const payload = JSON.stringify({
type: 'context',
sessionId: 'test-session-002',
projectPath: process.cwd()
});
const options = {
hostname: 'localhost',
port: Number(DASHBOARD_PORT),
path: '/api/hook?format=json',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
return new Promise((resolve, reject) => {
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const result = JSON.parse(data);
console.log('✅ Context Hook Response:');
console.log('─'.repeat(80));
console.log(`Status: ${res.statusCode}`);
console.log(`Success: ${result.success}`);
console.log(`Type: ${result.type}`);
console.log(`Format: ${result.format}`);
console.log('─'.repeat(80));
resolve(result);
} catch (error) {
console.error('❌ Failed to parse response:', error);
reject(error);
}
});
});
req.on('error', (error) => {
console.error('❌ Request failed:', error.message);
reject(error);
});
req.write(payload);
req.end();
});
}
// Run tests
async function runTests() {
try {
await testSessionStartHook();
await testContextHook();
console.log('\n✅ All tests completed successfully!');
} catch (error) {
console.error('\n❌ Tests failed:', error);
process.exit(1);
}
}
runTests();