mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-03 15:43:11 +08:00
Add comprehensive tests for keyword detection, session state management, and user abort detection
- Implement tests for KeywordDetector including keyword detection, sanitization, and priority handling. - Add tests for SessionStateService covering session validation, loading, saving, and state updates. - Create tests for UserAbortDetector to validate user abort detection logic and pattern matching.
This commit is contained in:
565
ccw/src/core/services/checkpoint-service.ts
Normal file
565
ccw/src/core/services/checkpoint-service.ts
Normal file
@@ -0,0 +1,565 @@
|
||||
/**
|
||||
* CheckpointService - Session Checkpoint Management
|
||||
*
|
||||
* Creates and manages session checkpoints for state preservation during
|
||||
* context compaction and workflow transitions.
|
||||
*
|
||||
* Features:
|
||||
* - Checkpoint creation with workflow and mode state
|
||||
* - Checkpoint storage in .workflow/checkpoints/
|
||||
* - Automatic cleanup of old checkpoints (keeps last 10)
|
||||
* - Recovery message formatting for context injection
|
||||
*
|
||||
* Based on oh-my-claudecode pre-compact pattern.
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
unlinkSync
|
||||
} from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
import { ExecutionMode, MODE_CONFIGS } from './mode-registry-service.js';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Checkpoint trigger type
|
||||
*/
|
||||
export type CheckpointTrigger = 'manual' | 'auto' | 'compact' | 'mode-switch' | 'session-end';
|
||||
|
||||
/**
|
||||
* Workflow state snapshot
|
||||
*/
|
||||
export interface WorkflowStateSnapshot {
|
||||
/** Workflow type identifier */
|
||||
type: string;
|
||||
/** Current phase of the workflow */
|
||||
phase: string;
|
||||
/** Task IDs in pending state */
|
||||
pending: string[];
|
||||
/** Task IDs in completed state */
|
||||
completed: string[];
|
||||
/** Additional workflow metadata */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode state snapshot for a single mode
|
||||
*/
|
||||
export interface ModeStateSnapshot {
|
||||
/** Whether the mode is active */
|
||||
active: boolean;
|
||||
/** Mode-specific phase or stage */
|
||||
phase?: string;
|
||||
/** ISO timestamp when mode was activated */
|
||||
activatedAt?: string;
|
||||
/** Additional mode-specific data */
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory context snapshot
|
||||
*/
|
||||
export interface MemoryContextSnapshot {
|
||||
/** Brief summary of accumulated context */
|
||||
summary: string;
|
||||
/** Key entities identified in the session */
|
||||
keyEntities: string[];
|
||||
/** Important decisions made */
|
||||
decisions?: string[];
|
||||
/** Open questions or blockers */
|
||||
openQuestions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full checkpoint data structure
|
||||
*/
|
||||
export interface Checkpoint {
|
||||
/** Unique checkpoint ID (timestamp-sessionId) */
|
||||
id: string;
|
||||
/** ISO timestamp of checkpoint creation */
|
||||
created_at: string;
|
||||
/** What triggered the checkpoint */
|
||||
trigger: CheckpointTrigger;
|
||||
/** Session ID this checkpoint belongs to */
|
||||
session_id: string;
|
||||
/** Project path */
|
||||
project_path: string;
|
||||
/** Workflow state snapshot */
|
||||
workflow_state: WorkflowStateSnapshot | null;
|
||||
/** Active mode states */
|
||||
mode_states: Partial<Record<ExecutionMode, ModeStateSnapshot>>;
|
||||
/** Memory context summary */
|
||||
memory_context: MemoryContextSnapshot | null;
|
||||
/** TODO summary if available */
|
||||
todo_summary?: {
|
||||
pending: number;
|
||||
in_progress: number;
|
||||
completed: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkpoint metadata for listing
|
||||
*/
|
||||
export interface CheckpointMeta {
|
||||
/** Checkpoint ID */
|
||||
id: string;
|
||||
/** Creation timestamp */
|
||||
created_at: string;
|
||||
/** Session ID */
|
||||
session_id: string;
|
||||
/** Trigger type */
|
||||
trigger: CheckpointTrigger;
|
||||
/** File path */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for checkpoint service
|
||||
*/
|
||||
export interface CheckpointServiceOptions {
|
||||
/** Project root path */
|
||||
projectPath: string;
|
||||
/** Maximum checkpoints to keep per session (default: 10) */
|
||||
maxCheckpointsPerSession?: number;
|
||||
/** Enable logging */
|
||||
enableLogging?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
/** Default maximum checkpoints to keep per session */
|
||||
const DEFAULT_MAX_CHECKPOINTS = 10;
|
||||
|
||||
/** Checkpoint directory name within .workflow */
|
||||
const CHECKPOINT_DIR_NAME = 'checkpoints';
|
||||
|
||||
// =============================================================================
|
||||
// CheckpointService Class
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Service for managing session checkpoints
|
||||
*/
|
||||
export class CheckpointService {
|
||||
private projectPath: string;
|
||||
private checkpointsDir: string;
|
||||
private maxCheckpoints: number;
|
||||
private enableLogging: boolean;
|
||||
|
||||
constructor(options: CheckpointServiceOptions) {
|
||||
this.projectPath = options.projectPath;
|
||||
this.checkpointsDir = join(this.projectPath, '.workflow', CHECKPOINT_DIR_NAME);
|
||||
this.maxCheckpoints = options.maxCheckpointsPerSession ?? DEFAULT_MAX_CHECKPOINTS;
|
||||
this.enableLogging = options.enableLogging ?? false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Checkpoint Creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a checkpoint for a session
|
||||
*
|
||||
* @param sessionId - The session ID
|
||||
* @param trigger - What triggered the checkpoint
|
||||
* @param options - Optional additional data
|
||||
* @returns Promise resolving to the created checkpoint
|
||||
*/
|
||||
async createCheckpoint(
|
||||
sessionId: string,
|
||||
trigger: CheckpointTrigger,
|
||||
options?: {
|
||||
workflowState?: WorkflowStateSnapshot | null;
|
||||
modeStates?: Partial<Record<ExecutionMode, ModeStateSnapshot>>;
|
||||
memoryContext?: MemoryContextSnapshot | null;
|
||||
todoSummary?: { pending: number; in_progress: number; completed: number };
|
||||
}
|
||||
): Promise<Checkpoint> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const checkpointId = this.generateCheckpointId(sessionId, timestamp);
|
||||
|
||||
const checkpoint: Checkpoint = {
|
||||
id: checkpointId,
|
||||
created_at: timestamp,
|
||||
trigger,
|
||||
session_id: sessionId,
|
||||
project_path: this.projectPath,
|
||||
workflow_state: options?.workflowState ?? null,
|
||||
mode_states: options?.modeStates ?? {},
|
||||
memory_context: options?.memoryContext ?? null,
|
||||
todo_summary: options?.todoSummary
|
||||
};
|
||||
|
||||
this.log(`Created checkpoint ${checkpointId} for session ${sessionId} (trigger: ${trigger})`);
|
||||
return checkpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a checkpoint to disk
|
||||
*
|
||||
* @param checkpoint - The checkpoint to save
|
||||
* @returns The checkpoint ID
|
||||
*/
|
||||
async saveCheckpoint(checkpoint: Checkpoint): Promise<string> {
|
||||
this.ensureCheckpointsDir();
|
||||
|
||||
const filename = `${checkpoint.id}.json`;
|
||||
const filepath = join(this.checkpointsDir, filename);
|
||||
|
||||
try {
|
||||
writeFileSync(filepath, JSON.stringify(checkpoint, null, 2), 'utf-8');
|
||||
this.log(`Saved checkpoint to ${filepath}`);
|
||||
|
||||
// Clean up old checkpoints for this session
|
||||
await this.cleanupOldCheckpoints(checkpoint.session_id);
|
||||
|
||||
return checkpoint.id;
|
||||
} catch (error) {
|
||||
this.log(`Error saving checkpoint: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a checkpoint from disk
|
||||
*
|
||||
* @param checkpointId - The checkpoint ID to load
|
||||
* @returns The checkpoint or null if not found
|
||||
*/
|
||||
async loadCheckpoint(checkpointId: string): Promise<Checkpoint | null> {
|
||||
const filepath = join(this.checkpointsDir, `${checkpointId}.json`);
|
||||
|
||||
if (!existsSync(filepath)) {
|
||||
this.log(`Checkpoint not found: ${checkpointId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filepath, 'utf-8');
|
||||
const checkpoint = JSON.parse(content) as Checkpoint;
|
||||
this.log(`Loaded checkpoint ${checkpointId}`);
|
||||
return checkpoint;
|
||||
} catch (error) {
|
||||
this.log(`Error loading checkpoint ${checkpointId}: ${(error as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Checkpoint Listing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List all checkpoints, optionally filtered by session
|
||||
*
|
||||
* @param sessionId - Optional session ID to filter by
|
||||
* @returns Array of checkpoint metadata
|
||||
*/
|
||||
async listCheckpoints(sessionId?: string): Promise<CheckpointMeta[]> {
|
||||
if (!existsSync(this.checkpointsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const files = readdirSync(this.checkpointsDir)
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.map(f => join(this.checkpointsDir, f));
|
||||
|
||||
const checkpoints: CheckpointMeta[] = [];
|
||||
|
||||
for (const filepath of files) {
|
||||
try {
|
||||
const content = readFileSync(filepath, 'utf-8');
|
||||
const checkpoint = JSON.parse(content) as Checkpoint;
|
||||
|
||||
// Filter by session if provided
|
||||
if (sessionId && checkpoint.session_id !== sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
checkpoints.push({
|
||||
id: checkpoint.id,
|
||||
created_at: checkpoint.created_at,
|
||||
session_id: checkpoint.session_id,
|
||||
trigger: checkpoint.trigger,
|
||||
path: filepath
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid checkpoint files
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by creation time (newest first)
|
||||
checkpoints.sort((a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
|
||||
return checkpoints;
|
||||
} catch (error) {
|
||||
this.log(`Error listing checkpoints: ${(error as Error).message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent checkpoint for a session
|
||||
*
|
||||
* @param sessionId - The session ID
|
||||
* @returns The most recent checkpoint or null
|
||||
*/
|
||||
async getLatestCheckpoint(sessionId: string): Promise<Checkpoint | null> {
|
||||
const checkpoints = await this.listCheckpoints(sessionId);
|
||||
if (checkpoints.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.loadCheckpoint(checkpoints[0].id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Recovery Message Formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a checkpoint as a recovery message for context injection
|
||||
*
|
||||
* @param checkpoint - The checkpoint to format
|
||||
* @returns Formatted markdown string
|
||||
*/
|
||||
formatRecoveryMessage(checkpoint: Checkpoint): string {
|
||||
const lines: string[] = [
|
||||
'# Session Checkpoint Recovery',
|
||||
'',
|
||||
`**Checkpoint ID:** ${checkpoint.id}`,
|
||||
`**Created:** ${checkpoint.created_at}`,
|
||||
`**Trigger:** ${checkpoint.trigger}`,
|
||||
`**Session:** ${checkpoint.session_id}`,
|
||||
''
|
||||
];
|
||||
|
||||
// Workflow state section
|
||||
if (checkpoint.workflow_state) {
|
||||
const ws = checkpoint.workflow_state;
|
||||
lines.push('## Workflow State');
|
||||
lines.push('');
|
||||
lines.push(`- **Type:** ${ws.type}`);
|
||||
lines.push(`- **Phase:** ${ws.phase}`);
|
||||
if (ws.pending.length > 0) {
|
||||
lines.push(`- **Pending Tasks:** ${ws.pending.length}`);
|
||||
}
|
||||
if (ws.completed.length > 0) {
|
||||
lines.push(`- **Completed Tasks:** ${ws.completed.length}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Active modes section
|
||||
const activeModes = Object.entries(checkpoint.mode_states)
|
||||
.filter(([, state]) => state.active);
|
||||
|
||||
if (activeModes.length > 0) {
|
||||
lines.push('## Active Modes');
|
||||
lines.push('');
|
||||
|
||||
for (const [mode, state] of activeModes) {
|
||||
const modeConfig = MODE_CONFIGS[mode as ExecutionMode];
|
||||
const modeName = modeConfig?.name ?? mode;
|
||||
lines.push(`- **${modeName}**`);
|
||||
if (state.phase) {
|
||||
lines.push(` - Phase: ${state.phase}`);
|
||||
}
|
||||
if (state.activatedAt) {
|
||||
const age = Math.round(
|
||||
(Date.now() - new Date(state.activatedAt).getTime()) / 60000
|
||||
);
|
||||
lines.push(` - Active for: ${age} minutes`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// TODO summary section
|
||||
if (checkpoint.todo_summary) {
|
||||
const todo = checkpoint.todo_summary;
|
||||
const total = todo.pending + todo.in_progress + todo.completed;
|
||||
|
||||
if (total > 0) {
|
||||
lines.push('## TODO Summary');
|
||||
lines.push('');
|
||||
lines.push(`- Pending: ${todo.pending}`);
|
||||
lines.push(`- In Progress: ${todo.in_progress}`);
|
||||
lines.push(`- Completed: ${todo.completed}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Memory context section
|
||||
if (checkpoint.memory_context) {
|
||||
const mem = checkpoint.memory_context;
|
||||
lines.push('## Context Memory');
|
||||
lines.push('');
|
||||
|
||||
if (mem.summary) {
|
||||
lines.push(mem.summary);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (mem.keyEntities.length > 0) {
|
||||
lines.push(`**Key Entities:** ${mem.keyEntities.join(', ')}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (mem.decisions && mem.decisions.length > 0) {
|
||||
lines.push('**Decisions Made:**');
|
||||
for (const decision of mem.decisions) {
|
||||
lines.push(`- ${decision}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (mem.openQuestions && mem.openQuestions.length > 0) {
|
||||
lines.push('**Open Questions:**');
|
||||
for (const question of mem.openQuestions) {
|
||||
lines.push(`- ${question}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery instructions
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
lines.push('*This checkpoint was created to preserve session state.*');
|
||||
lines.push('*Review the information above to resume work effectively.*');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Delete a specific checkpoint
|
||||
*
|
||||
* @param checkpointId - The checkpoint ID to delete
|
||||
* @returns true if deleted successfully
|
||||
*/
|
||||
async deleteCheckpoint(checkpointId: string): Promise<boolean> {
|
||||
const filepath = join(this.checkpointsDir, `${checkpointId}.json`);
|
||||
|
||||
if (!existsSync(filepath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
unlinkSync(filepath);
|
||||
this.log(`Deleted checkpoint ${checkpointId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.log(`Error deleting checkpoint ${checkpointId}: ${(error as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old checkpoints for a session, keeping only the most recent
|
||||
*
|
||||
* @param sessionId - The session ID
|
||||
* @returns Number of checkpoints removed
|
||||
*/
|
||||
async cleanupOldCheckpoints(sessionId: string): Promise<number> {
|
||||
const checkpoints = await this.listCheckpoints(sessionId);
|
||||
|
||||
if (checkpoints.length <= this.maxCheckpoints) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Remove oldest checkpoints (those beyond the limit)
|
||||
const toRemove = checkpoints.slice(this.maxCheckpoints);
|
||||
let removed = 0;
|
||||
|
||||
for (const meta of toRemove) {
|
||||
if (await this.deleteCheckpoint(meta.id)) {
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
this.log(`Cleaned up ${removed} old checkpoints for session ${sessionId}`);
|
||||
return removed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the checkpoints directory path
|
||||
*/
|
||||
getCheckpointsDir(): string {
|
||||
return this.checkpointsDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the checkpoints directory exists
|
||||
*/
|
||||
ensureCheckpointsDir(): void {
|
||||
if (!existsSync(this.checkpointsDir)) {
|
||||
mkdirSync(this.checkpointsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private: Helper Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a unique checkpoint ID
|
||||
*/
|
||||
private generateCheckpointId(sessionId: string, timestamp: string): string {
|
||||
// Format: YYYY-MM-DDTHH-mm-ss-sessionId
|
||||
const safeTimestamp = timestamp.replace(/[:.]/g, '-').substring(0, 19);
|
||||
return `${safeTimestamp}-${sessionId.substring(0, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message if logging is enabled
|
||||
*/
|
||||
private log(message: string): void {
|
||||
if (this.enableLogging) {
|
||||
console.log(`[CheckpointService] ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Function
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a CheckpointService instance
|
||||
*
|
||||
* @param projectPath - Project root path
|
||||
* @param options - Optional configuration
|
||||
* @returns CheckpointService instance
|
||||
*/
|
||||
export function createCheckpointService(
|
||||
projectPath: string,
|
||||
options?: Partial<CheckpointServiceOptions>
|
||||
): CheckpointService {
|
||||
return new CheckpointService({
|
||||
projectPath,
|
||||
...options
|
||||
});
|
||||
}
|
||||
336
ccw/src/core/services/hook-context-service.ts
Normal file
336
ccw/src/core/services/hook-context-service.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* HookContextService - Unified context generation for Claude Code hooks
|
||||
*
|
||||
* Provides centralized context generation for:
|
||||
* - session-start: MEMORY.md summary + cluster overview + hot entities + patterns
|
||||
* - per-prompt: vector search + intent matching
|
||||
* - session-end: task generation for async background processing
|
||||
*
|
||||
* Character limits:
|
||||
* - session-start: <= 1000 chars
|
||||
* - per-prompt: <= 500 chars
|
||||
*/
|
||||
|
||||
import type { SessionEndTask } from '../unified-context-builder.js';
|
||||
import { SessionStateService, type SessionState } from './session-state-service.js';
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
/** Maximum character count for session-start context */
|
||||
const SESSION_START_LIMIT = 1000;
|
||||
|
||||
/** Maximum character count for per-prompt context */
|
||||
const PER_PROMPT_LIMIT = 500;
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Options for building context
|
||||
*/
|
||||
export interface BuildContextOptions {
|
||||
/** Session ID for state tracking */
|
||||
sessionId: string;
|
||||
/** Project root path */
|
||||
projectId?: string;
|
||||
/** Whether this is the first prompt in the session */
|
||||
isFirstPrompt?: boolean;
|
||||
/** Character limit for the generated context */
|
||||
charLimit?: number;
|
||||
/** Current prompt text (for per-prompt context) */
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context generation result
|
||||
*/
|
||||
export interface ContextResult {
|
||||
/** Generated context content */
|
||||
content: string;
|
||||
/** Type of context generated */
|
||||
type: 'session-start' | 'context';
|
||||
/** Whether this was the first prompt */
|
||||
isFirstPrompt: boolean;
|
||||
/** Updated session state */
|
||||
state: SessionState;
|
||||
/** Character count of generated content */
|
||||
charCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for HookContextService
|
||||
*/
|
||||
export interface HookContextServiceOptions {
|
||||
/** Project root path */
|
||||
projectPath: string;
|
||||
/** Storage type for session state */
|
||||
storageType?: 'global' | 'session-scoped';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HookContextService
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Service for generating hook context
|
||||
*
|
||||
* This service wraps UnifiedContextBuilder and SessionStateService to provide
|
||||
* a unified interface for context generation across CLI hooks and API routes.
|
||||
*/
|
||||
export class HookContextService {
|
||||
private projectPath: string;
|
||||
private sessionStateService: SessionStateService;
|
||||
private unifiedContextBuilder: InstanceType<typeof import('../unified-context-builder.js').UnifiedContextBuilder> | null = null;
|
||||
private clusteringService: InstanceType<typeof import('../session-clustering-service.js').SessionClusteringService> | null = null;
|
||||
private initialized = false;
|
||||
|
||||
constructor(options: HookContextServiceOptions) {
|
||||
this.projectPath = options.projectPath;
|
||||
this.sessionStateService = new SessionStateService({
|
||||
storageType: options.storageType,
|
||||
projectPath: options.projectPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize lazy-loaded services
|
||||
*/
|
||||
private async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
// Try to load UnifiedContextBuilder (requires embedder)
|
||||
const { isUnifiedEmbedderAvailable } = await import('../unified-vector-index.js');
|
||||
if (isUnifiedEmbedderAvailable()) {
|
||||
const { UnifiedContextBuilder } = await import('../unified-context-builder.js');
|
||||
this.unifiedContextBuilder = new UnifiedContextBuilder(this.projectPath);
|
||||
}
|
||||
} catch {
|
||||
// UnifiedContextBuilder not available
|
||||
}
|
||||
|
||||
try {
|
||||
// Always load SessionClusteringService as fallback
|
||||
const { SessionClusteringService } = await import('../session-clustering-service.js');
|
||||
this.clusteringService = new SessionClusteringService(this.projectPath);
|
||||
} catch {
|
||||
// SessionClusteringService not available
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Context Generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build context for session-start hook
|
||||
*
|
||||
* @param options - Build context options
|
||||
* @returns Context generation result
|
||||
*/
|
||||
async buildSessionStartContext(options: BuildContextOptions): Promise<ContextResult> {
|
||||
await this.initialize();
|
||||
|
||||
const charLimit = options.charLimit ?? SESSION_START_LIMIT;
|
||||
|
||||
// Update session state
|
||||
const { isFirstPrompt, state } = this.sessionStateService.incrementLoad(
|
||||
options.sessionId,
|
||||
options.prompt
|
||||
);
|
||||
|
||||
let content = '';
|
||||
|
||||
// Try UnifiedContextBuilder first
|
||||
if (this.unifiedContextBuilder) {
|
||||
content = await this.unifiedContextBuilder.buildSessionStartContext();
|
||||
} else if (this.clusteringService) {
|
||||
// Fallback to SessionClusteringService
|
||||
content = await this.clusteringService.getProgressiveIndex({
|
||||
type: 'session-start',
|
||||
sessionId: options.sessionId
|
||||
});
|
||||
}
|
||||
|
||||
// Truncate if needed
|
||||
if (content.length > charLimit) {
|
||||
content = content.substring(0, charLimit - 20) + '...';
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
type: 'session-start',
|
||||
isFirstPrompt,
|
||||
state,
|
||||
charCount: content.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context for per-prompt hook
|
||||
*
|
||||
* @param options - Build context options
|
||||
* @returns Context generation result
|
||||
*/
|
||||
async buildPromptContext(options: BuildContextOptions): Promise<ContextResult> {
|
||||
await this.initialize();
|
||||
|
||||
const charLimit = options.charLimit ?? PER_PROMPT_LIMIT;
|
||||
|
||||
// Update session state
|
||||
const { isFirstPrompt, state } = this.sessionStateService.incrementLoad(
|
||||
options.sessionId,
|
||||
options.prompt
|
||||
);
|
||||
|
||||
let content = '';
|
||||
let contextType: 'session-start' | 'context' = 'context';
|
||||
|
||||
// First prompt uses session-start context
|
||||
if (isFirstPrompt) {
|
||||
contextType = 'session-start';
|
||||
if (this.unifiedContextBuilder) {
|
||||
content = await this.unifiedContextBuilder.buildSessionStartContext();
|
||||
} else if (this.clusteringService) {
|
||||
content = await this.clusteringService.getProgressiveIndex({
|
||||
type: 'session-start',
|
||||
sessionId: options.sessionId
|
||||
});
|
||||
}
|
||||
} else if (options.prompt && options.prompt.trim().length > 0) {
|
||||
// Subsequent prompts use per-prompt context
|
||||
contextType = 'context';
|
||||
if (this.unifiedContextBuilder) {
|
||||
content = await this.unifiedContextBuilder.buildPromptContext(options.prompt);
|
||||
} else if (this.clusteringService) {
|
||||
content = await this.clusteringService.getProgressiveIndex({
|
||||
type: 'context',
|
||||
sessionId: options.sessionId,
|
||||
prompt: options.prompt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate if needed
|
||||
if (content.length > charLimit) {
|
||||
content = content.substring(0, charLimit - 20) + '...';
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
type: contextType,
|
||||
isFirstPrompt,
|
||||
state,
|
||||
charCount: content.length
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Session End Tasks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build session end tasks for async background processing
|
||||
*
|
||||
* @param sessionId - Session ID for context
|
||||
* @returns Array of tasks to execute
|
||||
*/
|
||||
async buildSessionEndTasks(sessionId: string): Promise<SessionEndTask[]> {
|
||||
await this.initialize();
|
||||
|
||||
if (this.unifiedContextBuilder) {
|
||||
return this.unifiedContextBuilder.buildSessionEndTasks(sessionId);
|
||||
}
|
||||
|
||||
// No tasks available without UnifiedContextBuilder
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Session State Management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get session state
|
||||
*
|
||||
* @param sessionId - Session ID
|
||||
* @returns Session state or null if not found
|
||||
*/
|
||||
getSessionState(sessionId: string): SessionState | null {
|
||||
return this.sessionStateService.load(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is the first prompt for a session
|
||||
*
|
||||
* @param sessionId - Session ID
|
||||
* @returns true if this is the first prompt
|
||||
*/
|
||||
isFirstPrompt(sessionId: string): boolean {
|
||||
return this.sessionStateService.isFirstLoad(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get load count for a session
|
||||
*
|
||||
* @param sessionId - Session ID
|
||||
* @returns Load count (0 if not found)
|
||||
*/
|
||||
getLoadCount(sessionId: string): number {
|
||||
return this.sessionStateService.getLoadCount(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session state
|
||||
*
|
||||
* @param sessionId - Session ID
|
||||
* @returns true if state was cleared
|
||||
*/
|
||||
clearSessionState(sessionId: string): boolean {
|
||||
return this.sessionStateService.clear(sessionId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Utility Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if UnifiedContextBuilder is available
|
||||
*
|
||||
* @returns true if embedder is available
|
||||
*/
|
||||
async isAdvancedContextAvailable(): Promise<boolean> {
|
||||
await this.initialize();
|
||||
return this.unifiedContextBuilder !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the project path
|
||||
*/
|
||||
getProjectPath(): string {
|
||||
return this.projectPath;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Function
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a HookContextService instance
|
||||
*
|
||||
* @param projectPath - Project root path
|
||||
* @param storageType - Storage type for session state
|
||||
* @returns HookContextService instance
|
||||
*/
|
||||
export function createHookContextService(
|
||||
projectPath: string,
|
||||
storageType?: 'global' | 'session-scoped'
|
||||
): HookContextService {
|
||||
return new HookContextService({ projectPath, storageType });
|
||||
}
|
||||
75
ccw/src/core/services/index.ts
Normal file
75
ccw/src/core/services/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Core Services Exports
|
||||
*
|
||||
* Central export point for all CCW core services.
|
||||
*/
|
||||
|
||||
// Session State Management
|
||||
export {
|
||||
SessionStateService,
|
||||
loadSessionState,
|
||||
saveSessionState,
|
||||
clearSessionState,
|
||||
updateSessionState,
|
||||
incrementSessionLoad,
|
||||
getSessionStatePath,
|
||||
validateSessionId
|
||||
} from './session-state-service.js';
|
||||
export type {
|
||||
SessionState,
|
||||
SessionStateOptions,
|
||||
SessionStorageType
|
||||
} from './session-state-service.js';
|
||||
|
||||
// Hook Context Service
|
||||
export { HookContextService } from './hook-context-service.js';
|
||||
export type { BuildContextOptions, ContextResult } from './hook-context-service.js';
|
||||
|
||||
// Session End Service
|
||||
export { SessionEndService } from './session-end-service.js';
|
||||
export type { EndTask, TaskResult } from './session-end-service.js';
|
||||
|
||||
// Mode Registry Service
|
||||
export {
|
||||
ModeRegistryService,
|
||||
MODE_CONFIGS,
|
||||
EXCLUSIVE_MODES,
|
||||
STALE_MARKER_THRESHOLD,
|
||||
createModeRegistryService
|
||||
} from './mode-registry-service.js';
|
||||
export type {
|
||||
ModeConfig,
|
||||
ModeStatus,
|
||||
ModeRegistryOptions,
|
||||
CanStartResult,
|
||||
ExecutionMode
|
||||
} from './mode-registry-service.js';
|
||||
|
||||
// Checkpoint Service
|
||||
export { CheckpointService, createCheckpointService } from './checkpoint-service.js';
|
||||
export type {
|
||||
CheckpointServiceOptions,
|
||||
Checkpoint,
|
||||
CheckpointMeta,
|
||||
CheckpointTrigger,
|
||||
WorkflowStateSnapshot,
|
||||
ModeStateSnapshot,
|
||||
MemoryContextSnapshot
|
||||
} from './checkpoint-service.js';
|
||||
|
||||
// CLI Session Manager
|
||||
export { CliSessionManager } from './cli-session-manager.js';
|
||||
export type {
|
||||
CliSession,
|
||||
CreateCliSessionOptions,
|
||||
ExecuteInCliSessionOptions,
|
||||
CliSessionOutputEvent
|
||||
} from './cli-session-manager.js';
|
||||
|
||||
// Flow Executor
|
||||
export { FlowExecutor } from './flow-executor.js';
|
||||
export type { ExecutionContext, NodeResult } from './flow-executor.js';
|
||||
|
||||
// CLI Launch Registry
|
||||
export { getLaunchConfig } from './cli-launch-registry.js';
|
||||
export type { CliLaunchConfig, CliTool, LaunchMode } from './cli-launch-registry.js';
|
||||
730
ccw/src/core/services/mode-registry-service.ts
Normal file
730
ccw/src/core/services/mode-registry-service.ts
Normal file
@@ -0,0 +1,730 @@
|
||||
/**
|
||||
* ModeRegistryService - Centralized Mode State Management
|
||||
*
|
||||
* Provides unified mode state detection and management for CCW.
|
||||
* All modes store state in `.workflow/modes/` directory for consistency.
|
||||
*
|
||||
* Features:
|
||||
* - Mode activation/deactivation tracking
|
||||
* - Exclusive mode conflict detection
|
||||
* - Stale marker cleanup (1 hour threshold)
|
||||
* - File-based state persistence
|
||||
*
|
||||
* Based on oh-my-claudecode mode-registry pattern.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, readdirSync, statSync, rmSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Supported execution modes
|
||||
*/
|
||||
export type ExecutionMode =
|
||||
| 'autopilot'
|
||||
| 'ralph'
|
||||
| 'ultrawork'
|
||||
| 'swarm'
|
||||
| 'pipeline'
|
||||
| 'team'
|
||||
| 'ultraqa';
|
||||
|
||||
/**
|
||||
* Mode configuration
|
||||
*/
|
||||
export interface ModeConfig {
|
||||
/** Display name for the mode */
|
||||
name: string;
|
||||
/** Primary state file path (relative to .workflow/modes/) */
|
||||
stateFile: string;
|
||||
/** Property to check in JSON state for active status */
|
||||
activeProperty: string;
|
||||
/** Whether this mode is mutually exclusive with other exclusive modes */
|
||||
exclusive?: boolean;
|
||||
/** Description of the mode */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of a mode
|
||||
*/
|
||||
export interface ModeStatus {
|
||||
/** The mode identifier */
|
||||
mode: ExecutionMode;
|
||||
/** Whether the mode is currently active */
|
||||
active: boolean;
|
||||
/** Path to the state file */
|
||||
stateFilePath: string;
|
||||
/** Session ID if session-scoped */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of checking if a mode can be started
|
||||
*/
|
||||
export interface CanStartResult {
|
||||
/** Whether the mode can be started */
|
||||
allowed: boolean;
|
||||
/** The mode that is blocking (if not allowed) */
|
||||
blockedBy?: ExecutionMode;
|
||||
/** Human-readable message */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for mode registry operations
|
||||
*/
|
||||
export interface ModeRegistryOptions {
|
||||
/** Project root path */
|
||||
projectPath: string;
|
||||
/** Enable logging */
|
||||
enableLogging?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Stale marker threshold (1 hour)
|
||||
* Markers older than this are auto-removed to prevent crashed sessions
|
||||
* from blocking indefinitely.
|
||||
*/
|
||||
const STALE_MARKER_THRESHOLD = 60 * 60 * 1000; // 1 hour in milliseconds
|
||||
|
||||
/**
|
||||
* Mode configuration registry
|
||||
*
|
||||
* Maps each mode to its state file location and detection method.
|
||||
* All paths are relative to .workflow/modes/ directory.
|
||||
*/
|
||||
const MODE_CONFIGS: Record<ExecutionMode, ModeConfig> = {
|
||||
autopilot: {
|
||||
name: 'Autopilot',
|
||||
stateFile: 'autopilot-state.json',
|
||||
activeProperty: 'active',
|
||||
exclusive: true,
|
||||
description: 'Autonomous execution mode for multi-step tasks'
|
||||
},
|
||||
ralph: {
|
||||
name: 'Ralph',
|
||||
stateFile: 'ralph-state.json',
|
||||
activeProperty: 'active',
|
||||
exclusive: false,
|
||||
description: 'Research and Analysis Learning Pattern Handler'
|
||||
},
|
||||
ultrawork: {
|
||||
name: 'Ultrawork',
|
||||
stateFile: 'ultrawork-state.json',
|
||||
activeProperty: 'active',
|
||||
exclusive: false,
|
||||
description: 'Ultra-focused work mode for deep tasks'
|
||||
},
|
||||
swarm: {
|
||||
name: 'Swarm',
|
||||
stateFile: 'swarm-state.json',
|
||||
activeProperty: 'active',
|
||||
exclusive: true,
|
||||
description: 'Multi-agent swarm execution mode'
|
||||
},
|
||||
pipeline: {
|
||||
name: 'Pipeline',
|
||||
stateFile: 'pipeline-state.json',
|
||||
activeProperty: 'active',
|
||||
exclusive: true,
|
||||
description: 'Pipeline execution mode for sequential tasks'
|
||||
},
|
||||
team: {
|
||||
name: 'Team',
|
||||
stateFile: 'team-state.json',
|
||||
activeProperty: 'active',
|
||||
exclusive: false,
|
||||
description: 'Team collaboration mode'
|
||||
},
|
||||
ultraqa: {
|
||||
name: 'UltraQA',
|
||||
stateFile: 'ultraqa-state.json',
|
||||
activeProperty: 'active',
|
||||
exclusive: false,
|
||||
description: 'Ultra-focused QA mode'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Modes that are mutually exclusive (cannot run concurrently)
|
||||
*/
|
||||
const EXCLUSIVE_MODES: ExecutionMode[] = ['autopilot', 'swarm', 'pipeline'];
|
||||
|
||||
// Export for external use
|
||||
export { MODE_CONFIGS, EXCLUSIVE_MODES, STALE_MARKER_THRESHOLD };
|
||||
|
||||
// =============================================================================
|
||||
// ModeRegistryService
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Service for managing mode state
|
||||
*
|
||||
* This service provides centralized mode state management using file-based
|
||||
* persistence. It supports exclusive mode detection and stale marker cleanup.
|
||||
*/
|
||||
export class ModeRegistryService {
|
||||
private projectPath: string;
|
||||
private enableLogging: boolean;
|
||||
private modesDir: string;
|
||||
|
||||
constructor(options: ModeRegistryOptions) {
|
||||
this.projectPath = options.projectPath;
|
||||
this.enableLogging = options.enableLogging ?? false;
|
||||
this.modesDir = join(this.projectPath, '.workflow', 'modes');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Directory Management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the modes directory path
|
||||
*/
|
||||
getModesDir(): string {
|
||||
return this.modesDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the modes directory exists
|
||||
*/
|
||||
ensureModesDir(): void {
|
||||
if (!existsSync(this.modesDir)) {
|
||||
mkdirSync(this.modesDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Mode State Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if a specific mode is currently active
|
||||
*
|
||||
* @param mode - The mode to check
|
||||
* @param sessionId - Optional session ID to check session-scoped state
|
||||
* @returns true if the mode is active
|
||||
*/
|
||||
isModeActive(mode: ExecutionMode, sessionId?: string): boolean {
|
||||
const config = MODE_CONFIGS[mode];
|
||||
|
||||
if (sessionId) {
|
||||
// Check session-scoped path
|
||||
const sessionStateFile = this.getSessionStatePath(mode, sessionId);
|
||||
return this.isJsonModeActive(sessionStateFile, config, sessionId);
|
||||
}
|
||||
|
||||
// Check legacy shared path
|
||||
const stateFile = this.getStateFilePath(mode);
|
||||
return this.isJsonModeActive(stateFile, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mode has state (file exists)
|
||||
*
|
||||
* @param mode - The mode to check
|
||||
* @param sessionId - Optional session ID
|
||||
* @returns true if state file exists
|
||||
*/
|
||||
hasModeState(mode: ExecutionMode, sessionId?: string): boolean {
|
||||
const stateFile = sessionId
|
||||
? this.getSessionStatePath(mode, sessionId)
|
||||
: this.getStateFilePath(mode);
|
||||
return existsSync(stateFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active modes
|
||||
*
|
||||
* @param sessionId - Optional session ID to check session-scoped state
|
||||
* @returns Array of active mode identifiers
|
||||
*/
|
||||
getActiveModes(sessionId?: string): ExecutionMode[] {
|
||||
const modes: ExecutionMode[] = [];
|
||||
|
||||
for (const mode of Object.keys(MODE_CONFIGS) as ExecutionMode[]) {
|
||||
if (this.isModeActive(mode, sessionId)) {
|
||||
modes.push(mode);
|
||||
}
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any mode is currently active
|
||||
*
|
||||
* @param sessionId - Optional session ID
|
||||
* @returns true if any mode is active
|
||||
*/
|
||||
isAnyModeActive(sessionId?: string): boolean {
|
||||
return this.getActiveModes(sessionId).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active exclusive mode (if any)
|
||||
*
|
||||
* @returns The active exclusive mode or null
|
||||
*/
|
||||
getActiveExclusiveMode(): ExecutionMode | null {
|
||||
for (const mode of EXCLUSIVE_MODES) {
|
||||
if (this.isModeActiveInAnySession(mode)) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all modes
|
||||
*
|
||||
* @param sessionId - Optional session ID
|
||||
* @returns Array of mode statuses
|
||||
*/
|
||||
getAllModeStatuses(sessionId?: string): ModeStatus[] {
|
||||
return (Object.keys(MODE_CONFIGS) as ExecutionMode[]).map(mode => ({
|
||||
mode,
|
||||
active: this.isModeActive(mode, sessionId),
|
||||
stateFilePath: sessionId
|
||||
? this.getSessionStatePath(mode, sessionId)
|
||||
: this.getStateFilePath(mode),
|
||||
sessionId
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Mode Control
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if a new mode can be started
|
||||
*
|
||||
* @param mode - The mode to start
|
||||
* @param sessionId - Optional session ID
|
||||
* @returns CanStartResult with allowed status and blocker info
|
||||
*/
|
||||
canStartMode(mode: ExecutionMode, sessionId?: string): CanStartResult {
|
||||
const config = MODE_CONFIGS[mode];
|
||||
|
||||
// Check for mutually exclusive modes
|
||||
if (EXCLUSIVE_MODES.includes(mode)) {
|
||||
for (const exclusiveMode of EXCLUSIVE_MODES) {
|
||||
if (exclusiveMode !== mode && this.isModeActiveInAnySession(exclusiveMode)) {
|
||||
const exclusiveConfig = MODE_CONFIGS[exclusiveMode];
|
||||
return {
|
||||
allowed: false,
|
||||
blockedBy: exclusiveMode,
|
||||
message: `Cannot start ${config.name} while ${exclusiveConfig.name} is active. Cancel ${exclusiveConfig.name} first.`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already active in this session
|
||||
if (sessionId && this.isModeActive(mode, sessionId)) {
|
||||
return {
|
||||
allowed: false,
|
||||
blockedBy: mode,
|
||||
message: `${config.name} is already active in this session.`
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a mode
|
||||
*
|
||||
* @param mode - The mode to activate
|
||||
* @param sessionId - Session ID
|
||||
* @param context - Optional context to store with state
|
||||
* @returns true if activation was successful
|
||||
*/
|
||||
activateMode(mode: ExecutionMode, sessionId: string, context?: Record<string, unknown>): boolean {
|
||||
const config = MODE_CONFIGS[mode];
|
||||
|
||||
// Check if can start
|
||||
const canStart = this.canStartMode(mode, sessionId);
|
||||
if (!canStart.allowed) {
|
||||
this.log(`Cannot activate ${config.name}: ${canStart.message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.ensureModesDir();
|
||||
|
||||
const stateFile = this.getSessionStatePath(mode, sessionId);
|
||||
const stateDir = dirname(stateFile);
|
||||
if (!existsSync(stateDir)) {
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
}
|
||||
|
||||
const state = {
|
||||
[config.activeProperty]: true,
|
||||
session_id: sessionId,
|
||||
activatedAt: new Date().toISOString(),
|
||||
...context
|
||||
};
|
||||
|
||||
writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8');
|
||||
this.log(`Activated ${config.name} for session ${sessionId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.log(`Failed to activate ${config.name}: ${(error as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate a mode
|
||||
*
|
||||
* @param mode - The mode to deactivate
|
||||
* @param sessionId - Session ID
|
||||
* @returns true if deactivation was successful
|
||||
*/
|
||||
deactivateMode(mode: ExecutionMode, sessionId: string): boolean {
|
||||
const config = MODE_CONFIGS[mode];
|
||||
|
||||
try {
|
||||
const stateFile = this.getSessionStatePath(mode, sessionId);
|
||||
|
||||
if (!existsSync(stateFile)) {
|
||||
return true; // Already inactive
|
||||
}
|
||||
|
||||
unlinkSync(stateFile);
|
||||
this.log(`Deactivated ${config.name} for session ${sessionId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.log(`Failed to deactivate ${config.name}: ${(error as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all state for a mode
|
||||
*
|
||||
* @param mode - The mode to clear
|
||||
* @param sessionId - Optional session ID (if provided, only clears session state)
|
||||
* @returns true if successful
|
||||
*/
|
||||
clearModeState(mode: ExecutionMode, sessionId?: string): boolean {
|
||||
let success = true;
|
||||
|
||||
if (sessionId) {
|
||||
// Clear session-scoped state only
|
||||
const sessionStateFile = this.getSessionStatePath(mode, sessionId);
|
||||
if (existsSync(sessionStateFile)) {
|
||||
try {
|
||||
unlinkSync(sessionStateFile);
|
||||
} catch {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
// Clear all state for this mode
|
||||
const stateFile = this.getStateFilePath(mode);
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
unlinkSync(stateFile);
|
||||
} catch {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Also clear session-scoped states
|
||||
try {
|
||||
const sessionIds = this.listSessionIds();
|
||||
for (const sid of sessionIds) {
|
||||
const sessionFile = this.getSessionStatePath(mode, sid);
|
||||
if (existsSync(sessionFile)) {
|
||||
try {
|
||||
unlinkSync(sessionFile);
|
||||
} catch {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors scanning sessions
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all mode states (force clear)
|
||||
*
|
||||
* @returns true if all states were cleared
|
||||
*/
|
||||
clearAllModeStates(): boolean {
|
||||
let success = true;
|
||||
|
||||
for (const mode of Object.keys(MODE_CONFIGS) as ExecutionMode[]) {
|
||||
if (!this.clearModeState(mode)) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Session Management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if a mode is active in any session
|
||||
*
|
||||
* @param mode - The mode to check
|
||||
* @returns true if the mode is active in any session
|
||||
*/
|
||||
isModeActiveInAnySession(mode: ExecutionMode): boolean {
|
||||
// Check legacy path first
|
||||
if (this.isModeActive(mode)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Scan all session dirs
|
||||
const sessionIds = this.listSessionIds();
|
||||
for (const sid of sessionIds) {
|
||||
if (this.isModeActive(mode, sid)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all session IDs that have a specific mode active
|
||||
*
|
||||
* @param mode - The mode to check
|
||||
* @returns Array of session IDs with this mode active
|
||||
*/
|
||||
getActiveSessionsForMode(mode: ExecutionMode): string[] {
|
||||
const sessionIds = this.listSessionIds();
|
||||
return sessionIds.filter(sid => this.isModeActive(mode, sid));
|
||||
}
|
||||
|
||||
/**
|
||||
* List all session IDs that have mode state files
|
||||
*
|
||||
* @returns Array of session IDs
|
||||
*/
|
||||
listSessionIds(): string[] {
|
||||
const sessionsDir = join(this.modesDir, 'sessions');
|
||||
if (!existsSync(sessionsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return readdirSync(sessionsDir, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => dirent.name)
|
||||
.filter(name => this.isValidSessionId(name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Stale State Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Clear stale session directories
|
||||
*
|
||||
* Removes session directories that have no recent activity.
|
||||
*
|
||||
* @param maxAgeMs - Maximum age in milliseconds (default: 24 hours)
|
||||
* @returns Array of removed session IDs
|
||||
*/
|
||||
clearStaleSessionDirs(maxAgeMs: number = 24 * 60 * 60 * 1000): string[] {
|
||||
const sessionsDir = join(this.modesDir, 'sessions');
|
||||
if (!existsSync(sessionsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const removed: string[] = [];
|
||||
const sessionIds = this.listSessionIds();
|
||||
|
||||
for (const sid of sessionIds) {
|
||||
const sessionDir = this.getSessionDir(sid);
|
||||
try {
|
||||
const files = readdirSync(sessionDir);
|
||||
|
||||
// Remove empty directories
|
||||
if (files.length === 0) {
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
removed.push(sid);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check modification time of any state file
|
||||
let newest = 0;
|
||||
for (const f of files) {
|
||||
const stat = statSync(join(sessionDir, f));
|
||||
if (stat.mtimeMs > newest) {
|
||||
newest = stat.mtimeMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove if stale
|
||||
if (Date.now() - newest > maxAgeMs) {
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
removed.push(sid);
|
||||
}
|
||||
} catch {
|
||||
// Skip on error
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale markers (older than threshold)
|
||||
*
|
||||
* @returns Array of cleaned up session IDs
|
||||
*/
|
||||
cleanupStaleMarkers(): string[] {
|
||||
const cleaned: string[] = [];
|
||||
const sessionIds = this.listSessionIds();
|
||||
|
||||
for (const sid of sessionIds) {
|
||||
for (const mode of Object.keys(MODE_CONFIGS) as ExecutionMode[]) {
|
||||
const stateFile = this.getSessionStatePath(mode, sid);
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const content = readFileSync(stateFile, 'utf-8');
|
||||
const state = JSON.parse(content);
|
||||
|
||||
if (state.activatedAt) {
|
||||
const activatedAt = new Date(state.activatedAt).getTime();
|
||||
const age = Date.now() - activatedAt;
|
||||
|
||||
if (age > STALE_MARKER_THRESHOLD) {
|
||||
this.log(`Cleaning up stale ${mode} marker for session ${sid} (${Math.round(age / 60000)} min old)`);
|
||||
unlinkSync(stateFile);
|
||||
cleaned.push(sid);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(cleaned)); // Remove duplicates
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private: Utility Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the state file path for a mode (legacy shared path)
|
||||
*/
|
||||
private getStateFilePath(mode: ExecutionMode): string {
|
||||
const config = MODE_CONFIGS[mode];
|
||||
return join(this.modesDir, config.stateFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session-scoped state file path
|
||||
*/
|
||||
private getSessionStatePath(mode: ExecutionMode, sessionId: string): string {
|
||||
const config = MODE_CONFIGS[mode];
|
||||
return join(this.modesDir, 'sessions', sessionId, config.stateFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session directory path
|
||||
*/
|
||||
private getSessionDir(sessionId: string): string {
|
||||
return join(this.modesDir, 'sessions', sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a JSON-based mode is active
|
||||
*/
|
||||
private isJsonModeActive(
|
||||
stateFile: string,
|
||||
config: ModeConfig,
|
||||
sessionId?: string
|
||||
): boolean {
|
||||
if (!existsSync(stateFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(stateFile, 'utf-8');
|
||||
const state = JSON.parse(content);
|
||||
|
||||
// Validate session identity if sessionId provided
|
||||
if (sessionId && state.session_id && state.session_id !== sessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config.activeProperty) {
|
||||
return state[config.activeProperty] === true;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session ID format
|
||||
*/
|
||||
private isValidSessionId(sessionId: string): boolean {
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
return false;
|
||||
}
|
||||
// Allow alphanumeric, hyphens, and underscores only
|
||||
const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/;
|
||||
return SAFE_SESSION_ID_PATTERN.test(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message if logging is enabled
|
||||
*/
|
||||
private log(message: string): void {
|
||||
if (this.enableLogging) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[ModeRegistry ${timestamp}] ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Function
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a ModeRegistryService instance
|
||||
*
|
||||
* @param projectPath - Project root path
|
||||
* @param enableLogging - Enable logging
|
||||
* @returns ModeRegistryService instance
|
||||
*/
|
||||
export function createModeRegistryService(
|
||||
projectPath: string,
|
||||
enableLogging?: boolean
|
||||
): ModeRegistryService {
|
||||
return new ModeRegistryService({ projectPath, enableLogging });
|
||||
}
|
||||
408
ccw/src/core/services/session-end-service.ts
Normal file
408
ccw/src/core/services/session-end-service.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* SessionEndService - Unified session end handling
|
||||
*
|
||||
* Provides centralized management for session-end tasks:
|
||||
* - Task registration with priority
|
||||
* - Async execution with error handling
|
||||
* - Built-in tasks: incremental-embedding, clustering, heat-scores
|
||||
*
|
||||
* Design:
|
||||
* - Best-effort execution (failures logged but don't block)
|
||||
* - Priority-based ordering
|
||||
* - Support for async background execution
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A task to be executed at session end
|
||||
*/
|
||||
export interface EndTask {
|
||||
/** Unique task type identifier */
|
||||
type: string;
|
||||
/** Task priority (higher = executed first) */
|
||||
priority: number;
|
||||
/** Whether to run asynchronously in background */
|
||||
async: boolean;
|
||||
/** Task handler function */
|
||||
handler: () => Promise<void>;
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a task execution
|
||||
*/
|
||||
export interface TaskResult {
|
||||
/** Task type identifier */
|
||||
type: string;
|
||||
/** Whether the task succeeded */
|
||||
success: boolean;
|
||||
/** Execution duration in milliseconds */
|
||||
duration: number;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for SessionEndService
|
||||
*/
|
||||
export interface SessionEndServiceOptions {
|
||||
/** Project root path */
|
||||
projectPath: string;
|
||||
/** Whether to log task execution */
|
||||
enableLogging?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of session end execution
|
||||
*/
|
||||
export interface SessionEndSummary {
|
||||
/** Total tasks executed */
|
||||
totalTasks: number;
|
||||
/** Number of successful tasks */
|
||||
successful: number;
|
||||
/** Number of failed tasks */
|
||||
failed: number;
|
||||
/** Total execution time in milliseconds */
|
||||
totalDuration: number;
|
||||
/** Individual task results */
|
||||
results: TaskResult[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Built-in Task Types
|
||||
// =============================================================================
|
||||
|
||||
/** Task type for incremental vector embedding */
|
||||
export const TASK_INCREMENTAL_EMBEDDING = 'incremental-embedding';
|
||||
|
||||
/** Task type for incremental clustering */
|
||||
export const TASK_INCREMENTAL_CLUSTERING = 'incremental-clustering';
|
||||
|
||||
/** Task type for heat score updates */
|
||||
export const TASK_HEAT_SCORE_UPDATE = 'heat-score-update';
|
||||
|
||||
// =============================================================================
|
||||
// SessionEndService
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Service for managing and executing session-end tasks
|
||||
*
|
||||
* This service provides a unified interface for registering and executing
|
||||
* background tasks when a session ends. Tasks are executed best-effort
|
||||
* with proper error handling and logging.
|
||||
*/
|
||||
export class SessionEndService {
|
||||
private projectPath: string;
|
||||
private enableLogging: boolean;
|
||||
private tasks: Map<string, EndTask> = new Map();
|
||||
|
||||
constructor(options: SessionEndServiceOptions) {
|
||||
this.projectPath = options.projectPath;
|
||||
this.enableLogging = options.enableLogging ?? false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Task Registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register a session-end task
|
||||
*
|
||||
* @param task - Task to register
|
||||
* @returns true if task was registered (false if type already exists)
|
||||
*/
|
||||
registerEndTask(task: EndTask): boolean {
|
||||
if (this.tasks.has(task.type)) {
|
||||
this.log(`Task "${task.type}" already registered, skipping`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.tasks.set(task.type, task);
|
||||
this.log(`Registered task "${task.type}" with priority ${task.priority}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a session-end task
|
||||
*
|
||||
* @param type - Task type to unregister
|
||||
* @returns true if task was removed
|
||||
*/
|
||||
unregisterEndTask(type: string): boolean {
|
||||
const removed = this.tasks.delete(type);
|
||||
if (removed) {
|
||||
this.log(`Unregistered task "${type}"`);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task type is registered
|
||||
*
|
||||
* @param type - Task type to check
|
||||
* @returns true if task is registered
|
||||
*/
|
||||
hasTask(type: string): boolean {
|
||||
return this.tasks.has(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered task types
|
||||
*
|
||||
* @returns Array of task types
|
||||
*/
|
||||
getRegisteredTasks(): string[] {
|
||||
return Array.from(this.tasks.keys());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Task Execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Execute all registered session-end tasks
|
||||
*
|
||||
* Tasks are executed in priority order (highest first).
|
||||
* Failures are logged but don't prevent other tasks from running.
|
||||
*
|
||||
* @param sessionId - Session ID for context
|
||||
* @returns Summary of execution results
|
||||
*/
|
||||
async executeEndTasks(sessionId: string): Promise<SessionEndSummary> {
|
||||
const startTime = Date.now();
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
// Sort tasks by priority (descending)
|
||||
const sortedTasks = Array.from(this.tasks.values()).sort(
|
||||
(a, b) => b.priority - a.priority
|
||||
);
|
||||
|
||||
this.log(`Executing ${sortedTasks.length} session-end tasks for session ${sessionId}`);
|
||||
|
||||
// Execute tasks concurrently
|
||||
const executionPromises = sortedTasks.map(async (task) => {
|
||||
const taskStart = Date.now();
|
||||
|
||||
try {
|
||||
this.log(`Starting task "${task.type}"...`);
|
||||
await task.handler();
|
||||
|
||||
const duration = Date.now() - taskStart;
|
||||
this.log(`Task "${task.type}" completed in ${duration}ms`);
|
||||
|
||||
return {
|
||||
type: task.type,
|
||||
success: true,
|
||||
duration
|
||||
} as TaskResult;
|
||||
} catch (err) {
|
||||
const duration = Date.now() - taskStart;
|
||||
const errorMessage = (err as Error).message || 'Unknown error';
|
||||
this.log(`Task "${task.type}" failed: ${errorMessage}`);
|
||||
|
||||
return {
|
||||
type: task.type,
|
||||
success: false,
|
||||
duration,
|
||||
error: errorMessage
|
||||
} as TaskResult;
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all tasks to complete
|
||||
const taskResults = await Promise.allSettled(executionPromises);
|
||||
|
||||
// Collect results
|
||||
for (const result of taskResults) {
|
||||
if (result.status === 'fulfilled') {
|
||||
results.push(result.value);
|
||||
} else {
|
||||
// This shouldn't happen as we catch errors inside the task
|
||||
results.push({
|
||||
type: 'unknown',
|
||||
success: false,
|
||||
duration: 0,
|
||||
error: result.reason?.message || 'Task promise rejected'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - startTime;
|
||||
const successful = results.filter((r) => r.success).length;
|
||||
const failed = results.length - successful;
|
||||
|
||||
this.log(
|
||||
`Session-end tasks completed: ${successful}/${results.length} successful, ` +
|
||||
`${totalDuration}ms total`
|
||||
);
|
||||
|
||||
return {
|
||||
totalTasks: results.length,
|
||||
successful,
|
||||
failed,
|
||||
totalDuration,
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute only async (background) tasks
|
||||
*
|
||||
* This is useful for fire-and-forget background processing.
|
||||
*
|
||||
* @param sessionId - Session ID for context
|
||||
* @returns Promise that resolves immediately (tasks run in background)
|
||||
*/
|
||||
executeBackgroundTasks(sessionId: string): void {
|
||||
const asyncTasks = Array.from(this.tasks.values())
|
||||
.filter((t) => t.async)
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
if (asyncTasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fire-and-forget
|
||||
Promise.all(
|
||||
asyncTasks.map(async (task) => {
|
||||
try {
|
||||
this.log(`Background task "${task.type}" starting...`);
|
||||
await task.handler();
|
||||
this.log(`Background task "${task.type}" completed`);
|
||||
} catch (err) {
|
||||
this.log(`Background task "${task.type}" failed: ${(err as Error).message}`);
|
||||
}
|
||||
})
|
||||
).catch(() => {
|
||||
// Ignore errors - background tasks are best-effort
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: Built-in Tasks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register built-in session-end tasks
|
||||
*
|
||||
* This registers the standard tasks:
|
||||
* - incremental-embedding (priority 100)
|
||||
* - incremental-clustering (priority 50)
|
||||
* - heat-score-update (priority 25)
|
||||
*
|
||||
* @param sessionId - Session ID for context
|
||||
*/
|
||||
async registerBuiltinTasks(sessionId: string): Promise<void> {
|
||||
// Try to import and register embedding task
|
||||
try {
|
||||
const { isUnifiedEmbedderAvailable, UnifiedVectorIndex } = await import(
|
||||
'../unified-vector-index.js'
|
||||
);
|
||||
const { getMemoryMdContent } = await import('../memory-consolidation-pipeline.js');
|
||||
|
||||
if (isUnifiedEmbedderAvailable()) {
|
||||
this.registerEndTask({
|
||||
type: TASK_INCREMENTAL_EMBEDDING,
|
||||
priority: 100,
|
||||
async: true,
|
||||
description: 'Index new/updated content in vector store',
|
||||
handler: async () => {
|
||||
const vectorIndex = new UnifiedVectorIndex(this.projectPath);
|
||||
const memoryContent = getMemoryMdContent(this.projectPath);
|
||||
if (memoryContent) {
|
||||
await vectorIndex.indexContent(memoryContent, {
|
||||
source_id: 'MEMORY_MD',
|
||||
source_type: 'core_memory',
|
||||
category: 'core_memory'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Embedding dependencies not available
|
||||
this.log('Embedding task not registered: dependencies not available');
|
||||
}
|
||||
|
||||
// Try to import and register clustering task
|
||||
try {
|
||||
const { SessionClusteringService } = await import('../session-clustering-service.js');
|
||||
|
||||
this.registerEndTask({
|
||||
type: TASK_INCREMENTAL_CLUSTERING,
|
||||
priority: 50,
|
||||
async: true,
|
||||
description: 'Cluster unclustered sessions',
|
||||
handler: async () => {
|
||||
const clusteringService = new SessionClusteringService(this.projectPath);
|
||||
await clusteringService.autocluster({ scope: 'unclustered' });
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
this.log('Clustering task not registered: dependencies not available');
|
||||
}
|
||||
|
||||
// Try to import and register heat score task
|
||||
try {
|
||||
const { getMemoryStore } = await import('../memory-store.js');
|
||||
|
||||
this.registerEndTask({
|
||||
type: TASK_HEAT_SCORE_UPDATE,
|
||||
priority: 25,
|
||||
async: true,
|
||||
description: 'Update entity heat scores',
|
||||
handler: async () => {
|
||||
const memoryStore = getMemoryStore(this.projectPath);
|
||||
const hotEntities = memoryStore.getHotEntities(50);
|
||||
for (const entity of hotEntities) {
|
||||
if (entity.id != null) {
|
||||
memoryStore.calculateHeatScore(entity.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
this.log('Heat score task not registered: dependencies not available');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private: Utility Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Log a message if logging is enabled
|
||||
*/
|
||||
private log(message: string): void {
|
||||
if (this.enableLogging) {
|
||||
console.log(`[SessionEndService] ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Function
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a SessionEndService instance with built-in tasks
|
||||
*
|
||||
* @param projectPath - Project root path
|
||||
* @param sessionId - Session ID for context
|
||||
* @param enableLogging - Whether to enable logging
|
||||
* @returns SessionEndService instance with built-in tasks registered
|
||||
*/
|
||||
export async function createSessionEndService(
|
||||
projectPath: string,
|
||||
sessionId: string,
|
||||
enableLogging = false
|
||||
): Promise<SessionEndService> {
|
||||
const service = new SessionEndService({ projectPath, enableLogging });
|
||||
await service.registerBuiltinTasks(sessionId);
|
||||
return service;
|
||||
}
|
||||
330
ccw/src/core/services/session-state-service.ts
Normal file
330
ccw/src/core/services/session-state-service.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* SessionStateService - Unified session state management
|
||||
*
|
||||
* Provides centralized session state persistence across CLI hooks and API routes.
|
||||
* Supports both legacy global path (~/.claude/.ccw-sessions/) and session-scoped
|
||||
* paths (.workflow/sessions/{sessionId}/) for workflow integration.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
/**
|
||||
* Session state interface
|
||||
*/
|
||||
export interface SessionState {
|
||||
/** ISO timestamp of first session load */
|
||||
firstLoad: string;
|
||||
/** Number of times session has been loaded */
|
||||
loadCount: number;
|
||||
/** Last prompt text (optional) */
|
||||
lastPrompt?: string;
|
||||
/** Active mode for the session (optional) */
|
||||
activeMode?: 'analysis' | 'write' | 'review' | 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage type for session state
|
||||
*/
|
||||
export type SessionStorageType = 'global' | 'session-scoped';
|
||||
|
||||
/**
|
||||
* Options for session state operations
|
||||
*/
|
||||
export interface SessionStateOptions {
|
||||
/** Storage type: 'global' uses ~/.claude/.ccw-sessions/, 'session-scoped' uses .workflow/sessions/{sessionId}/ */
|
||||
storageType?: SessionStorageType;
|
||||
/** Project root path (required for session-scoped storage) */
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a session ID is safe to use in file paths.
|
||||
* Session IDs should be alphanumeric with optional hyphens and underscores.
|
||||
* This prevents path traversal attacks (e.g., "../../../etc").
|
||||
*
|
||||
* @param sessionId - The session ID to validate
|
||||
* @returns true if the session ID is safe, false otherwise
|
||||
*/
|
||||
export function validateSessionId(sessionId: string): boolean {
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
return false;
|
||||
}
|
||||
// Allow alphanumeric, hyphens, and underscores only
|
||||
// Must be 1-256 characters (reasonable length limit)
|
||||
// Must not start with a dot (hidden files) or hyphen
|
||||
const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/;
|
||||
return SAFE_SESSION_ID_PATTERN.test(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default global session state directory
|
||||
* Uses ~/.claude/.ccw-sessions/ for reliable persistence across sessions
|
||||
*/
|
||||
function getGlobalStateDir(): string {
|
||||
return join(homedir(), '.claude', '.ccw-sessions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session state file path
|
||||
*
|
||||
* Supports two storage modes:
|
||||
* - 'global': ~/.claude/.ccw-sessions/session-{sessionId}.json (default)
|
||||
* - 'session-scoped': {projectPath}/.workflow/sessions/{sessionId}/state.json
|
||||
*
|
||||
* @param sessionId - The session ID
|
||||
* @param options - Storage options
|
||||
* @returns Full path to the session state file
|
||||
*/
|
||||
export function getSessionStatePath(sessionId: string, options?: SessionStateOptions): string {
|
||||
if (!validateSessionId(sessionId)) {
|
||||
throw new Error(`Invalid session ID: ${sessionId}`);
|
||||
}
|
||||
|
||||
const storageType = options?.storageType ?? 'global';
|
||||
|
||||
if (storageType === 'session-scoped') {
|
||||
if (!options?.projectPath) {
|
||||
throw new Error('projectPath is required for session-scoped storage');
|
||||
}
|
||||
const stateDir = join(options.projectPath, '.workflow', 'sessions', sessionId);
|
||||
if (!existsSync(stateDir)) {
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
}
|
||||
return join(stateDir, 'state.json');
|
||||
}
|
||||
|
||||
// Global storage (default)
|
||||
const stateDir = getGlobalStateDir();
|
||||
if (!existsSync(stateDir)) {
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
}
|
||||
return join(stateDir, `session-${sessionId}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load session state from file
|
||||
*
|
||||
* @param sessionId - The session ID
|
||||
* @param options - Storage options
|
||||
* @returns SessionState if exists and valid, null otherwise
|
||||
*/
|
||||
export function loadSessionState(sessionId: string, options?: SessionStateOptions): SessionState | null {
|
||||
if (!validateSessionId(sessionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stateFile = getSessionStatePath(sessionId, options);
|
||||
if (!existsSync(stateFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = readFileSync(stateFile, 'utf-8');
|
||||
const parsed = JSON.parse(content) as SessionState;
|
||||
|
||||
// Validate required fields
|
||||
if (typeof parsed.firstLoad !== 'string' || typeof parsed.loadCount !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session state to file
|
||||
*
|
||||
* @param sessionId - The session ID
|
||||
* @param state - The session state to save
|
||||
* @param options - Storage options
|
||||
*/
|
||||
export function saveSessionState(sessionId: string, state: SessionState, options?: SessionStateOptions): void {
|
||||
if (!validateSessionId(sessionId)) {
|
||||
throw new Error(`Invalid session ID: ${sessionId}`);
|
||||
}
|
||||
|
||||
const stateFile = getSessionStatePath(sessionId, options);
|
||||
|
||||
// Ensure parent directory exists
|
||||
const stateDir = dirname(stateFile);
|
||||
if (!existsSync(stateDir)) {
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session state (for session-end cleanup)
|
||||
*
|
||||
* @param sessionId - The session ID
|
||||
* @param options - Storage options
|
||||
* @returns true if state was cleared, false if it didn't exist
|
||||
*/
|
||||
export function clearSessionState(sessionId: string, options?: SessionStateOptions): boolean {
|
||||
if (!validateSessionId(sessionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stateFile = getSessionStatePath(sessionId, options);
|
||||
|
||||
if (!existsSync(stateFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unlinkSync(stateFile);
|
||||
|
||||
// For session-scoped storage, also remove the session directory if empty
|
||||
if (options?.storageType === 'session-scoped' && options.projectPath) {
|
||||
const sessionDir = join(options.projectPath, '.workflow', 'sessions', sessionId);
|
||||
try {
|
||||
// Try to remove the directory (will fail if not empty)
|
||||
rmSync(sessionDir, { recursive: false, force: true });
|
||||
} catch {
|
||||
// Directory not empty or other error - ignore
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session state with new values
|
||||
*
|
||||
* This is a convenience function that loads existing state, merges with updates,
|
||||
* and saves the result.
|
||||
*
|
||||
* @param sessionId - The session ID
|
||||
* @param updates - Partial state to merge
|
||||
* @param options - Storage options
|
||||
* @returns The updated state
|
||||
*/
|
||||
export function updateSessionState(
|
||||
sessionId: string,
|
||||
updates: Partial<SessionState>,
|
||||
options?: SessionStateOptions
|
||||
): SessionState {
|
||||
const existing = loadSessionState(sessionId, options);
|
||||
|
||||
const newState: SessionState = existing
|
||||
? { ...existing, ...updates }
|
||||
: {
|
||||
firstLoad: new Date().toISOString(),
|
||||
loadCount: 1,
|
||||
...updates
|
||||
};
|
||||
|
||||
saveSessionState(sessionId, newState, options);
|
||||
return newState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the load count for a session
|
||||
*
|
||||
* This is a convenience function for the common pattern of tracking
|
||||
* how many times a session has been loaded.
|
||||
*
|
||||
* @param sessionId - The session ID
|
||||
* @param prompt - Optional prompt to record as lastPrompt
|
||||
* @param options - Storage options
|
||||
* @returns Object with isFirstPrompt flag and updated state
|
||||
*/
|
||||
export function incrementSessionLoad(
|
||||
sessionId: string,
|
||||
prompt?: string,
|
||||
options?: SessionStateOptions
|
||||
): { isFirstPrompt: boolean; state: SessionState } {
|
||||
const existing = loadSessionState(sessionId, options);
|
||||
const isFirstPrompt = !existing;
|
||||
|
||||
const state: SessionState = isFirstPrompt
|
||||
? {
|
||||
firstLoad: new Date().toISOString(),
|
||||
loadCount: 1,
|
||||
lastPrompt: prompt
|
||||
}
|
||||
: {
|
||||
...existing,
|
||||
loadCount: existing.loadCount + 1,
|
||||
...(prompt !== undefined && { lastPrompt: prompt })
|
||||
};
|
||||
|
||||
saveSessionState(sessionId, state, options);
|
||||
return { isFirstPrompt, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionStateService class for object-oriented usage
|
||||
*/
|
||||
export class SessionStateService {
|
||||
private options?: SessionStateOptions;
|
||||
|
||||
constructor(options?: SessionStateOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session state file path
|
||||
*/
|
||||
getStatePath(sessionId: string): string {
|
||||
return getSessionStatePath(sessionId, this.options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load session state
|
||||
*/
|
||||
load(sessionId: string): SessionState | null {
|
||||
return loadSessionState(sessionId, this.options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session state
|
||||
*/
|
||||
save(sessionId: string, state: SessionState): void {
|
||||
saveSessionState(sessionId, state, this.options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session state
|
||||
*/
|
||||
clear(sessionId: string): boolean {
|
||||
return clearSessionState(sessionId, this.options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session state
|
||||
*/
|
||||
update(sessionId: string, updates: Partial<SessionState>): SessionState {
|
||||
return updateSessionState(sessionId, updates, this.options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment load count
|
||||
*/
|
||||
incrementLoad(sessionId: string, prompt?: string): { isFirstPrompt: boolean; state: SessionState } {
|
||||
return incrementSessionLoad(sessionId, prompt, this.options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is first load
|
||||
*/
|
||||
isFirstLoad(sessionId: string): boolean {
|
||||
return this.load(sessionId) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get load count for session
|
||||
*/
|
||||
getLoadCount(sessionId: string): number {
|
||||
const state = this.load(sessionId);
|
||||
return state?.loadCount ?? 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user