feat: 添加会话持久化和 Todo 工具

会话持久化:
- 新增 SessionManager 和 SessionStorage,支持会话自动保存和恢复
- 会话数据存储在 ~/.local/share/ai-assist/,遵循 XDG 规范
- 支持对话历史、已发现工具、待办事项的持久化
- 启动时自动恢复同一工作目录的上次会话
- 支持会话归档和历史会话管理

Todo 工具:
- 新增 todoread 工具:读取当前会话的待办事项列表
- 新增 todowrite 工具:创建和更新待办事项
- 支持 pending/in_progress/completed 三种状态
- 待办事项随会话自动持久化

其他改进:
- ToolResult 类型新增可选的 metadata 字段
- Agent 支持会话管理器集成
- clearHistory 改为异步方法
This commit is contained in:
2025-12-10 22:55:37 +08:00
parent bc1ece3dad
commit 1e0ecc2de7
13 changed files with 862 additions and 4 deletions
+43 -1
View File
@@ -11,6 +11,7 @@ import {
import type { Tool, ToolResult, Message, AgentConfig, ProviderType } from '../types/index.js';
import { buildZodSchema } from '../types/index.js';
import { ToolRegistry } from '../tools/registry.js';
import { SessionManager } from '../session/index.js';
// Provider 工厂函数类型
type ProviderFactory = (apiKey: string) => (model: string) => LanguageModel;
@@ -41,6 +42,9 @@ export class Agent {
// 兼容旧模式:直接注册的工具
private legacyTools: Map<string, Tool> = new Map();
// 会话管理器(可选)
private sessionManager: SessionManager | null = null;
constructor(config: AgentConfig) {
this.config = config;
@@ -58,6 +62,26 @@ export class Agent {
this.registry = registry;
}
/**
* 设置会话管理器(启用会话持久化)
*/
setSessionManager(manager: SessionManager): void {
this.sessionManager = manager;
// 从会话恢复状态
const session = manager.getSession();
if (session) {
this.conversationHistory = [...session.messages];
this.discoveredTools = new Set(session.discoveredTools);
}
}
/**
* 获取会话管理器
*/
getSessionManager(): SessionManager | null {
return this.sessionManager;
}
/**
* 注册单个工具(兼容旧代码)
*/
@@ -206,15 +230,33 @@ export class Agent {
content: fullResponse,
});
// 持久化会话
await this.persistSession();
return fullResponse;
}
/**
* 持久化当前会话状态
*/
private async persistSession(): Promise<void> {
if (!this.sessionManager) return;
await this.sessionManager.setMessages(this.conversationHistory);
await this.sessionManager.setDiscoveredTools([...this.discoveredTools]);
}
/**
* 清空对话历史和发现的工具
*/
clearHistory(): void {
async clearHistory(): Promise<void> {
this.conversationHistory = [];
this.discoveredTools.clear();
// 如果有会话管理器,创建新会话
if (this.sessionManager) {
await this.sessionManager.newSession();
}
}
/**
+18 -2
View File
@@ -4,8 +4,9 @@ import { Command } from 'commander';
import { Agent } from './core/agent.js';
import { TerminalUI } from './ui/terminal.js';
import { loadConfig, initConfig } from './utils/config.js';
import { toolRegistry } from './tools/index.js';
import { toolRegistry, todoManager } from './tools/index.js';
import { getPermissionManager, promptPermission } from './permission/index.js';
import { SessionManager } from './session/index.js';
const program = new Command();
@@ -63,12 +64,27 @@ program.action(async () => {
// 设置工具注册表(支持动态工具发现)
agent.setRegistry(toolRegistry);
// 初始化会话管理器(支持会话持久化)
const sessionManager = new SessionManager();
await sessionManager.init(process.cwd());
agent.setSessionManager(sessionManager);
// 初始化 todoManager(让 todo 工具可以访问会话)
todoManager.setSessionManager(sessionManager);
// 显示会话恢复信息
const session = sessionManager.getSession();
if (session && session.messages.length > 0) {
console.log(`\n📂 已恢复会话 (${session.messages.length} 条消息)`);
}
// 启动终端 UI
const ui = new TerminalUI(agent);
// 优雅退出
process.on('SIGINT', () => {
process.on('SIGINT', async () => {
console.log('\n\n👋 再见!');
await sessionManager.close();
ui.close();
process.exit(0);
});
+10
View File
@@ -0,0 +1,10 @@
export type {
SessionData,
SessionSummary,
SessionManagerConfig,
Todo,
TodoStatus,
} from './types.js';
export { SessionStorage, sessionStorage } from './storage.js';
export { SessionManager, sessionManager } from './manager.js';
+218
View File
@@ -0,0 +1,218 @@
import type { ModelMessage } from 'ai';
import type { SessionData, Todo, SessionSummary } from './types.js';
import { SessionStorage, sessionStorage } from './storage.js';
/**
* 会话管理器
* 提供高级会话操作接口
*/
export class SessionManager {
private storage: SessionStorage;
private currentSession: SessionData | null = null;
private autoSaveInterval: ReturnType<typeof setInterval> | null = null;
constructor(storage?: SessionStorage) {
this.storage = storage || sessionStorage;
}
/**
* 初始化 - 尝试恢复或创建新会话
*/
async init(workdir: string): Promise<SessionData> {
// 尝试加载当前会话
const existing = await this.storage.loadCurrentSession();
if (existing && existing.workdir === workdir) {
// 同一工作目录,恢复会话
this.currentSession = existing;
} else {
// 不同目录或无会话,归档旧会话并创建新的
if (existing) {
await this.storage.archiveCurrentSession();
}
this.currentSession = this.createNewSession(workdir);
await this.save();
}
// 启动自动保存
this.startAutoSave();
return this.currentSession;
}
/**
* 创建新会话
*/
private createNewSession(workdir: string): SessionData {
return {
id: this.storage.generateSessionId(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
workdir,
messages: [],
discoveredTools: [],
todos: [],
};
}
/**
* 获取当前会话
*/
getSession(): SessionData | null {
return this.currentSession;
}
/**
* 保存当前会话
*/
async save(): Promise<void> {
if (this.currentSession) {
await this.storage.saveCurrentSession(this.currentSession);
}
}
/**
* 添加消息
*/
async addMessage(message: ModelMessage): Promise<void> {
if (!this.currentSession) return;
this.currentSession.messages.push(message);
await this.save();
}
/**
* 批量设置消息(用于同步整个对话历史)
*/
async setMessages(messages: ModelMessage[]): Promise<void> {
if (!this.currentSession) return;
this.currentSession.messages = messages;
await this.save();
}
/**
* 获取对话历史
*/
getMessages(): ModelMessage[] {
return this.currentSession?.messages || [];
}
/**
* 设置已发现的工具
*/
async setDiscoveredTools(tools: string[]): Promise<void> {
if (!this.currentSession) return;
this.currentSession.discoveredTools = tools;
await this.save();
}
/**
* 获取已发现的工具
*/
getDiscoveredTools(): string[] {
return this.currentSession?.discoveredTools || [];
}
/**
* 更新待办事项
*/
async setTodos(todos: Todo[]): Promise<void> {
if (!this.currentSession) return;
this.currentSession.todos = todos;
await this.save();
}
/**
* 获取待办事项
*/
getTodos(): Todo[] {
return this.currentSession?.todos || [];
}
/**
* 清空当前会话并创建新会话
*/
async newSession(workdir?: string): Promise<SessionData> {
// 归档当前会话
if (this.currentSession && this.currentSession.messages.length > 0) {
await this.storage.archiveCurrentSession();
}
// 创建新会话
const newWorkdir = workdir || this.currentSession?.workdir || process.cwd();
this.currentSession = this.createNewSession(newWorkdir);
await this.save();
return this.currentSession;
}
/**
* 恢复指定会话
*/
async restoreSession(sessionId: string): Promise<SessionData | null> {
const session = await this.storage.loadSession(sessionId);
if (!session) return null;
// 归档当前会话
if (this.currentSession && this.currentSession.messages.length > 0) {
await this.storage.archiveCurrentSession();
}
this.currentSession = session;
await this.save();
return session;
}
/**
* 列出历史会话
*/
async listSessions(): Promise<SessionSummary[]> {
return this.storage.listSessions();
}
/**
* 删除历史会话
*/
async deleteSession(sessionId: string): Promise<boolean> {
return this.storage.deleteSession(sessionId);
}
/**
* 启动自动保存(每 30 秒)
*/
private startAutoSave(): void {
if (this.autoSaveInterval) return;
this.autoSaveInterval = setInterval(async () => {
await this.save();
}, 30000);
}
/**
* 停止自动保存
*/
stopAutoSave(): void {
if (this.autoSaveInterval) {
clearInterval(this.autoSaveInterval);
this.autoSaveInterval = null;
}
}
/**
* 关闭管理器(保存并停止自动保存)
*/
async close(): Promise<void> {
this.stopAutoSave();
await this.save();
}
/**
* 清理旧会话
*/
async cleanup(keepCount?: number): Promise<number> {
return this.storage.cleanupOldSessions(keepCount);
}
}
// 导出默认实例
export const sessionManager = new SessionManager();
+205
View File
@@ -0,0 +1,205 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import type { SessionData, SessionSummary } from './types.js';
/**
* 获取默认存储目录
* 遵循 XDG 规范:~/.local/share/ai-assist/
*/
function getDefaultStorageDir(): string {
const xdgDataHome = process.env.XDG_DATA_HOME;
if (xdgDataHome) {
return path.join(xdgDataHome, 'ai-assist');
}
return path.join(os.homedir(), '.local', 'share', 'ai-assist');
}
/**
* 会话存储类
* 负责会话数据的读写操作
*/
export class SessionStorage {
private storageDir: string;
private sessionsDir: string;
private currentSessionFile: string;
constructor(storageDir?: string) {
this.storageDir = storageDir || getDefaultStorageDir();
this.sessionsDir = path.join(this.storageDir, 'sessions');
this.currentSessionFile = path.join(this.storageDir, 'current-session.json');
}
/**
* 确保存储目录存在
*/
async ensureDir(): Promise<void> {
await fs.mkdir(this.sessionsDir, { recursive: true });
}
/**
* 生成会话 ID
*/
generateSessionId(): string {
const now = new Date();
const timestamp = now.toISOString().slice(0, 10); // YYYY-MM-DD
const random = Math.random().toString(36).substring(2, 8);
return `${timestamp}_${random}`;
}
/**
* 保存当前会话
*/
async saveCurrentSession(session: SessionData): Promise<void> {
await this.ensureDir();
session.updatedAt = new Date().toISOString();
await fs.writeFile(
this.currentSessionFile,
JSON.stringify(session, null, 2),
'utf-8'
);
}
/**
* 加载当前会话
*/
async loadCurrentSession(): Promise<SessionData | null> {
try {
const content = await fs.readFile(this.currentSessionFile, 'utf-8');
return JSON.parse(content) as SessionData;
} catch {
return null;
}
}
/**
* 归档当前会话到历史
*/
async archiveCurrentSession(): Promise<void> {
const current = await this.loadCurrentSession();
if (!current || current.messages.length === 0) {
return;
}
await this.ensureDir();
const archivePath = path.join(this.sessionsDir, `${current.id}.json`);
await fs.writeFile(archivePath, JSON.stringify(current, null, 2), 'utf-8');
}
/**
* 删除当前会话文件
*/
async clearCurrentSession(): Promise<void> {
try {
await fs.unlink(this.currentSessionFile);
} catch {
// 文件不存在,忽略
}
}
/**
* 列出历史会话
*/
async listSessions(): Promise<SessionSummary[]> {
await this.ensureDir();
const files = await fs.readdir(this.sessionsDir);
const summaries: SessionSummary[] = [];
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const filePath = path.join(this.sessionsDir, file);
const content = await fs.readFile(filePath, 'utf-8');
const session = JSON.parse(content) as SessionData;
summaries.push({
id: session.id,
title: session.title || this.generateTitle(session),
workdir: session.workdir,
messageCount: session.messages.length,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
});
} catch {
// 跳过无法解析的文件
}
}
// 按更新时间降序排列
return summaries.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
}
/**
* 加载指定会话
*/
async loadSession(sessionId: string): Promise<SessionData | null> {
try {
const filePath = path.join(this.sessionsDir, `${sessionId}.json`);
const content = await fs.readFile(filePath, 'utf-8');
return JSON.parse(content) as SessionData;
} catch {
return null;
}
}
/**
* 删除指定会话
*/
async deleteSession(sessionId: string): Promise<boolean> {
try {
const filePath = path.join(this.sessionsDir, `${sessionId}.json`);
await fs.unlink(filePath);
return true;
} catch {
return false;
}
}
/**
* 清理旧会话(保留最近 N 个)
*/
async cleanupOldSessions(keepCount: number = 50): Promise<number> {
const sessions = await this.listSessions();
if (sessions.length <= keepCount) {
return 0;
}
const toDelete = sessions.slice(keepCount);
let deletedCount = 0;
for (const session of toDelete) {
if (await this.deleteSession(session.id)) {
deletedCount++;
}
}
return deletedCount;
}
/**
* 从会话生成标题
*/
private generateTitle(session: SessionData): string {
// 从第一条用户消息生成标题
const firstUserMessage = session.messages.find((m) => m.role === 'user');
if (firstUserMessage && typeof firstUserMessage.content === 'string') {
const content = firstUserMessage.content;
// 取前 50 个字符
return content.length > 50 ? content.substring(0, 50) + '...' : content;
}
return `会话 ${session.id}`;
}
/**
* 获取存储目录路径
*/
getStorageDir(): string {
return this.storageDir;
}
}
// 导出默认实例
export const sessionStorage = new SessionStorage();
+61
View File
@@ -0,0 +1,61 @@
import type { ModelMessage } from 'ai';
/**
* 待办项状态
*/
export type TodoStatus = 'pending' | 'in_progress' | 'completed';
/**
* 待办项
*/
export interface Todo {
id: string;
content: string;
status: TodoStatus;
createdAt: string;
updatedAt: string;
}
/**
* 会话数据(持久化存储格式)
*/
export interface SessionData {
/** 会话 ID */
id: string;
/** 创建时间 */
createdAt: string;
/** 最后更新时间 */
updatedAt: string;
/** 工作目录 */
workdir: string;
/** 会话标题(可选,从第一条消息生成) */
title?: string;
/** 对话历史 */
messages: ModelMessage[];
/** 已发现的工具 */
discoveredTools: string[];
/** 待办事项 */
todos: Todo[];
}
/**
* 会话摘要(用于列表展示)
*/
export interface SessionSummary {
id: string;
title: string;
workdir: string;
messageCount: number;
createdAt: string;
updatedAt: string;
}
/**
* 会话管理器配置
*/
export interface SessionManagerConfig {
/** 存储目录 */
storageDir: string;
/** 最大历史会话数量 */
maxHistorySessions?: number;
}
+4
View File
@@ -6,6 +6,7 @@ import { bashTool } from './shell/index.js';
// 核心工具
import { toolSearchTool } from './tool-search.js';
import { todoReadTool, todoWriteTool } from './todo/index.js';
// 文件系统工具
import {
@@ -27,6 +28,8 @@ const allToolsWithMetadata: ToolWithMetadata[] = [
// 核心工具 (deferLoading: false)
toolSearchTool,
bashTool,
todoReadTool,
todoWriteTool,
// 文件系统工具 (deferLoading: true)
readFileTool,
@@ -48,6 +51,7 @@ toolRegistry.registerAll(allToolsWithMetadata);
// 导出
export { toolRegistry } from './registry.js';
export { toolSearchTool } from './tool-search.js';
export { todoManager } from './todo/index.js';
export type { ToolWithMetadata, ToolMetadata, ToolCategory, ToolSearchResult } from './types.js';
// 兼容旧代码:导出所有工具数组(基础 Tool 类型)
+3
View File
@@ -0,0 +1,3 @@
export { todoReadTool } from './todoread.js';
export { todoWriteTool } from './todowrite.js';
export { todoManager } from './todo-manager.js';
+106
View File
@@ -0,0 +1,106 @@
import type { Todo, TodoStatus } from '../../session/types.js';
import type { SessionManager } from '../../session/index.js';
/**
* Todo 管理器
* 提供对当前会话 todo 列表的操作接口
*/
class TodoManager {
private sessionManager: SessionManager | null = null;
/**
* 设置会话管理器
*/
setSessionManager(manager: SessionManager): void {
this.sessionManager = manager;
}
/**
* 获取当前 todo 列表
*/
getTodos(): Todo[] {
if (!this.sessionManager) {
return [];
}
return this.sessionManager.getTodos();
}
/**
* 更新 todo 列表
*/
async setTodos(todos: Todo[]): Promise<void> {
if (!this.sessionManager) {
return;
}
await this.sessionManager.setTodos(todos);
}
/**
* 添加单个 todo
*/
async addTodo(content: string, status: TodoStatus = 'pending'): Promise<Todo> {
const todos = this.getTodos();
const now = new Date().toISOString();
const newTodo: Todo = {
id: this.generateId(),
content,
status,
createdAt: now,
updatedAt: now,
};
todos.push(newTodo);
await this.setTodos(todos);
return newTodo;
}
/**
* 更新 todo 状态
*/
async updateTodoStatus(id: string, status: TodoStatus): Promise<boolean> {
const todos = this.getTodos();
const todo = todos.find((t) => t.id === id);
if (!todo) return false;
todo.status = status;
todo.updatedAt = new Date().toISOString();
await this.setTodos(todos);
return true;
}
/**
* 删除 todo
*/
async deleteTodo(id: string): Promise<boolean> {
const todos = this.getTodos();
const index = todos.findIndex((t) => t.id === id);
if (index === -1) return false;
todos.splice(index, 1);
await this.setTodos(todos);
return true;
}
/**
* 清空所有 todo
*/
async clearTodos(): Promise<void> {
await this.setTodos([]);
}
/**
* 生成唯一 ID
*/
private generateId(): string {
return Math.random().toString(36).substring(2, 10);
}
/**
* 检查是否已初始化
*/
isInitialized(): boolean {
return this.sessionManager !== null;
}
}
// 导出单例
export const todoManager = new TodoManager();
+51
View File
@@ -0,0 +1,51 @@
import type { ToolResult } from '../../types/index.js';
import type { ToolWithMetadata } from '../types.js';
import { todoManager } from './todo-manager.js';
export const todoReadTool: ToolWithMetadata = {
name: 'todoread',
description: `读取当前会话的待办事项列表。
使用场景:
- 在对话开始时查看待处理的任务
- 开始新任务前了解当前进度
- 用户询问之前的任务或计划时
- 不确定下一步做什么时
- 完成任务后更新对剩余工作的理解
- 每隔几条消息检查一次以确保进度正常
返回格式:
- 返回 JSON 格式的待办事项列表
- 每个事项包含 id、content(内容)、status(状态)
- 状态:pending(待处理)、in_progress(进行中)、completed(已完成)`,
metadata: {
name: 'todoread',
category: 'core',
description: '读取待办事项列表',
keywords: ['todo', 'task', 'list', 'read', '待办', '任务', '列表', '进度'],
deferLoading: false, // 核心工具,始终加载
},
parameters: {},
execute: async (): Promise<ToolResult> => {
if (!todoManager.isInitialized()) {
return {
success: false,
output: '',
error: '会话管理器未初始化,无法读取待办事项',
};
}
const todos = todoManager.getTodos();
const pendingCount = todos.filter((t) => t.status !== 'completed').length;
return {
success: true,
output: JSON.stringify(todos, null, 2),
metadata: {
todos,
pendingCount,
totalCount: todos.length,
},
};
},
};
+139
View File
@@ -0,0 +1,139 @@
import type { ToolResult } from '../../types/index.js';
import type { ToolWithMetadata } from '../types.js';
import type { Todo, TodoStatus } from '../../session/types.js';
import { todoManager } from './todo-manager.js';
/**
* 验证 todo 项
*/
function validateTodo(item: unknown): item is { content: string; status: TodoStatus } {
if (typeof item !== 'object' || item === null) return false;
const obj = item as Record<string, unknown>;
if (typeof obj.content !== 'string' || obj.content.trim() === '') return false;
if (!['pending', 'in_progress', 'completed'].includes(obj.status as string)) return false;
return true;
}
export const todoWriteTool: ToolWithMetadata = {
name: 'todowrite',
description: `创建和管理当前会话的待办事项列表。用于跟踪进度、组织复杂任务,并向用户展示工作进展。
## 使用场景
主动使用此工具的情况:
1. 复杂多步骤任务 - 任务需要 3 个或更多步骤
2. 非平凡的复杂任务 - 需要仔细规划或多个操作的任务
3. 用户明确要求使用待办列表
4. 用户提供多个任务 - 用户给出编号列表或逗号分隔的任务
5. 收到新指令后 - 立即将用户需求记录为待办
6. 开始处理任务时 - 将其标记为 in_progress
7. 完成任务后 - 标记为 completed 并添加发现的后续任务
## 不使用此工具的情况
跳过使用的情况:
1. 只有单个简单任务
2. 任务太简单,跟踪没有意义
3. 任务可以在 3 个简单步骤内完成
4. 纯粹的对话或信息性请求
## 任务状态
- pending: 待处理,尚未开始
- in_progress: 进行中(同一时间只能有一个)
- completed: 已完成
## 任务管理规则
- 实时更新任务状态
- 完成后立即标记(不要批量标记)
- 同一时间只有一个任务处于 in_progress
- 完成当前任务后再开始新任务`,
metadata: {
name: 'todowrite',
category: 'core',
description: '创建和更新待办事项列表',
keywords: ['todo', 'task', 'write', 'update', 'create', '待办', '任务', '创建', '更新'],
deferLoading: false, // 核心工具,始终加载
},
parameters: {
todos: {
type: 'array',
description:
'更新后的待办事项列表。每个事项包含 content(任务内容)和 statuspending/in_progress/completed',
required: true,
},
},
execute: async (params: Record<string, unknown>): Promise<ToolResult> => {
if (!todoManager.isInitialized()) {
return {
success: false,
output: '',
error: '会话管理器未初始化,无法更新待办事项',
};
}
const todosInput = params.todos;
if (!Array.isArray(todosInput)) {
return {
success: false,
output: '',
error: 'todos 参数必须是数组',
};
}
// 验证并转换输入
const now = new Date().toISOString();
const existingTodos = todoManager.getTodos();
const existingMap = new Map(existingTodos.map((t) => [t.content, t]));
const newTodos: Todo[] = [];
for (let i = 0; i < todosInput.length; i++) {
const item = todosInput[i];
if (!validateTodo(item)) {
return {
success: false,
output: '',
error: `${i + 1} 个待办事项格式无效。需要 { content: string, status: 'pending' | 'in_progress' | 'completed' }`,
};
}
// 查找是否已存在(通过内容匹配)
const existing = existingMap.get(item.content);
if (existing) {
// 更新现有项
newTodos.push({
...existing,
status: item.status,
updatedAt: now,
});
} else {
// 创建新项
newTodos.push({
id: Math.random().toString(36).substring(2, 10),
content: item.content,
status: item.status,
createdAt: now,
updatedAt: now,
});
}
}
await todoManager.setTodos(newTodos);
const pendingCount = newTodos.filter((t) => t.status !== 'completed').length;
const completedCount = newTodos.filter((t) => t.status === 'completed').length;
const inProgressCount = newTodos.filter((t) => t.status === 'in_progress').length;
return {
success: true,
output: `待办事项已更新: ${pendingCount} 待处理, ${inProgressCount} 进行中, ${completedCount} 已完成`,
metadata: {
todos: newTodos,
pendingCount,
inProgressCount,
completedCount,
},
};
},
};
+2
View File
@@ -18,6 +18,8 @@ export interface ToolResult {
success: boolean;
output: string;
error?: string;
/** 额外的元数据(可选) */
metadata?: Record<string, unknown>;
}
// 工具定义(兼容 Vercel AI SDK 的 tool 格式)
+2 -1
View File
@@ -50,7 +50,8 @@ export class TerminalUI {
return true;
case '/clear':
this.agent.clearHistory();
// clearHistory 现在是异步的
void this.agent.clearHistory();
console.log(chalk.green('✓ 对话历史已清空\n'));
return true;