feat: Implement Skills Manager View and Notifier Module

- Added `skills-manager.js` for managing Claude Code skills with functionalities for loading, displaying, and editing skills.
- Introduced a Notifier module in `notifier.ts` for CLI to server communication, enabling notifications for UI updates on data changes.
- Created comprehensive documentation for the Chain Search implementation, including usage examples and performance tips.
- Developed a test suite for the Chain Search engine, covering basic search, quick search, symbol search, and files-only search functionalities.
This commit is contained in:
catlog22
2025-12-14 11:12:48 +08:00
parent 08dc0a0348
commit ac43cf85ec
26 changed files with 3827 additions and 2005 deletions

View File

@@ -63,6 +63,7 @@ const ParamsSchema = z.object({
id: z.string().optional(), // Custom execution ID (e.g., IMPL-001-step1)
noNative: z.boolean().optional(), // Force prompt concatenation instead of native resume
category: z.enum(['user', 'internal', 'insight']).default('user'), // Execution category for tracking
parentExecutionId: z.string().optional(), // Parent execution ID for fork/retry scenarios
});
// Execution category types

View File

@@ -38,6 +38,7 @@ export interface ConversationRecord {
turn_count: number;
latest_status: 'success' | 'error' | 'timeout';
turns: ConversationTurn[];
parent_execution_id?: string; // For fork/retry scenarios
}
export interface HistoryQueryOptions {
@@ -74,6 +75,20 @@ export interface NativeSessionMapping {
created_at: string;
}
// Review record interface
export type ReviewStatus = 'pending' | 'approved' | 'rejected' | 'changes_requested';
export interface ReviewRecord {
id?: number;
execution_id: string;
status: ReviewStatus;
rating?: number;
comments?: string;
reviewer?: string;
created_at: string;
updated_at: string;
}
/**
* CLI History Store using SQLite
*/
@@ -113,7 +128,9 @@ export class CliHistoryStore {
total_duration_ms INTEGER DEFAULT 0,
turn_count INTEGER DEFAULT 0,
latest_status TEXT DEFAULT 'success',
prompt_preview TEXT
prompt_preview TEXT,
parent_execution_id TEXT,
FOREIGN KEY (parent_execution_id) REFERENCES conversations(id) ON DELETE SET NULL
);
-- Turns table (individual conversation turns)
@@ -193,6 +210,23 @@ export class CliHistoryStore {
CREATE INDEX IF NOT EXISTS idx_insights_created ON insights(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_insights_tool ON insights(tool);
-- Reviews table for CLI execution reviews
CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
execution_id TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending',
rating INTEGER,
comments TEXT,
reviewer TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (execution_id) REFERENCES conversations(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_reviews_execution ON reviews(execution_id);
CREATE INDEX IF NOT EXISTS idx_reviews_status ON reviews(status);
CREATE INDEX IF NOT EXISTS idx_reviews_created ON reviews(created_at DESC);
`);
// Migration: Add category column if not exists (for existing databases)
@@ -207,6 +241,7 @@ export class CliHistoryStore {
// Check if category column exists
const tableInfo = this.db.prepare('PRAGMA table_info(conversations)').all() as Array<{ name: string }>;
const hasCategory = tableInfo.some(col => col.name === 'category');
const hasParentExecutionId = tableInfo.some(col => col.name === 'parent_execution_id');
if (!hasCategory) {
console.log('[CLI History] Migrating database: adding category column...');
@@ -221,6 +256,19 @@ export class CliHistoryStore {
}
console.log('[CLI History] Migration complete: category column added');
}
if (!hasParentExecutionId) {
console.log('[CLI History] Migrating database: adding parent_execution_id column...');
this.db.exec(`
ALTER TABLE conversations ADD COLUMN parent_execution_id TEXT;
`);
try {
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_conversations_parent ON conversations(parent_execution_id);`);
} catch (indexErr) {
console.warn('[CLI History] Parent execution index creation warning:', (indexErr as Error).message);
}
console.log('[CLI History] Migration complete: parent_execution_id column added');
}
} catch (err) {
console.error('[CLI History] Migration error:', (err as Error).message);
// Don't throw - allow the store to continue working with existing schema
@@ -314,8 +362,8 @@ export class CliHistoryStore {
: '';
const upsertConversation = this.db.prepare(`
INSERT INTO conversations (id, created_at, updated_at, tool, model, mode, category, total_duration_ms, turn_count, latest_status, prompt_preview)
VALUES (@id, @created_at, @updated_at, @tool, @model, @mode, @category, @total_duration_ms, @turn_count, @latest_status, @prompt_preview)
INSERT INTO conversations (id, created_at, updated_at, tool, model, mode, category, total_duration_ms, turn_count, latest_status, prompt_preview, parent_execution_id)
VALUES (@id, @created_at, @updated_at, @tool, @model, @mode, @category, @total_duration_ms, @turn_count, @latest_status, @prompt_preview, @parent_execution_id)
ON CONFLICT(id) DO UPDATE SET
updated_at = @updated_at,
total_duration_ms = @total_duration_ms,
@@ -350,7 +398,8 @@ export class CliHistoryStore {
total_duration_ms: conversation.total_duration_ms,
turn_count: conversation.turn_count,
latest_status: conversation.latest_status,
prompt_preview: promptPreview
prompt_preview: promptPreview,
parent_execution_id: conversation.parent_execution_id || null
});
for (const turn of conversation.turns) {
@@ -397,6 +446,7 @@ export class CliHistoryStore {
total_duration_ms: conv.total_duration_ms,
turn_count: conv.turn_count,
latest_status: conv.latest_status,
parent_execution_id: conv.parent_execution_id || undefined,
turns: turns.map(t => ({
turn: t.turn_number,
timestamp: t.timestamp,
@@ -935,6 +985,107 @@ export class CliHistoryStore {
return result.changes > 0;
}
/**
* Save or update a review for an execution
*/
saveReview(review: Omit<ReviewRecord, 'id' | 'created_at' | 'updated_at'> & { created_at?: string; updated_at?: string }): ReviewRecord {
const now = new Date().toISOString();
const created_at = review.created_at || now;
const updated_at = review.updated_at || now;
const stmt = this.db.prepare(`
INSERT INTO reviews (execution_id, status, rating, comments, reviewer, created_at, updated_at)
VALUES (@execution_id, @status, @rating, @comments, @reviewer, @created_at, @updated_at)
ON CONFLICT(execution_id) DO UPDATE SET
status = @status,
rating = @rating,
comments = @comments,
reviewer = @reviewer,
updated_at = @updated_at
`);
const result = stmt.run({
execution_id: review.execution_id,
status: review.status,
rating: review.rating ?? null,
comments: review.comments ?? null,
reviewer: review.reviewer ?? null,
created_at,
updated_at
});
return {
id: result.lastInsertRowid as number,
execution_id: review.execution_id,
status: review.status,
rating: review.rating,
comments: review.comments,
reviewer: review.reviewer,
created_at,
updated_at
};
}
/**
* Get review for an execution
*/
getReview(executionId: string): ReviewRecord | null {
const row = this.db.prepare(
'SELECT * FROM reviews WHERE execution_id = ?'
).get(executionId) as any;
if (!row) return null;
return {
id: row.id,
execution_id: row.execution_id,
status: row.status as ReviewStatus,
rating: row.rating,
comments: row.comments,
reviewer: row.reviewer,
created_at: row.created_at,
updated_at: row.updated_at
};
}
/**
* Get reviews with optional filtering
*/
getReviews(options: { status?: ReviewStatus; limit?: number } = {}): ReviewRecord[] {
const { status, limit = 50 } = options;
let sql = 'SELECT * FROM reviews';
const params: any = { limit };
if (status) {
sql += ' WHERE status = @status';
params.status = status;
}
sql += ' ORDER BY updated_at DESC LIMIT @limit';
const rows = this.db.prepare(sql).all(params) as any[];
return rows.map(row => ({
id: row.id,
execution_id: row.execution_id,
status: row.status as ReviewStatus,
rating: row.rating,
comments: row.comments,
reviewer: row.reviewer,
created_at: row.created_at,
updated_at: row.updated_at
}));
}
/**
* Delete a review
*/
deleteReview(executionId: string): boolean {
const result = this.db.prepare('DELETE FROM reviews WHERE execution_id = ?').run(executionId);
return result.changes > 0;
}
/**
* Close database connection
*/

View File

@@ -35,12 +35,27 @@ let bootstrapReady = false;
// Define Zod schema for validation
const ParamsSchema = z.object({
action: z.enum(['init', 'search', 'search_files', 'symbol', 'status', 'update', 'bootstrap', 'check']),
action: z.enum([
'init',
'search',
'search_files',
'symbol',
'status',
'config_show',
'config_set',
'config_migrate',
'clean',
'bootstrap',
'check',
]),
path: z.string().optional(),
query: z.string().optional(),
mode: z.enum(['text', 'semantic']).default('text'),
file: z.string().optional(),
files: z.array(z.string()).optional(),
key: z.string().optional(), // For config_set action
value: z.string().optional(), // For config_set action
newPath: z.string().optional(), // For config_migrate action
all: z.boolean().optional(), // For clean action
languages: z.array(z.string()).optional(),
limit: z.number().default(20),
format: z.enum(['json', 'table', 'plain']).default('json'),
@@ -75,7 +90,8 @@ interface ExecuteResult {
files?: unknown;
symbols?: unknown;
status?: unknown;
updateResult?: unknown;
config?: unknown;
cleanResult?: unknown;
ready?: boolean;
version?: string;
}
@@ -534,24 +550,105 @@ async function getStatus(params: Params): Promise<ExecuteResult> {
}
/**
* Update specific files in the index
* Show configuration
* @param params - Parameters
* @returns Execution result
*/
async function updateFiles(params: Params): Promise<ExecuteResult> {
const { files, path = '.' } = params;
if (!files || !Array.isArray(files) || files.length === 0) {
return { success: false, error: 'files parameter is required and must be a non-empty array' };
}
const args = ['update', ...files, '--json'];
const result = await executeCodexLens(args, { cwd: path });
async function configShow(): Promise<ExecuteResult> {
const args = ['config', 'show', '--json'];
const result = await executeCodexLens(args);
if (result.success && result.output) {
try {
result.updateResult = JSON.parse(result.output);
result.config = JSON.parse(result.output);
delete result.output;
} catch {
// Keep raw output if JSON parse fails
}
}
return result;
}
/**
* Set configuration value
* @param params - Parameters
* @returns Execution result
*/
async function configSet(params: Params): Promise<ExecuteResult> {
const { key, value } = params;
if (!key) {
return { success: false, error: 'key is required for config_set action' };
}
if (!value) {
return { success: false, error: 'value is required for config_set action' };
}
const args = ['config', 'set', key, value, '--json'];
const result = await executeCodexLens(args);
if (result.success && result.output) {
try {
result.config = JSON.parse(result.output);
delete result.output;
} catch {
// Keep raw output if JSON parse fails
}
}
return result;
}
/**
* Migrate indexes to new location
* @param params - Parameters
* @returns Execution result
*/
async function configMigrate(params: Params): Promise<ExecuteResult> {
const { newPath } = params;
if (!newPath) {
return { success: false, error: 'newPath is required for config_migrate action' };
}
const args = ['config', 'migrate', newPath, '--json'];
const result = await executeCodexLens(args, { timeout: 300000 }); // 5 min for migration
if (result.success && result.output) {
try {
result.config = JSON.parse(result.output);
delete result.output;
} catch {
// Keep raw output if JSON parse fails
}
}
return result;
}
/**
* Clean indexes
* @param params - Parameters
* @returns Execution result
*/
async function cleanIndexes(params: Params): Promise<ExecuteResult> {
const { path, all } = params;
const args = ['clean'];
if (all) {
args.push('--all');
} else if (path) {
args.push(path);
}
args.push('--json');
const result = await executeCodexLens(args);
if (result.success && result.output) {
try {
result.cleanResult = JSON.parse(result.output);
delete result.output;
} catch {
// Keep raw output if JSON parse fails
@@ -572,18 +669,35 @@ Usage:
codex_lens(action="search_files", query="x") # Search, return paths only
codex_lens(action="symbol", file="f.py") # Extract symbols
codex_lens(action="status") # Index status
codex_lens(action="update", files=["a.js"]) # Update specific files`,
codex_lens(action="config_show") # Show configuration
codex_lens(action="config_set", key="index_dir", value="/path/to/indexes") # Set config
codex_lens(action="config_migrate", newPath="/new/path") # Migrate indexes
codex_lens(action="clean") # Show clean status
codex_lens(action="clean", path=".") # Clean specific project
codex_lens(action="clean", all=true) # Clean all indexes`,
inputSchema: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['init', 'search', 'search_files', 'symbol', 'status', 'update', 'bootstrap', 'check'],
enum: [
'init',
'search',
'search_files',
'symbol',
'status',
'config_show',
'config_set',
'config_migrate',
'clean',
'bootstrap',
'check',
],
description: 'Action to perform',
},
path: {
type: 'string',
description: 'Target path (for init, search, search_files, status, update)',
description: 'Target path (for init, search, search_files, status, clean)',
},
query: {
type: 'string',
@@ -599,10 +713,22 @@ Usage:
type: 'string',
description: 'File path (for symbol action)',
},
files: {
type: 'array',
items: { type: 'string' },
description: 'File paths to update (for update action)',
key: {
type: 'string',
description: 'Config key (for config_set action, e.g., "index_dir")',
},
value: {
type: 'string',
description: 'Config value (for config_set action)',
},
newPath: {
type: 'string',
description: 'New index path (for config_migrate action)',
},
all: {
type: 'boolean',
description: 'Clean all indexes (for clean action)',
default: false,
},
languages: {
type: 'array',
@@ -658,8 +784,20 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
result = await getStatus(parsed.data);
break;
case 'update':
result = await updateFiles(parsed.data);
case 'config_show':
result = await configShow();
break;
case 'config_set':
result = await configSet(parsed.data);
break;
case 'config_migrate':
result = await configMigrate(parsed.data);
break;
case 'clean':
result = await cleanIndexes(parsed.data);
break;
case 'bootstrap': {
@@ -686,7 +824,7 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
default:
throw new Error(
`Unknown action: ${action}. Valid actions: init, search, search_files, symbol, status, update, bootstrap, check`
`Unknown action: ${action}. Valid actions: init, search, search_files, symbol, status, config_show, config_set, config_migrate, clean, bootstrap, check`
);
}

129
ccw/src/tools/notifier.ts Normal file
View File

@@ -0,0 +1,129 @@
/**
* Notifier Module - CLI to Server Communication
* Provides best-effort notification to running CCW Server
* when CLI commands modify data that should trigger UI updates
*/
import http from 'http';
// Default server configuration
const DEFAULT_HOST = 'localhost';
const DEFAULT_PORT = 3456;
const NOTIFY_TIMEOUT = 2000; // 2 seconds - quick timeout for best-effort
export type NotifyScope = 'memory' | 'history' | 'insights' | 'all';
export interface NotifyPayload {
type: 'REFRESH_REQUIRED' | 'MEMORY_UPDATED' | 'HISTORY_UPDATED' | 'INSIGHT_GENERATED';
scope: NotifyScope;
data?: {
entityType?: string;
entityId?: string | number;
action?: string;
executionId?: string;
[key: string]: unknown;
};
}
export interface NotifyResult {
success: boolean;
error?: string;
}
/**
* Send notification to CCW Server (best-effort, non-blocking)
* If server is not running or unreachable, silently fails
*/
export async function notifyServer(
payload: NotifyPayload,
options?: { host?: string; port?: number }
): Promise<NotifyResult> {
const host = options?.host || DEFAULT_HOST;
const port = options?.port || DEFAULT_PORT;
return new Promise((resolve) => {
const postData = JSON.stringify(payload);
const req = http.request(
{
hostname: host,
port: port,
path: '/api/system/notify',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
timeout: NOTIFY_TIMEOUT,
},
(res) => {
// Success if we get a 2xx response
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve({ success: true });
} else {
resolve({ success: false, error: `HTTP ${res.statusCode}` });
}
}
);
// Handle errors silently - server may not be running
req.on('error', () => {
resolve({ success: false, error: 'Server not reachable' });
});
req.on('timeout', () => {
req.destroy();
resolve({ success: false, error: 'Timeout' });
});
req.write(postData);
req.end();
});
}
/**
* Convenience: Notify memory update
*/
export async function notifyMemoryUpdate(data?: {
entityType?: string;
entityId?: string | number;
action?: string;
}): Promise<NotifyResult> {
return notifyServer({
type: 'MEMORY_UPDATED',
scope: 'memory',
data,
});
}
/**
* Convenience: Notify CLI history update
*/
export async function notifyHistoryUpdate(executionId?: string): Promise<NotifyResult> {
return notifyServer({
type: 'HISTORY_UPDATED',
scope: 'history',
data: executionId ? { executionId } : undefined,
});
}
/**
* Convenience: Notify insight generated
*/
export async function notifyInsightGenerated(executionId?: string): Promise<NotifyResult> {
return notifyServer({
type: 'INSIGHT_GENERATED',
scope: 'insights',
data: executionId ? { executionId } : undefined,
});
}
/**
* Convenience: Request full refresh
*/
export async function notifyRefreshRequired(scope: NotifyScope = 'all'): Promise<NotifyResult> {
return notifyServer({
type: 'REFRESH_REQUIRED',
scope,
});
}