Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/src/main/java/com/apk/claw/android/TaskOrchestrator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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<ChatMessage>()
messages.add(SystemMessage.from(fullSystemPrompt))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ object ToolRegistry {
register(RepeatActionsTool())
register(ClipboardTool())
register(SendFileTool())
register(RememberTool())
register(FinishTool())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,7 +33,7 @@ public String getDescriptionEN() {

@Override
public String getDescriptionCN() {
return "通过可见文本查找元素,返回详细信息(类名、边界、属性)。适用于在交互前检查特定元素。";
return "通过可见文本查找元素,返回详细信息(类名、边界、属性)。适用于在交互前检查特定元素。会在长期记忆中缓存结果,下次查找优先命中缓存。";
}

@Override
Expand All @@ -50,21 +51,88 @@ public ToolResult execute(Map<String, Object> params) {
}

String text = requireString(params, "text");
List<AccessibilityNodeInfo> 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<AccessibilityNodeInfo> 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) {
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -46,16 +49,64 @@ public List<ToolParameter> 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<String, Object> 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++;
}
}
}
119 changes: 119 additions & 0 deletions app/src/main/java/com/apk/claw/android/tool/impl/RememberTool.java
Original file line number Diff line number Diff line change
@@ -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<ToolParameter> 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<String, Object> 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<String, Object> 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<String, Object> 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);
}
}
Loading