feat: add Discuss and Explore subagents for dynamic critique and code exploration

- Implement Discuss Subagent for multi-perspective critique with dynamic perspectives.
- Create Explore Subagent for shared codebase exploration with centralized caching.
- Add tests for CcwToolsMcpCard component to ensure enabled tools are preserved on config save.
- Introduce SessionPreviewPanel component for previewing and selecting sessions for Memory V2 extraction.
- Develop CommandCreateDialog component for creating/importing commands with import and CLI generate modes.
This commit is contained in:
catlog22
2026-02-27 17:25:52 +08:00
parent 3db74cc7b0
commit 3b92bfae8c
45 changed files with 6508 additions and 128 deletions

View File

@@ -28,6 +28,8 @@ import {
} from './memory-v2-config.js';
import { EXTRACTION_SYSTEM_PROMPT, buildExtractionUserPrompt } from './memory-extraction-prompts.js';
import { redactSecrets } from '../utils/secret-redactor.js';
import { getNativeSessions, type NativeSession } from '../tools/native-session-discovery.js';
import { existsSync, readFileSync, statSync } from 'fs';
// -- Types --
@@ -58,6 +60,27 @@ export interface BatchExtractionResult {
errors: Array<{ sessionId: string; error: string }>;
}
export interface SessionPreviewItem {
sessionId: string;
source: 'ccw' | 'native';
tool: string;
timestamp: number;
eligible: boolean;
extracted: boolean;
bytes: number;
turns: number;
}
export interface PreviewResult {
sessions: SessionPreviewItem[];
summary: {
total: number;
eligible: number;
alreadyExtracted: number;
readyForExtraction: number;
};
}
// -- Turn type bitmask constants --
/** All turn types included */
@@ -77,6 +100,15 @@ const TRUNCATION_MARKER = '\n\n[... CONTENT TRUNCATED ...]\n\n';
const JOB_KIND_EXTRACTION = 'phase1_extraction';
// -- Authorization error for session access --
export class SessionAccessDeniedError extends Error {
constructor(sessionId: string, projectPath: string) {
super(`Session '${sessionId}' does not belong to project '${projectPath}'`);
this.name = 'SessionAccessDeniedError';
}
}
// -- Pipeline --
export class MemoryExtractionPipeline {
@@ -92,6 +124,58 @@ export class MemoryExtractionPipeline {
this.currentSessionId = options?.currentSessionId;
}
// ========================================================================
// Authorization
// ========================================================================
/**
* Verify that a session belongs to the current project path.
*
* This is a security-critical authorization check to prevent cross-project
* session access. Sessions are scoped to projects, and accessing a session
* from another project should be denied.
*
* @param sessionId - The session ID to verify
* @returns true if the session belongs to this project, false otherwise
*/
verifySessionBelongsToProject(sessionId: string): boolean {
const historyStore = getHistoryStore(this.projectPath);
const session = historyStore.getConversation(sessionId);
// If session exists in this project's history store, it's authorized
if (session) {
return true;
}
// Check native sessions - verify the session file is within project directory
const nativeTools = ['gemini', 'qwen', 'codex', 'claude', 'opencode'] as const;
for (const tool of nativeTools) {
try {
const nativeSessions = getNativeSessions(tool, { workingDir: this.projectPath });
const found = nativeSessions.some(s => s.sessionId === sessionId);
if (found) {
return true;
}
} catch {
// Skip tools with discovery errors
}
}
return false;
}
/**
* Verify session access and throw if unauthorized.
*
* @param sessionId - The session ID to verify
* @throws SessionAccessDeniedError if session doesn't belong to project
*/
private ensureSessionAccess(sessionId: string): void {
if (!this.verifySessionBelongsToProject(sessionId)) {
throw new SessionAccessDeniedError(sessionId, this.projectPath);
}
}
// ========================================================================
// Eligibility scanning
// ========================================================================
@@ -148,6 +232,122 @@ export class MemoryExtractionPipeline {
return eligible;
}
/**
* Preview eligible sessions with detailed information for selective extraction.
*
* Returns session metadata including byte size, turn count, and extraction status.
* Native sessions are returned empty in Phase 1 (Phase 2 will implement native integration).
*
* @param options - Preview options
* @param options.includeNative - Whether to include native sessions (placeholder for Phase 2)
* @param options.maxSessions - Maximum number of sessions to return
* @returns PreviewResult with sessions and summary counts
*/
previewEligibleSessions(options?: { includeNative?: boolean; maxSessions?: number }): PreviewResult {
const store = getCoreMemoryStore(this.projectPath);
const maxSessions = options?.maxSessions || MAX_SESSIONS_PER_STARTUP;
// Scan CCW sessions using existing logic
const ccwSessions = this.scanEligibleSessions(maxSessions);
const sessions: SessionPreviewItem[] = [];
// Process CCW sessions
for (const session of ccwSessions) {
const transcript = this.filterTranscript(session);
const bytes = Buffer.byteLength(transcript, 'utf-8');
const turns = session.turns?.length || 0;
const timestamp = new Date(session.created_at).getTime();
// Check if already extracted
const existingOutput = store.getStage1Output(session.id);
const extracted = existingOutput !== null;
sessions.push({
sessionId: session.id,
source: 'ccw',
tool: session.tool || 'unknown',
timestamp,
eligible: true,
extracted,
bytes,
turns,
});
}
// Native sessions integration (Phase 2)
if (options?.includeNative) {
const nativeTools = ['gemini', 'qwen', 'codex', 'claude', 'opencode'] as const;
const now = Date.now();
const maxAgeMs = MAX_SESSION_AGE_DAYS * 24 * 60 * 60 * 1000;
const minIdleMs = MIN_IDLE_HOURS * 60 * 60 * 1000;
for (const tool of nativeTools) {
try {
const nativeSessions = getNativeSessions(tool, { workingDir: this.projectPath });
for (const session of nativeSessions) {
// Age check: created within MAX_SESSION_AGE_DAYS
if (now - session.createdAt.getTime() > maxAgeMs) continue;
// Idle check: last updated at least MIN_IDLE_HOURS ago
if (now - session.updatedAt.getTime() < minIdleMs) continue;
// Skip current session
if (this.currentSessionId && session.sessionId === this.currentSessionId) continue;
// Get file stats for bytes
let bytes = 0;
let turns = 0;
try {
if (existsSync(session.filePath)) {
const stats = statSync(session.filePath);
bytes = stats.size;
// Parse session file to count turns
turns = this.countNativeSessionTurns(session);
}
} catch {
// Skip sessions with file access errors
continue;
}
// Check if already extracted
const existingOutput = store.getStage1Output(session.sessionId);
const extracted = existingOutput !== null;
sessions.push({
sessionId: session.sessionId,
source: 'native',
tool: session.tool,
timestamp: session.updatedAt.getTime(),
eligible: true,
extracted,
bytes,
turns,
});
}
} catch {
// Skip tools with discovery errors
}
}
}
// Compute summary
const eligible = sessions.filter(s => s.eligible && !s.extracted);
const alreadyExtracted = sessions.filter(s => s.extracted);
return {
sessions,
summary: {
total: sessions.length,
eligible: sessions.filter(s => s.eligible).length,
alreadyExtracted: alreadyExtracted.length,
readyForExtraction: eligible.length,
},
};
}
// ========================================================================
// Transcript filtering
// ========================================================================
@@ -202,6 +402,291 @@ export class MemoryExtractionPipeline {
return parts.join('\n\n');
}
// ========================================================================
// Native session handling
// ========================================================================
/**
* Count the number of turns in a native session file.
*
* Parses the session file based on tool-specific format:
* - Gemini: { messages: [{ type, content }] }
* - Qwen: JSONL with { type, message: { parts: [{ text }] } }
* - Codex: JSONL with session events
* - Claude: JSONL with { type, message } entries
* - OpenCode: Message files in message/<session-id>/ directory
*
* @param session - The native session to count turns for
* @returns Number of turns (user/assistant exchanges)
*/
countNativeSessionTurns(session: NativeSession): number {
try {
const content = readFileSync(session.filePath, 'utf8');
switch (session.tool) {
case 'gemini': {
// Gemini format: JSON with messages array
const data = JSON.parse(content);
if (data.messages && Array.isArray(data.messages)) {
// Count user messages as turns
return data.messages.filter((m: { type: string }) => m.type === 'user').length;
}
return 0;
}
case 'qwen': {
// Qwen format: JSONL
const lines = content.split('\n').filter(l => l.trim());
let turnCount = 0;
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Count user messages
if (entry.type === 'user' || entry.role === 'user') {
turnCount++;
}
} catch {
// Skip invalid lines
}
}
return turnCount;
}
case 'codex': {
// Codex format: JSONL with session events
const lines = content.split('\n').filter(l => l.trim());
let turnCount = 0;
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Count user_message events
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
turnCount++;
}
} catch {
// Skip invalid lines
}
}
return turnCount;
}
case 'claude': {
// Claude format: JSONL
const lines = content.split('\n').filter(l => l.trim());
let turnCount = 0;
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Count user messages (skip meta and command messages)
if (entry.type === 'user' &&
entry.message?.role === 'user' &&
!entry.isMeta) {
turnCount++;
}
} catch {
// Skip invalid lines
}
}
return turnCount;
}
case 'opencode': {
// OpenCode uses separate message files, count from session data
// For now, return a reasonable estimate based on file size
// Actual message counting would require reading message files
const stats = statSync(session.filePath);
// Rough estimate: 1 turn per 2KB of session file
return Math.max(1, Math.floor(stats.size / 2048));
}
default:
return 0;
}
} catch {
return 0;
}
}
/**
* Load and format transcript from a native session file.
*
* Extracts text content from the session file and formats it
* consistently with CCW session transcripts.
*
* @param session - The native session to load
* @returns Formatted transcript string
*/
loadNativeSessionTranscript(session: NativeSession): string {
try {
const content = readFileSync(session.filePath, 'utf8');
const parts: string[] = [];
let turnNum = 1;
switch (session.tool) {
case 'gemini': {
// Gemini format: { messages: [{ type, content }] }
const data = JSON.parse(content);
if (data.messages && Array.isArray(data.messages)) {
for (const msg of data.messages) {
if (msg.type === 'user' && msg.content) {
parts.push(`--- Turn ${turnNum} ---\n[USER] ${msg.content}`);
} else if (msg.type === 'assistant' && msg.content) {
parts.push(`[ASSISTANT] ${msg.content}`);
turnNum++;
}
}
}
break;
}
case 'qwen': {
// Qwen format: JSONL with { type, message: { parts: [{ text }] } }
const lines = content.split('\n').filter(l => l.trim());
for (const line of lines) {
try {
const entry = JSON.parse(line);
// User message
if (entry.type === 'user' && entry.message?.parts) {
const text = entry.message.parts
.filter((p: { text?: string }) => p.text)
.map((p: { text?: string }) => p.text)
.join('\n');
if (text) {
parts.push(`--- Turn ${turnNum} ---\n[USER] ${text}`);
}
}
// Assistant response
else if (entry.type === 'assistant' && entry.message?.parts) {
const text = entry.message.parts
.filter((p: { text?: string }) => p.text)
.map((p: { text?: string }) => p.text)
.join('\n');
if (text) {
parts.push(`[ASSISTANT] ${text}`);
turnNum++;
}
}
// Legacy format
else if (entry.role === 'user' && entry.content) {
parts.push(`--- Turn ${turnNum} ---\n[USER] ${entry.content}`);
} else if (entry.role === 'assistant' && entry.content) {
parts.push(`[ASSISTANT] ${entry.content}`);
turnNum++;
}
} catch {
// Skip invalid lines
}
}
break;
}
case 'codex': {
// Codex format: JSONL with { type, payload }
const lines = content.split('\n').filter(l => l.trim());
for (const line of lines) {
try {
const entry = JSON.parse(line);
// User message
if (entry.type === 'event_msg' &&
entry.payload?.type === 'user_message' &&
entry.payload.message) {
parts.push(`--- Turn ${turnNum} ---\n[USER] ${entry.payload.message}`);
}
// Assistant response
else if (entry.type === 'event_msg' &&
entry.payload?.type === 'assistant_message' &&
entry.payload.message) {
parts.push(`[ASSISTANT] ${entry.payload.message}`);
turnNum++;
}
} catch {
// Skip invalid lines
}
}
break;
}
case 'claude': {
// Claude format: JSONL with { type, message }
const lines = content.split('\n').filter(l => l.trim());
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.type === 'user' && entry.message?.role === 'user' && !entry.isMeta) {
const msgContent = entry.message.content;
// Handle string content
if (typeof msgContent === 'string' &&
!msgContent.startsWith('<command-') &&
!msgContent.includes('<local-command')) {
parts.push(`--- Turn ${turnNum} ---\n[USER] ${msgContent}`);
}
// Handle array content
else if (Array.isArray(msgContent)) {
for (const item of msgContent) {
if (item.type === 'text' && item.text) {
parts.push(`--- Turn ${turnNum} ---\n[USER] ${item.text}`);
break;
}
}
}
}
// Assistant response
else if (entry.type === 'assistant' && entry.message?.content) {
const msgContent = entry.message.content;
if (typeof msgContent === 'string') {
parts.push(`[ASSISTANT] ${msgContent}`);
turnNum++;
} else if (Array.isArray(msgContent)) {
const textParts = msgContent
.filter((item: { type?: string; text?: string }) => item.type === 'text' && item.text)
.map((item: { text?: string }) => item.text)
.join('\n');
if (textParts) {
parts.push(`[ASSISTANT] ${textParts}`);
turnNum++;
}
}
}
} catch {
// Skip invalid lines
}
}
break;
}
case 'opencode': {
// OpenCode stores messages in separate files
// For transcript extraction, read session metadata and messages
// This is a simplified extraction - full implementation would
// traverse message/part directories
try {
const sessionData = JSON.parse(content);
if (sessionData.title) {
parts.push(`--- Session ---\n[SESSION] ${sessionData.title}`);
}
if (sessionData.summary) {
parts.push(`[SUMMARY] ${sessionData.summary}`);
}
} catch {
// Return empty if parsing fails
}
break;
}
default:
break;
}
return parts.join('\n\n');
} catch {
return '';
}
}
// ========================================================================
// Truncation
// ========================================================================
@@ -354,20 +839,55 @@ export class MemoryExtractionPipeline {
/**
* Run the full extraction pipeline for a single session.
*
* Pipeline stages: Filter -> Truncate -> LLM Extract -> PostProcess -> Store
* Pipeline stages: Authorize -> Filter -> Truncate -> LLM Extract -> PostProcess -> Store
*
* SECURITY: This method includes authorization verification to ensure the session
* belongs to the current project path before processing.
*
* @param sessionId - The session to extract from
* @param options - Optional configuration
* @param options.source - 'ccw' for CCW history or 'native' for native CLI sessions
* @param options.nativeSession - Native session data (required when source is 'native')
* @param options.skipAuthorization - Internal use only: skip authorization (already validated)
* @returns The stored Stage1Output, or null if extraction failed
* @throws SessionAccessDeniedError if session doesn't belong to the project
*/
async runExtractionJob(sessionId: string): Promise<Stage1Output | null> {
const historyStore = getHistoryStore(this.projectPath);
const record = historyStore.getConversation(sessionId);
if (!record) {
throw new Error(`Session not found: ${sessionId}`);
async runExtractionJob(
sessionId: string,
options?: {
source?: 'ccw' | 'native';
nativeSession?: NativeSession;
skipAuthorization?: boolean;
}
): Promise<Stage1Output | null> {
// SECURITY: Authorization check - verify session belongs to this project
// Skip only if explicitly requested (for internal batch processing where already validated)
if (!options?.skipAuthorization) {
this.ensureSessionAccess(sessionId);
}
const source = options?.source || 'ccw';
let transcript: string;
let sourceUpdatedAt: number;
if (source === 'native' && options?.nativeSession) {
// Native session extraction
const nativeSession = options.nativeSession;
transcript = this.loadNativeSessionTranscript(nativeSession);
sourceUpdatedAt = Math.floor(nativeSession.updatedAt.getTime() / 1000);
} else {
// CCW session extraction (default)
const historyStore = getHistoryStore(this.projectPath);
const record = historyStore.getConversation(sessionId);
if (!record) {
throw new Error(`Session not found: ${sessionId}`);
}
// Stage 1: Filter transcript
transcript = this.filterTranscript(record);
sourceUpdatedAt = Math.floor(new Date(record.updated_at).getTime() / 1000);
}
// Stage 1: Filter transcript
const transcript = this.filterTranscript(record);
if (!transcript.trim()) {
return null; // Empty transcript, nothing to extract
}
@@ -385,7 +905,6 @@ export class MemoryExtractionPipeline {
const extracted = this.postProcess(llmOutput);
// Stage 5: Store result
const sourceUpdatedAt = Math.floor(new Date(record.updated_at).getTime() / 1000);
const generatedAt = Math.floor(Date.now() / 1000);
const output: Stage1Output = {
@@ -492,7 +1011,8 @@ export class MemoryExtractionPipeline {
const token = claim.ownership_token!;
try {
const output = await this.runExtractionJob(session.id);
// Batch extraction: sessions already validated by scanEligibleSessions(), skip auth check
const output = await this.runExtractionJob(session.id, { skipAuthorization: true });
if (output) {
const watermark = output.source_updated_at;
scheduler.markSucceeded(JOB_KIND_EXTRACTION, session.id, token, watermark);

View File

@@ -7,10 +7,13 @@
* - POST /api/commands/:name/toggle - Enable/disable single command
* - POST /api/commands/group/:groupName/toggle - Batch toggle commands by group
*/
import { existsSync, readdirSync, readFileSync, mkdirSync, renameSync } from 'fs';
import { existsSync, readdirSync, readFileSync, mkdirSync, renameSync, copyFileSync } from 'fs';
import { promises as fsPromises } from 'fs';
import { join, relative, dirname, basename } from 'path';
import { homedir } from 'os';
import { validatePath as validateAllowedPath } from '../../utils/path-validator.js';
import { executeCliTool } from '../../tools/cli-executor.js';
import { SmartContentFormatter } from '../../tools/cli-output-converter.js';
import type { RouteContext } from './types.js';
// ========== Types ==========
@@ -62,6 +65,38 @@ interface CommandGroupsConfig {
assignments: Record<string, string>; // commandName -> groupId mapping
}
/**
* Command creation mode type
*/
type CommandCreationMode = 'upload' | 'generate';
/**
* Parameters for creating a command
*/
interface CreateCommandParams {
mode: CommandCreationMode;
location: CommandLocation;
sourcePath?: string; // Required for 'upload' mode - path to uploaded file
skillName?: string; // Required for 'generate' mode - skill to generate from
description?: string; // Optional description for generated commands
projectPath: string;
cliType?: string; // CLI tool type for generation
}
/**
* Result of command creation operation
*/
interface CommandCreationResult extends CommandOperationResult {
commandInfo?: CommandMetadata | null;
}
/**
* Validation result for command file
*/
type CommandFileValidation =
| { valid: true; errors: string[]; commandInfo: CommandMetadata }
| { valid: false; errors: string[]; commandInfo: null };
// ========== Helper Functions ==========
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -126,6 +161,388 @@ function parseCommandFrontmatter(content: string): CommandMetadata {
return result;
}
/**
* Validate a command file for creation
* Checks file existence, reads content, parses frontmatter, validates required fields
*/
function validateCommandFile(filePath: string): CommandFileValidation {
const errors: string[] = [];
// Check file exists
if (!existsSync(filePath)) {
return { valid: false, errors: ['Command file does not exist'], commandInfo: null };
}
// Check file extension
if (!filePath.endsWith('.md')) {
return { valid: false, errors: ['Command file must be a .md file'], commandInfo: null };
}
// Read file content
let content: string;
try {
content = readFileSync(filePath, 'utf8');
} catch (err) {
return { valid: false, errors: [`Failed to read file: ${(err as Error).message}`], commandInfo: null };
}
// Parse frontmatter
const commandInfo = parseCommandFrontmatter(content);
// Validate required fields
if (!commandInfo.name || commandInfo.name.trim() === '') {
errors.push('Command name is required in frontmatter');
}
// Check for valid frontmatter structure
if (!content.startsWith('---')) {
errors.push('Command file must have YAML frontmatter (starting with ---)');
} else {
const endIndex = content.indexOf('---', 3);
if (endIndex < 0) {
errors.push('Command file has invalid frontmatter (missing closing ---)');
}
}
if (errors.length > 0) {
return { valid: false, errors, commandInfo: null };
}
return { valid: true, errors: [], commandInfo };
}
/**
* Upload (copy) a command file to the commands directory
* Handles group subdirectory creation and path security validation
* @param sourcePath - Source command file path
* @param targetGroup - Target group subdirectory (e.g., 'workflow/review')
* @param location - 'project' or 'user'
* @param projectPath - Project root path
* @param customName - Optional custom filename (without .md extension)
* @returns CommandCreationResult with success status and command info
*/
async function uploadCommand(
sourcePath: string,
targetGroup: string,
location: CommandLocation,
projectPath: string,
customName?: string
): Promise<CommandCreationResult> {
try {
// Validate source file exists and is .md
if (!existsSync(sourcePath)) {
return { success: false, message: 'Source command file does not exist', status: 404 };
}
if (!sourcePath.endsWith('.md')) {
return { success: false, message: 'Source file must be a .md file', status: 400 };
}
// Validate source file content
const validation = validateCommandFile(sourcePath);
if (!validation.valid) {
return { success: false, message: validation.errors.join(', '), status: 400 };
}
// Get target commands directory
const commandsDir = getCommandsDir(location, projectPath);
// Build target path with optional group subdirectory
let targetDir = commandsDir;
if (targetGroup && targetGroup.trim() !== '') {
// Sanitize group path - prevent path traversal
const sanitizedGroup = targetGroup
.replace(/\.\./g, '') // Remove path traversal attempts
.replace(/[<>:"|?*]/g, '') // Remove invalid characters
.replace(/\/+/g, '/') // Collapse multiple slashes
.replace(/^\/|\/$/g, ''); // Remove leading/trailing slashes
if (sanitizedGroup) {
targetDir = join(commandsDir, sanitizedGroup);
}
}
// Create target directory if needed
if (!existsSync(targetDir)) {
mkdirSync(targetDir, { recursive: true });
}
// Determine target filename
const sourceBasename = basename(sourcePath, '.md');
const targetFilename = (customName && customName.trim() !== '')
? `${customName.replace(/\.md$/, '')}.md`
: `${sourceBasename}.md`;
// Sanitize filename - prevent path traversal
const sanitizedFilename = targetFilename
.replace(/\.\./g, '')
.replace(/[<>:"|?*]/g, '')
.replace(/\//g, '');
const targetPath = join(targetDir, sanitizedFilename);
// Security check: ensure target path is within commands directory
const resolvedTarget = targetPath; // Already resolved by join
const resolvedCommandsDir = commandsDir;
if (!resolvedTarget.startsWith(resolvedCommandsDir)) {
return { success: false, message: 'Invalid target path - path traversal detected', status: 400 };
}
// Check if target already exists
if (existsSync(targetPath)) {
return { success: false, message: `Command '${sanitizedFilename}' already exists in target location`, status: 409 };
}
// Copy file to target path
copyFileSync(sourcePath, targetPath);
return {
success: true,
message: 'Command uploaded successfully',
commandName: validation.commandInfo.name,
location,
commandInfo: {
name: validation.commandInfo.name,
description: validation.commandInfo.description,
group: targetGroup || 'other'
}
};
} catch (error) {
return {
success: false,
message: (error as Error).message,
status: 500
};
}
}
/**
* Generation parameters for command generation via CLI
*/
interface CommandGenerationParams {
commandName: string;
description: string;
location: CommandLocation;
projectPath: string;
group?: string;
argumentHint?: string;
broadcastToClients?: (data: unknown) => void;
cliType?: string;
}
/**
* Generate command via CLI tool using command-generator skill
* Follows the pattern from skills-routes.ts generateSkillViaCLI
* @param params - Generation parameters including name, description, location, etc.
* @returns CommandCreationResult with success status and generated command info
*/
async function generateCommandViaCLI({
commandName,
description,
location,
projectPath,
group,
argumentHint,
broadcastToClients,
cliType = 'claude'
}: CommandGenerationParams): Promise<CommandCreationResult> {
// Generate unique execution ID for tracking
const executionId = `cmd-gen-${commandName}-${Date.now()}`;
try {
// Validate required inputs
if (!commandName || commandName.trim() === '') {
return { success: false, message: 'Command name is required', status: 400 };
}
if (!description || description.trim() === '') {
return { success: false, message: 'Description is required for command generation', status: 400 };
}
// Sanitize command name - prevent path traversal
if (commandName.includes('..') || commandName.includes('/') || commandName.includes('\\')) {
return { success: false, message: 'Invalid command name - path characters not allowed', status: 400 };
}
// Get target commands directory
const commandsDir = getCommandsDir(location, projectPath);
// Build target path with optional group subdirectory
let targetDir = commandsDir;
if (group && group.trim() !== '') {
const sanitizedGroup = group
.replace(/\.\./g, '')
.replace(/[<>:"|?*]/g, '')
.replace(/\/+/g, '/')
.replace(/^\/|\/$/g, '');
if (sanitizedGroup) {
targetDir = join(commandsDir, sanitizedGroup);
}
}
const targetPath = join(targetDir, `${commandName}.md`);
// Check if command already exists
if (existsSync(targetPath)) {
return {
success: false,
message: `Command '${commandName}' already exists in ${location} location${group ? ` (group: ${group})` : ''}`,
status: 409
};
}
// Ensure target directory exists
if (!existsSync(targetDir)) {
await fsPromises.mkdir(targetDir, { recursive: true });
}
// Build target location display for prompt
const targetLocationDisplay = location === 'project'
? '.claude/commands/'
: '~/.claude/commands/';
// Build structured command parameters for /command-generator skill
const commandParams = {
skillName: commandName,
description,
location,
group: group || '',
argumentHint: argumentHint || ''
};
// Prompt that invokes /command-generator skill with structured parameters
const prompt = `/command-generator
## Command Parameters (Structured Input)
\`\`\`json
${JSON.stringify(commandParams, null, 2)}
\`\`\`
## User Request
Create a new Claude Code command with the following specifications:
- **Command Name**: ${commandName}
- **Description**: ${description}
- **Target Location**: ${targetLocationDisplay}${group ? `${group}/` : ''}${commandName}.md
- **Location Type**: ${location === 'project' ? 'Project-level (.claude/commands/)' : 'User-level (~/.claude/commands/)'}
${group ? `- **Group**: ${group}` : ''}
${argumentHint ? `- **Argument Hint**: ${argumentHint}` : ''}
## Instructions
1. Use the command-generator skill to create a command file with proper YAML frontmatter
2. Include name, description in frontmatter${group ? '\n3. Include group in frontmatter' : ''}${argumentHint ? '\n4. Include argument-hint in frontmatter' : ''}
3. Generate useful command content and usage examples
4. Output the file to: ${targetPath}`;
// Broadcast CLI_EXECUTION_STARTED event
if (broadcastToClients) {
broadcastToClients({
type: 'CLI_EXECUTION_STARTED',
payload: {
executionId,
tool: cliType,
mode: 'write',
category: 'internal',
context: 'command-generation',
commandName
}
});
}
// Create onOutput callback for real-time streaming
const onOutput = broadcastToClients
? (unit: import('../../tools/cli-output-converter.js').CliOutputUnit) => {
const content = SmartContentFormatter.format(unit.content, unit.type);
broadcastToClients({
type: 'CLI_OUTPUT',
payload: {
executionId,
chunkType: unit.type,
data: content
}
});
}
: undefined;
// Execute CLI tool with write mode
const startTime = Date.now();
const result = await executeCliTool({
tool: cliType,
prompt,
mode: 'write',
cd: projectPath,
timeout: 600000, // 10 minutes
category: 'internal',
id: executionId
}, onOutput);
// Broadcast CLI_EXECUTION_COMPLETED event
if (broadcastToClients) {
broadcastToClients({
type: 'CLI_EXECUTION_COMPLETED',
payload: {
executionId,
success: result.success,
status: result.execution?.status || (result.success ? 'success' : 'error'),
duration_ms: Date.now() - startTime
}
});
}
// Check if execution was successful
if (!result.success) {
return {
success: false,
message: `CLI generation failed: ${result.stderr || 'Unknown error'}`,
status: 500
};
}
// Validate the generated command file exists
if (!existsSync(targetPath)) {
return {
success: false,
message: 'Generated command file not found at expected location',
status: 500
};
}
// Validate the generated command file content
const validation = validateCommandFile(targetPath);
if (!validation.valid) {
return {
success: false,
message: `Generated command is invalid: ${validation.errors.join(', ')}`,
status: 500
};
}
return {
success: true,
message: 'Command generated successfully',
commandName: validation.commandInfo.name,
location,
commandInfo: {
name: validation.commandInfo.name,
description: validation.commandInfo.description,
group: validation.commandInfo.group
}
};
} catch (error) {
return {
success: false,
message: (error as Error).message,
status: 500
};
}
}
/**
* Get command groups config file path
*/
@@ -616,5 +1033,103 @@ export async function handleCommandsRoutes(ctx: RouteContext): Promise<boolean>
return true;
}
// POST /api/commands/create - Create command (upload or generate)
if (pathname === '/api/commands/create' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
if (!isRecord(body)) {
return { success: false, message: 'Invalid request body', status: 400 };
}
const mode = body.mode;
const locationValue = body.location;
const sourcePath = typeof body.sourcePath === 'string' ? body.sourcePath : undefined;
const skillName = typeof body.skillName === 'string' ? body.skillName : undefined;
const description = typeof body.description === 'string' ? body.description : undefined;
const group = typeof body.group === 'string' ? body.group : undefined;
const argumentHint = typeof body.argumentHint === 'string' ? body.argumentHint : undefined;
const projectPathParam = typeof body.projectPath === 'string' ? body.projectPath : undefined;
const cliType = typeof body.cliType === 'string' ? body.cliType : 'claude';
// Validate mode
if (mode !== 'upload' && mode !== 'generate') {
return { success: false, message: 'Mode is required and must be "upload" or "generate"', status: 400 };
}
// Validate location
if (locationValue !== 'project' && locationValue !== 'user') {
return { success: false, message: 'Location is required (project or user)', status: 400 };
}
const location: CommandLocation = locationValue;
const projectPath = projectPathParam || initialPath;
// Validate project path for security
let validatedProjectPath = projectPath;
if (location === 'project') {
try {
validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message.includes('Access denied') ? 403 : 400;
console.error(`[Commands] Project path validation failed: ${message}`);
return { success: false, message: status === 403 ? 'Access denied' : 'Invalid path', status };
}
}
if (mode === 'upload') {
// Upload mode: copy existing command file
if (!sourcePath) {
return { success: false, message: 'Source path is required for upload mode', status: 400 };
}
// Validate source path for security
let validatedSourcePath: string;
try {
validatedSourcePath = await validateAllowedPath(sourcePath, { mustExist: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message.includes('Access denied') ? 403 : 400;
console.error(`[Commands] Source path validation failed: ${message}`);
return { success: false, message: status === 403 ? 'Access denied' : 'Invalid source path', status };
}
return await uploadCommand(
validatedSourcePath,
group || '',
location,
validatedProjectPath
);
} else if (mode === 'generate') {
// Generate mode: use CLI to generate command
if (!skillName) {
return { success: false, message: 'Skill name is required for generate mode', status: 400 };
}
if (!description) {
return { success: false, message: 'Description is required for generate mode', status: 400 };
}
// Validate skill name for security
if (skillName.includes('..') || skillName.includes('/') || skillName.includes('\\')) {
return { success: false, message: 'Invalid skill name - path characters not allowed', status: 400 };
}
return await generateCommandViaCLI({
commandName: skillName,
description,
location,
projectPath: validatedProjectPath,
group,
argumentHint,
broadcastToClients: ctx.broadcastToClients,
cliType
});
}
// This should never be reached due to mode validation above
return { success: false, message: 'Invalid mode', status: 400 };
});
return true;
}
return false;
}

View File

@@ -10,6 +10,54 @@ import { StoragePaths } from '../../config/storage-paths.js';
import { join } from 'path';
import { getDefaultTool } from '../../tools/claude-cli-tools.js';
// ========================================
// Error Handling Utilities
// ========================================
/**
* Sanitize error message for client response
* Logs full error server-side, returns user-friendly message to client
*/
function sanitizeErrorMessage(error: unknown, context: string): string {
const errorMessage = error instanceof Error ? error.message : String(error);
// Log full error for debugging (server-side only)
if (process.env.DEBUG || process.env.NODE_ENV === 'development') {
console.error(`[CoreMemoryRoutes] ${context}:`, error);
}
// Map common internal errors to user-friendly messages
const lowerMessage = errorMessage.toLowerCase();
if (lowerMessage.includes('enoent') || lowerMessage.includes('no such file')) {
return 'Resource not found';
}
if (lowerMessage.includes('eacces') || lowerMessage.includes('permission denied')) {
return 'Access denied';
}
if (lowerMessage.includes('sqlite') || lowerMessage.includes('database')) {
return 'Database operation failed';
}
if (lowerMessage.includes('json') || lowerMessage.includes('parse')) {
return 'Invalid data format';
}
// Return generic message for unexpected errors (don't expose internals)
return 'An unexpected error occurred';
}
/**
* Write error response with sanitized message
*/
function writeErrorResponse(
res: http.ServerResponse,
statusCode: number,
message: string
): void {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: message }));
}
/**
* Route context interface
*/
@@ -303,6 +351,190 @@ export async function handleCoreMemoryRoutes(ctx: RouteContext): Promise<boolean
return true;
}
// API: Preview eligible sessions for selective extraction
if (pathname === '/api/core-memory/extract/preview' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
const includeNative = url.searchParams.get('includeNative') === 'true';
const maxSessionsParam = url.searchParams.get('maxSessions');
const maxSessions = maxSessionsParam ? parseInt(maxSessionsParam, 10) : undefined;
// Validate maxSessions parameter
if (maxSessionsParam && (isNaN(maxSessions as number) || (maxSessions as number) < 1)) {
writeErrorResponse(res, 400, 'Invalid maxSessions parameter: must be a positive integer');
return true;
}
try {
const { MemoryExtractionPipeline } = await import('../memory-extraction-pipeline.js');
const pipeline = new MemoryExtractionPipeline(projectPath);
const preview = pipeline.previewEligibleSessions({
includeNative,
maxSessions,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
sessions: preview.sessions,
summary: preview.summary,
}));
} catch (error: unknown) {
// Log full error server-side, return sanitized message to client
writeErrorResponse(res, 500, sanitizeErrorMessage(error, 'extract/preview'));
}
return true;
}
// API: Selective extraction for specific sessions
if (pathname === '/api/core-memory/extract/selected' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { sessionIds, includeNative, path: projectPath } = body;
const basePath = projectPath || initialPath;
// Validate sessionIds - return 400 for invalid input
if (!Array.isArray(sessionIds)) {
return { error: 'sessionIds must be an array', status: 400 };
}
if (sessionIds.length === 0) {
return { error: 'sessionIds cannot be empty', status: 400 };
}
// Validate each sessionId is a non-empty string
for (const id of sessionIds) {
if (typeof id !== 'string' || id.trim() === '') {
return { error: 'Each sessionId must be a non-empty string', status: 400 };
}
}
try {
const store = getCoreMemoryStore(basePath);
const scheduler = new MemoryJobScheduler(store.getDb());
const { MemoryExtractionPipeline, SessionAccessDeniedError } = await import('../memory-extraction-pipeline.js');
const pipeline = new MemoryExtractionPipeline(basePath);
// Get preview to validate sessions (project-scoped)
const preview = pipeline.previewEligibleSessions({ includeNative });
const validSessionIds = new Set(preview.sessions.map(s => s.sessionId));
// Return 404 if no eligible sessions exist at all
if (validSessionIds.size === 0) {
return { error: 'No eligible sessions found for extraction', status: 404 };
}
const queued: string[] = [];
const skipped: string[] = [];
const invalidIds: string[] = [];
const unauthorizedIds: string[] = [];
for (const sessionId of sessionIds) {
// SECURITY: Verify session belongs to this project
// This double-checks that the sessionId is from the project-scoped preview
if (!validSessionIds.has(sessionId)) {
// Check if it's unauthorized (exists but not in this project)
if (!pipeline.verifySessionBelongsToProject(sessionId)) {
unauthorizedIds.push(sessionId);
} else {
invalidIds.push(sessionId);
}
continue;
}
// Check if already extracted
const existingOutput = store.getStage1Output(sessionId);
if (existingOutput) {
skipped.push(sessionId);
continue;
}
// Get session info for watermark
const historyStore = (await import('../../tools/cli-history-store.js')).getHistoryStore(basePath);
const session = historyStore.getConversation(sessionId);
if (!session) {
invalidIds.push(sessionId);
continue;
}
// Enqueue job
const watermark = Math.floor(new Date(session.updated_at).getTime() / 1000);
scheduler.enqueueJob('phase1_extraction', sessionId, watermark);
queued.push(sessionId);
}
// Return 409 Conflict if all sessions were already extracted
if (queued.length === 0 && skipped.length === sessionIds.length) {
return {
error: 'All specified sessions have already been extracted',
status: 409,
skipped
};
}
// Return 404 if no valid sessions were found (all were invalid or unauthorized)
if (queued.length === 0 && skipped.length === 0) {
return { error: 'No valid sessions found among the provided IDs', status: 404 };
}
// Generate batch job ID
const jobId = `batch-${Date.now()}`;
// Broadcast start event
broadcastToClients({
type: 'MEMORY_EXTRACTION_STARTED',
payload: {
timestamp: new Date().toISOString(),
jobId,
queuedCount: queued.length,
selective: true,
}
});
// Fire-and-forget: process queued sessions
// Sessions already validated above, skip auth check for efficiency
(async () => {
try {
for (const sessionId of queued) {
try {
await pipeline.runExtractionJob(sessionId, { skipAuthorization: true });
} catch (err) {
if (process.env.DEBUG) {
console.warn(`[SelectiveExtraction] Failed for ${sessionId}:`, (err as Error).message);
}
}
}
broadcastToClients({
type: 'MEMORY_EXTRACTION_COMPLETED',
payload: { timestamp: new Date().toISOString(), jobId }
});
} catch (err) {
broadcastToClients({
type: 'MEMORY_EXTRACTION_FAILED',
payload: {
timestamp: new Date().toISOString(),
jobId,
error: (err as Error).message,
}
});
}
})();
// Include unauthorizedIds in response for security transparency
return {
success: true,
jobId,
queued: queued.length,
skipped: skipped.length,
invalidIds,
...(unauthorizedIds.length > 0 && { unauthorizedIds }),
};
} catch (error: unknown) {
// Log full error server-side, return sanitized message to client
return { error: sanitizeErrorMessage(error, 'extract/selected'), status: 500 };
}
});
return true;
}
// API: Get extraction pipeline status
if (pathname === '/api/core-memory/extract/status' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;