mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
- Added loading of plan.json in scanMultiCliDir to improve task extraction. - Implemented normalization of tasks from plan.json format to support new UI. - Updated CSS for multi-CLI plan summary and task item badges for better visibility. - Refactored hook-manager to use Node.js for cross-platform compatibility in command execution. - Improved i18n support for new CLI tool configuration in the hook wizard. - Enhanced lite-tasks view to utilize normalized tasks and provide better fallback mechanisms. - Updated memory-update-queue to return string messages for better integration with hooks.
494 lines
13 KiB
JavaScript
494 lines
13 KiB
JavaScript
/**
|
|
* Memory Update Queue Tool
|
|
* Queue mechanism for batching CLAUDE.md updates
|
|
*
|
|
* Configuration:
|
|
* - Threshold: 5 paths trigger update
|
|
* - Timeout: 5 minutes auto-trigger
|
|
* - Storage: ~/.claude/.memory-queue.json
|
|
* - Deduplication: Same path only kept once
|
|
*/
|
|
|
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
import { join, dirname, resolve } from 'path';
|
|
import { homedir } from 'os';
|
|
|
|
// Default configuration
|
|
const DEFAULT_THRESHOLD = 5;
|
|
const DEFAULT_TIMEOUT_SECONDS = 300; // 5 minutes
|
|
const QUEUE_FILE_PATH = join(homedir(), '.claude', '.memory-queue.json');
|
|
|
|
/**
|
|
* Get queue configuration (from file or defaults)
|
|
* @returns {{ threshold: number, timeoutMs: number }}
|
|
*/
|
|
function getQueueConfig() {
|
|
try {
|
|
if (existsSync(QUEUE_FILE_PATH)) {
|
|
const content = readFileSync(QUEUE_FILE_PATH, 'utf8');
|
|
const data = JSON.parse(content);
|
|
return {
|
|
threshold: data.config?.threshold || DEFAULT_THRESHOLD,
|
|
timeoutMs: (data.config?.timeout || DEFAULT_TIMEOUT_SECONDS) * 1000
|
|
};
|
|
}
|
|
} catch (e) {
|
|
// Use defaults
|
|
}
|
|
return {
|
|
threshold: DEFAULT_THRESHOLD,
|
|
timeoutMs: DEFAULT_TIMEOUT_SECONDS * 1000
|
|
};
|
|
}
|
|
|
|
// In-memory timeout reference (for cross-call persistence, we track via file timestamp)
|
|
let scheduledTimeoutId = null;
|
|
|
|
/**
|
|
* Ensure parent directory exists
|
|
*/
|
|
function ensureDir(filePath) {
|
|
const dir = dirname(filePath);
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load queue from file
|
|
* @returns {{ items: Array<{path: string, tool: string, strategy: string, addedAt: string}>, createdAt: string | null, config?: { threshold: number, timeout: number } }}
|
|
*/
|
|
function loadQueue() {
|
|
try {
|
|
if (existsSync(QUEUE_FILE_PATH)) {
|
|
const content = readFileSync(QUEUE_FILE_PATH, 'utf8');
|
|
const data = JSON.parse(content);
|
|
return {
|
|
items: Array.isArray(data.items) ? data.items : [],
|
|
createdAt: data.createdAt || null,
|
|
config: data.config || null
|
|
};
|
|
}
|
|
} catch (e) {
|
|
console.error('[MemoryQueue] Failed to load queue:', e.message);
|
|
}
|
|
return { items: [], createdAt: null, config: null };
|
|
}
|
|
|
|
/**
|
|
* Save queue to file
|
|
* @param {{ items: Array<{path: string, tool: string, strategy: string, addedAt: string}>, createdAt: string | null }} data
|
|
*/
|
|
function saveQueue(data) {
|
|
try {
|
|
ensureDir(QUEUE_FILE_PATH);
|
|
writeFileSync(QUEUE_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
|
} catch (e) {
|
|
console.error('[MemoryQueue] Failed to save queue:', e.message);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize path for comparison (handle Windows/Unix differences)
|
|
* @param {string} p
|
|
* @returns {string}
|
|
*/
|
|
function normalizePath(p) {
|
|
return resolve(p).replace(/\\/g, '/').toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Add path to queue with deduplication
|
|
* @param {string} path - Module path to update
|
|
* @param {{ tool?: string, strategy?: string }} options
|
|
* @returns {{ queued: boolean, queueSize: number, willFlush: boolean, message: string }}
|
|
*/
|
|
function addToQueue(path, options = {}) {
|
|
const { tool = 'gemini', strategy = 'single-layer' } = options;
|
|
const queue = loadQueue();
|
|
const config = getQueueConfig();
|
|
const normalizedPath = normalizePath(path);
|
|
const now = new Date().toISOString();
|
|
|
|
// Check for duplicates
|
|
const existingIndex = queue.items.findIndex(
|
|
item => normalizePath(item.path) === normalizedPath
|
|
);
|
|
|
|
if (existingIndex !== -1) {
|
|
// Update existing entry timestamp but keep it deduplicated
|
|
queue.items[existingIndex].addedAt = now;
|
|
queue.items[existingIndex].tool = tool;
|
|
queue.items[existingIndex].strategy = strategy;
|
|
saveQueue(queue);
|
|
|
|
return {
|
|
queued: false,
|
|
queueSize: queue.items.length,
|
|
willFlush: queue.items.length >= config.threshold,
|
|
message: `Path already in queue (updated): ${path}`
|
|
};
|
|
}
|
|
|
|
// Add new item
|
|
queue.items.push({
|
|
path,
|
|
tool,
|
|
strategy,
|
|
addedAt: now
|
|
});
|
|
|
|
// Set createdAt if this is the first item
|
|
if (!queue.createdAt) {
|
|
queue.createdAt = now;
|
|
}
|
|
|
|
saveQueue(queue);
|
|
|
|
const willFlush = queue.items.length >= config.threshold;
|
|
|
|
// Schedule timeout if not already scheduled
|
|
scheduleTimeout();
|
|
|
|
return {
|
|
queued: true,
|
|
queueSize: queue.items.length,
|
|
willFlush,
|
|
message: willFlush
|
|
? `Queue threshold reached (${queue.items.length}/${config.threshold}), will flush`
|
|
: `Added to queue (${queue.items.length}/${config.threshold})`
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get current queue status
|
|
* @returns {{ queueSize: number, threshold: number, items: Array, timeoutMs: number | null, createdAt: string | null }}
|
|
*/
|
|
function getQueueStatus() {
|
|
const queue = loadQueue();
|
|
const config = getQueueConfig();
|
|
let timeUntilTimeout = null;
|
|
|
|
if (queue.createdAt && queue.items.length > 0) {
|
|
const createdTime = new Date(queue.createdAt).getTime();
|
|
const elapsed = Date.now() - createdTime;
|
|
timeUntilTimeout = Math.max(0, config.timeoutMs - elapsed);
|
|
}
|
|
|
|
return {
|
|
queueSize: queue.items.length,
|
|
threshold: config.threshold,
|
|
items: queue.items,
|
|
timeoutMs: config.timeoutMs,
|
|
timeoutSeconds: config.timeoutMs / 1000,
|
|
timeUntilTimeout,
|
|
createdAt: queue.createdAt
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Configure queue settings
|
|
* @param {{ threshold?: number, timeout?: number }} settings
|
|
* @returns {{ success: boolean, config: { threshold: number, timeout: number } }}
|
|
*/
|
|
function configureQueue(settings) {
|
|
const queue = loadQueue();
|
|
const currentConfig = getQueueConfig();
|
|
|
|
const newConfig = {
|
|
threshold: settings.threshold || currentConfig.threshold,
|
|
timeout: settings.timeout || (currentConfig.timeoutMs / 1000)
|
|
};
|
|
|
|
// Validate
|
|
if (newConfig.threshold < 1 || newConfig.threshold > 20) {
|
|
throw new Error('Threshold must be between 1 and 20');
|
|
}
|
|
if (newConfig.timeout < 60 || newConfig.timeout > 1800) {
|
|
throw new Error('Timeout must be between 60 and 1800 seconds');
|
|
}
|
|
|
|
queue.config = newConfig;
|
|
saveQueue(queue);
|
|
|
|
return {
|
|
success: true,
|
|
config: newConfig,
|
|
message: `Queue configured: threshold=${newConfig.threshold}, timeout=${newConfig.timeout}s`
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Flush queue - execute batch update
|
|
* @returns {Promise<{ success: boolean, processed: number, results: Array, errors: Array }>}
|
|
*/
|
|
async function flushQueue() {
|
|
const queue = loadQueue();
|
|
|
|
if (queue.items.length === 0) {
|
|
return {
|
|
success: true,
|
|
processed: 0,
|
|
results: [],
|
|
errors: [],
|
|
message: 'Queue is empty'
|
|
};
|
|
}
|
|
|
|
// Clear timeout
|
|
clearScheduledTimeout();
|
|
|
|
// Import update_module_claude dynamically to avoid circular deps
|
|
const { updateModuleClaudeTool } = await import('./update-module-claude.js');
|
|
|
|
const results = [];
|
|
const errors = [];
|
|
|
|
// Group by tool and strategy for efficiency
|
|
const groups = new Map();
|
|
for (const item of queue.items) {
|
|
const key = `${item.tool}:${item.strategy}`;
|
|
if (!groups.has(key)) {
|
|
groups.set(key, []);
|
|
}
|
|
groups.get(key).push(item);
|
|
}
|
|
|
|
// Process each group
|
|
for (const [key, items] of groups) {
|
|
const [tool, strategy] = key.split(':');
|
|
console.log(`[MemoryQueue] Processing ${items.length} items with ${tool}/${strategy}`);
|
|
|
|
for (const item of items) {
|
|
try {
|
|
const result = await updateModuleClaudeTool.execute({
|
|
path: item.path,
|
|
tool: item.tool,
|
|
strategy: item.strategy
|
|
});
|
|
|
|
results.push({
|
|
path: item.path,
|
|
success: result.success !== false,
|
|
result
|
|
});
|
|
} catch (e) {
|
|
console.error(`[MemoryQueue] Failed to update ${item.path}:`, e.message);
|
|
errors.push({
|
|
path: item.path,
|
|
error: e.message
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear queue after processing
|
|
saveQueue({ items: [], createdAt: null });
|
|
|
|
return {
|
|
success: errors.length === 0,
|
|
processed: queue.items.length,
|
|
results,
|
|
errors,
|
|
message: `Processed ${results.length} items, ${errors.length} errors`
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Schedule timeout for auto-flush
|
|
*/
|
|
function scheduleTimeout() {
|
|
// We use file-based timeout tracking for persistence across process restarts
|
|
// The actual timeout check happens on next add/status call
|
|
const queue = loadQueue();
|
|
const config = getQueueConfig();
|
|
|
|
if (!queue.createdAt || queue.items.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const createdTime = new Date(queue.createdAt).getTime();
|
|
const elapsed = Date.now() - createdTime;
|
|
|
|
if (elapsed >= config.timeoutMs) {
|
|
// Timeout already exceeded, should flush
|
|
console.log('[MemoryQueue] Timeout exceeded, auto-flushing');
|
|
// Don't await here to avoid blocking
|
|
flushQueue().catch(e => {
|
|
console.error('[MemoryQueue] Auto-flush failed:', e.message);
|
|
});
|
|
} else if (!scheduledTimeoutId) {
|
|
// Schedule in-memory timeout for current process
|
|
const remaining = config.timeoutMs - elapsed;
|
|
scheduledTimeoutId = setTimeout(() => {
|
|
scheduledTimeoutId = null;
|
|
const currentQueue = loadQueue();
|
|
if (currentQueue.items.length > 0) {
|
|
console.log('[MemoryQueue] Timeout reached, auto-flushing');
|
|
flushQueue().catch(e => {
|
|
console.error('[MemoryQueue] Auto-flush failed:', e.message);
|
|
});
|
|
}
|
|
}, remaining);
|
|
|
|
// Prevent timeout from keeping process alive
|
|
if (scheduledTimeoutId.unref) {
|
|
scheduledTimeoutId.unref();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear scheduled timeout
|
|
*/
|
|
function clearScheduledTimeout() {
|
|
if (scheduledTimeoutId) {
|
|
clearTimeout(scheduledTimeoutId);
|
|
scheduledTimeoutId = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if timeout has expired and auto-flush if needed
|
|
* @returns {Promise<{ expired: boolean, flushed: boolean, result?: object }>}
|
|
*/
|
|
async function checkTimeout() {
|
|
const queue = loadQueue();
|
|
const config = getQueueConfig();
|
|
|
|
if (!queue.createdAt || queue.items.length === 0) {
|
|
return { expired: false, flushed: false };
|
|
}
|
|
|
|
const createdTime = new Date(queue.createdAt).getTime();
|
|
const elapsed = Date.now() - createdTime;
|
|
|
|
if (elapsed >= config.timeoutMs) {
|
|
console.log('[MemoryQueue] Timeout expired, triggering flush');
|
|
const result = await flushQueue();
|
|
return { expired: true, flushed: true, result };
|
|
}
|
|
|
|
return { expired: false, flushed: false };
|
|
}
|
|
|
|
/**
|
|
* Main execute function for tool interface
|
|
* @param {Record<string, unknown>} params
|
|
* @returns {Promise<unknown>}
|
|
*/
|
|
async function execute(params) {
|
|
const { action, path, tool = 'gemini', strategy = 'single-layer', threshold, timeout } = params;
|
|
|
|
switch (action) {
|
|
case 'add':
|
|
if (!path) {
|
|
throw new Error('Parameter "path" is required for add action');
|
|
}
|
|
// Check timeout first
|
|
const timeoutCheck = await checkTimeout();
|
|
if (timeoutCheck.flushed) {
|
|
// Queue was flushed due to timeout, add to fresh queue
|
|
const result = addToQueue(path, { tool, strategy });
|
|
return `[MemoryQueue] Timeout flush (${timeoutCheck.result.processed} items) → ${result.message}`;
|
|
}
|
|
|
|
const addResult = addToQueue(path, { tool, strategy });
|
|
|
|
// Auto-flush if threshold reached
|
|
if (addResult.willFlush) {
|
|
const flushResult = await flushQueue();
|
|
// Return string for hook-friendly output
|
|
return `[MemoryQueue] ${addResult.message} → Flushed ${flushResult.processed} items`;
|
|
}
|
|
|
|
// Return string for hook-friendly output
|
|
return `[MemoryQueue] ${addResult.message}`;
|
|
|
|
case 'status':
|
|
// Check timeout first
|
|
await checkTimeout();
|
|
return getQueueStatus();
|
|
|
|
case 'flush':
|
|
return await flushQueue();
|
|
|
|
case 'configure':
|
|
return configureQueue({ threshold, timeout });
|
|
|
|
default:
|
|
throw new Error(`Unknown action: ${action}. Valid actions: add, status, flush, configure`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tool Definition
|
|
*/
|
|
export const memoryQueueTool = {
|
|
name: 'memory_queue',
|
|
description: `Memory update queue management. Batches CLAUDE.md updates for efficiency.
|
|
|
|
Actions:
|
|
- add: Add path to queue (auto-flushes at configured threshold/timeout)
|
|
- status: Get queue status and configuration
|
|
- flush: Immediately execute all queued updates
|
|
- configure: Set threshold and timeout settings`,
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
action: {
|
|
type: 'string',
|
|
enum: ['add', 'status', 'flush', 'configure'],
|
|
description: 'Queue action to perform'
|
|
},
|
|
path: {
|
|
type: 'string',
|
|
description: 'Module directory path (required for add action)'
|
|
},
|
|
threshold: {
|
|
type: 'number',
|
|
description: 'Number of paths to trigger flush (1-20, for configure action)',
|
|
minimum: 1,
|
|
maximum: 20
|
|
},
|
|
timeout: {
|
|
type: 'number',
|
|
description: 'Timeout in seconds to trigger flush (60-1800, for configure action)',
|
|
minimum: 60,
|
|
maximum: 1800
|
|
},
|
|
tool: {
|
|
type: 'string',
|
|
enum: ['gemini', 'qwen', 'codex'],
|
|
description: 'CLI tool to use (default: gemini)',
|
|
default: 'gemini'
|
|
},
|
|
strategy: {
|
|
type: 'string',
|
|
enum: ['single-layer', 'multi-layer'],
|
|
description: 'Update strategy (default: single-layer)',
|
|
default: 'single-layer'
|
|
}
|
|
},
|
|
required: ['action']
|
|
},
|
|
execute
|
|
};
|
|
|
|
// Export individual functions for direct use
|
|
export {
|
|
loadQueue,
|
|
saveQueue,
|
|
addToQueue,
|
|
getQueueStatus,
|
|
flushQueue,
|
|
configureQueue,
|
|
scheduleTimeout,
|
|
clearScheduledTimeout,
|
|
checkTimeout,
|
|
DEFAULT_THRESHOLD,
|
|
DEFAULT_TIMEOUT_SECONDS,
|
|
QUEUE_FILE_PATH
|
|
};
|