/** * TODO 管理工具(1 个) * * todo_write — 会话级 TODO 列表管理 * * 使用内存 Map 存储,按 sessionId 隔离。 * 与 task_manager 区别:todo_write 用于临时思考拆解(内存,会话级), * task_manager 用于持久化任务跟踪(数据库,跨会话)。 * * v0.3.1 修复 FAIL-2: 添加 LRU 策略,限制最大会话数 50, * 超出时淘汰最久未访问的会话。提供 clearSession 静态方法供外部清理。 * * v0.3.1 修复 WARN-3: 真正的 LRU 实现 — 访问命中时通过 delete + re-insert * 刷新会话位置,确保活跃会话不被淘汰,仅淘汰最久未访问的会话。 */ import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; import log from 'electron-log'; type TodoStatus = 'pending' | 'in_progress' | 'completed'; type TodoPriority = 'high' | 'medium' | 'low'; interface TodoItem { id: string; content: string; status: TodoStatus; priority: TodoPriority; createdAt: number; updatedAt: number; } const VALID_STATUSES: TodoStatus[] = ['pending', 'in_progress', 'completed']; const VALID_PRIORITIES: TodoPriority[] = ['high', 'medium', 'low']; // v0.3.1 修复 FAIL-2: LRU 策略限制最大会话数,防止内存泄漏 const MAX_SESSIONS = 50; export class TodoWriteTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'todo_write', description: 'Manage a session-level TODO list in memory. Actions: create, update, list, complete, clear. Unlike task_manager (persistent DB), todo_write is for temporary task breakdown within a session and does not persist across sessions.', parameters: { type: 'object', properties: { action: { type: 'string', description: 'Operation type', enum: ['create', 'update', 'list', 'complete', 'clear'], }, id: { type: 'string', description: 'TODO item ID (required for update/complete)' }, content: { type: 'string', description: 'TODO content (required for create/update)' }, status: { type: 'string', description: 'Status (for update)', enum: ['pending', 'in_progress', 'completed'], }, priority: { type: 'string', description: 'Priority (for create, default medium)', enum: ['high', 'medium', 'low'], }, }, required: ['action'], }, // v0.3.1 修复 WARN-9: 改为 CUSTOM(内存操作,非数据库) category: MetonaToolCategory.CUSTOM, riskLevel: MetonaRiskLevel.SAFE, requiresPermission: false, timeoutMs: 5_000, }; // v0.3.1 修复 FAIL-2: 会话级 TODO 存储 + LRU 淘汰 private static todosBySession = new Map>(); private static counter = 0; /** * v0.3.1 修复 FAIL-2: 清理指定会话的 TODO(供外部调用,如会话结束时) */ static clearSession(sessionId: string): void { const removed = TodoWriteTool.todosBySession.delete(sessionId); if (removed) { log.debug(`[TodoWriteTool] Cleared TODOs for session ${sessionId}`); } } /** * v0.3.1 修复 FAIL-2: LRU 淘汰 — 超过 MAX_SESSIONS 时删除最久未访问的会话 * Map 的迭代顺序按插入顺序,第一个即最久未访问 * * v0.3.1 修复 WARN-3: 配合 getSessionMap 的访问刷新位置,实现真正的 LRU。 * 活跃会话因访问而刷新到 Map 末尾,不会被淘汰。 */ private static evictIfFull(): void { while (TodoWriteTool.todosBySession.size >= MAX_SESSIONS) { const oldest = TodoWriteTool.todosBySession.keys().next().value; if (oldest === undefined) break; TodoWriteTool.todosBySession.delete(oldest); log.debug(`[TodoWriteTool] LRU evicted session ${oldest}`); } } async execute(args: Record, context: ToolExecutionContext): Promise { try { const action = args.action as string; switch (action) { case 'create': return this.create(args, context); case 'update': return this.update(args, context); case 'list': return this.list(context); case 'complete': return this.complete(args, context); case 'clear': return this.clear(context); default: return { success: false, error: `Unknown action: ${action}` }; } } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); return { error: errMsg, success: false }; } } /** * 获取指定会话的 TODO Map * * v0.3.1 修复 WARN-3: 真正的 LRU — 命中已有会话时通过 delete + re-insert * 将会话移到 Map 末尾(最近使用位置),防止活跃会话被淘汰。 * 新会话插入前触发 evictIfFull。 */ private getSessionMap(context: ToolExecutionContext): Map { const existing = TodoWriteTool.todosBySession.get(context.sessionId); if (existing) { // LRU 刷新位置:delete + re-insert → 移到 Map 末尾 TodoWriteTool.todosBySession.delete(context.sessionId); TodoWriteTool.todosBySession.set(context.sessionId, existing); return existing; } // 新会话插入前检查 LRU 淘汰 TodoWriteTool.evictIfFull(); const map = new Map(); TodoWriteTool.todosBySession.set(context.sessionId, map); return map; } private create(args: Record, context: ToolExecutionContext): unknown { const content = args.content as string; if (!content) { return { success: false, id: '', todo: null, error: 'content is required for create action' }; } const priority = (args.priority as TodoPriority) ?? 'medium'; if (!VALID_PRIORITIES.includes(priority)) { return { success: false, id: '', todo: null, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` }; } const now = Date.now(); const id = `todo_${now}_${TodoWriteTool.counter++}`; const todo: TodoItem = { id, content, status: 'pending', priority, createdAt: now, updatedAt: now, }; this.getSessionMap(context).set(id, todo); return { success: true, id, todo }; } private update(args: Record, context: ToolExecutionContext): unknown { const id = args.id as string; if (!id) { return { success: false, id: '', todo: null, error: 'id is required for update action' }; } const map = this.getSessionMap(context); const todo = map.get(id); if (!todo) { return { success: false, id, todo: null, error: `TODO not found: ${id}` }; } if (args.content !== undefined) { todo.content = args.content as string; } if (args.status !== undefined) { const status = args.status as TodoStatus; if (!VALID_STATUSES.includes(status)) { return { success: false, id, todo: null, error: `Invalid status: ${status}. Must be one of: ${VALID_STATUSES.join(', ')}` }; } todo.status = status; } if (args.priority !== undefined) { const priority = args.priority as TodoPriority; if (!VALID_PRIORITIES.includes(priority)) { return { success: false, id, todo: null, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` }; } todo.priority = priority; } todo.updatedAt = Date.now(); return { success: true, id, todo }; } private list(context: ToolExecutionContext): unknown { const map = this.getSessionMap(context); const todos = Array.from(map.values()).sort((a, b) => a.createdAt - b.createdAt); return { todos, count: todos.length, pending: todos.filter((t) => t.status === 'pending').length, completed: todos.filter((t) => t.status === 'completed').length, }; } private complete(args: Record, context: ToolExecutionContext): unknown { const id = args.id as string; if (!id) { return { success: false, id: '', todo: null, error: 'id is required for complete action' }; } const map = this.getSessionMap(context); const todo = map.get(id); if (!todo) { return { success: false, id, todo: null, error: `TODO not found: ${id}` }; } todo.status = 'completed'; todo.updatedAt = Date.now(); return { success: true, id, todo }; } private clear(context: ToolExecutionContext): unknown { const map = this.getSessionMap(context); const cleared = map.size; map.clear(); return { success: true, cleared }; } }