mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user