feat: 从 remember 工具中拆分出独立的 todo 工具

- 新增 todo 工具:add/complete/delete/update,共享 memory.json
- remember 去掉 todo 类型和 remind_at 字段
- recall 对 todo 条目显示完成状态(未完成/完成于时间)
- 提醒触发后自动标记完成 + 清理 remind_at
- 提醒消息加上 todo ID 便于关联
- 更新 system prompt 拆分记忆/待办指导
- UI 显示适配:待办 新增/完成/删除/更新 · 内容 · 时间
This commit is contained in:
JOJO 2026-05-01 03:25:51 +08:00
parent 8dda2dae66
commit e82ab6a70e
9 changed files with 295 additions and 16 deletions

View File

@ -272,11 +272,46 @@
}
}
},
{
"type": "function",
"function": {
"name": "todo",
"description": "管理用户的待办事项。支持创建、完成、删除和更新待办。可设置提醒时间,到提醒时间后系统会自动发送消息通知你提醒用户。\n查询待办请使用 recall 工具。",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "complete", "delete", "update"],
"description": "操作类型。add=新增待办complete=标记完成delete=删除待办update=更新待办内容或提醒时间。"
},
"content": {
"type": "string",
"description": "待办内容。add 时必填update 时可选(不提供则保留原内容)。内容中必须使用明确的绝对日期,禁止使用「明天」「下周三」等相对时间表述。"
},
"id": {
"type": "string",
"description": "待办 ID由系统自动生成。complete、delete、update 时必填,从 recall 工具的搜索结果中获取。add 时不需要提供。"
},
"remind_at": {
"type": "string",
"description": "提醒触发时间ISO 8601 格式,例如 2026-05-01T14:45:00.000Z。仅 add 和 update 时有效。add 时可选不提供则不设置提醒。update 时传 null 可取消已有提醒。"
},
"keywords": {
"type": "array",
"items": { "type": "string" },
"description": "关键词数组,便于 recall 搜索匹配。add 和 update 时可选。"
}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "remember",
"description": "记录用户个人记忆。当用户表达了个人偏好、习惯、待办事项、身份信息、人际关系、重要事件等值得记住的信息时调用。\n注意不要记录无意义信息例如用户的打招呼、让调低音量等一次性操作。",
"description": "记录用户个人记忆。当用户表达了个人偏好、习惯、身份信息、人际关系、重要事件等值得记住的信息时调用。\n注意不要记录无意义信息例如用户的打招呼、让调低音量等一次性操作。\n待办事项请使用 todo 工具。",
"parameters": {
"type": "object",
"properties": {
@ -287,8 +322,8 @@
},
"type": {
"type": "string",
"enum": ["preference", "habit", "event", "todo", "identity", "relationship", "machine", "other"],
"description": "记忆类型。preference=偏好喜欢什么不喜欢什么habit=用户的生活习惯event=过去需要记住的重要事件,todo=未来需要完成的任务,identity=用户的身份信息relationship=用户和其他人的关联machine=本机电脑上的信息如特定路径、脚本、配置等other=其他"
"enum": ["preference", "habit", "event", "identity", "relationship", "machine", "other"],
"description": "记忆类型。preference=偏好喜欢什么不喜欢什么habit=用户的生活习惯event=过去需要记住的重要事件,identity=用户的身份信息relationship=用户和其他人的关联machine=本机电脑上的信息如特定路径、脚本、配置等other=其他"
},
"content": {
"type": "string",

View File

@ -98,9 +98,6 @@ set_identity 和 remember 的本质区别:
→ type=habit
- 用户分享了重要事件(如「上个月我换了新电脑」)
→ type=event
- 用户提到了待办事项如「明天下午3点开会」
→ type=todocontent 必须包含明确的绝对日期如「2026年5月1日下午3点开会」禁止使用「明天」「下周三」「月底」等相对时间
→ 当前时间是 {current_time},请据此将相对时间转换为绝对日期
- 用户告知了个人身份信息如「我是iOS开发者」
→ type=identity
- 用户提及了人际关系(如「张三是我同事」)
@ -117,13 +114,14 @@ set_identity 和 remember 的本质区别:
- 一次性操作(调高音量、打开应用、问天气)
- 纯粹的技术问题怎么截图、Python怎么用
- 不包含个人信息的闲聊
- 待办事项(请使用 todo 工具)
- 任何不属于上述类型中的无意义信息
2) 何时搜索记忆recall 工具):
当用户提问可能涉及个人偏好、历史或已记录信息时才搜索,例如:
- 「我喜欢什么...」「我之前说过...」「帮我查一下我的...」
- 涉及个人习惯、喜好、身份的询问
- 涉及待办事项或日程
- 涉及待办事项或日程(搜索时指定 types=["todo"]
不需要搜索的情况:
- 简单的系统操作(调音量、截图、打开应用)
@ -140,6 +138,30 @@ set_identity 和 remember 的本质区别:
绝对不要在回答中显式提到「记忆」「搜索」「记录」等词,也不要说「根据我的记忆」。
【待办系统 — 重要,必须严格遵守】
你有独立的待办系统,用于管理用户的待办事项。
1) 何时创建待办todo 工具 add 操作):
用户提到需要完成的任务时,使用 todo add。
- content 必须包含明确的绝对日期如「2026年5月1日下午3点开会」禁止使用「明天」「下周三」「月底」等相对时间。当前时间是 {current_time},请据此将相对时间转换为绝对日期。
- 如果用户希望被提醒,设置 remind_atISO 8601 格式,例如 2026-05-01T14:45:00.000Z)。如果用户只说任务没要求提醒,不设置 remind_at。
- 可以附加 keywords 便于后续搜索。
2) 何时完成待办todo complete
用户说任务做完了、搞定了、完成了时使用。需要提供 id从 recall 搜索结果中获取)。
完成后提醒自动取消。
3) 何时删除待办todo delete
用户明确说取消某个待办、不需要了时使用。
4) 何时更新待办todo update
用户修改任务内容或提醒时间时使用。
5) 查询待办:
使用 recall 工具搜索types=["todo"]),不要用 todo 工具查询。从搜索结果中获取 id用于 complete、delete、update 操作。
【工具使用原则】
- 处理用户请求时,优先用 run_command 执行 macOS 命令
- 需要外部信息时用 web_search

