From e82ab6a70ede121ef6f85a37bd53a272856bcf20 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Fri, 1 May 2026 03:25:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=8E=20remember=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E4=B8=AD=E6=8B=86=E5=88=86=E5=87=BA=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E7=9A=84=20todo=20=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 todo 工具:add/complete/delete/update,共享 memory.json - remember 去掉 todo 类型和 remind_at 字段 - recall 对 todo 条目显示完成状态(未完成/完成于时间) - 提醒触发后自动标记完成 + 清理 remind_at - 提醒消息加上 todo ID 便于关联 - 更新 system prompt 拆分记忆/待办指导 - UI 显示适配:待办 新增/完成/删除/更新 · 内容 · 时间 --- Agent/doc/tools.json | 41 ++++++++- Agent/prompts/system.txt | 30 ++++++- Agent/src/bridge.js | 1 + Agent/src/tools/dispatcher.js | 32 ++++++- Agent/src/tools/remember.js | 4 +- Agent/src/tools/todo.js | 159 ++++++++++++++++++++++++++++++++++ Agent/src/ui/tool_display.js | 27 +++++- Sources/App/AppDelegate.swift | 12 ++- Sources/Utils/Config.swift | 5 ++ 9 files changed, 295 insertions(+), 16 deletions(-) create mode 100644 Agent/src/tools/todo.js diff --git a/Agent/doc/tools.json b/Agent/doc/tools.json index 29ed939..2327483 100644 --- a/Agent/doc/tools.json +++ b/Agent/doc/tools.json @@ -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", diff --git a/Agent/prompts/system.txt b/Agent/prompts/system.txt index eafae5b..37dd0f3 100644 --- a/Agent/prompts/system.txt +++ b/Agent/prompts/system.txt @@ -98,9 +98,6 @@ set_identity 和 remember 的本质区别: → type=habit - 用户分享了重要事件(如「上个月我换了新电脑」) → type=event -- 用户提到了待办事项(如「明天下午3点开会」) - → type=todo,content 必须包含明确的绝对日期(如「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_at(ISO 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 diff --git a/Agent/src/bridge.js b/Agent/src/bridge.js index f3edaa2..eeeaf6e 100644 --- a/Agent/src/bridge.js +++ b/Agent/src/bridge.js @@ -385,6 +385,7 @@ rl.on('line', (line) => { } persistentState.thinkingMode = persistentThinkingMode; } + // 提醒由 Swift 端调度器主导,无需在此注入 } await runAgent(msg.message); } diff --git a/Agent/src/tools/dispatcher.js b/Agent/src/tools/dispatcher.js index c5c151c..4fb3fe8 100644 --- a/Agent/src/tools/dispatcher.js +++ b/Agent/src/tools/dispatcher.js @@ -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 || '失败'); diff --git a/Agent/src/tools/remember.js b/Agent/src/tools/remember.js index 5274541..9e9bc7c 100644 --- a/Agent/src/tools/remember.js +++ b/Agent/src/tools/remember.js @@ -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 }; diff --git a/Agent/src/tools/todo.js b/Agent/src/tools/todo.js new file mode 100644 index 0000000..f7b6ab7 --- /dev/null +++ b/Agent/src/tools/todo.js @@ -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 }; diff --git a/Agent/src/ui/tool_display.js b/Agent/src/ui/tool_display.js index 7f8b047..33bbf5f 100644 --- a/Agent/src/ui/tool_display.js +++ b/Agent/src/ui/tool_display.js @@ -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; diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 35e9cb9..f39a82b 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -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) } } } diff --git a/Sources/Utils/Config.swift b/Sources/Utils/Config.swift index d11785a..c8d3203 100644 --- a/Sources/Utils/Config.swift +++ b/Sources/Utils/Config.swift @@ -426,6 +426,11 @@ final class Config { userDataDir + "/user_profile.json" } + /// 记忆文件路径 + var memoryPath: String { + userDataDir + "/memory/memory.json" + } + // MARK: - models.json 读写 func loadAgentModels() -> AgentModelsFile {