diff --git a/app/src/main/java/com/apk/claw/android/TaskOrchestrator.kt b/app/src/main/java/com/apk/claw/android/TaskOrchestrator.kt index 8f150d8..ff5108c 100644 --- a/app/src/main/java/com/apk/claw/android/TaskOrchestrator.kt +++ b/app/src/main/java/com/apk/claw/android/TaskOrchestrator.kt @@ -164,8 +164,8 @@ class TaskOrchestrator( val app = ClawApplication.instance val status = if (result.isSuccess) app.getString(R.string.channel_msg_tool_success) else app.getString(R.string.channel_msg_tool_failure) var data = if (result.isSuccess) result.data else result.error - if (data != null && data.length > 300) { - data = data.substring(0, 300) + "...(truncated)" + if (data != null && data.length > 2000) { + data = data.substring(0, 2000) + "...(truncated)" } if (!result.isSuccess) { XLog.e(TAG, "!!!!!!!!!!Fail: $toolName, $parameters $data") diff --git a/app/src/main/java/com/apk/claw/android/agent/AgentConfig.kt b/app/src/main/java/com/apk/claw/android/agent/AgentConfig.kt index 0f2b546..6a4c996 100644 --- a/app/src/main/java/com/apk/claw/android/agent/AgentConfig.kt +++ b/app/src/main/java/com/apk/claw/android/agent/AgentConfig.kt @@ -97,7 +97,7 @@ data class AgentConfig( private var baseUrl: String = "" private var modelName: String = "" private var systemPrompt: String = DEFAULT_SYSTEM_PROMPT - private var maxIterations: Int = 20 + private var maxIterations: Int = 60 private var temperature: Double = 0.1 private var provider: LlmProvider = LlmProvider.OPENAI private var streaming: Boolean = false diff --git a/app/src/main/java/com/apk/claw/android/agent/DefaultAgentService.kt b/app/src/main/java/com/apk/claw/android/agent/DefaultAgentService.kt index 16e4f6b..4c768e5 100644 --- a/app/src/main/java/com/apk/claw/android/agent/DefaultAgentService.kt +++ b/app/src/main/java/com/apk/claw/android/agent/DefaultAgentService.kt @@ -135,6 +135,34 @@ class DefaultAgentService : AgentService { sb.append("- 包名: ").append(app.packageName).append("\n") sb.append("- 当用户提到'自己/本应用/这个应用'时,指的就是上述应用\n") + // General operation rules + sb.append("\n## 通用操作规则\n") + sb.append("- 进入任何新页面后,必须先调用 get_screen_info 确认当前页面身份\n") + sb.append("- 执行任何 tap/swipe 前,必须确认目标元素在当前页面确实存在\n") + sb.append("- 如果操作后屏幕没有任何变化,说明操作无效或点错了位置,立即改用其他方法\n") + sb.append("- 不要连续 3 次以上执行相同操作——如果 3 次都无效,完全换个思路\n\n") + + // WeChat-specific operation guide + sb.append("\n## 微信操作注意事项\n") + sb.append("### 微信底部导航\n") + sb.append("- 微信底部 Tab(微信/通讯录/发现/我)的 TextView 是 clickable=false,必须 tap Tab 容器(以坐标中心点点击)\n") + sb.append("- 自动化操作请保持仅在「微信」Tab 下的聊天功能,注意不要进入「发现」Tab,以免触发账号风控\n\n") + sb.append("### 微信聊天界面操作\n") + sb.append("- 聊天列表在「微信」Tab 下,包含所有最近会话,每个会话显示联系人名称、最后一条消息预览\n") + sb.append("- 进入具体聊天:在聊天列表中找到目标联系人名称,tap 该区域即可\n") + sb.append("- 聊天界面底部有输入框(EditText),点击后可输入文字;右侧有发送按钮\n") + sb.append("- 页面顶部显示当前聊天对象名称——用它验证是否进入了正确的对话\n") + sb.append("- 从聊天界面回退到列表:system_key(key=\"back\")\n") + sb.append("- 回到微信首页:多次 back 或 system_key(key=\"home\") 后重新打开微信\n\n") + + // Long-term memory instructions + sb.append("\n## 长期记忆使用规则\n") + sb.append("- 每次任务成功完成后,必须调用 remember(action=\"save\", ...) 记录经验\n") + sb.append("- 记录内容应包括:任务描述(task)、关键步骤(summary)、经验教训(learnings用|分隔)、是否成功(success)\n") + sb.append("- 开始新任务时,若看到上面的[长期记忆]区域中有相关经验,直接参考它来加速执行\n") + sb.append("- 如果需要查找特定经验,可以使用 remember(action=\"search\", keyword=\"...\")\n") + sb.append("- 示例: remember(action=\"save\", task=\"打开微信\", summary=\"按Home→点击微信图标\", learnings=\"微信图标在桌面第2屏|图标位置约[540,1200]\", success=true)\n") + return sb.toString() } @@ -317,8 +345,9 @@ class DefaultAgentService : AgentService { return } - // 构建 System Prompt(原始 + 设备上下文) - val fullSystemPrompt = config.systemPrompt + buildDeviceContext() + // 构建 System Prompt(原始 + 设备上下文 + 长期记忆) + val memoryContext = com.apk.claw.android.utils.TaskMemory.recallForSystemPrompt() + val fullSystemPrompt = config.systemPrompt + buildDeviceContext() + memoryContext val messages = mutableListOf() messages.add(SystemMessage.from(fullSystemPrompt)) diff --git a/app/src/main/java/com/apk/claw/android/tool/ToolRegistry.kt b/app/src/main/java/com/apk/claw/android/tool/ToolRegistry.kt index 9538158..734839a 100644 --- a/app/src/main/java/com/apk/claw/android/tool/ToolRegistry.kt +++ b/app/src/main/java/com/apk/claw/android/tool/ToolRegistry.kt @@ -37,6 +37,7 @@ object ToolRegistry { register(RepeatActionsTool()) register(ClipboardTool()) register(SendFileTool()) + register(RememberTool()) register(FinishTool()) } diff --git a/app/src/main/java/com/apk/claw/android/tool/impl/FindNodeInfoTool.java b/app/src/main/java/com/apk/claw/android/tool/impl/FindNodeInfoTool.java index b3470ba..7f9ee0f 100644 --- a/app/src/main/java/com/apk/claw/android/tool/impl/FindNodeInfoTool.java +++ b/app/src/main/java/com/apk/claw/android/tool/impl/FindNodeInfoTool.java @@ -8,6 +8,7 @@ import com.apk.claw.android.tool.BaseTool; import com.apk.claw.android.tool.ToolParameter; import com.apk.claw.android.tool.ToolResult; +import com.apk.claw.android.utils.ScreenMemory; import java.util.Collections; import java.util.List; @@ -32,7 +33,7 @@ public String getDescriptionEN() { @Override public String getDescriptionCN() { - return "通过可见文本查找元素,返回详细信息(类名、边界、属性)。适用于在交互前检查特定元素。"; + return "通过可见文本查找元素,返回详细信息(类名、边界、属性)。适用于在交互前检查特定元素。会在长期记忆中缓存结果,下次查找优先命中缓存。"; } @Override @@ -50,21 +51,88 @@ public ToolResult execute(Map params) { } String text = requireString(params, "text"); - List nodes = service.findNodesByText(text); + String fgPkg = service.getForegroundPackage(); + + // --- Check long-term memory first --- + if (fgPkg != null && !fgPkg.isEmpty()) { + ScreenMemory.Entry mem = ScreenMemory.recall(fgPkg, text); + if (mem != null) { + // Cache hit — return immediately, then verify with scan below + // We'll prepend the memory hint but still do a live scan for accuracy + } + } + + // Use anti-obfuscation search when WeChat is in foreground + List nodes; + if (service.isAntiA11yAppForeground()) { + nodes = service.findNodesByTextAntiObfuscated(text); + } else { + nodes = service.findNodesByText(text); + } if (nodes.isEmpty()) { - return ToolResult.error("No elements found with text: " + text); + return ToolResult.error("No elements found with text: " + text + + (service.isAntiA11yAppForeground() ? " (anti-obfuscation search used)" : "")); } try { StringBuilder sb = new StringBuilder(); + + // Prepend memory hint if available for the foreground app + if (fgPkg != null) { + ScreenMemory.Entry mem = ScreenMemory.recall(fgPkg, text); + if (mem != null) { + sb.append("[记忆缓存命中] 之前找到过该元素:\n") + .append(" bounds=").append(mem.bounds) + .append(" class=").append(mem.className) + .append(" clickable=").append(mem.clickable) + .append(mem.clickable ? "" : " (需tap父容器)") + .append("\n center_xy=").append(mem.centerXY()) + .append("\n当前扫描结果如下:\n\n"); + } + } + sb.append("Found ").append(nodes.size()).append(" element(s):\n"); for (int i = 0; i < nodes.size(); i++) { - sb.append("[").append(i).append("] ").append(service.getNodeDetail(nodes.get(i))).append("\n"); + android.view.accessibility.AccessibilityNodeInfo node = nodes.get(i); + String detail = service.getNodeDetail(node); + sb.append("[").append(i).append("] ").append(detail).append("\n"); + + // Save to memory: persist successful finds + if (fgPkg != null && detail != null && !detail.isEmpty()) { + recordToMemory(fgPkg, text, node, detail); + } } return ToolResult.success(sb.toString()); } finally { ClawAccessibilityService.recycleNodes(nodes); } } + + private void recordToMemory(String pkg, String text, + android.view.accessibility.AccessibilityNodeInfo node, + String detail) { + try { + android.graphics.Rect bounds = new android.graphics.Rect(); + node.getBoundsInScreen(bounds); + String boundsStr = "[" + bounds.left + "," + bounds.top + "][" + + bounds.right + "," + bounds.bottom + "]"; + boolean clickable = node.isClickable(); + String klass = node.getClassName() != null ? node.getClassName().toString() : "?"; + // Extract short class name + int dot = klass.lastIndexOf('.'); + if (dot >= 0 && dot < klass.length() - 1) klass = klass.substring(dot + 1); + + StringBuilder hint = new StringBuilder(); + if (!clickable) hint.append("tap parent"); + String viewId = node.getViewIdResourceName(); + if (viewId != null && !viewId.isEmpty()) { + if (hint.length() > 0) hint.append("; "); + hint.append("id=").append(viewId); + } + + ScreenMemory.record(pkg, text, boundsStr, klass, clickable, hint.toString()); + } catch (Exception ignored) { + } + } } diff --git a/app/src/main/java/com/apk/claw/android/tool/impl/GetScreenInfoTool.java b/app/src/main/java/com/apk/claw/android/tool/impl/GetScreenInfoTool.java index adebec3..5808367 100644 --- a/app/src/main/java/com/apk/claw/android/tool/impl/GetScreenInfoTool.java +++ b/app/src/main/java/com/apk/claw/android/tool/impl/GetScreenInfoTool.java @@ -6,10 +6,13 @@ import com.apk.claw.android.tool.BaseTool; import com.apk.claw.android.tool.ToolParameter; import com.apk.claw.android.tool.ToolResult; +import com.apk.claw.android.utils.ScreenMemory; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class GetScreenInfoTool extends BaseTool { @@ -46,16 +49,64 @@ public List getParameters() { */ public static boolean useFullTree = false; + private static final Pattern ELEMENT_PATTERN = Pattern.compile( + "\\[(\\w+)\\] (?:text|desc)=\"([^\"]+)\".*?bounds=\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"); + @Override public ToolResult execute(Map params) { ClawAccessibilityService service = ClawAccessibilityService.getInstance(); if (service == null) { return ToolResult.error("Accessibility service is not running"); } - String tree = useFullTree ? service.getScreenTreeFull() : service.getScreenTree(); + String tree; + if (useFullTree) { + tree = service.getScreenTreeFull(); + } else { + tree = service.getScreenTreeSmart(); + } if (tree == null) { return ToolResult.error(SYSTEM_DIALOG_BLOCKED); } - return ToolResult.success(tree); + + // --- Long-term memory integration --- + StringBuilder output = new StringBuilder(); + + // Prepend known element positions for the current foreground app + String fgPkg = service.getForegroundPackage(); + if (fgPkg != null && !fgPkg.isEmpty()) { + String hints = ScreenMemory.recallHints(fgPkg); + if (!hints.isEmpty()) { + output.append(hints).append("\n\n"); + } + + // Auto-record: save key elements from this screen to memory + autoRecord(fgPkg, tree); + } + + output.append(tree); + return ToolResult.success(output.toString()); + } + + /** + * Extracts clickable / labelled elements from the screen tree and saves + * them to ScreenMemory so future tasks can skip the initial scan. + */ + private void autoRecord(String packageName, String tree) { + if (tree == null || tree.length() < 10) return; + Matcher m = ELEMENT_PATTERN.matcher(tree); + int count = 0; + while (m.find() && count < 15) { + String klass = m.group(1); + String text = m.group(2); + if (text.isEmpty()) continue; + String bounds = "[" + m.group(3) + "," + m.group(4) + "][" + m.group(5) + "," + m.group(6) + "]"; + // Determine clickable from context — the pattern captures the element line + String line = m.group(0); + boolean clickable = line.contains("[clickable]"); + + ScreenMemory.record(packageName, text, bounds, klass, clickable, + clickable ? "" : "tap parent container"); + count++; + } } } diff --git a/app/src/main/java/com/apk/claw/android/tool/impl/RememberTool.java b/app/src/main/java/com/apk/claw/android/tool/impl/RememberTool.java new file mode 100644 index 0000000..bbe8092 --- /dev/null +++ b/app/src/main/java/com/apk/claw/android/tool/impl/RememberTool.java @@ -0,0 +1,119 @@ +package com.apk.claw.android.tool.impl; + +import com.apk.claw.android.ClawApplication; +import com.apk.claw.android.R; +import com.apk.claw.android.tool.BaseTool; +import com.apk.claw.android.tool.ToolParameter; +import com.apk.claw.android.tool.ToolResult; +import com.apk.claw.android.utils.TaskMemory; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Lets the model persist and query cross-task memories. + * + * The model should call this tool: + * - After a successful task: save learnings so future instances benefit + * - Before tackling a complex task: search for relevant past experience + * + * Memories persist across app restarts and survive for unlimited time + * (max 50 entries, oldest evicted). + */ +public class RememberTool extends BaseTool { + + @Override + public String getName() { + return "remember"; + } + + @Override + public String getDisplayName() { + return ClawApplication.Companion.getInstance().getString(R.string.tool_name_remember); + } + + @Override + public String getDescriptionEN() { + return "Save or search long-term memories across tasks. " + + "Use action='save' to record what you learned after a successful task. " + + "Use action='search' to find relevant past experiences before starting. " + + "Use action='list' to see all recent memories."; + } + + @Override + public String getDescriptionCN() { + return "跨任务长期记忆。action='save' 记录经验," + + "action='search' 查找相关历史," + + "action='list' 列出最近记忆。"; + } + + @Override + public List getParameters() { + return Arrays.asList( + new ToolParameter("action", "string", + "Action: 'save', 'search', or 'list'", true), + new ToolParameter("task", "string", + "For 'save': one-line description of the user's task", false), + new ToolParameter("summary", "string", + "For 'save': summary of what you did (key steps)", false), + new ToolParameter("learnings", "string", + "For 'save': things you learned, separated by '|'", false), + new ToolParameter("success", "boolean", + "For 'save': whether the task succeeded", false), + new ToolParameter("keyword", "string", + "For 'search': keyword to search in memories", false) + ); + } + + @Override + public ToolResult execute(Map params) { + String action = requireString(params, "action"); + + switch (action.toLowerCase()) { + case "save": + return doSave(params); + case "search": + return doSearch(params); + case "list": + return doList(); + default: + return ToolResult.error("Unknown action: " + action + ". Use 'save', 'search', or 'list'."); + } + } + + private ToolResult doSave(Map params) { + String task = requireString(params, "task"); + String summary = requireString(params, "summary"); + + TaskMemory.Entry entry = new TaskMemory.Entry(); + entry.userTask = task; + entry.summary = summary; + entry.success = params.containsKey("success") && Boolean.TRUE.equals(params.get("success")); + + String learningsRaw = params.containsKey("learnings") ? params.get("learnings").toString() : ""; + if (!learningsRaw.isEmpty()) { + entry.learnings = Arrays.asList(learningsRaw.split("\\|")); + } else { + entry.learnings = Collections.emptyList(); + } + + TaskMemory.save(entry); + return ToolResult.success("Memory saved (#" + entry.id + "). Total memories: " + TaskMemory.count()); + } + + private ToolResult doSearch(Map params) { + String keyword = requireString(params, "keyword"); + String result = TaskMemory.search(keyword); + return ToolResult.success(result); + } + + private ToolResult doList() { + String result = TaskMemory.recallForSystemPrompt(); + if (result.isEmpty()) { + return ToolResult.success("[记忆] 暂无历史经验记录。"); + } + return ToolResult.success(result); + } +} diff --git a/app/src/main/java/com/apk/claw/android/utils/ScreenMemory.java b/app/src/main/java/com/apk/claw/android/utils/ScreenMemory.java new file mode 100644 index 0000000..e049274 --- /dev/null +++ b/app/src/main/java/com/apk/claw/android/utils/ScreenMemory.java @@ -0,0 +1,206 @@ +package com.apk.claw.android.utils; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.util.HashMap; +import java.util.Map; + +/** + * Long-term UI element memory backed by MMKV. + * + * Persists element positions across tasks so the model can skip expensive + * screen scanning and trial-and-error the next time it visits the same app page. + * + * Keyed by {@code packageName → elementText}, each entry stores bounds, class, + * clickable state, and a timestamp for staleness detection. + */ +public class ScreenMemory { + + private static final String TAG = "ScreenMemory"; + private static final String MMKV_KEY = "screen_memory"; + private static final long MAX_AGE_MS = 7 * 24 * 3600 * 1000L; // 7 days + private static final Gson GSON = new Gson(); + + /** In-memory cache, loaded from MMKV on first access */ + private static Map> store; + + // ======================== Public API ======================== + + /** + * Records an element in long-term memory. + * + * @param packageName e.g. "com.tencent.mm" + * @param text the visible text / contentDescription + * @param bounds formatted bounds string "[left,top][right,bottom]" + * @param className e.g. "TextView", "ImageView", "LinearLayout" + * @param clickable whether the element itself is clickable + * @param extraHint additional info (parent clickable, viewId, etc.) + */ + public static void record(String packageName, String text, + String bounds, String className, boolean clickable, + String extraHint) { + if (packageName == null || text == null || text.isEmpty()) return; + ensureLoaded(); + Map pkg = store.computeIfAbsent(packageName, k -> new HashMap<>()); + String key = text.trim().toLowerCase(); + Entry e = new Entry(); + e.bounds = bounds; + e.className = className; + e.clickable = clickable; + e.extraHint = extraHint != null ? extraHint : ""; + e.timestamp = System.currentTimeMillis(); + pkg.put(key, e); + XLog.d(TAG, "Recorded [" + text + "] in " + packageName + " at " + bounds); + persistAsync(); + } + + /** + * Looks up a single element from memory. + * + * @return Entry if found and not stale, null otherwise + */ + public static Entry recall(String packageName, String text) { + if (packageName == null || text == null) return null; + ensureLoaded(); + Map pkg = store.get(packageName); + if (pkg == null) return null; + Entry e = pkg.get(text.trim().toLowerCase()); + if (e == null) return null; + if (isStale(e)) { + pkg.remove(text.trim().toLowerCase()); + persistAsync(); + return null; + } + return e; + } + + /** + * Returns a human-readable hint string of all remembered elements + * for the given package. Intended to be prepended to screen-info output. + * + * @return formatted hint string, or empty string if no memories + */ + public static String recallHints(String packageName) { + if (packageName == null) return ""; + ensureLoaded(); + Map pkg = store.get(packageName); + if (pkg == null || pkg.isEmpty()) return ""; + + // Remove stale entries while building output + StringBuilder sb = new StringBuilder(); + sb.append("[记忆 — 该 App 已知元素位置]\n"); + int count = 0; + for (Map.Entry kv : pkg.entrySet()) { + if (isStale(kv.getValue())) continue; + if (count > 0) sb.append("\n"); + sb.append("- ").append(kv.getKey()) + .append(": bounds=").append(kv.getValue().bounds); + if (!kv.getValue().clickable) { + sb.append(" ⚠非clickable,需tap父容器"); + } + count++; + if (count >= 20) break; // limit + } + return count > 0 ? sb.toString() : ""; + } + + /** + * Explicitly remove a stale entry. + */ + public static void forget(String packageName, String text) { + if (packageName == null || text == null) return; + ensureLoaded(); + Map pkg = store.get(packageName); + if (pkg != null) { + pkg.remove(text.trim().toLowerCase()); + persistAsync(); + } + } + + /** + * Clear all memories for a package. + */ + public static void clearPackage(String packageName) { + ensureLoaded(); + store.remove(packageName); + persistAsync(); + } + + // ======================== Entry ======================== + + public static class Entry { + public String bounds; + public String className; + public boolean clickable; + public String extraHint; + public long timestamp; + + public String centerXY() { + if (bounds == null) return "?"; + // bounds format: "[left,top][right,bottom]" or "[left,top][right,bottom]" + try { + int b1 = bounds.indexOf('['); + int c1 = bounds.indexOf(',', b1); + int b2 = bounds.indexOf("]["); + int c2 = bounds.indexOf(',', b2); + int b3 = bounds.lastIndexOf(']'); + int left = Integer.parseInt(bounds.substring(b1 + 1, c1).trim()); + int top = Integer.parseInt(bounds.substring(c1 + 1, b2).trim()); + int right = Integer.parseInt(bounds.substring(b2 + 2, c2).trim()); + int bottom = Integer.parseInt(bounds.substring(c2 + 1, b3).trim()); + return (left + right) / 2 + "," + (top + bottom) / 2; + } catch (Exception e) { + return "?"; + } + } + } + + // ======================== Internal ======================== + + private static void ensureLoaded() { + if (store != null) return; + store = new HashMap<>(); + String raw = KVUtils.INSTANCE.getString(MMKV_KEY, ""); + if (raw.isEmpty()) return; + try { + JsonObject root = JsonParser.parseString(raw).getAsJsonObject(); + for (Map.Entry pkgEntry : root.entrySet()) { + Map pkg = new HashMap<>(); + JsonObject pkgJson = pkgEntry.getValue().getAsJsonObject(); + for (Map.Entry elemEntry : pkgJson.entrySet()) { + Entry e = GSON.fromJson(elemEntry.getValue(), Entry.class); + pkg.put(elemEntry.getKey(), e); + } + store.put(pkgEntry.getKey(), pkg); + } + } catch (Exception e) { + XLog.w(TAG, "Failed to parse screen memory, starting fresh", e); + store.clear(); + } + } + + private static void persistAsync() { + if (store == null) return; + // Prune stale entries while serializing + JsonObject root = new JsonObject(); + for (Map.Entry> pkgEntry : store.entrySet()) { + JsonObject pkgJson = new JsonObject(); + for (Map.Entry elemEntry : pkgEntry.getValue().entrySet()) { + if (!isStale(elemEntry.getValue())) { + pkgJson.add(elemEntry.getKey(), GSON.toJsonTree(elemEntry.getValue())); + } + } + if (pkgJson.size() > 0) { + root.add(pkgEntry.getKey(), pkgJson); + } + } + KVUtils.INSTANCE.putString(MMKV_KEY, root.toString()); + } + + private static boolean isStale(Entry e) { + return System.currentTimeMillis() - e.timestamp > MAX_AGE_MS; + } +} diff --git a/app/src/main/java/com/apk/claw/android/utils/TaskMemory.java b/app/src/main/java/com/apk/claw/android/utils/TaskMemory.java new file mode 100644 index 0000000..49e21ec --- /dev/null +++ b/app/src/main/java/com/apk/claw/android/utils/TaskMemory.java @@ -0,0 +1,194 @@ +package com.apk.claw.android.utils; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Cross-task conversation memory. + * + * Persists summaries of successful tasks and the model's own learnings so + * that future tasks can benefit from past experience instead of starting + * from scratch every time. + * + * Memory entries are stored in MMKV under the key {@code task_memory} as a + * JSON array, with a maximum of 50 entries (oldest evicted). + */ +public class TaskMemory { + + private static final String TAG = "TaskMemory"; + private static final String MMKV_KEY = "task_memory"; + private static final int MAX_ENTRIES = 100; + private static final Gson GSON = new Gson(); + + /** In-memory cache, loaded lazily */ + private static List store; + + // ======================== Entry ======================== + + public static class Entry { + public int id; + /** One-line description of what the user asked for */ + public String userTask; + /** Whether the task completed successfully */ + public boolean success; + /** Short summary of what the model did (key steps) */ + public String summary; + /** Things the model learned — reusable across tasks */ + public List learnings; + public long timestamp; + + public String toPromptString() { + StringBuilder sb = new StringBuilder(); + sb.append("任务: ").append(userTask != null ? userTask : "?").append("\n"); + sb.append("结果: ").append(success ? "成功" : "失败").append("\n"); + if (summary != null && !summary.isEmpty()) { + sb.append("过程: ").append(summary).append("\n"); + } + if (learnings != null && !learnings.isEmpty()) { + sb.append("经验:\n"); + for (String l : learnings) { + sb.append(" - ").append(l).append("\n"); + } + } + return sb.toString(); + } + } + + // ======================== Public API ======================== + + /** + * Saves a new memory entry. The model should call this via the + * "remember" tool when it learns something worth keeping. + */ + public static void save(Entry entry) { + ensureLoaded(); + entry.id = nextId(); + if (entry.timestamp == 0) entry.timestamp = System.currentTimeMillis(); + if (entry.learnings == null) entry.learnings = Collections.emptyList(); + store.add(entry); + XLog.d(TAG, "Saved memory #" + entry.id + ": " + (entry.userTask != null ? entry.userTask : "?")); + trimAndPersist(); + } + + /** + * Returns all memories as a formatted string suitable for injection + * into the system prompt. Most recent first, up to 10 entries. + */ + public static String recallForSystemPrompt() { + ensureLoaded(); + if (store.isEmpty()) return ""; + + // Sort most recent first + List sorted = new ArrayList<>(store); + sorted.sort(Comparator.comparingLong((Entry e) -> e.timestamp).reversed()); + + StringBuilder sb = new StringBuilder(); + sb.append("\n\n## 长期记忆(跨任务经验)\n"); + sb.append("以下是你过去执行任务的经验记录。参考它们可以加快任务完成速度、避免重复错误。\n\n"); + + int limit = Math.min(10, sorted.size()); + for (int i = 0; i < limit; i++) { + sb.append("### 记忆 #").append(i + 1).append("\n"); + sb.append(sorted.get(i).toPromptString()).append("\n"); + } + return sb.toString(); + } + + /** + * Searches memories by keyword (simple case-insensitive text match on + * userTask + summary + learnings). Most recent first. + */ + public static String search(String keyword) { + ensureLoaded(); + if (keyword == null || keyword.isEmpty()) return recallForSystemPrompt(); + + String lower = keyword.toLowerCase().trim(); + List matches = new ArrayList<>(); + for (Entry e : store) { + StringBuilder haystack = new StringBuilder(); + if (e.userTask != null) haystack.append(e.userTask).append(" "); + if (e.summary != null) haystack.append(e.summary).append(" "); + if (e.learnings != null) { + for (String l : e.learnings) haystack.append(l).append(" "); + } + if (haystack.toString().toLowerCase().contains(lower)) { + matches.add(e); + } + } + if (matches.isEmpty()) return "[记忆] 没有找到与 \"" + keyword + "\" 相关的历史经验。"; + + matches.sort(Comparator.comparingLong((Entry e) -> e.timestamp).reversed()); + + int limit = Math.min(5, matches.size()); + StringBuilder sb = new StringBuilder(); + sb.append("[记忆] 找到 ").append(matches.size()).append(" 条相关经验:\n\n"); + for (int i = 0; i < limit; i++) { + sb.append("--- #").append(i + 1).append(" ---\n"); + sb.append(matches.get(i).toPromptString()).append("\n"); + } + return sb.toString(); + } + + /** + * Returns the count of stored memories. + */ + public static int count() { + ensureLoaded(); + return store.size(); + } + + /** + * Clears all memories. + */ + public static void clear() { + store = new ArrayList<>(); + KVUtils.INSTANCE.putString(MMKV_KEY, "[]"); + } + + // ======================== Internal ======================== + + private static void ensureLoaded() { + if (store != null) return; + store = new ArrayList<>(); + String raw = KVUtils.INSTANCE.getString(MMKV_KEY, ""); + if (raw.isEmpty()) return; + try { + JsonArray arr = JsonParser.parseString(raw).getAsJsonArray(); + for (JsonElement elem : arr) { + Entry e = GSON.fromJson(elem, Entry.class); + if (e != null) store.add(e); + } + } catch (Exception e) { + XLog.w(TAG, "Failed to parse task memory, starting fresh", e); + store.clear(); + } + } + + private static int nextId() { + int max = 0; + for (Entry e : store) { + if (e.id > max) max = e.id; + } + return max + 1; + } + + private static void trimAndPersist() { + // Keep only the most recent MAX_ENTRIES + if (store.size() > MAX_ENTRIES) { + store.sort(Comparator.comparingLong((Entry e) -> e.timestamp).reversed()); + while (store.size() > MAX_ENTRIES) { + store.remove(store.size() - 1); + } + } + String json = GSON.toJson(store); + KVUtils.INSTANCE.putString(MMKV_KEY, json); + } +} diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 91d4184..f4661b4 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -196,6 +196,7 @@ 发送文件 等待 获取屏幕信息 + 长期记忆 完成任务 打开应用 获取应用列表 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4d7ef34..e682abf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -196,6 +196,7 @@ Send File Wait Get Screen Info + Remember Finish Task Open App Get Installed Apps