View File

@ -385,6 +385,7 @@ rl.on('line', (line) => {
}
persistentState.thinkingMode = persistentThinkingMode;
}
// 提醒由 Swift 端调度器主导,无需在此注入
}
await runAgent(msg.message);
}

View File

@ -8,6 +8,7 @@ const { searchWorkspaceTool } = require('./search_workspace');
const { readMediafileTool } = require('./read_mediafile');
const { rememberTool } = require('./remember');
const { recallTool } = require('./recall');
const { todoTool } = require('./todo');
const { setIdentityTool } = require('./set_identity');
const path = require('path');
@ -129,7 +130,7 @@ function formatRemember(result) {
if (!result.success) return formatFailure(result.error || '记忆操作失败');
const typeNames = {
preference: '偏好', habit: '习惯', event: '事件',
todo: '待办', identity: '身份', relationship: '关系', other: '其他',
identity: '身份', relationship: '关系', other: '其他',
};
const typeName = typeNames[result.entry?.type] || result.entry?.type || '未知';
if (result.action === 'add') return `已记录 [${typeName}]: ${result.entry.content}`;
@ -138,6 +139,22 @@ function formatRemember(result) {
return '未知操作';
}
function formatTodo(result) {
if (!result.success) return formatFailure(result.error || '待办操作失败');
let line = '';
if (result.action === 'add') line = `已创建待办: ${result.entry.content}`;
else if (result.action === 'complete') line = `已完成待办: ${result.entry.content}`;
else if (result.action === 'update') line = `已更新待办: ${result.entry.content}`;
else if (result.action === 'delete') line = `已删除待办: ${result.entry.content}`;
else return '未知操作';
if (result.entry?.remind_at) {
const d = new Date(result.entry.remind_at);
const timeStr = `${d.getFullYear()}${d.getMonth()+1}${d.getDate()}${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`;
line += ` ⏰将在 ${timeStr} 提醒`;
}
return line;
}
function formatRecall(result) {
if (!result.success) return formatFailure(result.error || '搜索失败');
if (result.returned === 0) return '未找到相关记忆';
@ -149,7 +166,16 @@ function formatRecall(result) {
for (const m of result.results) {
const typeName = typeNames[m.type] || m.type;
const date = (m.updated_at || m.created_at || '').slice(0, 10);
lines.push(`[${typeName}] ${m.content} (记录于${date}) id:${m.id}`);
let line = `[${typeName}] ${m.content} (记录于${date}) id:${m.id}`;
if (m.type === 'todo') {
if (m.completed) {
const completedDate = (m.completed_at || '').slice(0, 10);
line += ` 完成于${completedDate}`;
} else {
line += ' 未完成';
}
}
lines.push(line);
}
return lines.join('\n');
}
@ -237,6 +263,7 @@ async function executeTool({ workspace, config, allowMode, toolCall, abortSignal
} else if (name === 'search_workspace') raw = await searchWorkspaceTool(workspace, args);
else if (name === 'read_mediafile') raw = readMediafileTool(workspace, args);
else if (name === 'remember') raw = rememberTool(args);
else if (name === 'todo') raw = todoTool(args);
else if (name === 'recall') raw = recallTool(args);
else if (name === 'set_identity') raw = setIdentityTool(args);
else raw = { success: false, error: '未知工具' };
@ -250,6 +277,7 @@ async function executeTool({ workspace, config, allowMode, toolCall, abortSignal
else if (name === 'search_workspace') formatted = formatSearchWorkspace(raw);
else if (name === 'read_mediafile') formatted = formatReadMediafile(raw);
else if (name === 'remember') formatted = formatRemember(raw);
else if (name === 'todo') formatted = formatTodo(raw);
else if (name === 'recall') formatted = formatRecall(raw);
else if (name === 'set_identity') formatted = formatSetIdentity(raw);
else formatted = raw.success ? '' : formatFailure(raw.error || '失败');

View File

@ -8,7 +8,7 @@ const crypto = require('crypto');
const MEMORY_DIR = path.join(os.homedir(), 'Library', 'Application Support', 'VoiceInput', 'memory');
const MEMORY_FILE = path.join(MEMORY_DIR, 'memory.json');
const VALID_TYPES = ['preference', 'habit', 'event', 'todo', 'identity', 'relationship', 'machine', 'other'];
const VALID_TYPES = ['preference', 'habit', 'event', 'identity', 'relationship', 'machine', 'other'];
function ensureDir() {
if (!fs.existsSync(MEMORY_DIR)) {
@ -124,4 +124,4 @@ function rememberTool(args) {
return { success: false, error: '未知 action' };
}
module.exports = { rememberTool };
module.exports = { rememberTool, loadMemories, saveMemories, MEMORY_FILE };

159
Agent/src/tools/todo.js Normal file
View File

@ -0,0 +1,159 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const MEMORY_DIR = path.join(os.homedir(), 'Library', 'Application Support', 'VoiceInput', 'memory');
const MEMORY_FILE = path.join(MEMORY_DIR, 'memory.json');
function ensureDir() {
if (!fs.existsSync(MEMORY_DIR)) {
fs.mkdirSync(MEMORY_DIR, { recursive: true });
}
}
function loadMemories() {
ensureDir();
if (!fs.existsSync(MEMORY_FILE)) {
return [];
}
try {
const raw = fs.readFileSync(MEMORY_FILE, 'utf8');
return JSON.parse(raw);
} catch (_) {
return [];
}
}
function saveMemories(memories) {
ensureDir();
fs.writeFileSync(MEMORY_FILE, JSON.stringify(memories, null, 2), 'utf8');
}
function generateId() {
return crypto.randomUUID().slice(0, 8);
}
function todoTool(args) {
const { action, content, id, remind_at, keywords } = args;
if (!['add', 'complete', 'delete', 'update'].includes(action)) {
return { success: false, error: 'action 必须是 add / complete / delete / update' };
}
// 校验 remind_at如果提供
if (remind_at !== undefined && remind_at !== null) {
if (action !== 'add' && action !== 'update') {
return { success: false, error: `remind_at 仅在 add 和 update 时有效` };
}
const ts = new Date(remind_at).getTime();
if (isNaN(ts)) {
return { success: false, error: 'remind_at 必须是有效的 ISO 8601 时间字符串,例如 2026-05-01T14:45:00.000Z' };
}
}
const memories = loadMemories();
const now = new Date().toISOString();
// --- add ---
if (action === 'add') {
if (!content || !content.trim()) {
return { success: false, error: 'content 不能为空' };
}
const entry = {
id: generateId(),
type: 'todo',
content: content.trim(),
keywords: Array.isArray(keywords) ? keywords.filter(k => k && typeof k === 'string') : [],
completed: false,
completed_at: null,
created_at: now,
updated_at: now,
};
if (remind_at !== undefined && remind_at !== null) {
entry.remind_at = new Date(remind_at).toISOString();
entry.reminded = false;
}
memories.push(entry);
saveMemories(memories);
return {
success: true,
action: 'add',
entry,
total: memories.length,
};
}
// --- complete / delete / update (都需要 id) ---
if (!id) {
return { success: false, error: `${action} 需要提供 id请从 recall 工具的搜索结果中获取` };
}
const idx = memories.findIndex(m => m.id === id && m.type === 'todo');
if (idx === -1) {
return { success: false, error: `未找到 id=${id} 的待办` };
}
// --- complete ---
if (action === 'complete') {
memories[idx].completed = true;
memories[idx].completed_at = now;
// 已完成的不再提醒
delete memories[idx].remind_at;
delete memories[idx].reminded;
memories[idx].updated_at = now;
saveMemories(memories);
return {
success: true,
action: 'complete',
entry: memories[idx],
total: memories.length,
};
}
// --- delete ---
if (action === 'delete') {
const removed = memories.splice(idx, 1)[0];
saveMemories(memories);
return {
success: true,
action: 'delete',
entry: removed,
total: memories.length,
};
}
// --- update ---
if (action === 'update') {
if (content !== undefined && content !== null) {
memories[idx].content = String(content).trim();
}
if (keywords !== undefined) {
memories[idx].keywords = Array.isArray(keywords) ? keywords.filter(k => k && typeof k === 'string') : [];
}
// 更新提醒时间
if (remind_at !== undefined) {
if (remind_at === null) {
delete memories[idx].remind_at;
delete memories[idx].reminded;
} else {
memories[idx].remind_at = new Date(remind_at).toISOString();
memories[idx].reminded = false;
}
}
memories[idx].updated_at = now;
saveMemories(memories);
return {
success: true,
action: 'update',
entry: memories[idx],
total: memories.length,
};
}
return { success: false, error: '未知 action' };
}
module.exports = { todoTool };

View File

@ -17,6 +17,7 @@ function toolNameMap(name) {
search_workspace: '文件搜索',
read_mediafile: '读取媒体文件',
remember: '记住',
todo: '待办',
recall: '回忆',
set_identity: '设置身份',
};
@ -80,7 +81,7 @@ function buildStartLine(name, args) {
return `${label} ${args.path}`;
}
if (name === 'remember') {
const typeNames = { preference: '偏好', habit: '习惯', event: '事件', todo: '待办', identity: '身份', relationship: '关系', other: '其他' };
const typeNames = { preference: '偏好', habit: '习惯', event: '事件', identity: '身份', relationship: '关系', other: '其他' };
const typeLabel = typeNames[args.type] || args.type || '';
const content = args.content || '';
const preview = content.length > 40 ? content.slice(0, 40) + '…' : content;
@ -92,6 +93,23 @@ function buildStartLine(name, args) {
}
return `${label} ${typeLabel ? `[${typeLabel}] ` : ''}${preview}`;
}
if (name === 'todo') {
const actionNames = { add: '新增', complete: '完成', delete: '删除', update: '更新' };
const actionLabel = actionNames[args.action] || args.action || '';
const content = args.content || '';
const preview = content.length > 40 ? content.slice(0, 40) + '…' : content;
let timeStr = '';
if (args.remind_at) {
try {
const d = new Date(args.remind_at);
timeStr = `${d.getFullYear()}${d.getMonth()+1}${d.getDate()}${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
} catch (_) {}
}
const parts = [`${label} ${actionLabel}`];
if (preview) parts.push(preview);
if (timeStr) parts.push(timeStr);
return parts.join(' · ');
}
if (name === 'recall') {
const typeNames = { preference: '偏好', habit: '习惯', event: '事件', todo: '待办', identity: '身份', relationship: '关系', other: '其他' };
const typeLabels = (args.types || []).map(t => typeNames[t] || t).filter(Boolean);
@ -243,6 +261,13 @@ function formatResultLines(name, args, raw) {
if (raw.action === 'update') return ['已更新'];
return ['已记录'];
}
if (name === 'todo') {
if (raw.action === 'add') return ['已创建待办'];
if (raw.action === 'complete') return ['已完成待办'];
if (raw.action === 'delete') return ['已删除待办'];
if (raw.action === 'update') return ['已更新待办'];
return ['完成'];
}
if (name === 'recall') {
const total = raw.total || 0;
const returned = raw.returned || 0;

View File

@ -521,10 +521,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel
try? data.write(to: URL(fileURLWithPath: path), options: .atomic)
}
private func markMemoryReminded(_ memoryId: String) {
private func markTodoCompleted(_ memoryId: String) {
var memories = loadMemories()
guard let idx = memories.firstIndex(where: { ($0["id"] as? String) == memoryId }) else { return }
memories[idx]["reminded"] = true
let nowISO = ISO8601DateFormatter().string(from: Date())
memories[idx]["completed"] = true
memories[idx]["completed_at"] = nowISO
memories[idx].removeValue(forKey: "remind_at")
memories[idx].removeValue(forKey: "reminded")
saveMemories(memories)
}
@ -576,7 +580,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel
df.locale = Locale(identifier: "zh_CN")
df.dateFormat = "yyyy年M月d日 H:mm"
let timeStr = df.string(from: now)
let reminderMsg = "[系统提醒] 现在是 \(timeStr),请提醒用户:\(content)"
let reminderMsg = "[系统自动发送的消息] 现在是 \(timeStr),请提醒用户:\(content)\n待办ID\(memoryId)"
print("[Reminder] ✅ 触发提醒 id=\(memoryId)")
triggerReminder(message: reminderMsg, memoryId: memoryId)
@ -640,7 +644,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel
print("[Reminder] 消息已发送")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.markMemoryReminded(memoryId)
self.markTodoCompleted(memoryId)
}
}
}

View File

@ -426,6 +426,11 @@ final class Config {
userDataDir + "/user_profile.json"
}
///
var memoryPath: String {
userDataDir + "/memory/memory.json"
}
// MARK: - models.json
func loadAgentModels() -> AgentModelsFile {