mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-04 01:40:45 +08:00
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:
321
.claude/commands/workflow/debug.md
Normal file
321
.claude/commands/workflow/debug.md
Normal 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 |
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
11
.mcp.json
11
.mcp.json
@@ -1,3 +1,12 @@
|
||||
{
|
||||
"mcpServers": {}
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@latest"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
297
ccw/IMPLEMENTATION_SUMMARY.md
Normal file
297
ccw/IMPLEMENTATION_SUMMARY.md
Normal 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. **渐进式披露索引**
|
||||
- 显示活动集群信息(名称、意图、成员数)
|
||||
- 表格展示相关 session(Session 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
|
||||
```
|
||||
294
ccw/docs/hooks-integration.md
Normal file
294
ccw/docs/hooks-integration.md
Normal 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`
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
842
ccw/src/core/session-clustering-service.ts
Normal file
842
ccw/src/core/session-clustering-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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': '移除成员失败',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
388
ccw/src/templates/dashboard-js/views/core-memory-clusters.js
Normal file
388
ccw/src/templates/dashboard-js/views/core-memory-clusters.js
Normal 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();
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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')}
|
||||
|
||||
60
ccw/src/templates/hooks-config-example.json
Normal file
60
ccw/src/templates/hooks-config-example.json
Normal 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
157
ccw/test-hooks.js
Normal 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();
|
||||
Reference in New Issue
Block a user