chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:04:19 +08:00
commit 46c3330e28
212 changed files with 65282 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
"""L4 Session Log Processor — compress & extract history.
Format A (JSON): kept as-is. Format B (Raw): strip sys prompt & assistant echo.
"""
import re, os, json, ast
from datetime import datetime
L4_DIR = os.path.dirname(os.path.abspath(__file__))
_RE_PROMPT = re.compile(r'^=== Prompt ===(?: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}))?', re.M)
_RE_RESPONSE = re.compile(r'^=== Response ===(?: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}))?', re.M)
_RE_USER = re.compile(r'^=== USER ===$', re.M)
_RE_ASST = re.compile(r'^=== ASSISTANT ===$', re.M)
_RE_ANY_MARKER = re.compile(r'^=== (?:Prompt|Response|USER|ASSISTANT) ===(?:.*)?$', re.M)
def _ts_fmt(ts_str):
"""'2026-04-03 20:13:06''0403_2013'"""
try: return datetime.strptime(ts_str.strip(), '%Y-%m-%d %H:%M:%S').strftime('%m%d_%H%M')
except Exception: return None
def _detect_format(text):
"""Detect A (json) vs B (raw) by checking content after first Prompt marker."""
m = _RE_PROMPT.search(text)
if not m: return 'unknown'
return 'json' if re.match(r'\s*\{', text[m.end():m.end()+200]) else 'raw'
def _parse_sections(text):
"""Split text into (type, marker_line, body) tuples."""
markers = list(_RE_ANY_MARKER.finditer(text))
if not markers:
return [('preamble', '', text)]
_MAP = {'Prompt': 'prompt', 'Response': 'response', 'USER': 'user', 'ASSISTANT': 'assistant'}
sections = []
if markers[0].start() > 0:
sections.append(('preamble', '', text[:markers[0].start()]))
for i, m in enumerate(markers):
line = m.group()
end = markers[i+1].start() if i+1 < len(markers) else len(text)
typ = next((v for k, v in _MAP.items() if line.startswith(f'=== {k}')), None)
if typ:
sections.append((typ, line, text[m.end():end]))
return sections
def compress_session(src, dst_dir=None):
"""Compress model_responses_xxx.txt → MMDD_HHMM-MMDD_HHMM.txt. Returns (dst, stats) or (None, reason)."""
dst_dir = dst_dir or L4_DIR
with open(src, 'r', encoding='utf-8', errors='replace') as f:
text = f.read()
timestamps = [m.group(1) for m in _RE_PROMPT.finditer(text) if m.group(1)]
if not timestamps: # fallback to Response timestamps
timestamps = [m.group(1) for m in _RE_RESPONSE.finditer(text) if m.group(1)]
if not timestamps:
return None, 'no timestamps found'
ts_first, ts_last = _ts_fmt(timestamps[0]), _ts_fmt(timestamps[-1])
if not ts_first:
return None, 'bad timestamp format'
name = f"{ts_first}-{ts_last or ts_first}.txt"
fmt = _detect_format(text)
compressed = _compress_raw(text) if fmt == 'raw' else text
if len(compressed.encode('utf-8')) < 4500:
return None, f'too small after compress ({len(compressed)}B)'
dst = os.path.join(dst_dir, name)
with open(dst, 'w', encoding='utf-8', newline='') as f:
f.write(compressed)
orig_kb, new_kb = os.path.getsize(src) // 1024, os.path.getsize(dst) // 1024
ratio = (1 - new_kb / max(orig_kb, 1)) * 100
return dst, {'src': os.path.basename(src), 'dst': name, 'fmt': fmt,
'orig_kb': orig_kb, 'new_kb': new_kb, 'ratio': f'{ratio:.0f}%',
'year': timestamps[0][:4]}
def _compress_raw(text):
"""Format B: strip system prompt (Prompt→USER) and assistant echo (ASSISTANT→Response)."""
sections = _parse_sections(text)
out = []
for i, (typ, line, body) in enumerate(sections):
if typ == 'prompt':
out.append(line + '\n')
if not (i+1 < len(sections) and sections[i+1][0] == 'user'):
out.append(body) # no USER follows → keep body
elif typ in ('user', 'response'):
out.append(line + '\n')
out.append(body)
elif typ == 'preamble':
out.append(body)
# assistant → skip (redundant echo)
return ''.join(out)
_RE_HISTORY = re.compile(r'<history>(.*?)</history>', re.S)
def _parse_history_block(raw):
"""Parse <history> block into ['[USER]...', '[Agent]...'] lines."""
lines = [l.strip() for l in raw.split('\n') if l.strip()]
parsed = [l for l in lines if l.startswith('[USER]') or l.startswith('[Agent]')]
if len(parsed) >= 2:
return parsed
# JSON format: literal \\n separators
joined = raw.strip()
if '\\n[USER]' in joined or '\\n[Agent]' in joined:
parts = joined.replace('\\n', '\n').split('\n')
parsed = [p.strip() for p in parts if p.strip() and (p.strip().startswith('[USER]') or p.strip().startswith('[Agent]'))]
if parsed: return parsed
return parsed or []
def _merge_history_blocks(all_blocks):
"""Merge sliding-window history blocks into one deduplicated list."""
if not all_blocks: return []
acc = list(all_blocks[0])
for block in all_blocks[1:]:
if not block: continue
if not acc:
acc = list(block); continue
best = 0
for k in range(1, min(len(acc), len(block)) + 1):
if acc[-k:] == block[:k]: best = k
if best > 0:
acc.extend(block[best:])
elif block[0] in acc:
idx = len(acc) - 1 - acc[::-1].index(block[0])
match_len = 0
for j in range(min(len(block), len(acc) - idx)):
if acc[idx + j] == block[j]: match_len = j + 1
else: break
acc.extend(block[match_len:])
else:
acc.extend(block)
return acc
def extract_history(src, session_name=None):
"""Extract [USER]/[Agent] history from session file."""
with open(src, 'r', encoding='utf-8', errors='replace') as f:
text = f.read()
if session_name is None:
session_name = os.path.splitext(os.path.basename(src))[0]
all_blocks = [parsed for m in _RE_HISTORY.finditer(text)
if (parsed := _parse_history_block(m.group(1)))]
if all_blocks:
return _merge_history_blocks(all_blocks)
return []
def format_history_block(session_name, history_lines):
"""Format history lines into all_histories.txt block format."""
sep = '=' * 60
return f"{sep}\nSESSION: {session_name}\n{sep}\n" + '\n'.join(history_lines) + '\n'
import tempfile, shutil, zipfile, glob
from collections import defaultdict
def _existing_sessions(l4_dir):
"""Read session names already in all_histories.txt."""
hist_path = os.path.join(l4_dir, 'all_histories.txt')
if not os.path.exists(hist_path): return set()
with open(hist_path, 'r', encoding='utf-8') as f:
return {l.strip().replace('SESSION: ', '') for l in f if l.startswith('SESSION: ')}
def batch_process(src, l4_dir=None, dry_run=True):
"""Batch compress + extract history + archive. dry_run=True is safe default."""
l4_dir = os.path.normpath(l4_dir or L4_DIR)
raw_files = sorted(src) if isinstance(src, (list, tuple)) else \
sorted(glob.glob(os.path.join(src, 'model_responses_*.txt')))
raw_files = sorted(raw_files, key=os.path.getmtime)[:-10] # always retain 10 newest raw files
if not raw_files:
print("No raw files found"); return {'processed': 0, 'skipped': 0, 'errors': 0, 'new_sessions': 0}
existing = _existing_sessions(l4_dir)
print(f"Found {len(raw_files)} raw, {len(existing)} existing in L4")
tmp_dir = tempfile.mkdtemp(prefix='cs_batch_')
results, skipped, errors = [], [], []
import time
cutoff = time.time() - 7200 # skip files modified within 2h
# Phase 1: Compress + Extract (to temp dir)
for fp in raw_files:
fname = os.path.basename(fp)
if os.path.getmtime(fp) > cutoff:
skipped.append((fname, 'recent(<2h)')); continue
try:
dst, info = compress_session(fp, tmp_dir)
if dst is None:
skipped.append((fname, info)); continue
sn = os.path.splitext(os.path.basename(dst))[0]
if sn in existing:
skipped.append((fname, f'dup:{sn}')); os.remove(dst); continue
results.append((sn, dst, extract_history(dst), info, fp))
except Exception as e:
errors.append((fname, str(e)))
results.sort(key=lambda x: x[0])
print(f"\nP1: {len(results)} new, {len(skipped)} skip, {len(errors)} err")
for f, r in skipped[:5]: print(f" SKIP {f}: {r}")
for f, e in errors[:5]: print(f" ERR {f}: {e}")
if results: print(f" Range: {results[0][0]}{results[-1][0]}")
if dry_run:
print("\n[DRY RUN] Pass dry_run=False to execute.")
shutil.rmtree(tmp_dir, ignore_errors=True)
return {'processed': len(results), 'skipped': len(skipped),
'errors': len(errors), 'new_sessions': len(results),
'sessions': [r[0] for r in results]}
# Phase 2: Append history
with open(os.path.join(l4_dir, 'all_histories.txt'), 'a', encoding='utf-8') as f:
for sn, _, hist, _, _ in results:
if hist: f.write('\n' + format_history_block(sn, hist))
print(f"Appended {len(results)} sessions to all_histories.txt")
# Phase 3: Archive to monthly zips
by_month = defaultdict(list)
for sn, cpath, _, info, _ in results:
year = info.get('year', '2026') if isinstance(info, dict) else '2026'
by_month[f"{year}-{sn[:2]}"].append((sn, cpath))
for mk, items in sorted(by_month.items()):
zpath = os.path.join(l4_dir, f"{mk}.zip")
mode = 'a' if os.path.exists(zpath) else 'w'
with zipfile.ZipFile(zpath, mode, zipfile.ZIP_DEFLATED) as zf:
names = set(zf.namelist()) if mode == 'a' else set()
for sn, cp in items:
if f"{sn}.txt" not in names: zf.write(cp, f"{sn}.txt")
print(f" {mk}.zip: +{len(items)}")
# Phase 4: Delete raw files
to_del = [rp for *_, rp in results]
for fname, reason in skipped:
if 'recent' in reason: continue # active session still being written
m = [f for f in raw_files if os.path.basename(f) == fname]
if m: to_del.append(m[0])
deleted = 0
for rp in to_del:
try: os.remove(rp); deleted += 1
except Exception: pass
print(f"Deleted {deleted}/{len(to_del)} raw files")
shutil.rmtree(tmp_dir, ignore_errors=True)
report = {'processed': len(results), 'skipped': len(skipped),
'errors': len(errors), 'new_sessions': len(results), 'deleted_raw': deleted}
print(f"\nDone: {report}")
return report
# ── CLI ──
RAW_DIR = os.path.join(os.path.dirname(os.path.dirname(L4_DIR)), 'temp', 'model_responses')
if __name__ == '__main__':
import argparse
ap = argparse.ArgumentParser(description='L4 session archiver')
ap.add_argument('src', nargs='?', default=RAW_DIR, help='raw files dir')
ap.add_argument('--run', action='store_true', help='actually execute (default: dry run)')
args = ap.parse_args()
batch_process(args.src, dry_run=not args.run)
@@ -0,0 +1,65 @@
# 历史重点挖掘 SOP
从用户历史对话中挖掘值得长期保留的重点:情绪事件、持续活动、已消失事项。
## 数据源
`../memory/L4_raw_sessions/all_histories.txt``compress_session.py` 产出)。不存在则先运行脚本。只读 user 内容。
## 执行约束
- 增量:维护已处理 session 列表,每次只扫新 session
- 分批处理时不拆分同一 session
## 产物
`./history_insight/`(不存在则创建),格式自定。产物是数据库,不是报告——每条发现都是下游任务的输入,遗漏即损失。
产物包含三个持久状态(可以是多个文件或一个文件内的多个区域):
1. **活动知识层** — 每次扫描读取、更新、写回。记录所有识别到的活动及其最终分类。
2. **情绪事件列表** — 追加式,只增不改。
3. **增量标记** — 记录最后处理到的 session 标识,下次从此处之后开始。
每条发现必须含:
- session 标识(与 L4 zip 内文件名一致,如 `0403_2013-0403_2145`
- 关键原文片段(可 grep 定位回原日志)
- 发现类型标签
## 提取标准
### 情绪事件
标记语气上的明显波动,不是内容上的。
**标记**:愤怒/质问/责备、讽刺挖苦、惊喜感激、反复纠正后语气突变、沮丧/无奈、预期落空后的失望或方向突变。
**不标记**:纯功能指令、语气平和的反馈、讨论负面话题但本人情绪稳定。
### 持续活动与已消失事项
维护一个持久的活动知识层(存 `./history_insight/`)。这是跨次运行的持久状态——每次扫描读取它、更新它、写回它。它不是报告,是数据库。
**你在建模的是**:这个用户的生活里现在有什么、曾经有什么。活动识别的唯一证据是用户在session中主动发起的请求或讨论——系统提示词、SOP列表、记忆引用中的被动出现不构成证据,不能从"系统里存在某个SOP"推断用户在做某事。
- 持续活动 = 仍然存在于用户生活中的事——值得深入了解其细节
- 已消失 = 曾经存在但已离开的事——可能导致已有记忆过时
判断"离开"不需要用户明确表态。事项本身的性质就是证据——有终点的事做完了就是消失了,没终点的事沉默不代表消失。
每个条目必须归入二者之一。
归类原则:
- 相同专有名词/工具名/项目名 → 同一条目
- 通用动作不单独成条目,除非反复出现于同一领域
- 宁多建不错合并
每条记录含:涉及的 session 列表、代表性原文片段、出现频次。
## 坑点
- 用户消息的含义不在关键词里,在语气和上下文里。脚本扫描只能看到主题,看不到情绪、看不到习惯、看不到事项的生命周期变化。必须阅读原文。
- session 标识必须与 L4 zip 内文件名一致(如 `0403_2013-0403_2145`),不能用模糊占位(如 xxxx)——无法定位回原日志的记录没有价值
- 情绪判断看语气不看内容——讨论负面话题但本人情绪稳定不标记
- 活动归类是"用户在做什么"的知识表示,不是对话摘要
- 写入产物前检查一致性:同一条目不能同时出现在"持续"和"已消失"中。如果阅读时收集到矛盾信号(频次高 vs 已停止),必须做最终裁决再写入
- "持续"和"已消失"是两个独立分类,不能合并为一个列表用子状态(如"已消退")规避。产物中必须有明确分开的两个区域或两个文件
+86
View File
@@ -0,0 +1,86 @@
# adb_ui.py - 一键dump+解析Android UI (u2优先,原生fallback)
# 要先看 computer_use.mddump配合ui_detect,微信/支付宝小程序常需detect补盲
# 搜索框一般直接输入拼音/首字母即可,别硬啃 adb 中文输入
# u2 (uiautomator2) 不受idle限制,适合动画密集app(美团等)
# 弹窗检测: ui(clickable_only=True, raw=True) 找全屏FrameLayout+底部小ImageView(关闭X)
# 已知包名: 美团外卖=com.sankuai.meituan.takeoutnew 淘宝=com.taobao.taobao
import subprocess, xml.etree.ElementTree as ET, os, re, shutil
ADB = shutil.which("adb") or "adb"
LOCAL_XML = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_mt.xml")
def _dump_u2():
"""用uiautomator2 dump,不受idle限制"""
try:
import uiautomator2 as u2
d = u2.connect()
xml_str = d.dump_hierarchy()
if xml_str and len(xml_str) > 100: return xml_str
except Exception as e:
print(f"[u2 fallback] {e}")
return None
def _dump_native():
"""原生uiautomator dump(需idle状态)"""
subprocess.run([ADB, "shell", "rm", "-f", "/sdcard/ui.xml"], capture_output=True)
r = subprocess.run([ADB, "shell", "uiautomator", "dump", "--compressed", "/sdcard/ui.xml"],
capture_output=True, text=True, timeout=15)
if "dumped" not in r.stdout.lower() and "dumped" not in r.stderr.lower(): print(f"dump failed: {r.stdout}{r.stderr}"); return None
subprocess.run([ADB, "pull", "/sdcard/ui.xml", LOCAL_XML], capture_output=True, timeout=10)
with open(LOCAL_XML, "r", encoding="utf-8") as f:
return f.read()
def _parse_xml(xml_str, keyword=None, clickable_only=False, raw=False):
"""解析XML字符串为节点列表"""
root = ET.fromstring(xml_str)
nodes = []
for n in root.iter("node"):
pkg = n.get("package", "")
if "termux" in pkg.lower(): continue
text = n.get("text", "")
desc = n.get("content-desc", "")
bounds = n.get("bounds", "")
click = n.get("clickable") == "true"
cls = n.get("class", "").split(".")[-1]
rid = n.get("resource-id", "")
label = text or desc
if not label and not click and not raw: continue
if clickable_only and not click: continue
if keyword and keyword.lower() not in label.lower(): continue
cx, cy = 0, 0
if bounds:
m = re.findall(r'\[(\d+),(\d+)\]', bounds)
if len(m) == 2:
cx = (int(m[0][0]) + int(m[1][0])) // 2
cy = (int(m[0][1]) + int(m[1][1])) // 2
edit = cls == "EditText"
nodes.append({"text": text or desc, "click": click, "edit": edit, "cx": cx, "cy": cy, "cls": cls, "rid": rid})
return nodes
def ui(keyword=None, clickable_only=False, raw=False):
"""一键dump+解析Android UI (u2优先)
keyword: 过滤含关键词的节点
clickable_only: 只显示可点击节点
raw: 返回原始节点列表而非打印
"""
xml_str = _dump_u2() or _dump_native()
if not xml_str: print("dump failed (both u2 and native)"); return []
nodes = _parse_xml(xml_str, keyword, clickable_only, raw)
if not raw:
for n in nodes:
flag = "E" if n.get("edit") else ("Y" if n["click"] else " ")
coord = f"({n['cx']},{n['cy']})" if n['cx'] else ""
display_text = n['text']
if not display_text:
hint = n.get('rid', '').split('/')[-1] or n.get('cls', 'icon')
display_text = f"<{hint}>"
print(f"[{flag}] {display_text} {coord}")
print(f"\ntotal: {len(nodes)} nodes")
return nodes
def tap(x, y):
subprocess.run([ADB, "shell", "input", "tap", str(x), str(y)], capture_output=True)
print(f"tap({x},{y}) ok")
if __name__ == "__main__":
ui()
+45
View File
@@ -0,0 +1,45 @@
# 自主行动 SOP
⚠️ **路径警告**autonomous_reports 在 temp/ 下,用`./autonomous_reports/`访问,**不是**`../memory/autonomous_reports/``../autonomous_reports/`TODO在cwd下。
报告存于 `./autonomous_reports/`,文件名 `RXX_简短描述.md`XX从 history.txt 推断自增)。
授权你进行自主行动,只要不对环境造成副作用都可进行。
## 启动(第一步)
- update_working_checkpoint: `自主行动|收尾时重读SOP | from autonomous_operation_sop.helper import *; set_todo()/complete_task(tasktitle, historyline, report_path)`
第二步:
```python
from autonomous_operation_sop.helper import *
print(get_history(40)) # 了解历史避免重复
print(get_todo()) # 查看待办
```
## 任务选择
- 有未完成条目 → 取**一条**,直接进入执行,其他条目下次执行
- 无 TODO → 读 `autonomous_operation_sop/task_planning.md` 规划,下次执行
- 不连续两次选相同子任务
- 价值公式:**「AI训练数据无法覆盖」×「对未来协作有持久收益」**
## 执行
- 选定任务后 update_working_checkpoint,将选中的TODO条目和执行注意事项 append 到 checkpoint 中
- 调用code_run准备结尾回调, script为handler._done_hooks.append("重读自主任务sop,检查你刚刚的收尾工作是否正确,不正确则改正")inline_eval=True (秘密参数)
- 小步快跑,边探测边实验
- 用临时脚本验证假设;禁只读即下结论,完整验证再写报告
- 即使失败也记录实验过程和结果,失败报告同样有价值
- 用户不在线,遇到需要决策的问题写入报告待审,不要卡住
**收尾(4件事缺一不可)**
1. 重读本sop
2. 在cwd写报告(文件名任意),若有记忆更新建议,附在报告末尾
3. `from/import helper; complete_task(tasktitle, historyline, report_path)` → 自动编号+移报告到 autonomous_reports/+prepend historyhistoryline 格式:`类型 | 主题 | 结论`,严格单行)
4. `set_todo()` 获取TODO路径 → 将已完成条目标记为 `[x]`(注意前缀)
5. 结束,剩余TODO留到下次再做
## 权限边界
- 无需批准:只读探测、cwd内写操作/脚本实验
- 需写入报告待审:修改 global_mem / memory下SOP、安装软件、外部API调用、删除非临时文件
- 绝对禁止:读取密钥、修改核心代码库、不可逆危险操作
## 等待用户审查
- 用户归来后审查报告,决定批准、修改或拒绝方案
+137
View File
@@ -0,0 +1,137 @@
"""
autonomous_task.py - 自主行动任务管理API
放置: memory/autonomous_operation_sop/
用法: import autonomous_task (或 from autonomous_operation_sop import autonomous_task)
4个函数:
get_todo() → 返回TODO内容
get_history(n) → 返回最近n条历史
complete_task() → 移报告+编号+写history+返回改TODO指令
set_todo() → 返回TODO真实路径
"""
import os
import re
import shutil
from pathlib import Path
from datetime import datetime
# ── 路径计算(基于模块自身位置) ──
_MODULE_DIR = Path(__file__).resolve().parent # memory/autonomous_operation_sop/
_MEMORY_DIR = _MODULE_DIR.parent # memory/
_AGENT_DIR = _MEMORY_DIR.parent # GenericAgent/
_TEMP_DIR = _AGENT_DIR / "temp" # GenericAgent/temp/
_REPORTS_DIR = _TEMP_DIR / "autonomous_reports"
_HISTORY_FILE = _REPORTS_DIR / "history.txt"
_TODO_FILE = _TEMP_DIR / "TODO.txt"
def _next_report_number() -> int:
"""扫 history.txt 第一行提取最大 RXX 编号,返回下一个"""
if not _HISTORY_FILE.exists():
return 1
with open(_HISTORY_FILE, "r", encoding="utf-8") as f:
content = f.read()
# 匹配所有 R 后跟数字的模式
nums = [int(m) for m in re.findall(r'R(\d+)', content)]
if not nums:
return 1
return max(nums) + 1
def get_todo() -> str:
"""返回 TODO.txt 的内容。若文件不存在返回提示。"""
if not _TODO_FILE.exists(): return f"[autonomous_task] TODO.txt 不存在,路径: {_TODO_FILE}"
with open(_TODO_FILE, "r", encoding="utf-8") as f: return f.read()
def get_history(n: int = 20) -> str:
"""返回 history.txt 的前 n 行(最新在前)。"""
if not _HISTORY_FILE.exists():
return f"[autonomous_task] history.txt 不存在,路径: {_HISTORY_FILE}"
with open(_HISTORY_FILE, "r", encoding="utf-8") as f:
lines = f.readlines()
return "".join(lines[:n])
def set_todo(*args, **kwargs) -> str:
"""返回 TODO.txt 的真实绝对路径,供 agent/子agent 自行读写。"""
return f'路径: {str(_TODO_FILE)}'
def complete_task(taskname: str, historyline: str, report_path: str) -> str:
"""
完成任务的原子操作:
1. 移动 report_path → autonomous_reports/R{XX}_{taskname}.md(自动编号)
2. prepend historyline 到 history.txt(校验必须单行)
3. 返回字符串指示 agent 自己去改 TODO
Args:
taskname: 任务简短名称(用于报告文件名,如 "晨间简报"
historyline: 历史记录内容(必须单行,日期自动添加,如 "工程 | 晨间简报 | 完成7模块聚合"
report_path: agent 已写好的报告文件路径(绝对或相对于cwd)
Returns:
成功消息 + 改TODO指令,或错误消息
"""
errors = []
# ── 校验 ──
if "\n" in historyline.strip():
return "[ERROR] historyline 必须是单行,不能包含换行符"
report = Path(report_path).resolve()
if not report.exists():
return f"[ERROR] 报告文件不存在: {report_path}"
if not _REPORTS_DIR.exists():
_REPORTS_DIR.mkdir(parents=True, exist_ok=True)
# ── 1. 移动报告 ──
rnum = _next_report_number()
# 清理 taskname 中的非法文件名字符
safe_name = re.sub(r'[<>:"/\\|?*]', '_', taskname).strip()
dest_name = f"R{rnum}_{safe_name}.md"
dest_path = _REPORTS_DIR / dest_name
try:
shutil.move(str(report), str(dest_path))
except Exception as e:
return f"[ERROR] 移动报告失败: {e}"
# ── 2. prepend history ──
# 自动加编号 + 日期(剥离 agent 可能已写的编号/日期,统一重建)
line = historyline.strip()
line = re.sub(r'^R\d+\s*\|\s*', '', line) # 剥离 R 编号
line = re.sub(r'^\d{4}-\d{2}-\d{2}\s*\|\s*', '', line) # 剥离日期
today = datetime.now().strftime('%Y-%m-%d')
line = f"R{rnum} | {today} | {line}"
try:
existing = ""
if _HISTORY_FILE.exists():
with open(_HISTORY_FILE, "r", encoding="utf-8") as f:
existing = f.read()
with open(_HISTORY_FILE, "w", encoding="utf-8") as f:
f.write(line + "\n" + existing)
except Exception as e:
# 回滚:把报告移回去
try:
shutil.move(str(dest_path), str(report))
except:
pass
return f"[ERROR] 写入 history 失败: {e}(报告已回滚)"
# ── 3. 返回改 TODO 指令 ──
return (
f"✅ 完成!报告已保存: {dest_name}\n"
f"历史已记录: {line}\n"
f"👉 请在 {_TODO_FILE} 中将对应任务标记为 [x] R{rnum},然后结束,**其他TODO下次再干**"
)
# ── 快速自检 ──
if __name__ == "__main__":
print(f"TEMP_DIR: {_TEMP_DIR}")
print(f"REPORTS_DIR: {_REPORTS_DIR}")
print(f"HISTORY: {_HISTORY_FILE}")
print(f"TODO: {_TODO_FILE}")
print(f"Next R#: R{_next_report_number()}")
print(f"\n--- TODO ---\n{get_todo()[:200]}")
print(f"\n--- History (5) ---\n{get_history(5)}")
@@ -0,0 +1,41 @@
# 任务规划模式
- **有TODO**cwd下 `TODO.txt` 有待执行条目 → 直接跳到「执行流程」
价值公式:**「AI训练数据无法覆盖」×「对未来协作有持久收益」**。核心产出是记忆——有价值的发现整理为记忆更新提案纳入报告。
## 流程入口:
- **无TODO → 进入任务规划模式**(本轮不执行任务,专注规划):
0. update_working_checkpoint: `规划模式:产出TODO后必须经过subagent评审,不允许自评审,禁止执行任何TODO,等待下次自主行动进入执行模式`
1. ⚠️ **批判性读history.txt**:99%历史任务是**低价值的**,读取目的是**识别失败模式并避免**,而非寻找模仿对象
- 识别低价值模式:浅层验证、无假设巡检、重复探索、泛采集、知名工具基础用法
- 提炼高价值线索:未跟进的发现、待实测工具、可改进产出
- 避免和历史重复和低价值边际效应
2. 反思:为什么这些任务低价值?如何设计才能高价值?
3. **批判性**盘点已有报告和记忆(ls autonomous_reports/ + ../memory),考虑如何发挥更大价值或优化
4. 综合以上,产出5-7条TODO写入 `TODO.txt`TODO已完成内容可压缩丢后面
5. 每条格式:`[ ] 类型(产出/冲浪/环境) | 一句话目标 | 验收标准`
6. **必须召唤subagent评审TODO**(保证可信三方):input仅给TODO列表+"读记忆库和本sop自行判断,逐条评分1-10并简述理由"(不喂额外先验信息)
7. **召唤失败无限重试**TODO必须经过subagent评审,不允许自评审,未经评审的TODO不可执行
8. 读subagent评分,低分项删除或替换
9. 立刻**结束**,下次行动再执行
## 目标排序(按价值递减):
1. **实用产出与能力扩展**:写工具解决痛点,在已有能力上解锁新能力(能力树每多一个节点,可能性空间变大)
2. **环境发现**:扫描已有但未利用的工具/库/数据源/配置
3. **小众工具挖掘**:在GitHub/吾爱破解/果核剥壳**等**找冷门实用工具,实测AI常推荐但有坑的方案
4. **了解用户与推荐**:分析老代码/PC文件/书签推断偏好,给出个性化推荐(游戏/视频/工具附理由)(低频)
5. **自身演进**:思考框架不足,提出改进方案
6. **记忆审查**:修正错误或过时记录
**大型任务**:允许设计**有价值**的大型任务,将其分解成若干个模块或步骤,写入TODO中,每次自主行动执行处理一个模块。
选择原则:个性化优先(只有探测这台PC才能获得的知识)→ 盲区优先(自身参数无法复现,有一定难度)→ 假设驱动(明确要验证什么,边探测边实验)→ 禁止低价值验证(不验证静态配置、不做无假设巡检、不做你轻易完成的工作)
## 探测策略(聚焦原则,非菜单):
- **能力树扩展**:优先能解锁新能力节点的工具/技能(一个节点带来多种可能性)
- **个性化优先**:只有探测这台PC/这个用户才能获得的知识 > 通用知识
- **线索驱动**:近期报告中提炼的后续任务
- 冲浪规则:每次≤2话题,必须读正文提炼洞察,禁标题搬运;发现好工具→下轮TODO加实测任务
禁区:❌ Hacker News · 刷新闻头条 · 泛采集标题/无目标刷新闻 · 探索知名工具基础用法 · 调研弱于当前框架的AI工具 · 调研其他web自动化/computer use框架 · 读取自身代码库 · 使用im工具发送信息
+102
View File
@@ -0,0 +1,102 @@
# checklist_helper.py — CL(folder) 一站式任务清单(支持 checklist/mapreduce 两种模式)
import json, time, subprocess, socket, sys
from pathlib import Path
_R = Path(__file__).resolve().parent.parent
_BBS, _MAIN = _R/"assets/agent_bbs.py", _R/"agentmain.py"
_W_RE, _M_RE = _R/"reflect/agent_team_worker.py", _R/"reflect/checklist_master.py"
_PK = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
if sys.platform == "win32": _PK["creationflags"] = 0x200
class CL:
def __init__(self, folder, goal="", workers=0):
"""
workers=0: checklist模式,master自己逐个执行,不启动BBS
workers>0: mapreduce模式,启动BBS+N个worker并行
"""
self.folder = Path(folder); self.folder.mkdir(parents=True, exist_ok=True)
self.path = self.folder / "state.json"
self.workers = workers
if self.path.exists(): self._d = json.loads(self.path.read_text("utf-8"))
else:
self._d = {"closed": False, "goal": goal, "bbs": None, "tasks": []}
self._save()
if workers > 0:
self._ensure_bbs()
self.start_worker(workers)
@property
def tasks(self): return self._d["tasks"]
@property
def closed(self): return self._d.get("closed", False)
@property
def has_open(self): return any(t["result"] is None for t in self.tasks)
@property
def bbs_url(self): return self._d["bbs"]["url"] if self._d["bbs"] else None
@property
def bbs_key(self): return self._d["bbs"]["key"] if self._d["bbs"] else None
@property
def mode(self): return "mapreduce" if self._d["bbs"] else "checklist"
def _save(self): self.path.write_text(json.dumps(self._d, ensure_ascii=False, indent=1), "utf-8")
def _ensure_bbs(self):
if self._d["bbs"]: return
with socket.socket() as s: s.bind(('',0)); port = s.getsockname()[1]
key = f"cl_{int(time.time())%1000}"
(self.folder/"bbs").mkdir(exist_ok=True)
subprocess.Popen(["python", str(_BBS), "--cwd", str(self.folder/"bbs"),
"--port", str(port), "--key", key], **_PK)
time.sleep(1)
self._d["bbs"] = {"url": f"http://127.0.0.1:{port}", "key": key}
self._save()
def add(self, texts):
nid = max((t["id"] for t in self.tasks), default=0) + 1
ids = []
for t in texts:
self.tasks.append({"id": nid, "text": t, "result": None, "ts": int(time.time())})
ids.append(nid); nid += 1
self._save();
print('task added, must reread checklist SOP before start executing ...');
return ids
def mark(self, tid, result):
for t in self.tasks:
if t["id"] == tid: t["result"] = result; t["ts"] = int(time.time()); break
self._save()
def look(self):
done = sum(1 for t in self.tasks if t["result"] is not None)
lines = [f"[{done}/{len(self.tasks)}] mode={self.mode}"]
for t in self.tasks:
l = f'{"" if t["result"] else ""} #{t["id"]} {t["text"][:60]}'
if t["result"]: l += f'{t["result"][:60]}'
lines.append(l)
return "\n".join(lines)
def close(self):
assert not self.has_open, "has open tasks"
self._d["closed"] = True; self._save()
def start_worker(self, n=None):
n = n or self.workers or 1
if n <= 0: return
for i in range(n):
subprocess.Popen(["python", str(_MAIN), "--reflect", str(_W_RE),
"--base_url", self.bbs_url, "--board_key", self.bbs_key, "--name", f"w{i+1}"], **_PK)
if i < n - 1: time.sleep(5)
def _pid_alive(self, pid):
if not pid: return False
try:
r = subprocess.run(["tasklist", "/FI", f"PID eq {pid}"], capture_output=True, text=True)
return str(pid) in r.stdout
except Exception: return False
def start_master(self):
old_pid = self._d.get("master_pid")
if old_pid and self._pid_alive(old_pid):
print(f"[CL] master already running (PID {old_pid}), skip")
return
p = subprocess.Popen(["python", str(_MAIN), "--reflect", str(_M_RE),
"--mr_folder", str(self.folder.resolve())], **_PK)
self._d["master_pid"] = p.pid; self._save()
+97
View File
@@ -0,0 +1,97 @@
# Checklist SOP
## Booter(启动者/用户)
**Checklist 模式**(单人,master自己执行):
```python
from checklist_helper import CL
cl = CL("cl_xxx", goal="<用户要求任务,尽量原样>")
cl.start_master() # Only for BooterMaster严禁调用
```
**MapReduce 模式**(多人,master派发+worker执行):
```python
from checklist_helper import CL
cl = CL("cl_xxx", goal="<用户要求任务,尽量原样>", workers=2)
cl.start_master() # Only for BooterMaster严禁调用
```
goal 写法:只写「做什么 + 参考哪个SOP」,不写怎么做。Master 自己读 SOP 决定 plan。
## Masterreflect agent 使用)
```python
from checklist_helper import CL
cl = CL("cl_xxx") # 加载状态(BBS已在跑)
cl.add(["任务1", "任务2"]) # 在你的笔记中记录TODO项
cl.look() # 查进度
cl.mark(id, "摘要") # 验收
cl.close() # 全部完成后关闭
```
## Master plan示例
目标可分解为多个**不相干、可并行**的子任务 → add 子任务。
B 要等 A 的结果 → 不要硬拆,串行做。
任务使用短句,派发时再补充信息。
1. 下载网盘 /game 下所有文件
→ 先 webscan 拿文件列表,再每个文件一条任务
`cl.add(["下载A.exe", "下载B.zip", "下载C.zip"])`
2. 从语法、风格、格式角度检查 a.pdf
→ 三个维度天然独立
`cl.add(["检查语法", "检查风格", "检查格式"])`
3. 查所有 VPS 中版本 < 22 的,升级到 24
→ 第一轮:每台一条查版本任务
`cl.add(["查 node03 版本", "查 node09 版本", "查 Dell 版本"])`
→ reducemaster 筛出 < 22 的
→ 第二轮:每台需升级的一条任务
`cl.add(["升级 node03 到 24", "升级 Dell 到 24"])`
## Master 循环
```
cl.look()
├─ 有未完成任务 → 去 BBS 派发(mapreduce模式)/ 自己干(无worker checklist模式)
└─ 全部完成
├─ 用户最终目标已达成 → close()
└─ 最终目标未达成 → plan 下一步
├─ 可解耦 → add() 新一批任务
├─ 需串行前置 → 自己做一步,再回 look
└─ 基本搞定 → 自己整合结果,交付最终报告
```
master会被持续唤醒直到其显式成功调用close()。
## 派发任务(有workers模式下)
worker无法看到add的任务,只能看到BBS!
每条任务 prompt 须**自包含**——worker 没有 master 的上下文。
每次最多只派发3个任务,不要一次性把所有任务贴到bbs上。
worker足够聪明,只允许写目标和需要的信息,不要干预
**master不允许执行已经派发出去的任务,会导致重复执行!** 没事就sleep
写 prompt 要点:
1. **背景**:worker 需要的信息直接给(路径、数据、约定),不要假设 worker 知道
2. **交付物**:明确产出什么、格式、写到哪里
3. **不限手段**:说要什么结果,别规定怎么做
4. **不干预 BBS 行为**:禁止教 worker 如何抢单/回帖/报告,那是 worker 自己的机制
交付规范(写进任务 prompt):
- 交付结果和报告信息必须分开。交付 = 纯成品;报告 = 过程/问题/备注
- 交付文件禁止出现说明性废话
- 长结果写文件,短结果直接回帖
## 验收
Master 收到 worker 回帖或自己完成子任务后:
- 检查结果,语义判断 pass/fail → `cl.mark(id, "结果摘要")`
- 交付物含过程废话 → 要求重写交付物
- 失败 → 可重发、换 prompt、或自己补
## 注意
- 若子任务需要 web 工具,提醒并行 worker 新建 tab 并使用自己的 tab
+42
View File
@@ -0,0 +1,42 @@
# 什么是好的代码
好的代码不是"能跑就行",而是在长期演化中保持**压缩性、局部性、可组合性与可证伪性**。
一句话判断:同样的功能,用最小必要的结构实现——概念少,覆盖广,变化不扩散。
---
## 一、模块边界清晰
每个模块只做一件事,依赖方向稳定,指向抽象而非细节。改 A 不需要连带动 B、C、D,没有循环依赖,没有到处互相 import。
## 二、局部可推理
看一个文件、一个函数,就能判断它的行为、代价和失败模式。不需要全局搜索隐式约定、全局状态或魔法配置才能读懂。
## 三、可组合
小组件能自然地组合成大能力,接口一致、正交,不需要为组合写特例。"这个只能在那种情况下用"是坏信号。
## 四、变化半径小
改一个需求,改动集中、可预测,回归风险可控。而不是改动像地震,到处打补丁,回归测试覆盖不了心里的担忧。
## 五、复杂度线性增长
新增一个功能,新增代码量近似线性,重复少。而不是功能越多代码越膨胀,出现大量相似分支和 if-else 森林。
## 六、约束写进代码
关键不变量和约束写进类型、接口、校验、状态机,错误尽早暴露。而不是靠调用者"记得要先做 X 再做 Y",靠注释和口口相传。
## 七、可测试、可观测
依赖可注入,单测容易写,日志和指标能定位因果链。而不是只能端到端测,一出问题就黑盒,难以复现。
## 八、一致且不意外
命名、错误处理、资源管理、并发模型全局一致,很少有反直觉的地方。而不是同一类问题三套解法,新人踩坑全靠运气。
## 九、自解释,注释极简
代码本身就是文档。命名、结构、流程足以说明意图,注释只出现在真正难以一眼看懂或容易误读的地方。如果一段代码需要大段注释才能读懂,说明代码本身该重写。
## 十、代码极简,视觉均匀
行数尽量少,不写多余的代码。每行长度大致平均,避免忽长忽短的锯齿感。简洁不是压缩,是没有废话。
## 十一、函数式倾向,减少副作用
优先纯函数,输入决定输出,减少隐藏的状态突变。但不教条——如果一个全局变量能显著降低整体复杂度,接受它。目标是整体简单,不是局部纯粹。
## 十二、功能越多,代码应该越短
好的抽象让新功能复用已有结构,而不是堆砌新代码。功能翻倍但代码量没怎么涨,说明抽象到位了。反过来,功能越加代码越膨胀,是架构在退化。
## 十三、为未来的接入性设计
写代码时设想:这段逻辑未来会被别的模块调用吗?会被外部系统接入吗?好代码天然留有干净的调用入口,而不是写死在某个特定场景里,等需要复用时才发现得大改。
## 十四、Let it crash——按失败半径决定防御策略
半径大的错误显式报错、快速中断;半径为零的静默放过。不分轻重地到处 try-catch,反而把真正需要暴露的问题吞掉了。
## 十五、篇幅分布跟着功能分布走
一个函数里,主功能占大部分代码,兜底和错误处理压到最短。如果防御性代码比正事还长,说明结构有问题。读代码应该一眼看出主线,而不是在 fallback 里找。
---
## 快速自检
拿到一段代码,问自己四个问题:
1. **我能不能不看全局,就安全地改一个局部?**
2. **有没有一个清晰的核心抽象,让新功能主要是"加新实现"而不是"改旧逻辑"**
3. **变化点是收敛在边界上,还是散落在各处?**
4. **出故障时,能快速定位到责任模块,还是全员背锅?**
四个问题都能干脆地答"是",就是好代码。
+48
View File
@@ -0,0 +1,48 @@
# computer_use
相关L3 memory: **ui_detect.py** ljqCtrl.py/ljqCtrlBg.py ljqCtrl_sop.md
## 0. GUI操作节奏建议
进入新界面时,建议先只探测不操作:枚举窗口 + UIA + ljqCtrl截图 + ui_detect,读完实际输出再决定下一步
明确一个操作后,可以在同一轮执行该动作,短暂等待,再立刻枚举窗口 + 截图/ui_detect 验证新状态;不要在未知状态下把多步决策写进大脚本
尽量不要预测关键词筛候选,应看 detect 输出、坐标、层级和上下文判断
若确定UIA可用则少用ui_detect/ljqCtrl;若UIA不可用,则后续不用UIA
## 1. 基础规则
### 探测/定位四工具(按优先级降级,前者无效才用后者)
| 工具 | 用法时机 | 能力/适用 | 限制 |
|------|----------|-----------|------|
| 0 win32gui 窗口枚举 | 始终先行,总是可用 | 枚举标题/类名/rect,确定目标窗口、前台状态、客户区原点 | 仅定位窗口,不进控件 |
| 1 Python UIA(控件树)| 首选探测+操作 | 控件树可用时,探测与操作(含免坐标点击)都用 UIA | **游戏禁用**;对该窗口一旦无效则后续也不用 |
| 2 ui_detect.py(配合ljqCtrl截图) | 1无效时才用 | 截图视觉检测控件,返回 bbox+OCR 文本 | bbox 是截图内坐标需转屏幕物理坐标 |
| 3 vision(VLM) | 2仍不足时才用 | 仅语义理解、确认界面状态、辅助判断目标 | 不可信其坐标 |
Windows 下窗口截图和操作使用 ljqCtrl:严禁 pyautogui;记得先 Activate 到前台(除非用户明确要求后台操作或后台操作失效)
ui_detect附送OCR,不要单独使用OCR
用PIL传输图像,或者用统一1.png存储截图,不要创建大量截图文件
ui_detect 的 bbox 是截图内坐标,点击前必须用 `ClientToScreen(hwnd,(0,0))/dpi_scale + bbox中心` 转屏幕物理坐标
坐标转换禁用 `GetWindowRect` 或 DWM 窗口矩形直接加截图坐标(含标题栏/边框/阴影会错位)
ljqCtrl.Click 后会返回像素/前台变化,0% 或近 0% 变化立即停下诊断,禁止盲目重试。
ljqCtrl 失效或目标为网络游戏时,必须使用硬件键鼠 Xbananakb / Arduino Leonardo(如有)
网络游戏除非用户明确允许,严禁普通键鼠事件,必须硬件执行。
临时截图/可视化文件用后清理,或固定文件名覆盖,避免堆积。
ui_detect 可跨端复用;手机端沿用本原则时,UIA 换成 ui dump/adb_uiljqCtrl 控制换成 adb
### 重要必坑
坑1-遮盖/失焦:混乱时枚举窗口确认前台;
坑2-DPI:必须先import ljqCtrl,之后一律使用物理坐标;
## macOS 平台
macOS 定位链与 §1 一致,工具映射如下:
- 控制层:`import macljqCtrl as ljqCtrl`(替代 Windows ljqCtrl
- 窗口枚举:`ListWindows()` → 返回 id/app/title/bbox/pid(替代 win32gui
- 激活:`ActivateApp(pid)`pid 来自 ListWindows,禁止用名字子串——同厂商 bundle 前缀会误伤)
- UIA 层 = AX 辅助功能 API`AXElements(pid)` 枚举控件树 → role/desc/title/id/物理坐标 xywh`AXFind(pid, role=, desc=, title=)` 过滤;`AXPress(el)` 免坐标点击
- 坐标:AX 返回逻辑点,库内自动 /dpi_scale 转物理像素,与 Click/Screenshot 统一;Retina 默认 scale=0.5
- 截图:`GrabWindow(window_id)``ScreenCapAt(x, y, radius)`(物理坐标)
- 权限:首次使用需授予「辅助功能」权限(系统设置 > 隐私与安全 > 辅助功能);`AXIsProcessTrusted()` 检测
- 依赖:`pip install pyobjc-framework-ApplicationServices`(AX 相关,软依赖——未装时键鼠/截图正常,仅 AX 函数不可用)
- 节奏同 §2:先 ListWindows + AXElements 探测,确认控件后再操作;AXPress 优先,回退 Click(phys_cx, phys_cy)
+49
View File
@@ -0,0 +1,49 @@
# Goal Hive Master 工作 SOP
Master 是 Hive 的总体设计部:不亲自生产子任务产物,只负责**拆解子任务、判断、汇总**,靠调度 worker 把核心交付物在给定时间内稳定推向用户满意。Master 无权停止自己,不得设计自停条件。
本 SOP 按**第一性原理**从**工程控制论**推出:把交付当受控系统——**J\*=用户真正要的价值(目标/价值函数,哲学层不变;变的只是你对它的形式化估计 Ĵ)**,**y=当前产物**,**e=J\*−y(偏差)**。每轮的活=测 e、压 e,让 y 单调逼近 J\*;预算到点就交当前最好的 y。"失稳"=系统跑偏/空转(见 §1)。
Master也应基于**第一性原理**思考如何完成用户任务。
## 0. 怎么跑(每轮照做)
**三条铁律**
1. Master 只做两件事:**拆**(把阶段目标切成互不重叠的独立子任务派给 worker)和**汇**(汇总产物、判断、排序)。绝不自己下场生产产物。
2. 始终维护一个"**当前最优已验收版本**"作锚点;每轮只在锚点上**增量改**(在已有产物上找可优化点修改,能不重写就不重写);**验收让 J 升才合入,变差就回退**——锚点只增不减。
3. **循环到预算用尽才停**,交当前锚点版(不是"做完"才停)。任何时刻必须清楚自己在 `x.几`
**一轮 = 探测 → 设计 → 执行 → 检查 →(重读本 SOP)→ 下一轮**。每阶段有自己的阶段目标,分**发散求全**(探测/检查:靠多 worker 并行、独立、去相关地铺开)和**收敛择优**(设计/执行:Master 判断、择一、忠实落地)两种。
### x.1 探测(阶段目标=查得尽量全)
- **查什么**:1 分析用户需求 2 探测环境现状 3 记忆中的重要信息/原则 4 调研可用的方案·材料·方法建议 5 上轮的结果·变化·检查报告。
- **锁边界(先于动手,校准 Ĵ)**:钉死 J\* 范围——**要什么、明确不要什么**;需求模糊/有歧义处(如"接入"是只收还是收发)按"**最小必要 + 简单优雅**"收敛,或向用户澄清,**禁止臆测扩张 scope**。
- **拆**:按上面四项切成独立调研子任务,**分头**派多个 worker。
- **汇**:收齐 → 按对 J* 的重要性排序 → 写 `探测报告Tx.md`(只留重点和变化)。
- 第 2 轮起只查"上轮变了/没查清"的,不重查。环境一变就重探变化的部分。
### x.2 设计(阶段目标=收敛出最优那一个方案)
- **Master 亲自做**(这阶段没什么可并行):据探测报告 + 上轮检查报告,定这轮**改哪几处**、拆几个执行子任务、各自验收线。
-`执行方案Tx.md`,内含 **changelog =这轮要改的 P0/P1 清单**(来自上轮检查报告,逐条对准缺口,不在已饱和处精雕)。
### x.3 执行(阶段目标=忠实落地选定方案)
- **拆**:每个执行子任务=独立接口(输入/输出/放哪里/合格线),派给 worker,能并行就并行。
- **汇**:Master 不下场,只盯进度、收产物、按 changelog 增量改锚点。产物按需。
### x.4 检查(阶段目标=挑出尽量多问题)
- **拆**:从**多角度派独立**的挑刺/测试子任务给**不同** worker——① 用户视角试用 ② 攻击者/反面假设 ③ 边界与 corner case(测例尽量多、全、广)④ 第三方独立复核 ⑤ **回到需求质疑目标本身**:对照 J\* 看 Ĵ 是否做多/做偏/过度设计(如造了需求不要的能力)——校准 Ĵ,不只校准产物 y。独立才挑得出不同问题。
- **验收线**:每个挑刺/测试子任务必须交**可复现的物理证据**,且**证据形态匹配交付形态**——代码→端到端跑通的命令+原始输出(不止单元桩测);文稿→**全文通读**+按需渲染/视觉核对版式;数据→实跑校验。"声明已完成"不算验收。
- **汇**:汇总所有问题 → 按对 J* 的伤害**排成 P0/P1** → 写 `检查报告Tx.md`
- **这份 P0/P1 报告就是下一轮 x.2 的 changelog**——偏差 `e = J*y` 被具体化、带进下一轮增量修。
## 1. 失稳急刹(出现任一信号,立即按序处置)
信号:worker 忙但 J 不升 / 局部产物多但整体不可用 / 过程证明取代用户价值 / 多人改同一产物冲突 / Master 被细节牵走丢全局 / 额外产出污染核心交付。
处置:① 停止新派发 → ② 回读用户需求与 J* 重新对齐"现在最重要的一件事" → ③ 查接口是否未冻结 → ④ 砍掉与主目标最弱的在途任务 → ⑤ 某维度连续两轮 J 不升即判饱和,换离达标最远的维度 → 恢复闭环再派。
## 2. 底线
- 核心产物只放用户要用的成品(说人话、给成品、取舍随场景);来源/验证/尝试记录另放,不污染成品。
- 不确定性要么查证补全、要么删除,自己搞定,不留半成品、不推给用户。
- 时间够就修到更优;预算到点仍未通过的项,必须**如实写入交付报告**。诚实记录写报告,不写进成品本身。
- BBS_CWD 保持整洁:中间产物归子目录或带标注,核心交付物一眼可定位。
+53
View File
@@ -0,0 +1,53 @@
# Goal Hive Mode SOP
## 定义
Goal Hive = Goal Mode 的多 worker 协作协议
Hive模式单独运行,不要和plan/supervisor/subagent混杂
## 启动
1. 选一个空闲端口 `PORT` 和本次协作 key `BOARD_KEY`
2. 创建本次 Hive 数据目录:`BBS_CWD=<CodeRoot>/temp/hive_<目标短名>`
3. 启动 BBS`start /b python <CodeRoot>/assets/agent_bbs.py --cwd <BBS_CWD> --port <PORT> --key <BOARD_KEY>`
4. requests访问http://127.0.0.1:<PORT>/readme?key=<BOARD_KEY>。
- 手动发帖/传文件 API:写请求带 header `X-API-Key: <BOARD_KEY>`;先 `POST /register``token`,再 `POST /post`;文件用 `POST /file/upload`
5. 在bbs发第一个帖子,按照以下“第一帖规范”
6. 后台启动首个worker
7. 询问用户时间预算,按`goal_mode_sop.md`后台启动hive master
8. Hive masterworkers都是与你不同的独立进程,你启动它们后应当报告用户并停止
### 第一帖规范
BBS 第一帖必须包含以下四项:
1. 任务目标
2. 下方「Hive Master 职责」全文4点(一字不改)
3. 工作目录说明:优先使用 `<BBS_CWD>` 进行文件传输而非BBS文件功能
4. 附加说明(一字不改):`此为最终目标,worker不要接单,先等hive master拆分子任务。`
### Hive Master 职责
1. master必须阅读记忆中goal_hive_master_duty.md,持续检查问题、寻找改进点
2. 你**负责任务调度和团队组织**,只能干上述duty中提到的内容,不允许亲自干活导致 worker 空转
3. 终极目标是要做到**完美的找不到任何问题的**任务交付结果,保证用户满意,围绕核心产出
4. 如果子任务很多,worker做不过来,可以参照Goal Hive Mode SOP拉起更多worker
## Hive Master
### goal_state.json 规范
`objective` 必须包含以下几块,缺一不可:
1. 用户目标(简明描述任务与交付物)
2. BBS地址(用requests):`http://127.0.0.1:<PORT>/readme?key=<BOARD_KEY>`
3. 上方「Hive Master 职责」全文(一字不改)
4. 阅读记忆中goal_hive_master_duty.md了解如何分派和管理工作
`done_prompt` 必须设置为以下固定文本(一字不改):
`关闭所有你拉起的worker,并在BBS发一条帖子宣告你管理的任务结束,worker除了明确追加任务外,不应再回应。`
启动 master 前必须回读 `goal_state.json`,逐项确认 objective 完整、done_prompt 原文匹配,否则不得启动。
## 拉起 worker
启动 worker`start /b python <CodeRoot>/agentmain.py --reflect <CodeRoot>/reflect/agent_team_worker.py --base_url http://127.0.0.1:<PORT> --board_key <BOARD_KEY> --name hive-worker-1`
后续 worker 由 Goal Master 按需要增加(不能超过5个,一般任务2-4个足够)。
+50
View File
@@ -0,0 +1,50 @@
# Goal Mode SOP
## 何时使用
用户给出开放目标 + 时间预算(如"花3小时持续优化X"、"没事也找事干"),且不是一次性闭环任务。
## 设置
`temp/goal_state.json`(或自定义路径):
```json
{
"objective": "用户原话目标",
"budget_seconds": 10800,
"start_time": <time.time()>,
"turns_used": 0,
"max_turns": 200,
"status": "running",
"done_prompt": ""
}
```
- `budget_seconds`:最少 3 小时(10800),按用户要求调整
- `max_turns`:防空转上限,一般 200 够用
- `status`:必须为 `"running"`
## 启动
必须后台启动(长时间运行,不占前台终端):
```bash
# 默认路径 temp/goal_state.json
start /b python agentmain.py --reflect reflect/goal_mode.py
# 自定义路径(多实例)
set GOAL_STATE=temp/goal_xxx.json && start /b python agentmain.py --reflect reflect/goal_mode.py
# 用其他模型跑(--llm_no 选择已配置的第N个LLM,从0开始)
set GOAL_STATE=temp/goal_xxx.json && start /b python agentmain.py --reflect reflect/goal_mode.py --llm_no 1
```
## 停止
- 预算耗尽时自动进入收口轮,然后停止
- 手动停:杀进程
## 观察进度
- 状态:读 goal_state.json 的 `turns_used` / `status`
- 详情:看 `temp/model_responses/` 下最近修改的文件尾部
+29
View File
@@ -0,0 +1,29 @@
# Incubator SOP — 远程部署GA实例
自我复制到任意节点的agent网络。每个节点有独立记忆,可以通过编辑memory干预它们的行为
## 文件清单
```
GARoot/*.py
GARoot/assets/*.txt *.json
mykey.py
GARoot/memory/
```
memory下大部分文件不适合分发,不要复制 memory 下未被 gitignore 白名单的文件!
不要复制 memory 下的L1/L2文件(global_mem(_insight).txt),会自动初始化
打包红线:严格按上面4行清单执行,`*` 是 glob 全匹配,不得擅自改成“必要文件/可启动闭包”。
- `GARoot/*.py` 必须包含根目录所有 `.py`
- `GARoot/assets/*.txt *.json` 必须包含 assets 顶层所有 `.txt`/`.json`
- `GARoot/memory/` 只取 `.gitignore` 白名单/已允许分发文件;排除 `global_mem.txt``global_mem_insight.txt``__pycache__/``*.pyc`
- 按当前清单实测压缩包约153KB/55文件;正常不应超过200KB,文件数不应超过60。
## 依赖
requests beautifulsoup4
尽量复用远端已有python/venv
## 通信
1. **首选** 阅读 `assets/ga_httpapp.py`HTTP API~50行自解释)
2. 备选:subagent.md 文件协议 或 reflect worker + bbs
## 干预记忆
直接编辑远端 memory/ 下的文件(SOP/全局记忆)
+55
View File
@@ -0,0 +1,55 @@
"""Keychain: save key to a file, then keys.set("name", file="path"); keys.name.use() to retrieve (use but no print)."""
import json, os, hashlib, pathlib, getpass
_PATH = pathlib.Path.home() / "ga_keychain.enc"
try: _user = os.getlogin()
except OSError: _user = getpass.getuser()
_MASK = hashlib.sha256(f"{_user}@ga_keychain".encode()).digest()
def _xor(data: bytes) -> bytes:
return bytes(b ^ _MASK[i % len(_MASK)] for i, b in enumerate(data))
print('# SecretStr.use() to get raw, do not print raw value! | keys.ls() to list all keys')
class SecretStr:
def __init__(self, name: str, val: str):
self._name, self._val = name, val
def use(self) -> str: return self._val
def __repr__(self):
n = len(self._val)
if n <= 4: preview = '***'
elif n <= 16: preview = f"{self._val[:3]}···{self._val[-3:]}"
elif n <= 40: preview = f"{self._val[:6]}···{self._val[-6:]} len={n}"
else: preview = f"{self._val[:10]}···{self._val[-6:]} len={n}"
return f"SecretStr({self._name}={preview})"
__str__ = __repr__
class _Keys:
def __init__(self):
self._d = {}
if _PATH.exists():
try:
raw = json.loads(_xor(_PATH.read_bytes()))
self._d = {k: SecretStr(k, v) for k, v in raw.items()}
except Exception as e:
print(f"[keychain] WARNING: failed to load {_PATH}: {e}")
print(f"[keychain] Starting with empty keychain. Old file kept as .bak")
_PATH.rename(_PATH.with_suffix('.enc.bak'))
def _save(self):
raw = {k: v.use() for k, v in self._d.items()}
_PATH.write_bytes(_xor(json.dumps(raw).encode()))
def __getattr__(self, k):
if k.startswith('_'): raise AttributeError(k)
if k not in self._d: raise KeyError(f"No secret: {k}")
return self._d[k]
def __repr__(self):
return f"Keychain({len(self._d)} secrets: {', '.join(self._d.keys())})"
def set(self, k, v=None, *, file=None):
if file: v = pathlib.Path(file).read_text().strip()
self._d[k] = SecretStr(k, v)
self._save()
def ls(self): return list(self._d.keys())
keys = _Keys()
def __getattr__(name): return getattr(keys, name)
+177
View File
@@ -0,0 +1,177 @@
"""
CRITICAL: 严禁在此工具链中 import pyautogui (会污染 win32api 导致逻辑冲突)。
ljqCtrl Quick Reference:
- dpi_scale: float (Logical = Physical * dpi_scale)
- Click(x, y, check=True): Use Physical Coordinates. check=True → 自动比前后像素变化,返回周边图像
- SetCursorPos(z): Use Physical Coordinates z=(x, y)
- Press(cmd, staytime=0): Keyboard shortcuts (e.g. 'ctrl+v')
- FindBlock(fn, wrect=None, threshold=0.8) -> (obj_center_phys, is_found)
- MouseDClick(staytime=0.05), MouseClick(staytime=0.05)
- GrabWindow(hwnd) -> PIL Image: DPI-safe window screenshot (needs foreground)
- GrabWindowBg(hwnd_or_name) -> PIL Image: WGC background capture (Win10+, pip install windows-capture)
"""
import os, sys, time, random, math, win32api, win32con, win32gui, ctypes
import numpy as np
print('[TIPS] always use physical coordinates!')
dpi_scale = 1
try:
from PIL import ImageGrab, Image, ImageEnhance, ImageFilter, ImageDraw
import cv2
except: pass
ctypes.windll.user32.SetProcessDPIAware()
_hdc = ctypes.windll.user32.GetDC(0)
swidth = ctypes.windll.gdi32.GetDeviceCaps(_hdc, 118) # DESKTOPHORZRES (物理)
sheight = ctypes.windll.gdi32.GetDeviceCaps(_hdc, 117) # DESKTOPVERTRES
ctypes.windll.user32.ReleaseDC(0, _hdc)
cwidth = win32api.GetSystemMetrics(win32con.SM_CXSCREEN) # 逻辑
cheight = win32api.GetSystemMetrics(win32con.SM_CYSCREEN)
dpi_scale = cwidth / swidth
print('Screen width & height:', swidth, sheight)
print('dpi_scale:', dpi_scale)
def MouseDown(): win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN,0,0)
def MouseUp(): win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP,0,0)
def MouseClick(staytime=0.05):
MouseDown(); time.sleep(staytime)
MouseUp(); time.sleep(0.05)
def MouseDClick(staytime=0.05):
MouseDown(); MouseUp()
MouseDown(); MouseUp()
time.sleep(0.05)
def SetCursorPos(z):
z = tuple(map(lambda v:int(v*dpi_scale), z))
win32api.SetCursorPos(z)
time.sleep(0.05)
def Click(x, y=None, check=True):
if type(x) is type(tuple()): x, y = int(x[0]), int(x[1])
if check: before, fg_before = ScreenCapAt(x, y), win32gui.GetForegroundWindow()
SetCursorPos( (x, y) )
MouseClick()
if check:
time.sleep(0.5)
after = ScreenCapAt(x, y)
b, a = np.array(before), np.array(after)
diff = np.sum(np.any(b != a, axis=2))
total = b.shape[0] * b.shape[1]
fg_after = win32gui.GetForegroundWindow()
fg_title = win32gui.GetWindowText(fg_after)
fg_changed = fg_before != fg_after
print(f'[Click check] {diff}/{total} px changed ({diff/total*100:.1f}%) | fg: "{fg_title}" {"⚠️CHANGED" if fg_changed else ""}')
return after
click = Click
def Press(cmd, staytime=0):
if type(cmd) is list: cmds = [x.lower() for x in cmd]
else: cmds = cmd.lower().split('+')
for z in cmds:
win32api.keybd_event(VK_CODE[z], 0, 0, 0)
time.sleep(staytime)
for z in reversed(cmds):
time.sleep(staytime)
win32api.keybd_event(VK_CODE[z], 0, win32con.KEYEVENTF_KEYUP, 0)
press = Press
VK_CODE = {'backspace':0x08, 'tab':0x09, 'clear':0x0C, 'enter':0x0D, 'shift':0x10, 'ctrl':0x11, 'alt':0x12, 'pause':0x13, 'caps_lock':0x14, 'esc':0x1B, 'escape':0x1B, 'space':0x20, 'page_up':0x21, 'page_down':0x22, 'end':0x23, 'home':0x24, 'left_arrow':0x25, 'up_arrow':0x26, 'right_arrow':0x27, 'down_arrow':0x28, 'select':0x29, 'print':0x2A, 'execute':0x2B, 'print_screen':0x2C, 'ins':0x2D, 'del':0x2E, 'help':0x2F, '0':0x30, '1':0x31, '2':0x32, '3':0x33, '4':0x34, '5':0x35, '6':0x36, '7':0x37, '8':0x38, '9':0x39, 'a':0x41, 'b':0x42, 'c':0x43, 'd':0x44, 'e':0x45, 'f':0x46, 'g':0x47, 'h':0x48, 'i':0x49, 'j':0x4A, 'k':0x4B, 'l':0x4C, 'm':0x4D, 'n':0x4E, 'o':0x4F, 'p':0x50, 'q':0x51, 'r':0x52, 's':0x53, 't':0x54, 'u':0x55, 'v':0x56, 'w':0x57, 'x':0x58, 'y':0x59, 'z':0x5A, 'numpad_0':0x60, 'numpad_1':0x61, 'numpad_2':0x62, 'numpad_3':0x63, 'numpad_4':0x64, 'numpad_5':0x65, 'numpad_6':0x66, 'numpad_7':0x67, 'numpad_8':0x68, 'numpad_9':0x69, 'multiply_key':0x6A, 'add_key':0x6B, 'separator_key':0x6C, 'subtract_key':0x6D, 'decimal_key':0x6E, 'divide_key':0x6F, 'F1':0x70, 'F2':0x71, 'F3':0x72, 'F4':0x73, 'F5':0x74, 'F6':0x75, 'F7':0x76, 'F8':0x77, 'F9':0x78, 'F10':0x79, 'F11':0x7A, 'F12':0x7B, 'F13':0x7C, 'F14':0x7D, 'F15':0x7E, 'F16':0x7F, 'F17':0x80, 'F18':0x81, 'F19':0x82, 'F20':0x83, 'F21':0x84, 'F22':0x85, 'F23':0x86, 'F24':0x87, 'num_lock':0x90, 'scroll_lock':0x91, 'left_shift':0xA0, 'right_shift ':0xA1, 'left_control':0xA2, 'right_control':0xA3, 'left_menu':0xA4, 'right_menu':0xA5, 'browser_back':0xA6, 'browser_forward':0xA7, 'browser_refresh':0xA8, 'browser_stop':0xA9, 'browser_search':0xAA, 'browser_favorites':0xAB, 'browser_start_and_home':0xAC, 'volume_mute':0xAD, 'volume_Down':0xAE, 'volume_up':0xAF, 'next_track':0xB0, 'previous_track':0xB1, 'stop_media':0xB2, 'play/pause_media':0xB3, 'start_mail':0xB4, 'select_media':0xB5, 'start_application_1':0xB6, 'start_application_2':0xB7, 'attn_key':0xF6, 'crsel_key':0xF7, 'exsel_key':0xF8, 'play_key':0xFA, 'zoom_key':0xFB, 'clear_key':0xFE, '+':0xBB, ',':0xBC, '-':0xBD, '.':0xBE, '/':0xBF, '`':0xC0, ';':0xBA, '[':0xDB, '\\':0xDC, ']':0xDD, "'":0xDE}
VK_CODE = {k.lower():v for k,v in VK_CODE.items()}
def Activate(hwnd):
"""稳定切换前台窗口。绕过Windows前台锁限制。"""
# 如果窗口最小化先恢复
if ctypes.windll.user32.IsIconic(hwnd):
ctypes.windll.user32.ShowWindow(hwnd, 9) # SW_RESTORE
# 发假Alt-up骗过前台锁
ctypes.windll.user32.keybd_event(0x12, 0, 2, 0) # VK_MENU up
time.sleep(0.02)
try:
win32gui.SetForegroundWindow(hwnd)
except Exception:
# fallback: BringWindowToTop + SetFocus
ctypes.windll.user32.BringWindowToTop(hwnd)
ctypes.windll.user32.SetFocus(hwnd)
time.sleep(0.15)
activate = Activate
def GrabWindow(hwnd):
if isinstance(hwnd, str): hwnd = win32gui.FindWindow(None, hwnd); assert hwnd, f'窗口未找到'
Activate(hwnd); time.sleep(0.25)
# 只截客户区(不含标题栏边框), 与GrabWindowBg一致 → 截图内坐标统一用ClientToScreen原点做偏移
l, t = win32gui.ClientToScreen(hwnd, (0, 0))
cr = win32gui.GetClientRect(hwnd) # (0,0,w,h)
bbox = (l, t, l + cr[2], t + cr[3])
bbox = tuple(int(v / dpi_scale) for v in bbox)
return ImageGrab.grab(bbox)
def GrabWindowBg(hwnd_or_name, timeout=5):
"""WGC后台截图(Win10+), 传hwnd(int)或窗口标题(str), 返回PIL Image"""
import threading, tempfile
from windows_capture import WindowsCapture, Frame, CaptureControl
tmp = tempfile.mktemp(suffix='.png')
done = threading.Event()
kw = {'window_hwnd': hwnd_or_name} if isinstance(hwnd_or_name, int) else {'window_name': hwnd_or_name}
cap = WindowsCapture(cursor_capture=False, draw_border=False, **kw)
@cap.event
def on_frame_arrived(frame: Frame, capture_control: CaptureControl):
frame.save_as_image(tmp)
capture_control.stop(); done.set()
@cap.event
def on_closed(): done.set()
cap.start_free_threaded()
done.wait(timeout=timeout)
if os.path.exists(tmp):
img = Image.open(tmp); img.load(); os.remove(tmp); return img
def imshow(mt, sec=0):
cv2.imshow('cc', mt)
cv2.waitKey(sec)
def GetWRect(sr):
num = int(sr[-1])
l, u, r, b = 0, 0, swidth, sheight
if 'left' in sr: r = swidth // num
if 'right' in sr: l = swidth * (num-1) // num
if 'top' in sr: b = sheight // num
if 'bottom' in sr: u = sheight * (num-1) // num
return [l, u, r, b]
def FindBlock(fn, wrect=None, verbose=0, threshold=0.8):
tic = time.process_time()
if wrect is not None and isinstance(wrect, Image.Image):
scr, wrect = wrect, None
else:
if isinstance(wrect, str): wrect = GetWRect(wrect)
scr = ImageGrab.grab(wrect)
blc = Image.open(fn) if isinstance(fn, str) else fn
T = cv2.cvtColor(np.array(blc), cv2.COLOR_RGB2BGR)
B = cv2.cvtColor(np.array(scr), cv2.COLOR_RGB2BGR)
tsh, tsw = T.shape[:2]
if verbose: print('T.shape:', T.shape, '\t', 'B.shape:', B.shape)
res = cv2.matchTemplate(B, T, cv2.TM_CCOEFF_NORMED)
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)
oj, oi = max_loc
if wrect is None: wrect = [0, 0, scr.size[0], scr.size[1]]
obj = (oj + wrect[0] + tsw//2, oi + wrect[1] + tsh//2)
if verbose:
print(f'Max match: {max_val:.4f} at ({oj}, {oi}) cost: {time.process_time() - tic:.3f}s')
#sscr = scr.crop([oj, oi, oj+tsw, oi+tsh])
#sscr.show()
return obj, max_val
def ScreenCapAt(x, y, r=100):
"""物理坐标(x,y)为中心±r的屏幕截图 → PIL Image"""
from PIL import ImageGrab
return ImageGrab.grab((x-r, y-r, x+r, y+r))
if __name__ == '__main__':
#time.sleep(3)
#SetCursorPos( (1640, 131) )
#MouseClick()
#print(FindBlock('z:/z.png', [1638, 214, 5838, 414], verbose=1))
print('completed %.3f' % time.process_time())
+200
View File
@@ -0,0 +1,200 @@
"""ljqCtrlBg: concise background window control in client pixels.
Never activates a window, moves the cursor, or injects global input. Posted
mouse/key messages are best-effort; always verify visible effects by screenshot.
"""
from __future__ import annotations
import ctypes, time
from typing import Any, NamedTuple, Optional, Sequence, Union
import win32con, win32gui, win32ui
from PIL import Image, ImageChops
try:
ctypes.windll.user32.SetProcessDPIAware()
except Exception: pass
HwndLike = Union[int, str]
SMTO_SAFE = win32con.SMTO_BLOCK | win32con.SMTO_ABORTIFHUNG
CWP_SKIP = win32con.CWP_SKIPINVISIBLE | win32con.CWP_SKIPDISABLED | win32con.CWP_SKIPTRANSPARENT
MOUSE = {"left": (win32con.WM_LBUTTONDOWN, win32con.WM_LBUTTONUP, win32con.MK_LBUTTON),
"right": (win32con.WM_RBUTTONDOWN, win32con.WM_RBUTTONUP, win32con.MK_RBUTTON),
"middle": (win32con.WM_MBUTTONDOWN, win32con.WM_MBUTTONUP, win32con.MK_MBUTTON)}
KEYS = {"backspace": 8, "tab": 9, "enter": 13, "return": 13, "shift": 16, "ctrl": 17, "control": 17,
"alt": 18, "esc": 27, "escape": 27, "space": 32, "pageup": 33, "pagedown": 34, "end": 35,
"home": 36, "left": 37, "up": 38, "right": 39, "down": 40, "delete": 46, "del": 46}
class CaptureResult(NamedTuple):
image: Image.Image
hwnd: int
backend: str
client_origin: tuple[int, int]
client_size: tuple[int, int]
size = property(lambda self: self.image.size)
origin_screen_phys = property(lambda self: self.client_origin)
client_size_phys = property(lambda self: self.client_size)
def ListWindows(visible_only: bool = True) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
def each(hwnd: int, _: Any) -> bool:
title, vis = win32gui.GetWindowText(hwnd), win32gui.IsWindowVisible(hwnd); rect = tuple(map(int, win32gui.GetWindowRect(hwnd)))
if (vis or not visible_only) and (title or not visible_only):
rows.append({"hwnd": int(hwnd), "title": title, "class": win32gui.GetClassName(hwnd), "rect": rect, "visible": bool(vis)})
return True
win32gui.EnumWindows(each, None); return rows
def FindWindow(name: str, exact: bool = False, class_name: Optional[str] = None, visible_only: bool = True) -> int:
needle = str(name).lower()
for row in ListWindows(visible_only):
title = row["title"] or ""; ok = (title == name) if exact else (needle in title.lower())
if ok and (class_name is None or row["class"] == class_name): return int(row["hwnd"])
raise RuntimeError(f"window not found: {name!r}")
def ResolveHwnd(hwnd_or_name: HwndLike) -> int:
hwnd = int(hwnd_or_name) if isinstance(hwnd_or_name, int) else FindWindow(str(hwnd_or_name))
if not win32gui.IsWindow(hwnd): raise RuntimeError(f"invalid hwnd: {hwnd!r}")
return hwnd
def GetWRect(hwnd_or_name: HwndLike) -> tuple[int, int, int, int]:
return tuple(map(int, win32gui.GetWindowRect(ResolveHwnd(hwnd_or_name))))
def ClientSize(hwnd_or_name: HwndLike) -> tuple[int, int]:
l, t, r, b = win32gui.GetClientRect(ResolveHwnd(hwnd_or_name)); return int(r - l), int(b - t)
def ClientOrigin(hwnd_or_name: HwndLike) -> tuple[int, int]:
return tuple(map(int, win32gui.ClientToScreen(ResolveHwnd(hwnd_or_name), (0, 0))))
def ClientRectScreen(hwnd_or_name: HwndLike) -> tuple[int, int, int, int]:
x, y = ClientOrigin(hwnd_or_name); w, h = ClientSize(hwnd_or_name); return x, y, x + w, y + h
def ScreenToClient(hwnd_or_name: HwndLike, x: int, y: int) -> tuple[int, int]:
return tuple(map(int, win32gui.ScreenToClient(ResolveHwnd(hwnd_or_name), (int(x), int(y)))))
def ClientToScreen(hwnd_or_name: HwndLike, x: int, y: int) -> tuple[int, int]:
return tuple(map(int, win32gui.ClientToScreen(ResolveHwnd(hwnd_or_name), (int(x), int(y)))))
def ChildAt(hwnd_or_name: HwndLike, x: int, y: int, coords: str = "client", deep: bool = True) -> tuple[int, int, int]:
if coords not in {"client", "screen"}: raise ValueError("coords must be 'client' or 'screen'")
root = ResolveHwnd(hwnd_or_name); sx, sy = ClientToScreen(root, x, y) if coords == "client" else (int(x), int(y))
hwnd = root
while deep:
cx, cy = win32gui.ScreenToClient(hwnd, (sx, sy))
child = win32gui.ChildWindowFromPointEx(hwnd, (cx, cy), CWP_SKIP)
if not child or child == hwnd: break
hwnd = child
cx, cy = win32gui.ScreenToClient(hwnd, (sx, sy)); return int(hwnd), int(cx), int(cy)
def _crop_client(hwnd: int, image: Image.Image, size: tuple[int, int]) -> Image.Image:
if image.size == size: return image
wx, wy, _, _ = win32gui.GetWindowRect(hwnd); ox, oy = ClientOrigin(hwnd); w, h = size; dx, dy = ox - wx, oy - wy
if 0 <= dx and 0 <= dy and dx + w <= image.width and dy + h <= image.height: return image.crop((dx, dy, dx + w, dy + h))
if image.width >= w and image.height >= h: return image.crop((0, 0, w, h))
raise RuntimeError(f"capture frame {image.size} smaller than client area {size}")
def _grab_wgc(hwnd: int, size: tuple[int, int], timeout: float) -> Image.Image:
from windows_capture import WindowsCapture # type: ignore
frames: list[Any] = []; errors: list[BaseException] = []
cap = WindowsCapture(cursor_capture=False, draw_border=False, window_hwnd=hwnd)
@cap.event
def on_frame_arrived(frame: Any, control: Any) -> None:
try: frames.append(frame.frame_buffer.copy())
except BaseException as exc: errors.append(exc)
finally:
try: control.stop()
except Exception: pass
@cap.event
def on_closed() -> None: pass
control = cap.start_free_threaded(); end = time.monotonic() + float(timeout)
while not frames and not errors and time.monotonic() < end: time.sleep(0.02)
if not frames:
try: control.stop()
except Exception: pass
if errors: raise RuntimeError("WGC callback failed") from errors[0]
raise TimeoutError(f"WGC did not produce a frame within {timeout:.1f}s")
arr = frames[0]
if getattr(arr, "ndim", 0) != 3 or arr.shape[2] < 3: raise RuntimeError(f"bad WGC frame shape: {getattr(arr, 'shape', None)}")
return _crop_client(hwnd, Image.fromarray(arr[:, :, :3][:, :, ::-1]).copy(), size)
def _grab_printwindow(hwnd: int, size: tuple[int, int]) -> tuple[Image.Image, bool]:
w, h = size; hdc = win32gui.GetWindowDC(hwnd); src = win32ui.CreateDCFromHandle(hdc)
mem = src.CreateCompatibleDC(); bmp = win32ui.CreateBitmap(); bmp.CreateCompatibleBitmap(src, w, h); old = mem.SelectObject(bmp)
try:
ok = bool(ctypes.windll.user32.PrintWindow(hwnd, mem.GetSafeHdc(), 1))
info, bits = bmp.GetInfo(), bmp.GetBitmapBits(True)
return Image.frombuffer("RGB", (info["bmWidth"], info["bmHeight"]), bits, "raw", "BGRX", 0, 1).copy(), ok
finally:
mem.SelectObject(old); win32gui.DeleteObject(bmp.GetHandle()); mem.DeleteDC(); src.DeleteDC(); win32gui.ReleaseDC(hwnd, hdc)
def GrabWindowBg(hwnd_or_name: HwndLike, backend: str = "auto", timeout: float = 3.0) -> CaptureResult:
hwnd = ResolveHwnd(hwnd_or_name); size = ClientSize(hwnd); mode = backend.lower(); wgc_error = ""
if min(size) <= 0: raise RuntimeError(f"empty client area for hwnd={hwnd}")
if mode in {"auto", "wgc"}:
try: return CaptureResult(_grab_wgc(hwnd, size, timeout), hwnd, "wgc", ClientOrigin(hwnd), size)
except BaseException as exc:
if mode == "wgc": raise
wgc_error = f";wgc-error={type(exc).__name__}"
if mode in {"auto", "printwindow", "pw"}:
image, ok = _grab_printwindow(hwnd, size); label = "printwindow" if ok else "printwindow-best-effort"
return CaptureResult(image, hwnd, label + wgc_error, ClientOrigin(hwnd), size)
raise ValueError("backend must be 'auto', 'wgc', or 'printwindow'")
def GrabClientBg(hwnd_or_name: HwndLike, **kwargs: Any) -> Image.Image:
return GrabWindowBg(hwnd_or_name, **kwargs).image
def _lparam(x: int, y: int) -> int: return (int(x) & 0xFFFF) | ((int(y) & 0xFFFF) << 16)
def _send(hwnd: int, msg: int, wp: int = 0, lp: int = 0, post: bool = True) -> None:
if post: win32gui.PostMessage(hwnd, msg, int(wp), int(lp))
else: win32gui.SendMessageTimeout(hwnd, msg, int(wp), int(lp), SMTO_SAFE, 1000)
def ClickBg(hwnd_or_name: HwndLike, x: int, y: int, button: str = "left", coords: str = "client", target_child: bool = True, post: bool = True, interval: float = 0.03, check: bool = True, r: int = 80, wait: float = 0.5) -> bool:
root = ResolveHwnd(hwnd_or_name); wins0 = {w["hwnd"]: (w["title"], w["class"]) for w in ListWindows(False)} if check else {}; cap1 = GrabWindowBg(root) if check else None
if button.lower() not in MOUSE: raise ValueError(f"unsupported button: {button!r}")
if target_child: hwnd, cx, cy = ChildAt(root, x, y, coords)
else: hwnd, cx, cy = root, *(ScreenToClient(root, x, y) if coords == "screen" else (int(x), int(y)))
down, up, mk = MOUSE[button.lower()]; lp = _lparam(cx, cy); _send(hwnd, win32con.WM_MOUSEMOVE, 0, lp, post); _send(hwnd, down, mk, lp, post)
if interval: time.sleep(float(interval))
_send(hwnd, up, 0, lp, post)
if check:
time.sleep(float(wait)); wins1 = {w["hwnd"]: (w["title"], w["class"]) for w in ListWindows(False)}; new = {k: v for k, v in wins1.items() if k not in wins0}; gone = {k: v for k, v in wins0.items() if k not in wins1}; bbox = None
if win32gui.IsWindow(root): cap2 = GrabWindowBg(root); im1 = cap1.image.crop((max(0, x-r), max(0, y-r), min(cap1.size[0], x+r), min(cap1.size[1], y+r))); im2 = cap2.image.crop((max(0, x-r), max(0, y-r), min(cap2.size[0], x+r), min(cap2.size[1], y+r))); bbox = ImageChops.difference(im1, im2).getbbox()
print(f"[ClickBg check] changed={bool(bbox)} bbox={bbox} new={new} gone={gone}")
return True
def Click(hwnd_or_name: HwndLike, x: int, y: int, **kwargs: Any) -> bool: return ClickBg(hwnd_or_name, x, y, **kwargs)
def _vk(key: Union[str, int]) -> int:
if isinstance(key, int): return int(key)
s = str(key).strip(); low = s.lower()
if low in KEYS: return int(KEYS[low])
if low.startswith("f") and low[1:].isdigit() and 1 <= int(low[1:]) <= 24: return win32con.VK_F1 + int(low[1:]) - 1
if len(s) == 1: return int(ctypes.windll.user32.VkKeyScanW(ord(s)) & 0xFF)
raise ValueError(f"unknown key: {key!r}")
def _key_lparam(vk: int, up: bool = False) -> int:
lp = 1 | (int(ctypes.windll.user32.MapVirtualKeyW(int(vk), 0)) << 16)
return lp | ((1 << 30) | (1 << 31) if up else 0)
def PressBg(hwnd_or_name: HwndLike, key: Union[str, int], modifiers: Optional[Sequence[Union[str, int]]] = None, post: bool = True, interval: float = 0.02) -> bool:
hwnd = ResolveHwnd(hwnd_or_name)
if isinstance(key, str) and "+" in key and modifiers is None:
parts = [p.strip() for p in key.split("+") if p.strip()]; mods, main = [_vk(p) for p in parts[:-1]], _vk(parts[-1])
else: mods, main = [_vk(m) for m in (modifiers or [])], _vk(key)
for vk in [*mods, main]: _send(hwnd, win32con.WM_KEYDOWN, vk, _key_lparam(vk), post)
if interval: time.sleep(float(interval))
for vk in [main, *reversed(mods)]: _send(hwnd, win32con.WM_KEYUP, vk, _key_lparam(vk, True), post)
return True
def Press(hwnd_or_name: HwndLike, key: Union[str, int], **kwargs: Any) -> bool: return PressBg(hwnd_or_name, key, **kwargs)
def TypeTextBg(hwnd_or_name: HwndLike, text: str, interval: float = 0.0, post: bool = True) -> bool:
hwnd = ResolveHwnd(hwnd_or_name)
for ch in str(text):
_send(hwnd, win32con.WM_CHAR, ord(ch), 1, post)
if interval: time.sleep(float(interval))
return True
def SetTextBg(hwnd_or_name: HwndLike, text: str) -> bool:
win32gui.SendMessage(ResolveHwnd(hwnd_or_name), win32con.WM_SETTEXT, 0, str(text)); return True
def GetTextBg(hwnd_or_name: HwndLike) -> str: return win32gui.GetWindowText(ResolveHwnd(hwnd_or_name))
if __name__ == "__main__": print(f"ljqCtrlBg ready; windows={len(ListWindows())}")
+53
View File
@@ -0,0 +1,53 @@
# ljqCtrl 使用与坐标转换 SOP
> **must call update working ckp**`一律使用物理坐标|禁pyautogui|操作前先激活窗口`
## 0. API 快速参考 (Signatures)
- `ljqCtrl.dpi_scale`: float (缩放系数 = 逻辑宽度 / 物理宽度)
- `ljqCtrl.Click(x, y=None)`: 模拟点击。支持 `Click((x, y))``Click(x, y)`
- `ljqCtrl.Press(cmd, staytime=0)`: 模拟按键。如 `Press('ctrl+c')`
- `ljqCtrl.FindBlock(fn, wrect=None, threshold=0.8)`: 找图。返回 `((center_x, center_y), is_found)`
- `ljqCtrl.GrabWindow(hwnd_or_name)`: 前台截图(先Activate), 传hwnd(int)或窗口标题子串(str), 返回PIL Image
- `ljqCtrl.GrabWindowBg(hwnd_or_name, timeout=5)`: WGC后台截图(Win10+)
- `ljqCtrl.MouseDClick(staytime=0.05)`: 鼠标双击
- 可先阅读computer_use.md
## 1. 环境载入
import ljqCtrl
> **macOS**: 改 `import macljqCtrl as ljqCtrl`API 镜像,Quartz/screencapture 实现)。依赖 `pyobjc-framework-Quartz`/`-Cocoa`,首次用前 `macljqCtrl.check_permissions()` 自检辅助功能/录屏授权。
## 2. 核心:High-DPI 物理坐标换算
`ljqCtrl``Click/MoveTo` 接口接收的是**物理像素坐标**。
当使用 `pygetwindow` 等其他工具获取窗口位置(逻辑坐标)时,必须除以缩放系数。
- **换算公式**`物理坐标 = 逻辑坐标 / ljqCtrl.dpi_scale`
## 3. 截图bbox → 屏幕物理坐标(核心公式)
```python
# ui_detect获取的都是物理坐标
# ClientToScreen拿客户区原点(逻辑) → 除dpi_scale得物理偏移
cx, cy = win32gui.ClientToScreen(hwnd, (0, 0))
ox, oy = int(cx / ljqCtrl.dpi_scale), int(cy / ljqCtrl.dpi_scale)
ljqCtrl.Click(ox + (bbox[0]+bbox[2])//2, oy + (bbox[1]+bbox[3])//2)
```
禁止全屏ImageGrab(必须针对窗口),所有逻辑坐标都要转物理。
**macOS (`macljqCtrl`)**`GrabScreen(bbox)` 区域截图后,图内点转屏幕物理坐标用 `CropToScreen(bbox, px, py)`,别手搓 `screencapture -R`(它吃逻辑点,会点歪)。
## 4. 避坑指南
- **⚠️ 一律使用物理坐标**:传给 ljqCtrl.Click/SetCursorPos 的坐标必须是物理坐标(=截图像素坐标)。禁止传入逻辑坐标。
- **物理验证**:模拟操作前必须确保窗口已通过 `activate()` 置于前台。
- **坐标对齐**: 物理坐标 = 截图坐标;ljqCtrl 自动处理 DPI 换算,禁止手动重复计算。
- **⚠️ 窗口坐标转换陷阱**:使用 `win32gui.GetWindowRect(hwnd)` 获取的矩形包含标题栏和边框,而截图内容是客户区。点击截图内元素时,必须用 `win32gui.ClientToScreen(hwnd, (0, 0))` 获取客户区原点的屏幕坐标,再加上截图内坐标。禁止直接用 GetWindowRect 左上角 + 截图坐标。**同理禁止 `DwmGetWindowAttribute(hwnd, 9, ...)` 取窗口矩形替代 ClientToScreen,它也包含标题栏/阴影。**
- **⚠️ Click 后 0% 像素变化 = 点歪了**ljqCtrl.Click 会报告像素变化百分比。若为 0% 或接近 0%,说明点击落在了错误位置(坐标计算有误),必须立即停下来诊断坐标转换逻辑,禁止盲目重试。常见原因:用了错误的窗口原点API、忘记 `/dpi_scale`、混淆了客户区与窗口矩形。macOS 上多为忘加裁剪原点(应走 `CropToScreen`)。
- **⚠️ win32 DPI 坐标陷阱**:未调用 `SetProcessDPIAware()` 时,`GetWindowRect/ClientToScreen/GetClientRect` 等拿到的窗口/客户区坐标通常是**逻辑坐标**,必须进行换算!
- **文本输入**ljqCtrl 无 TypeText/SendKeys。向输入框键入文本:先点击/三击选中字段,再 `pyperclip.copy('文本'); ljqCtrl.Press('ctrl+v')`
## 5. macOSOCR/vision 认不准图标时,用辅助功能 API 枚举真实控件(强烈推荐)
> **两条通路**:①`macljqCtrl.py` 已封装原生 pyobjc AX API(首选,免 shell):`AXElements(pid或bundle_id或app名)` 枚举控件树(带 role/desc/title/id/value/**enabled**/物理坐标)`AXFind(...,enabled_only=)` 过滤,`AXClick(node)` = AXPress 优先失败回退物理坐标 Click。②无 pyobjc 时回退下述 osascript 方案。
图标类按钮(···更多 / 铅笔编辑 / 关闭等)靠 OCR/vision 极易误判误点。优先走 GUI 优先链的「UIA」层:用 `osascript` 的 System Events 递归 `entire contents` 枚举进程**所有窗口**的真实控件,拿到 `AXRole + description(标识符) + position`,直接 `perform action "AXPress"` 点中。
- **关键坑**:弹窗/详情卡常是**独立子窗口**,`front window` 只返回主窗(如红绿灯按钮)。必须 `every window` 遍历 + `entire contents`,否则找不到目标控件。
- 控件常自带语义化 `description`/`identifier`(如 `xxx_button_more`),按 description 精确匹配比坐标稳定,枚举一次记下目标标识即可复用。
- **坐标换算**:AX 返回的是**逻辑坐标**,截图/Click 用**物理坐标**retina 屏 ×2(逻辑(537,121)↔物理(1074,242)实测吻合)。AX `AXPress` 直接作用元素免换算;若 AX 偶发 NOTFOUND(时序波动),用换算后物理坐标 `Click` 兜底。
- **失焦陷阱**:点击坐标若落在窗口边界外,会点到背后别的 app 导致目标失焦。osascript `tell application "<App>" to activate` 比 ljqCtrl 的 ActivateApp 更可靠,激活后用 `frontmost` 确认。
+590
View File
@@ -0,0 +1,590 @@
"""
macljqCtrl —— ljqCtrl 的 macOS 等价实现 (Quartz CGEvent + screencapture)。
与 ljqCtrl.py API 镜像对齐, 跨平台代码可写 `import macljqCtrl as ljqCtrl`。
CRITICAL: 严禁 import pyautogui。
依赖: pyobjc-framework-Quartz, pyobjc-framework-Cocoa (其余 numpy/opencv/Pillow 同 Windows 版)。
坐标约定 (与 Windows 版完全一致):
- 对外 API 接收【物理像素坐标】(= screencapture/ui_detect 截图内坐标)
- dpi_scale = 逻辑点 / 物理像素 (Retina=0.5, 普通屏=1.0)
- 逻辑坐标 = 物理坐标 * dpi_scale (CGEvent 内部用逻辑点)
权限前置 (缺失则键鼠/截图静默失败):
- 辅助功能(Accessibility): 系统设置>隐私与安全性>辅助功能, 授权 GA 宿主进程
- 屏幕录制(Screen Recording): 同上>屏幕录制
用 macljqCtrl.check_permissions() 自检。
API 快速参考:
- dpi_scale: float
- Click(x, y=None, check=True): 物理坐标; check=True 比较前后像素变化, 返回变化信息
- SetCursorPos((x,y)): 物理坐标移动鼠标
- Press(cmd, staytime=0): 快捷键, 如 'cmd+v' 'cmd+c' 'enter' 'cmd+shift+4'
- TypeText(s): 直接键入文本(Unicode, 无需剪贴板)
- MouseClick / MouseDClick / MouseDown / MouseUp / RightClick
- GrabWindow(win) -> PIL Image: 指定窗口截图(物理像素)。win=窗口号(int)或标题/应用名子串(str)
- GrabScreen(bbox=None) -> PIL Image: 全屏或区域截图, bbox=(l,u,r,b)物理像素
- ScreenCapAt(x, y, r=100) -> PIL Image: 物理坐标(x,y)±r 区域截图
- FindBlock(fn, wrect=None, threshold=0.8) -> ((cx,cy), is_found): 模板匹配, 物理坐标
- ListWindows(name=None) -> [dict]: 枚举窗口(替代 win32gui), 返回号/标题/应用/物理bbox
- ActivateApp(name): 激活应用到前台(替代 win32gui.SetForegroundWindow)
"""
import os, sys, time, subprocess, tempfile, math
import numpy as np
try:
from PIL import Image, ImageGrab, ImageEnhance, ImageFilter, ImageDraw
import cv2
_HAS_CV2 = True
except Exception:
_HAS_CV2 = False
import Quartz
from AppKit import NSScreen, NSWorkspace, NSPasteboard, NSStringPboardType, NSRunningApplication
verbose_click = True # Click 像素变化打印开关
# ---------- 屏幕几何 & dpi_scale ----------
_main = Quartz.CGMainDisplayID()
_bounds = Quartz.CGDisplayBounds(_main)
cwidth = int(_bounds.size.width) # 逻辑点宽 (CGEvent 坐标系)
cheight = int(_bounds.size.height)
_mode = Quartz.CGDisplayCopyDisplayMode(_main)
swidth = int(Quartz.CGDisplayModeGetPixelWidth(_mode)) # 物理像素宽
sheight = int(Quartz.CGDisplayModeGetPixelHeight(_mode))
dpi_scale = cwidth / swidth if swidth else 1.0 # 逻辑/物理, Retina=0.5
def check_permissions(verbose=True):
"""返回 (accessibility_ok, screen_recording_ok)。缺失时打印授权指引。"""
sc = bool(Quartz.CGPreflightScreenCaptureAccess()) if hasattr(Quartz, 'CGPreflightScreenCaptureAccess') else None
ax = None
try:
import HIServices
ax = bool(HIServices.AXIsProcessTrusted())
except Exception:
try:
from ApplicationServices import AXIsProcessTrusted
ax = bool(AXIsProcessTrusted())
except Exception:
ax = None
if verbose:
print(f'[PERM] Accessibility(键鼠): {ax} ScreenRecording(截图): {sc}')
if ax is False:
print(' → 系统设置>隐私与安全性>辅助功能, 勾选 GA 宿主进程后重启 GA')
if sc is False:
print(' → 系统设置>隐私与安全性>屏幕录制, 勾选 GA 宿主进程后重启 GA')
return ax, sc
# ---------- 鼠标 ----------
def _post(ev):
Quartz.CGEventPost(Quartz.kCGHIDEventTap, ev)
def _cursor_logical():
e = Quartz.CGEventCreate(None)
loc = Quartz.CGEventGetLocation(e)
return loc.x, loc.y
def _phys_to_logical(x, y):
return x * dpi_scale, y * dpi_scale
def SetCursorPos(z):
"""z=(x,y) 物理坐标。移动鼠标(不点击)。"""
lx, ly = _phys_to_logical(int(z[0]), int(z[1]))
ev = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventMouseMoved,
Quartz.CGPointMake(lx, ly), Quartz.kCGMouseButtonLeft)
_post(ev)
time.sleep(0.05)
def _mouse_event(etype, button=Quartz.kCGMouseButtonLeft):
lx, ly = _cursor_logical()
ev = Quartz.CGEventCreateMouseEvent(None, etype, Quartz.CGPointMake(lx, ly), button)
_post(ev)
def MouseDown():
_mouse_event(Quartz.kCGEventLeftMouseDown)
def MouseUp():
_mouse_event(Quartz.kCGEventLeftMouseUp)
def MouseClick(staytime=0.05):
MouseDown(); time.sleep(staytime)
MouseUp(); time.sleep(0.05)
def MouseDClick(staytime=0.05):
# 真双击: 同坐标连发2次, 用 click state=2
lx, ly = _cursor_logical()
p = Quartz.CGPointMake(lx, ly)
for state in (1, 2):
down = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDown, p, Quartz.kCGMouseButtonLeft)
Quartz.CGEventSetIntegerValueField(down, Quartz.kCGMouseEventClickState, state)
_post(down)
up = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseUp, p, Quartz.kCGMouseButtonLeft)
Quartz.CGEventSetIntegerValueField(up, Quartz.kCGMouseEventClickState, state)
_post(up)
time.sleep(0.05)
def RightClick(staytime=0.05):
lx, ly = _cursor_logical()
p = Quartz.CGPointMake(lx, ly)
down = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseDown, p, Quartz.kCGMouseButtonRight)
_post(down); time.sleep(staytime)
up = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseUp, p, Quartz.kCGMouseButtonRight)
_post(up); time.sleep(0.05)
# ---------- 键盘 ----------
# macOS 虚拟键码 (kVK_*)
_KEYMAP = {
'a':0,'s':1,'d':2,'f':3,'h':4,'g':5,'z':6,'x':7,'c':8,'v':9,'b':11,'q':12,
'w':13,'e':14,'r':15,'y':16,'t':17,'1':18,'2':19,'3':20,'4':21,'6':22,'5':23,
'=':24,'9':25,'7':26,'-':27,'8':28,'0':29,']':30,'o':31,'u':32,'[':33,'i':34,
'p':35,'l':37,'j':38,"'":39,'k':40,';':41,'\\':42,',':43,'/':44,'n':45,'m':46,
'.':47,'`':50,
'enter':36,'return':36,'tab':48,'space':49,' ':49,'delete':51,'backspace':51,
'esc':53,'escape':53,'forwarddelete':117,
'left':123,'right':124,'down':125,'up':126,
'home':115,'end':119,'pageup':116,'pagedown':121,
'f1':122,'f2':120,'f3':99,'f4':118,'f5':96,'f6':97,'f7':98,'f8':100,'f9':101,
'f10':109,'f11':103,'f12':111,
}
_MODS = {
'cmd':Quartz.kCGEventFlagMaskCommand, 'command':Quartz.kCGEventFlagMaskCommand,
'ctrl':Quartz.kCGEventFlagMaskControl, 'control':Quartz.kCGEventFlagMaskControl,
'alt':Quartz.kCGEventFlagMaskAlternate, 'option':Quartz.kCGEventFlagMaskAlternate,
'opt':Quartz.kCGEventFlagMaskAlternate,
'shift':Quartz.kCGEventFlagMaskShift,
'fn':Quartz.kCGEventFlagMaskSecondaryFn,
}
def _key_tap(keycode, flags=0):
down = Quartz.CGEventCreateKeyboardEvent(None, keycode, True)
if flags: Quartz.CGEventSetFlags(down, flags)
_post(down)
up = Quartz.CGEventCreateKeyboardEvent(None, keycode, False)
if flags: Quartz.CGEventSetFlags(up, flags)
_post(up)
def Press(cmd, staytime=0):
"""快捷键。如 'cmd+v' 'cmd+shift+4' 'enter' 'cmd+c'。Win版的 ctrl 在mac多对应 cmd, 调用方自行决定。"""
parts = [p.strip().lower() for p in str(cmd).split('+') if p.strip()]
flags = 0; key = None
for p in parts:
if p in _MODS: flags |= _MODS[p]
else: key = p
if key is None:
return
kc = _KEYMAP.get(key)
if kc is None:
# 单字符走 TypeText
TypeText(key)
else:
_key_tap(kc, flags)
if staytime: time.sleep(staytime)
time.sleep(0.03)
def TypeText(s):
"""直接键入 Unicode 文本 (无需剪贴板)。"""
for ch in str(s):
ev = Quartz.CGEventCreateKeyboardEvent(None, 0, True)
Quartz.CGEventKeyboardSetUnicodeString(ev, len(ch), ch)
_post(ev)
ev2 = Quartz.CGEventCreateKeyboardEvent(None, 0, False)
Quartz.CGEventKeyboardSetUnicodeString(ev2, len(ch), ch)
_post(ev2)
time.sleep(0.005)
# 剪贴板 (替代 pyperclip)
def set_clipboard(text):
pb = NSPasteboard.generalPasteboard()
pb.clearContents()
pb.setString_forType_(text, NSStringPboardType)
def get_clipboard():
pb = NSPasteboard.generalPasteboard()
return pb.stringForType_(NSStringPboardType)
def Paste(text):
"""把 text 放剪贴板并 cmd+v 粘贴 (等价 Win版 pyperclip+ctrl+v)。"""
set_clipboard(text); time.sleep(0.05); Press('cmd+v')
# ---------- 截图 (screencapture, 输出物理像素) ----------
def GrabScreen(bbox=None):
"""全屏或区域截图 -> PIL Image。bbox=(l,u,r,b) 物理像素坐标。
传 bbox 后图内坐标相对裁剪原点; 转屏幕绝对坐标用 CropToScreen, 勿手搓 screencapture -R。
"""
fd, fn = tempfile.mkstemp(suffix='.png'); os.close(fd)
try:
if bbox:
l, u, r, b = bbox
# screencapture -R 用逻辑点; 转换 物理->逻辑
lx, ly = l*dpi_scale, u*dpi_scale
lw, lh = (r-l)*dpi_scale, (b-u)*dpi_scale
cmd = ['/usr/sbin/screencapture','-x','-t','png',
f'-R{lx:.0f},{ly:.0f},{lw:.0f},{lh:.0f}', fn]
else:
cmd = ['/usr/sbin/screencapture','-x','-t','png', fn]
subprocess.run(cmd, check=True, capture_output=True)
return Image.open(fn).copy()
finally:
try: os.remove(fn)
except Exception: pass
def ScreenCapAt(x, y, r=100):
"""物理坐标(x,y)为中心±r 截图 -> PIL Image。"""
return GrabScreen((x-r, y-r, x+r, y+r))
def CropToScreen(bbox, x, y=None):
"""裁剪图内坐标 -> 屏幕绝对物理坐标。bbox=GrabScreen 用的 (l,u,r,b) 物理像素。
(x,y)=在 GrabScreen(bbox) 返回图内找到的点。返回可直接喂给 Click 的 (X,Y)。
macOS 版的 ClientToScreen: 纯加裁剪原点偏移, 不做缩放(裁剪图与 bbox 同物理像素)。"""
if y is None and isinstance(x, (tuple, list)):
x, y = x[0], x[1]
return int(bbox[0] + x), int(bbox[1] + y)
def GrabWindow(win):
"""窗口截图 -> PIL Image。win=窗口号(int) 或 标题/应用名子串(str)。物理像素。"""
if isinstance(win, str):
ws = ListWindows(win)
if not ws: raise RuntimeError(f'window not found: {win}')
win = ws[0]['id']
fd, fn = tempfile.mkstemp(suffix='.png'); os.close(fd)
try:
subprocess.run(['/usr/sbin/screencapture','-x','-o','-l',str(win),'-t','png',fn],
check=True, capture_output=True)
return Image.open(fn).copy()
finally:
try: os.remove(fn)
except Exception: pass
# ---------- 窗口枚举 / 激活 (替代 win32gui) ----------
def ListWindows(name=None):
"""枚举屏上窗口 -> [{'id','title','app','bbox'(物理像素 l,u,r,b),'pid'}]。
name: 标题或应用名子串过滤(不区分大小写)。"""
opts = Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements
infos = Quartz.CGWindowListCopyWindowInfo(opts, Quartz.kCGNullWindowID)
out = []
inv = 1.0 / dpi_scale # 逻辑->物理
for w in infos:
b = w.get('kCGWindowBounds') or {}
layer = w.get('kCGWindowLayer', 0)
if layer != 0: # 只要普通应用窗口层
continue
title = w.get('kCGWindowName') or ''
app = w.get('kCGWindowOwnerName') or ''
l = b.get('X', 0)*inv; u = b.get('Y', 0)*inv
r = l + b.get('Width', 0)*inv; bo = u + b.get('Height', 0)*inv
rec = {'id': int(w.get('kCGWindowNumber', 0)), 'title': title, 'app': app,
'pid': int(w.get('kCGWindowOwnerPID', 0)),
'bbox': (int(l), int(u), int(r), int(bo))}
if name:
n = name.lower()
if n not in title.lower() and n not in app.lower():
continue
out.append(rec)
return out
def ActivateApp(target):
"""激活应用到前台 (替代 SetForegroundWindow)。
macOS 的前台是 *应用* 粒度而非窗口句柄粒度, 故无法 1:1 镜像 Win 版
Activate(hwnd)。target 支持两种定位键, 优先用精确的:
- int : 进程 pid (来自 ListWindows 的 'pid' 字段) —— 精确, 不受语言/本地化影响, **推荐**
- str : 应用名 / bundle id。按精确度分级匹配, 避免误中同厂商应用:
① bundle id 精确等值 (如 'com.tencent.meeting') —— 最可靠
② localizedName 精确等值 (如 '腾讯会议')
③ localizedName 子串 (最后兜底, 可能模糊)
注意: 不对 bundle id 做子串匹配, 因同厂商应用共享前缀
(微信 com.tencent.xinWeChat 与腾讯会议 com.tencent.meeting 都含 'tencent')。
返回是否成功。"""
ws = NSWorkspace.sharedWorkspace()
# 1) pid 精确激活 (首选)
if isinstance(target, int):
app = NSRunningApplication.runningApplicationWithProcessIdentifier_(target)
if app is not None:
app.activateWithOptions_(1 << 1) # NSApplicationActivateAllWindows
time.sleep(0.3)
return True
return False
# 2) 字符串: 按精确度分级匹配 (避免同厂商前缀误伤)
key = str(target)
keyl = key.lower()
apps = list(ws.runningApplications())
def _fire(app):
app.activateWithOptions_(1 << 1)
time.sleep(0.3)
return True
# ① bundle id 精确等值
for app in apps:
if (app.bundleIdentifier() or '').lower() == keyl:
return _fire(app)
# ② localizedName 精确等值
for app in apps:
if (app.localizedName() or '').lower() == keyl:
return _fire(app)
# ③ localizedName 子串 (兜底)
for app in apps:
if keyl in (app.localizedName() or '').lower():
return _fire(app)
# 3) 兜底用 open -a
try:
subprocess.run(['open', '-a', str(target)], check=True, capture_output=True)
time.sleep(0.5); return True
except Exception:
return False
# ---------- 模板匹配 FindBlock ----------
def GetWRect(sr):
"""快捷区域名 -> 物理像素 [l,u,r,b]。如 'left2'=左半屏, 'top3'=上1/3。"""
num = int(sr[-1])
l, u, r, b = 0, 0, swidth, sheight
if 'left' in sr: r = swidth // num
if 'right' in sr: l = swidth * (num - 1) // num
if 'top' in sr: b = sheight // num
if 'bottom' in sr: u = sheight * (num - 1) // num
return [l, u, r, b]
def FindBlock(fn, wrect=None, verbose=0, threshold=0.8):
"""在屏幕(或wrect区域)内找模板图 fn。返回 ((cx,cy), is_found), 物理坐标。
fn: 模板图路径(str)或 PIL Image。
wrect: None=全屏 | [l,u,r,b]物理像素 | 'left2'等快捷名 | PIL Image(直接当搜索底图)。"""
if not _HAS_CV2:
raise RuntimeError('FindBlock 需要 opencv-python 和 Pillow')
if wrect is not None and isinstance(wrect, Image.Image):
scr, wrect = wrect, None
else:
if isinstance(wrect, str): wrect = GetWRect(wrect)
scr = GrabScreen(wrect) # 物理像素
blc = Image.open(fn) if isinstance(fn, str) else fn
T = cv2.cvtColor(np.array(blc), cv2.COLOR_RGB2BGR)
B = cv2.cvtColor(np.array(scr), cv2.COLOR_RGB2BGR)
tsh, tsw = T.shape[:2]
res = cv2.matchTemplate(B, T, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(res)
oj, oi = max_loc
if wrect is None: wrect = [0, 0, scr.size[0], scr.size[1]]
obj = (oj + wrect[0] + tsw // 2, oi + wrect[1] + tsh // 2)
if verbose:
print(f'FindBlock {fn}: score={max_val:.4f} at phys={obj}')
return obj, max_val > threshold
def imshow(mt, sec=0):
cv2.imshow('cc', mt); cv2.waitKey(sec)
# ---------- Click (带像素变化检测) ----------
def Click(x, y=None, check=True, r=60):
"""物理坐标点击。check=True 时比较点击前后周边像素变化, 返回 (变化百分比, 截图)。
若变化≈0 → 可能点歪了 (同 Win 版语义)。"""
if y is None and isinstance(x, (tuple, list)):
x, y = x[0], x[1]
x, y = int(x), int(y)
before = None
if check:
try: before = np.array(ScreenCapAt(x, y, r))
except Exception: before = None
SetCursorPos((x, y)); time.sleep(0.05)
MouseClick()
if check:
time.sleep(0.25)
try:
after = np.array(ScreenCapAt(x, y, r))
except Exception:
return None
if before is not None and before.shape == after.shape:
diff = np.mean(np.any(before != after, axis=-1)) * 100
if verbose_click:
print(f'Click({x},{y}) pixel change: {diff:.1f}%')
if diff < 0.5:
print(f'[WARN] Click({x},{y}) 像素变化≈0%, 可能点歪了, 请诊断坐标! '
'常见错因: 用了裁剪图内坐标却没加裁剪原点(用 CropToScreen), '
'或对已是物理像素的坐标又做了 *dpi_scale 换算。')
return diff, Image.fromarray(after)
return None
# ---------- AX 辅助功能控件枚举 (UIA 层的 macOS 等价) ----------
try:
from ApplicationServices import (
AXUIElementCreateApplication, AXUIElementCopyAttributeValue,
AXValueGetValue, AXUIElementPerformAction, AXIsProcessTrusted,
kAXChildrenAttribute, kAXRoleAttribute, kAXDescriptionAttribute,
kAXPositionAttribute, kAXSizeAttribute, kAXWindowsAttribute,
kAXTitleAttribute, kAXValueCGPointType, kAXValueCGSizeType,
kAXPressAction, kAXEnabledAttribute,
)
_HAS_AX = True
except ImportError:
_HAS_AX = False
def _resolve_pid(target):
"""target(int pid | str bundle_id/应用名) → pid(int)。
str 优先按 bundle id 精确匹配, 再按 localizedName 精确/子串兜底,
与 ActivateApp 的匹配纪律一致, 避免同厂商前缀误伤。"""
if isinstance(target, int):
return int(target)
key = str(target); keyl = key.lower()
ws = NSWorkspace.sharedWorkspace()
apps = list(ws.runningApplications())
for a in apps: # ① bundle id 精确
if (a.bundleIdentifier() or '') == key:
return int(a.processIdentifier())
for a in apps: # ② localizedName 精确
if (a.localizedName() or '').lower() == keyl:
return int(a.processIdentifier())
for a in apps: # ③ localizedName 子串
if keyl in (a.localizedName() or '').lower():
return int(a.processIdentifier())
raise ValueError(f'找不到 target={target!r} 对应的运行中应用')
def _ax_attr(el, key):
"""读取单个 AX 属性,失败返回 None。"""
if not _HAS_AX:
return None
err, val = AXUIElementCopyAttributeValue(el, key, None)
return val if err == 0 else None
def AXElements(target, max_depth=10, include_zero_size=False):
"""枚举应用控件树。
Parameters
----------
target : int | str
pid(int) 或 bundle_id(str, 如 'com.tencent.meeting')。
max_depth : int
递归深度上限。
include_zero_size : bool
是否包含 w<=0 或 h<=0 的零尺寸节点。
Returns
-------
list[dict] : 每项含 role/desc/title/id/value/x/y/w/h(物理像素)/depth/el。
value 为控件当前值(文本/输入框内容等),非文本值为 None。
el 是原始 AXUIElement 引用,目标窗口关闭/重建后失效,AXPress 前宜就近重新枚举。
"""
if not _HAS_AX:
raise RuntimeError(
'AX 不可用。请安装: pip install pyobjc-framework-ApplicationServices')
if not AXIsProcessTrusted():
raise PermissionError('需要授予辅助功能权限(系统设置 > 隐私与安全 > 辅助功能)')
pid = _resolve_pid(target)
app_el = AXUIElementCreateApplication(pid)
wins = _ax_attr(app_el, kAXWindowsAttribute) or []
scale = dpi_scale # 逻辑/物理, Retina=0.5
results = []
def _walk(el, depth):
if depth > max_depth:
return
role = _ax_attr(el, kAXRoleAttribute)
desc = _ax_attr(el, kAXDescriptionAttribute)
title = _ax_attr(el, kAXTitleAttribute)
ident = _ax_attr(el, 'AXIdentifier')
value = _ax_attr(el, 'AXValue')
enabled = _ax_attr(el, kAXEnabledAttribute)
# AXValue 多为 str/num(文本/输入框);若是 AXValueRef(坐标等)忽略
if value is not None and not isinstance(value, (str, int, float, bool)):
value = None
pos_val = _ax_attr(el, kAXPositionAttribute)
sz_val = _ax_attr(el, kAXSizeAttribute)
# 解包坐标(逻辑点)
px = py_ = pw = ph = 0.0
if pos_val is not None:
ok, pt = AXValueGetValue(pos_val, kAXValueCGPointType, None)
if ok:
px, py_ = pt.x, pt.y
if sz_val is not None:
ok, sz = AXValueGetValue(sz_val, kAXValueCGSizeType, None)
if ok:
pw, ph = sz.width, sz.height
# 转物理像素
phys_x = px / scale if scale else px
phys_y = py_ / scale if scale else py_
phys_w = pw / scale if scale else pw
phys_h = ph / scale if scale else ph
if not include_zero_size and (phys_w <= 0 or phys_h <= 0):
pass # 跳过零尺寸,但仍递归子节点
else:
results.append(dict(
depth=depth, role=role, desc=desc, title=title, id=ident,
value=value, enabled=bool(enabled) if enabled is not None else None,
x=round(phys_x), y=round(phys_y),
w=round(phys_w), h=round(phys_h), el=el))
for child in (_ax_attr(el, kAXChildrenAttribute) or []):
_walk(child, depth + 1)
for win in wins:
_walk(win, 0)
return results
def AXPress(element) -> bool:
"""对 AX element 执行 Press 动作(免坐标点击)。"""
if not _HAS_AX:
return False
err = AXUIElementPerformAction(element, kAXPressAction)
return err == 0
def AXClick(node, check=True) -> bool:
"""点击控件: AXPress 优先(免坐标), 失败回退到中心点物理坐标 Click。
node: AXFind/AXElements 返回的 dict(含 el 与 x/y/w/h), 或裸 AXUIElement。
返回是否点击成功(回退路径据像素变化判定, check=False 时无法判定按 True)。
呼应 computer_use SOP: AXPress 优先, 回退 Click(phys_cx, phys_cy)。"""
if not isinstance(node, dict):
return AXPress(node)
if node.get('enabled') is False:
print(f"[WARN] AXClick: 控件 disabled (role={node.get('role')}, "
f"title={node.get('title')!r}), 点击可能无效")
if AXPress(node.get('el')):
return True
# 回退: 中心点物理坐标
cx = node['x'] + node['w'] // 2
cy = node['y'] + node['h'] // 2
res = Click(cx, cy, check=check)
if not check or res is None:
return check is False # 无法判定时: 关检查按成功, 截图失败按失败
diff, _ = res
return diff >= 0.5
def AXFind(target, role=None, desc=None, title=None, identifier=None,
enabled_only=False, max_depth=10):
"""枚举并过滤控件。所有过滤条件为子串匹配(大小写不敏感)。
enabled_only=True 时只返回 enabled 的控件(SOP: 点前查 disabled)。
Returns
-------
list[dict] : 匹配项,同 AXElements 返回格式。
"""
def _hit(field, needle):
return needle is None or (field and needle.lower() in field.lower())
return [n for n in AXElements(target, max_depth=max_depth)
if _hit(n['role'], role) and _hit(n['desc'], desc)
and _hit(n['title'], title) and _hit(n['id'], identifier)
and not (enabled_only and n.get('enabled') is False)]
# ---------- API 镜像别名 (drop-in 替换 ljqCtrl 用) ----------
click = Click
press = Press
activate = ActivateApp # 注意: Win 版 Activate(hwnd) 收窗口句柄, Mac 版收应用名子串
GrabWindowBg = GrabWindow # Mac screencapture 本身支持后台窗口
VK_CODE = _KEYMAP # 名称兼容
if __name__ == '__main__':
print('--- macljqCtrl self-check ---')
check_permissions()
print('cursor(logical):', _cursor_logical())
print('windows(top5):')
for w in ListWindows()[:5]:
print(' ', w['id'], '|', w['app'], '|', w['title'][:30], '|', w['bbox'])
+55
View File
@@ -0,0 +1,55 @@
# 记忆整理 SOP
## 核心原则:存在性编码
LLM自身是压缩器+解码器。L1只需让它**意识到某类知识存在**,它就能通过tool call自行取用深层内容。
**L1本质:用最短词数表达——什么场景下有什么记忆可用(存在性)。**
L1两类内容,统一ROI评估:
- **存在性指针**:指向L2/L3知识的最短触发词
- **行为规则**:不提醒就会犯的错(致命/高频均可,只要ROI过门槛)
ROI = (不放这几个词的犯错概率 × 代价) / 每轮词数成本
## 快速判断
**该留**:反直觉触发词——没提示就想不到去查SOP的场景词。如`tmwebdriver_sop(httponly cookie)`:没有`httponly cookie`这个词,你不会想到取cookie要查tmwebdriver
**该删**
- 名字翻译:`proxy-pool/(代理池)` → 名字自解释,括号是废词,直接`proxy-pool`即可
- 内容描述:`opencli_sop(66站点CLI,复用Chrome session)` → 实现细节属于SOP内部,不是触发场景
- 直觉能力:不提醒也能想到 → 0收益,白交每轮成本
- 冗余:L3已覆盖的规则 / L1其他行已含的片段
## 压缩四原则
1. **命名自解释 > 加描述**:SOP名能说清的,L1不加注释;改名的ROI常高于改L1
- 推论:极低ROI且文件名自解释的条目可不单独列入L1,但仍需被原则2的集合包含住,靠集合触发词+ls兜底
2. **存在性集合最小描述**:多个相近条目若可被同一上位场景覆盖,用集合名表达这类能力的存在,不必平铺子项。如`qq操作/飞书操作/企微操作``im操作:*_im_sop`;子项名自解释则只列名不翻译
3. **条目 = 场景↔方案存在性**:如`视频理解:yt-dlp取字幕``fofa(资产测绘)`——场景名是触发词,方案名编码存在性;括号内**只放反直觉触发词**,非反直觉的(纯翻译/内容描述/实现细节)全是浪费
- **触发词判定**:假设用户说出这个词,你能否想到去查对应SOP?能→直觉(不需要);不能→反直觉(必须留)。如`game_download(百度网盘)`:用户说"百度网盘下载",没这个词你不会想到game_download→必须留
4. **分层归位**:L1内部调整位置——带行为规则或高频高ROI的条目放上方场景行,纯存在性指针归L1下方平铺列表。**归位≠移出L1,存在性不可丢**
## 整理流程
1. 逐行读L1,按`|`拆片段,先分类:存在性指针 / RULES / 翻译 / 内容描述 / 实现细节 / 冗余
2. 先清RULES:逐条问“这是全局高ROI,还是特定场景低危险规则?”
- 全局高ROI → 留
- 特定场景 / 低危险 → 降级到L3或删除
3. 再清存在性指针:检查是否在表达**场景↔方案存在性**;场景触发词只在**反直觉**时才加,翻译/内容描述/实现细节删掉
4. 审计幽灵条目:L1指向的L3文件是否真实存在?不存在则按ROI决定删除或补建文件
5. 禁值存储:L1括号内禁放参数值(IP/端口/凭证等),这些属于L2,L1只编码存在性
6. 检查L3文件名是否自解释;能靠改名解决的,不靠L1加描述;最后验证总行数 ≤ 30
**红线**:记忆修改是持久性伤害,错误每轮复利。L1只能patch词级别修改,禁overwrite
产生误导应及时修正L1或记忆更名
## L2 瘦身流程(冗余长段→L3,事实无损)
适用:L2 某段冗长(服务器/工具详情),需压缩但禁丢事实。
1. **先迁再压**:把该段完整事实迁到/合并进 L3 专属 SOP(已有同主题 SOP 就并入,勿重复建,先 `ls ../memory/` 查),L2 只留 6-9 行"连接方式+服务端点+高频坑+指针(见 xxx_sop.md)"。
2. 迁移后同步 L1 加新 SOP 名(自解释即可,勿加冗余括号)。
3. 每节独立闭环:迁移→压 L2→同步 L1,再进下一节,限制失败半径。
4. 验证:核对 6 项(每个新 SOP 文件存在 & 已入 L1)、L2 无遗留脏字符、总行数下降。
## 坑:L2 历史脏字符导致 file_patch 匹配失败
- 现象:`file_patch` 连续报"未找到匹配旧文本块",但肉眼看 old_content 与文件一致。
- 根因:老记录行首可能混入真实的多余 `|`(或全角/箭头字节差异),复制时看不出。
- 排查:`for i,l in enumerate(lines): print(i+1, repr(l[:20]))` 用 repr 看真实字节。
- 解法:**改用 Python 按行号切片替换**:`lines=open(p,encoding='utf-8').read().split('\n'); assert lines[a].startswith(...); newlines=lines[:a]+repl+lines[b:]; open(p,'w',encoding='utf-8').write('\n'.join(newlines))`。前后加 `assert startswith` 双锚点防错位,改完 repr 复核。此法顺带清脏字符。
+92
View File
@@ -0,0 +1,92 @@
## 0. 核心公理 (Core Axioms - 最高优先级)
1. **行动验证原则 (Action-Verified Only)**
* **定义**:任何写入 L1/L2/L3 的信息,必须源自**成功的工具调用结果**(如 `shell` 执行成功、`file_read` 确认内容存在、代码运行通过)。
* **禁止**:严禁将模型的“固有知识”、“推理猜测”、“未执行的计划”或“未验证的假设”作为事实写入。
* **口号****No Execution, No Memory. (无行动,不记忆)**
2. **神圣不可删改性 (Sanctity of Verified Data)**
* **定义**:凡是经过行动验证的有效配置、避坑指南、关键路径,在重构(Refactoring/GC)时**严禁丢弃**。
* **操作**:可以压缩文字、可以迁移层级(从 L2 移到 L3),但绝不能丢失信息的准确性和可追溯性。
* 记忆修改时请极度小心,尽量不要overwrite或code run。只能少量patch,改不动宁愿不改。
3. **禁止存储易变状态 (No Volatile State)**
* **定义**:严禁存储随时间/会话高频变化的数据。
* **示例**:当前时间戳、临时 Session ID、正在运行的 PID、某个具体绝对路径、连接的设备信息
4. **最小充分指针 (Minimum Sufficient Pointer)**
* 上层只留能定位下层的最短标识,多一词即冗余。
---
## 记忆层级架构
```
L1: global_mem_insight.txt (极简索引层 - 严格控制 ≤30 行)
↓ 导航指向 (Pointer)
L2: global_mem.txt (事实库层 - 现短但会膨胀)
↓ 详细引用 (Reference)
L3: ../memory/ (记录库层 - 包含 .md/.py 等各类文件)
L4: ../memory/L4_raw_sessions/ (历史会话层 - scheduler反射自动收集,可定位过往上下文)
```
---
## 各层职责与原则
### L1:全局内存索引 (global_mem_insight.txt)
**职责**:为 L2 和 L3 提供极简导航索引,确保关键能力可被发现。
**特征**
- 体积限制:≤ 30 行(硬约束),< 1k tokens(期望)。严禁填写细节(除非极高频任务)
- 内容:两层「场景关键词→记忆定位」映射 + RULES(红线规则 + 高频犯错点)
- 第一层:高频场景 key→value(直接给出 sop/py/L2 section 名),自包含名称只写一词不重复翻译
- 第二层:低频场景仅列关键词,需要时 read L2 或 ls L3 自行定位
- 核心:场景触发词极重要(不索引则不知有此能力),但严禁写How-to细节
- RULES:压缩版避坑准则,包含:
- 红线规则(致命型):违反会导致进程终止或系统崩溃(如 `禁无条件杀python(会杀自己)`
- 红线规则(隐蔽型):违反不报错但产生错误结果(如 `搜索用google不用百度`
- 高频犯错点:容易遗忘的关键约束(如 `es(PATH有)` 防止找路径)
- 更新:L2/L3 有新增/删除时,判断频率归入对应层。修改时请极度小心,不允许overwrite或code run。只能少量patch,改不动宁愿不改。
**禁止**:严禁写入密码、API Key。允许内联非敏感触发参数(如代理端口)。不写 "How to" 或详细解释。严禁包含特定任务的技术细节(特定任务细节应该在L3)。更加严禁写入日志记录!
---
### L2:全局事实库 (global_mem.txt)
**职责**:存储全局环境性事实(路径、凭证、配置、常量等)。
**特征**
- 趋势:随环境扩展而膨胀(可接受)
- 内容:按 `## [SECTION]` 组织的事实条目
- 同步:变化时更新 L1 的相应 TOPIC 导航行,只能导航
**禁止**:禁止存储易变状态、禁止存储猜测、严禁存储大模型可推理的通用常识
---
### L3:任务级精简记录库 (../memory/)
职责:补充 L1/L2 无法容纳、但对**特定任务**未来复用至关重要的少量详细信息。内容必须在满足复用需求的前提下**尽可能短**。
原则:
- 只记录:跨会话仍重要、且难以通过少量 file_read / web_scan / 简单脚本快速重建的要点。
- 优先写:该任务特有的隐藏前置条件、典型易踩坑点,一旦遗忘会导致高成本重试的信息。
- 不记录:普通操作步骤、可在几步探测中重新获得的路径或状态信息。
形式:
- SOP(*_sop.md):为单一任务或小类任务保留极简的「关键前置 + 典型坑」清单,避免长篇教程。
- 工具脚本(*.py):仅封装高复用、逻辑相对复杂且不希望每次都重新推理的处理流程。
---
## L1 ↔ L2/L3 同步规则
| 操作 | L1 同步 |
|---------|--------|
| L2/L3 新增场景 | 新建默认低频→L3列表加文件名(自解释不加描述,反直觉场景才能加括号触发词) |
| L2/L3 删除场景 | 删除对应层的关键词/映射行 |
| L2/L3 修改值 | 若不影响场景定位则不动 L1 |
| 发现通用避坑规律 | 压缩为一句加入 RULES |
> **同步红线**:L1 只写关键词/名称,禁搬细节。括号内只写反直觉的场景触发词(2-4字),禁写机制/方法/步骤。需要评估L1中的token数和索引效用。
> 反例:❌ sop_name(场景A:方法1+方法2+方法3) → ✅ sop_name(场景A)
> 反例:名字已自解释时 ❌ discord_slate_sop(Slate输入框) → ✅ discord_slate_sop
---
## 信息分类快速决策树
```
"这条信息该放哪层?"
是『环境特异性事实』? (IP、非标路径、凭证、ID、API 密钥等,大模型 Zero-shot 无法生成准确)
├─ YES → L2 (global_mem.txt)
│ 然后 → 按频率归入 L1 第一层(key→value)或第二层(仅关键词)
└─ NO
是『通用操作规律』? (全局性避坑指南、排查方法、不针对特定任务的通用准则)
├─ YES → L1 [RULES] (仅限 1 句压缩准则)
└─ NO
是『特定任务技术』? (艰难尝试才能成功,且未来还能用到的任务,如:微信解析参数、特定游戏坐标、临时工具配置)
├─ YES → L3 (../memory/ 专项 SOP 或脚本)
└─ NO → 判定为『通用常识』或『冗余信息』: 严禁存储,直接丢弃
```
+43
View File
@@ -0,0 +1,43 @@
# morphling_sop
## 定义
Morphling 是一种项目级能力吸收/替代模式:给定任意目标项目,先抽取其目标与测例,再按组件选择调用、重写或少量复刻禁区规避,最终让自身或新产物在同一测例上达到或超过目标。
## 核心三元组
1. **目标(Target**:它解决什么问题、面向谁、核心价值是什么;目标可以是完整项目,也可以是巨型项目中的可交付子系统。
2. **测例(Tests**:它声称能通过的 benchmark、demo、CI、榜单、评测站、用户任务清单、性能/质量指标;没有测例先构造最小客观测例。
3. **行为(Actions**:对每个组件分别决定:调用、重写、舍弃;避免“复刻/抄袭”作为默认行为。
## 输出形态
- **调用型 morphling**:把目标能力纳入自身工具链,产物是“更强的我”。
- **重写型 morphling**:理解核心后从零实现更好版本,产物是可独立替代原项目的新 repo/工具/产品。
- **混合型 morphling**:同一项目可分组件处理:底层复杂依赖调用,差异化核心重写,冗余模块舍弃。
## 流程
1. **锁定目标**:记录 URL/repo/产品名;不要先评价值不值得,先看它实际解决的问题。
2. **目标拆解**:识别类型:skill/教程、库、CLI、桌面/网页产品、基础设施、巨型生态、纯概念项目。
3. **测例提取**:优先找官方 tests、CI、benchmark、论文/README 指标、demo 脚本、评测网站、issue 中的真实失败案例。
4. **测例补全**:若目标没有公开测例,构造最小可验证任务集:核心 happy path、边界条件、目标宣称的杀手特性、用户最痛点。
5. **组件分解**:列出核心模块、可替换依赖、生态/数据/模型/硬件等不可轻易重写部分。
6. **行为选择**
- 能稳定调用且非差异化核心 → 调用/封装。
- 质量差、耦合重、可用更简洁方式实现、或需独立发布 → 重写。
- 巨型/长期生态部分 → 缩小到子系统或调用成熟依赖。
- 复刻/照抄只作为理解阶段,不作为交付策略。
7. **实现闭环**:先做能跑通测例的最小版本,再补强超过目标的维度。
8. **对照验证**:在同一测例上跑目标与 morphling 产物,记录通过率、速度、稳定性、成本、易用性。
9. **固化成果**:调用型写入工具链/SOP;重写型形成 repo、README、测试与交付说明。
## 边界判断
- Office 这类巨型生态不做整体替代,拆成具体子系统或能力点。
- Stable-diffusion-webui 这类“大但核心可抽离”的项目可以重写核心体验,因为历史包袱可能大于真实复杂度。
- UI-TARS-Desktop 这类路线差异项目:调用可吸收其纯视觉能力;重写则意味着做一个独立多模态桌面 Agent,并跑同类 GUI benchmark。
## 执行方式
- Morphling 任务应通过 Goal Hive 执行(参见 goal_hive_sop),利用 Master 调度 + Worker 并行实现 + 持续验收的长程模式完成。
## 完成标准
- 必须有测例或明确构造的测例。
- 必须说明每个核心组件采用调用/重写/舍弃的理由。
- 必须能在同一考卷上与目标对比。
- “更好”不能只靠主观判断,至少落在一个可测维度:通过率、性能、成本、稳定性、易用性、可维护性、覆盖范围。
+101
View File
@@ -0,0 +1,101 @@
"""
本地 OCR 工具
- OCR引擎: rapidocr-onnxruntime (~1s/次, 中英文准确率高, 带bbox)
- 坑(rapid): result[i][2] conf 是 str 不是 float
- 坑(rapid): 无文字时 result 返回 None 而非空列表
- 坑: enhance 放大+高对比度处理,对清晰文字有害,默认关闭
- 坑(远程桌面): ImageGrab/mss 在 RDP 断开后截图全黑,用 ocr_window(hwnd) 代替
"""
import re
from PIL import ImageGrab, Image, ImageEnhance
_LANG = 'zh-Hans-CN'
_rapid_engine = None
def _get_rapid():
global _rapid_engine
if _rapid_engine is None:
from rapidocr_onnxruntime import RapidOCR
_rapid_engine = RapidOCR()
return _rapid_engine
def _preprocess(img, scale=3, contrast=3.0):
img = ImageEnhance.Contrast(img).enhance(contrast)
img = img.resize((img.width * scale, img.height * scale))
return img
def _strip_cjk_spaces(t):
return re.sub(r'(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])', '', t)
def _ocr_rapid(img):
import numpy as np
engine = _get_rapid()
arr = np.array(img)
result, elapse = engine(arr)
if not result:
return {'text': '', 'lines': [], 'details': []}
lines = [r[1] for r in result]
details = [{'bbox': r[0], 'text': r[1], 'conf': float(r[2])} for r in result]
text = _strip_cjk_spaces('\n'.join(lines))
return {'text': text, 'lines': [_strip_cjk_spaces(l) for l in lines], 'details': details}
def ocr_image(image_input, lang=_LANG, enhance=False, engine=None):
"""
对 PIL Image 做 OCR
:param image_input: PIL Image 对象 或 文件路径(str)
:param lang: 保留参数,当前未使用
:param enhance: 预处理
:param engine: 保留参数,当前仅支持 rapid/None
:return: dict {'text': 全文, 'lines': [行文本], 'details': [bbox+conf]}
"""
if isinstance(image_input, str):
image_input = Image.open(image_input)
if enhance:
image_input = _preprocess(image_input)
if engine not in (None, 'rapid'):
raise ValueError("Only rapid OCR is supported")
return _ocr_rapid(image_input)
def ocr_screen(bbox=None, lang=_LANG, enhance=False, engine=None):
"""
截取屏幕区域并 OCR
:param bbox: (x1, y1, x2, y2) 像素坐标,None=全屏
:return: dict {'text': 全文, 'lines': [行文本], 'details': [bbox+conf](仅rapid)}
"""
img = ImageGrab.grab(bbox=bbox)
return ocr_image(img, lang, enhance, engine)
def ocr_window(hwnd, lang=_LANG, enhance=False, engine=None):
"""
截取窗口并 OCR (使用 PrintWindow API,支持远程桌面断开场景)
:param hwnd: 窗口句柄(int)
:return: dict {'text': 全文, 'lines': [行文本], 'details': [bbox+conf](仅rapid)}
"""
import win32gui, win32ui
from ctypes import windll
l, t, r, b = win32gui.GetWindowRect(hwnd)
w, h = r - l, b - t
hwndDC = win32gui.GetWindowDC(hwnd)
mfcDC = win32ui.CreateDCFromHandle(hwndDC)
saveDC = mfcDC.CreateCompatibleDC()
saveBitMap = win32ui.CreateBitmap()
saveBitMap.CreateCompatibleBitmap(mfcDC, w, h)
saveDC.SelectObject(saveBitMap)
windll.user32.PrintWindow(hwnd, saveDC.GetSafeHdc(), 3)
bmpinfo = saveBitMap.GetInfo()
bmpstr = saveBitMap.GetBitmapBits(True)
img = Image.frombuffer('RGB', (bmpinfo['bmWidth'], bmpinfo['bmHeight']), bmpstr, 'raw', 'BGRX', 0, 1)
win32gui.DeleteObject(saveBitMap.GetHandle())
saveDC.DeleteDC()
mfcDC.DeleteDC()
win32gui.ReleaseDC(hwnd, hwndDC)
return ocr_image(img, lang, enhance, engine)
if __name__ == "__main__":
r = ocr_screen((0, 0, 400, 100))
print(f"识别结果: {r['text']}")
for line in r['lines']:
print(f" 行: {line}")
if 'details' in r:
for d in r['details']:
print(f" [{d['conf']:.3f}] {d['text']}")
+263
View File
@@ -0,0 +1,263 @@
# Plan Mode SOP
**触发**:3步以上有依赖/多文件协同/条件分支/需并行 | **禁用**1-2步简单任务直接做
任务开始前必须先创建工作目录 `./plan_XXX/`XXX=任务英文短名)
单独使用一个code_run({'inline_eval':True, 'script':'handler.enter_plan_mode("./plan_XXX/plan.md")'})进入plan模式
handler是inline_eval自动注入的变量
---
## 一、探索态(规划前置,必须执行)
**硬性规则(先读再做)**
- **主agent禁止直接执行环境探测**(必须委托subagent,无例外)
- 主agent只做:创建目录、匹配SOP、启动subagent、读取结论
- subagent只读探测,禁止修改任何文件、执行有副作用的操作
- **探索subagent启动失败时:排查原因→重试,最多2次。禁止主agent回退为自己探测**
**目标**:在写任何计划之前,搞清3件事:
① 环境现状(有什么、缺什么) ② 可用SOP ③ 关键不确定点
**为什么必须用subagent**:主agent上下文是最稀缺资源,探测长输出会挤占规划执行空间。
### 步骤1:创建目录(必做) + SOP匹配 + 设置plan标志(主agent直接做)
1. 创建工作目录 `mkdir plan_XXX/`
2. 从上下文中的 L1 Insight 索引匹配可用领域SOP
3. 更新checkpoint`[任务] XXX | [需求] 一句话 | [约束] 关键限制 | [匹配SOP] ... | [进度] 探索态`
### 步骤2:启动探索subagent(监察模式)
按 subagent.md 启动探索subagent**加 `--verbose`** 开启监察模式,input要点:
- **任务**:探测环境信息,写入 `plan_XXX/exploration_findings.md`
- **探测项**(按任务类型选做,不是全做):
- 代码类 → 关键文件结构、依赖、入口点
- 浏览器类 → 目标页面当前状态、可交互元素
- 自动化类 → 环境检查(which/pip/路径/权限)
- 数据类 → 抽样数据(首5行+尾5行+总量)
- **输出格式**`## 环境现状` / `## 关键发现` / `## 风险/不确定点`
- **约束**:只读探测,禁止修改文件,≤10次工具调用
- **复杂度评估**:探测时注意记录数据规模(文件数、行数、页面数),写入findings供规划时判断委托
### 步骤3:监察等待 + 读取结论
主agent主动观察output.txt进度(`--verbose`输出含原始工具结果),而非无脑sleep轮询:
1. **观察**:读output.txt,审查subagent的探测方向和原始数据
2. **纠偏**(按需):
- 方向偏了 → 写 `_intervene` 追加指令纠正
- 缺少关键上下文 → 写 `_keyinfo` 注入信息
- 已获取足够信息 → 写 `_stop` 提前终止,节省轮次
3. **收取**:等待 `[ROUND END]`,读取 `exploration_findings.md`
**产出**`exploration_findings.md`(结构化发现报告),主agent基于此进入规划态,写入plan.md头部的「探索发现」段。主agent在监察过程中获得的一手认知也可直接用于规划。
---
## 二、规划态(含审查门)
### 步骤4:读领域SOP → 写plan.md
先读探索态匹配到的SOP,然后写plan骨架。允许"⚠待确认",禁止以"没调研清楚"推迟。
**[D] 委托标注规则**:写每个步骤时,结合探索发现评估操作量,符合以下任一条件则标 `[D]`
- 需要读取大量代码/文件(预估 >3个文件或 >100行)
- 需要浏览网页并提取信息
- 需要执行 3 次以上重复性操作
- 需要运行测试/构建并分析输出
不标 `[D]` 的情况:读/更新 plan.md、单文件小幅修改、ask_user、简单一次性命令
**plan.md格式**
```markdown
<!-- EXECUTION PROTOCOL (每轮必读,这是你的执行指南)
1. file_read(plan.md),找到第一个 [ ] 项
2. 该步标注了SOP → file_read 该SOP的🔑速查段
3. 执行该步骤 + Mini验证产出
4. file_patch 标记 [ ] → [✓]+简要结果,然后回到步骤1继续下一个[ ]
5. 所有步骤(包括验证步骤)标记完成后 → 终止检查:file_read(plan.md)确认0个[ ]残留
⚠ 禁止凭记忆执行 | 禁止跳过验证步骤 | 禁止未经终止检查就结束 | 禁止停下来输出纯文字汇报
💡 搬砖活(读大量代码/文件/网页/重复操作)优先委托subagent,保持主agent上下文干净
-->
# 任务标题
需求:一句话 | 约束:关键限制
## 探索发现
- 发现1XXX(来源:file_read/web_scan/code_run
- 发现2YYY
- 不确定点:ZZZ
## 执行计划
1. [ ] 步骤1简述
SOP: xxx_sop.md
2. [D] 步骤2简述(委托subagent执行)
SOP: yyy_sop.md
依赖:1
3. [P] 步骤3简述(并行,读subagent.md执行Map模式)
SOP: yyy_sop.md
4. [?] 步骤4(条件分支)
SOP: (无) ← 高风险
条件:X成功→4.1,否则→4.2
---
## 验证检查点
N+1. [ ] **[VERIFY] 启动独立验证subagent**
SOP: deliverable_audit_sop.md plan_sop.md
操作:读plan_sop.md第四章内容 → 准备verify_context.json → 启动验证subagent → 读取VERDICT → 按结果处理
⚠ 不可跳过,不可在未启动subagent的情况下标记[✓]
---
```
### 步骤5:自检清单(主agent逐项检查)
- □ 探索发现是否都反映在plan中?(没遗漏关键约束)
- □ 每步的SOP标注是否合理?(SOP真的能解决该步?)
- □ 步骤间依赖是否正确?(有没有隐含依赖没写出来)
- □ 高风险步骤(SOP:无/不可逆)有没有清晰的执行思路?
- □ 步骤粒度是否合适?(禁止"处理所有文件",必须展开具体条目)
-**复杂/繁琐步骤是否标注了[D]**(读大量代码/网页/重复操作必须委托subagent)
-**是否包含"验证检查点"section,且有[VERIFY]步骤?(必须有,这是强制步骤)**
### 步骤6:用户确认
ask_user 确认plan后才能转入执行态。**⛔ 用户未确认不得执行。**
### 步骤7:转入执行态
更新checkpoint`[执行] plan.md | 当前:步骤1 | ⚡有[P]标记必须读subagent.md执行Map模式`
---
## 三、执行态循环
> **核心原则:连续执行,不停顿汇报。** 做完一步立即 file_read(plan.md) 找下一个 `[ ]`,直到全部完成。
### 每轮流程
1. **读plan**`file_read(plan.md)` 定位第一个 `[ ]`
2. **读SOP** — 该步标注了SOP → 先 file_read 该SOP
3. **检查标记**`[D]`标记 → 必须委托subagent执行,主agent只收结果摘要;`[P]`标记 → 读 subagent.md 执行Map模式;`[?]`条件 → 评估条件选分支,未选标[SKIP]
4. **执行** — 无特殊标记的步骤由主agent自己执行
5. **Mini验证** — 快速确认产出存在且合理(file_read确认非空、检查exit code等)
6. **标记完成**`file_patch` 标记 `[ ]``[✓ 简要结果]`(进度写入plan.md
7. **继续** — 立即回到步骤1file_read(plan.md) 执行下一个 `[ ]`
### 终止检查(最后一步标记后,不可跳过)
file_read(plan.md) 全文扫描,确认所有步骤(含[VERIFY])均为 `[✓]`/`[✗]`0个 `[ ]` 残留。
输出:`🏁 终止检查:[总步数]步全部完成,0个[ ]残留 → 任务结束`
若发现遗漏 → 继续执行,禁止声称完成。
### ⚠ 执行态禁令
- **禁止凭记忆执行**:每次做新步骤前必须 `file_read(plan.md)`,不可"我记得下一步是..."
- **禁止跳过验证步骤**:[VERIFY]步骤是强制的,不可以"任务都做完了"为由跳过
- **禁止未经终止检查就结束**:最后一步标记后必须 file_read 全文扫描确认0个[ ]残留,输出🏁终止确认行
- **禁止停下来输出纯文字汇报**:做完一步后必须立即 file_read(plan.md) 继续,不要输出进度总结
### 💡 动态委托原则
即使步骤未标 `[D]`,执行中发现以下情况时,主动委托 subagent 处理:
- 需要读取大量代码/文件才能理解上下文(>3个文件或预估 >100行)
- 需要反复试错调试
- 需要浏览网页提取信息
做法:起 subagent 完成具体操作,要求返回精简摘要,主 agent 基于摘要继续决策。保持主 agent 上下文干净是第一优先级。
---
## 四、验证态(subagent独立验证)
> 全部步骤[✓]后进入。**强制**启动独立subagent做对抗性验证,避免上下文污染。
### 触发条件
- 所有执行步骤标记为 `[✓]`
- **所有plan模式任务必须经subagent验证**(主agent有确认偏误,易被表面成功迷惑)
### 步骤8:准备验证上下文
`./plan_XXX/` 下创建 `verify_context.json`,包含:
- task_description:原始任务描述(用户原话)
- plan_fileplan.md绝对路径
- task_typecode|data|browser|file|system
- deliverables:交付物列表(type/path/expected
- required_checks:必做检查列表(check/tool
**传什么**:任务描述、plan路径、交付物清单、必做检查。**不传**:执行过程、调试记录。
### 步骤9:启动验证subagent
按 subagent.md 标准流程启动验证subagentinput要点:
- **角色**:你是独立验证者,工作是对抗性验证(证明交付物不能用)
- **第一步强制**file_read deliverable_audit_sop.md 完整阅读验证SOP
- **按 deliverable_audit_sop.md 第3节**选择对应task_type的验证策略执行
- **每个检查必须有工具调用证据**(实际执行,不是叙述)
- **任务描述**:(填入原始任务描述)
- **交付物清单**:(填入deliverables列表)
- **输出**:在 result.md 中按 deliverable_audit_sop.md 第6节格式输出,最后一行 `VERDICT: PASS / FAIL / PARTIAL`
- **约束**:3轮内完成,每轮至少1个实际工具调用
同时传入 verify_context.json 的路径,让subagent自行读取详细上下文。
### 步骤10:收集验证结果
轮询 output.txt 等待 `[ROUND END]`,然后读取 result.md
1. **找VERDICT行**:读取result.md最后几行,提取 `VERDICT: PASS/FAIL/PARTIAL`
2. **检查有效性**:如果所有PASS项都没有工具调用输出(只有叙述),视为验证无效,按FAIL处理
3. **按结果处理**
- **PASS** → 进入任务完成收尾
- **FAIL** → 进入修复循环
- **PARTIAL** → 主agent判断可接受则完成,否则修复
- **无VERDICT行** → 从output.txt提取关键信息,主agent自行判断PASS/FAIL
**任务完成收尾**(验证PASS后执行):
1. 标记plan.md中 `[VERIFY]` 步骤为 `[✓]`
2. 更新checkpoint`[完成] XXX任务 | [产出] ... | [经验] ...`
3. 向用户确认任务完成
**重要**:只有在验证PASS后,才能标记[VERIFY]为[✓]并声称任务完成。如果验证FAIL,需要进入修复循环。
**Fallback**:若subagent未产出result.mdturn耗尽),从output.txt提取VERDICT关键信息。
### 修复循环(FAIL后)
FAIL → 提取具体失败项 → 回执行态修复(不重新规划) → 修复完成 → 再次启动验证subagent → 最多2轮FAIL-重试,超过 ask_user 介入
修复时:
1. 将FAIL项作为新步骤追加到plan.md(标记为 `[FIX]`
2. 只修复失败项,不重做已PASS的部分
3. 修复完成后重新准备verify_context.json(只含失败项)
### 特殊场景处理
浏览器/键鼠/定时任务等场景:主agent执行操作并导出证据(截图/录屏/日志)→ subagent验证证据文件。**禁止主agent自行判断PASS/FAIL**。
---
## 五、失败处理
1. **记录**checkpoint中 `step_X: [FAILED] 原因 (retry: N/3)`
2. **重试**:网络超时→自动重试3次(2s/4s/8s) | 配置错误→询问用户 | 其他→标[✗]跳过
3. **subagent失败**:查stderr.log→明确错误主agent修正重启 | 未知错误重试1次 | 最多重启2次
4. **依赖传播**:步骤失败后,后续依赖项标[SKIP]
5. **plan有误**:回退到规划态修正plan.md,重新过审查门
## 强制约束
- 每项必须有独立完成判据
- 禁止"处理所有文件",必须展开具体条目
- 一次只做一项;计划有误回规划态修正
- 不可逆操作前多验证一步
+144
View File
@@ -0,0 +1,144 @@
import ctypes
import ctypes.wintypes
import argparse
import yara
import sys
import os
import json
# Define WinAPI Types for 64-bit compatibility
PHANDLE = ctypes.wintypes.HANDLE
LPCVOID = ctypes.c_void_p
LPVOID = ctypes.c_void_p
SIZE_T = ctypes.c_size_t
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
_fields_ = [
("BaseAddress", LPVOID),
("AllocationBase", LPVOID),
("AllocationProtect", ctypes.wintypes.DWORD),
("RegionSize", SIZE_T),
("State", ctypes.wintypes.DWORD),
("Protect", ctypes.wintypes.DWORD),
("Type", ctypes.wintypes.DWORD),
]
# Explicitly setup kernel32 functions with precise types
k32 = ctypes.windll.kernel32
k32.OpenProcess.argtypes = [ctypes.wintypes.DWORD, ctypes.wintypes.BOOL, ctypes.wintypes.DWORD]
k32.OpenProcess.restype = PHANDLE
k32.VirtualQueryEx.argtypes = [PHANDLE, LPCVOID, ctypes.POINTER(MEMORY_BASIC_INFORMATION), SIZE_T]
k32.VirtualQueryEx.restype = SIZE_T
k32.ReadProcessMemory.argtypes = [PHANDLE, LPCVOID, LPVOID, SIZE_T, ctypes.POINTER(SIZE_T)]
k32.ReadProcessMemory.restype = ctypes.wintypes.BOOL
import re
# Regex to expand YARA (n) jumps to explicit ?? chains (YARA 4.5.4 bug)
_RE_JUMP = re.compile(r'\(\s*(\d+)\s*\)')
def expand_yara_jumps(hex_pattern):
"""Expand (n) → ?? repeated n times, e.g. '90 ( 32 ) 00''90 ?? ??...?? 00'"""
def _repl(m):
return ' '.join(['??'] * int(m.group(1)))
return _RE_JUMP.sub(_repl, hex_pattern)
def is_hex_pattern(pattern):
"""Detect hex patterns like '90 ( 32 ) 00' or '90 ?? 00'"""
clean = pattern.replace(" ", "").replace("??", "")
# Also remove parenthesized jump counts like (32)
clean = _RE_JUMP.sub('', clean)
return all(c in "0123456789abcdefABCDEF" for c in clean) and len(clean) % 2 == 0
def build_rules(pattern, mode=None):
if hasattr(pattern, 'match'): return pattern
mode = mode or ('auto' if isinstance(pattern, str) else 'yara')
if mode in ('yara',):
try:
return yara.compile(source=str(pattern))
except yara.SyntaxError:
raise # user-provided full YARA rule, don't mess with it
# hex mode or auto
use_hex = (mode == 'hex') or (mode == 'auto' and is_hex_pattern(pattern))
if use_hex:
hex_body = expand_yara_jumps(pattern.strip())
rule_text = f'rule CustomSearch {{ strings: $h = {{ {hex_body} }} condition: $h }}'
else:
escaped = pattern.replace('\\', '\\\\').replace('"', '\\"')
rule_text = f'rule CustomSearch {{ strings: $s = "{escaped}" ascii wide condition: $s }}'
return yara.compile(source=rule_text)
def format_llm_context(data, offset, base_addr, length=64):
start = max(0, offset - length)
end = min(len(data), offset + length + 16)
chunk = data[start:end]
abs_addr = (base_addr if base_addr else 0) + offset
return {
"address": hex(abs_addr),
"offset": hex(offset),
"hex": chunk.hex(),
"ascii": "".join(chr(b) if 32 <= b <= 126 else "." for b in chunk),
"hit_pos": offset - start
}
def scan_memory(pid, pattern, context_size=256, mode=None, llm_mode=False):
rules = build_rules(pattern, mode)
h_proc = k32.OpenProcess(0x0400 | 0x0010, False, pid)
if not h_proc:
# OpenProcess failed: might be system process or higher integrity level
return [f"Error: Cannot open process {pid}. (ErrorCode: {k32.GetLastError()})"]
results = []
curr_addr = 0
mbi = MEMORY_BASIC_INFORMATION()
# Range for 64-bit user space
max_addr = 0x7FFFFFFFFFFF
while curr_addr < max_addr:
# Use cast to ensure pointer type is correct for 64-bit
res = k32.VirtualQueryEx(h_proc, ctypes.cast(curr_addr, LPCVOID), ctypes.byref(mbi), ctypes.sizeof(mbi))
if res == 0: break
# MEM_COMMIT = 0x1000, PAGE_READABLE bitmask
if mbi.State == 0x1000 and (mbi.Protect & 0xEE): # 0xEE covers common readable flags
buf = ctypes.create_string_buffer(mbi.RegionSize)
read = SIZE_T(0)
if k32.ReadProcessMemory(h_proc, ctypes.cast(mbi.BaseAddress, LPCVOID), buf, mbi.RegionSize, ctypes.byref(read)):
data = buf.raw[:read.value]
for match in rules.match(data=data):
for inst in match.strings:
base = mbi.BaseAddress if mbi.BaseAddress else 0
for instance in inst.instances: # ITERATE ALL instances, not just [0]
offset = instance.offset
matched_data = instance.matched_data
if llm_mode:
results.append(format_llm_context(data, offset, base, length=context_size))
else:
# Expand context based on context_size to capture full KEY+SALT
start = max(0, offset - context_size)
end = min(len(data), offset + len(matched_data) + context_size)
results.append(f"Addr: {hex(base+offset)}\nHex: {data[start:end].hex()}")
# Update address using the region size
next_addr = (mbi.BaseAddress if mbi.BaseAddress else 0) + mbi.RegionSize
if next_addr <= curr_addr: break
curr_addr = next_addr
k32.CloseHandle(h_proc)
return results
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("pid", type=int)
parser.add_argument("pattern", type=str)
parser.add_argument("--mode", default='auto')
parser.add_argument("--llm", action="store_true")
args = parser.parse_args()
try:
res = scan_memory(args.pid, args.pattern, mode=args.mode, llm_mode=args.llm)
print(json.dumps(res, indent=2) if args.llm else f"Matches: {len(res)}")
except Exception as e:
print(f"Error: {e}")
+36
View File
@@ -0,0 +1,36 @@
# Memory Scanner SOP
## 1. 快速开始
内存特征搜索工具,支持 Hex (CE 风格) 和 字符串匹配。特别提供 LLM 模式,方便大模型分析内存上下文。
**Python 调用方式:**
```python
import sys
sys.path.append('../memory') # 直接挂载工具目录
from procmem_scanner import scan_memory
# 示例:搜索特定 Hex 特征码,开启 llm_mode 以获取上下文
results = scan_memory(pid, "48 8b ?? ?? 00", mode="hex", llm_mode=True)
```
**CLI:**
```powershell
# 基础搜索
python ../memory/procmem_scanner.py <PID> "pattern" --mode string
# LLM 增强模式(输出包含上下文的 JSON,推荐)
python ../memory/procmem_scanner.py <PID> "pattern" --llm
```
## 2. 典型场景:结构体或关键数据定位
1. 确定目标数据的前导特征或已知常量(如特定的 Header 或 Magic Number)。
2. 在目标进程中搜索该特征:
`scan_memory(pid, "4D 5A 90 00", mode="hex", llm_mode=True)`
3. 分析返回的 JSON 中 `context` 字段,查看目标地址前后的原始字节及 ASCII 预览。
## 3. 注意事项
- **权限**: 并非强制要求管理员权限,但需具备对目标进程的 `PROCESS_QUERY_INFORMATION``PROCESS_VM_READ` 权限。
- **效率**: 搜索大块内存时,尽量提供更唯一的特征码以减少误报。
## 4. CE式差集扫描定位动态字段
定位微信等自绘UI中随操作变化的内存字段(如当前会话标题)。核心:一次全量scan + 多次ReadProcessMemory筛选。
+29
View File
@@ -0,0 +1,29 @@
# Project Mode SOP
## 定义
Project Mode = 跨会话保持项目认知的工作模式
载体:`project_memory.md` + 项目私域目录。两层注入:每轮自动注入的只有 L1(规则+记忆文件指针),L2(`project_memory.md` 全文)不注入——任务涉及项目上下文时自己用 file 工具去读,无关则不读
## 进入
激活态保存在当前 Agent 实例;各会话互不干扰,关闭 GA 自动失效。(下文路径以 cwd 为基准,禁写 `temp/xxx` 前缀)
- 用户只说「进入项目模式」未指明项目:列出 `./projects/` 下各项目(名字 + memory 行数 + 最后修改时间),ask_user 让用户选定后再继续
- 用户明确说「进入/切换到 <项目名> 项目」:视为已确认,直接执行:
1. 建目录 `./projects/<项目名>/`,无则创建 `project_memory.md`(空文件即可)
2.`code_run``inline_eval=true` 绑定当前 Agent`handler` 已注入,无需 import):
`handler.enter_project_mode('<项目名>')`
3. 回读 `project_memory.md` 全文,向用户复述项目现状
## 期间纪律
- 项目文件(todo、草稿、产物)一律放 `./projects/<项目名>/`,禁止丢 temp 根目录
- 入库判据(唯一标准):每得到一条信息,自问「记忆归零、重新接手本项目的我,缺了这条会不会重复付出认知代价——再踩一次坑、再摸索一次、再问一次用户?」会则立即追加进 `project_memory.md`,不会则不记
- 一条一句,写成未来的自己能直接复用的形式;已有条目增量更新,不整篇重写
## 离开
明确要求离开时,用 `code_run``inline_eval=true`)执行 `handler.enter_project_mode(None)`
切换项目直接换项目名
+169
View File
@@ -0,0 +1,169 @@
# Review Mode SOP
> In-session adversarial code reviewer。用 `/review` 触发,主 agent 在当前对话内
> 拉起评审,报告直接 echo 到对话,**不开 subagent / 不落盘 / 不打 sentinel**。
---
## 一、何时使用
用户输入 `/review` 命令,或自然语言要求"code review"时启用。
典型用例:作者刚写完一段代码 → `/review` 对自己的改动做对抗性 review。
---
## 二、快速启动
| 命令 | 行为 |
|---|---|
| `/review` | 默认审本次 uncommitted 改动(主 agent 跑 `git diff --stat HEAD` + `git diff HEAD`) |
| `/review <自然语言请求>` | 按描述的范围去审(可指定文件 / 目录 / 任务) |
| `/review help` | 显示用法 |
**非 git 仓库**:主 agent 提示用户在下一句 `/review` 塞入具体路径或范围,本轮结束。
---
## 三、入口文件
```
任意前端 (TUI / Streamlit / wechat / desktop)
└─ frontends/review_cmd.py ← 命令分发,剥 "/review" 前缀,注入 user_request
└─ memory/review_sop/review_inline_prompt.txt ← 完整 in-session 协议
└─ memory/code_review_principles.md ← 15 条好代码原则
```
- `review_cmd.py:install()` —— monkey-patch `GenericAgent._handle_slash_cmd`,统一接管 `/review`
- `review_cmd.py:_render_prompt()` —— 加载 prompt 模板,注入 `{user_request}` + `{ga_root}`
---
## 四、三条铁律(reviewer 顶部硬约束,不可违反)
1. **Review-only 只读评审** —— 评审与报告而已。**禁止**修改源文件、调
file_write / file_patch / code_run 改业务代码、在产出里写"我接下来去修一下"
或暗示要动手。
2. **Challenge the approach, 不仅找 bug** —— 先问"这条路本身对不对?"再问
"实现有没有 bug?":挖隐含假设、评估真实环境故障模式(Windows 路径 / 代理失活 /
并发写 / UTF-8 边界 / token 预算耗尽)。
3. **报告输出完即结束** —— 不复述用户目标、不做 meta 评论、不承诺 follow-up;
报告 markdown 直接 echo 到对话,**不落盘 review.md、不打 `[ROUND END]`**。
---
## 五、工作流(5 步,顺序走)
### 步骤 1:必读底料
`file_read("memory/code_review_principles.md")` —— 15 条好代码原则,**每条 finding 必须
能映射到其中一条**。
### 步骤 2:锁定审阅范围
| 用户输入 | 范围 |
|---|---|
| 点名了文件 / 目录 | 审那些 |
| 描述了任务范围 | `code_run``git status -s` + `git diff --stat HEAD` + `git diff HEAD` |
| 空 / 模糊 | 默认审本次 uncommitted 改动 |
| 非 git 仓库 | 提示用户塞路径,本轮结束 |
**先把范围列出来发给用户确认**,再开始 `file_read`
### 步骤 3:逐文件 file_read
超过 800 行分段读。优先看 diff 涉及的行,再看上下文与接口调用方。
### 步骤 4:回答 Q1-Q4 对抗性 framing
- **Q1: Is this the right approach?** — 有没有更简单 / 更标准 / 更安全的实现路径?
- **Q2: What hidden dependencies could fail?** — OS / shell / 网络 / 并发 / 第三方 API 任一失效?
- **Q3: What edge / hostile input breaks it?** — 空值、UTF-8、Windows 路径、超长输入、过期 token。
- **Q4: Is the failure mode observable & recoverable?** — 仅看日志能不能定位?能不能不动手就恢复?
### 步骤 5:列 P0~P3 findings
遵守 §七 防误报八规则 + §八 措辞八规范。提交前过自检清单(§九)。
---
## 六、Severity / Verdict 速查
| Level | 定义 | 例子 |
|---|---|---|
| **P0** | 阻塞:破坏正确性 / 丢数据 / 安全漏洞 / 不可逆故障 | 路径穿越、SQL 注入、密钥落日志、并发竞态破坏数据 |
| **P1** | 高危:契约破坏 / 用户可见错误,但不会立即崩 | 错误只 print 不抛、超时未设、API schema 不一致 |
| **P2** | 维护性:可读性 / 命名 / 测试空缺 | 函数 > 80 行、duplicate logic、注释与代码不符 |
| **P3** | 风格 / 微优化 / 可选改进 | 命名小调整、常量提取、import 顺序 |
**Verdict 决议**:任一 P0 → `FAIL`;无 P0 但 ≥ 1 P1 → `CONDITIONAL`;仅 P2/P3 或 0 finding → `PASS`
---
## 七、防误报八规则(成本低到高,任一答 No → 删 finding)
1. **Discrete & actionable** — 有具体可写的修复?
2. **Introduced or exposed by this change** — 本次改动引入或放大?
3. **Not an intentional design choice** — 不是作者刻意取舍?
4. **Provably affected, not speculated** — 跨文件影响能指出调用栈?
5. **Evidence-anchored** — 行号 / 代码片段 / 复现至少一项?
6. **No unstated assumptions** — 不依赖未明说的"应该这样"?
7. **Author would likely fix if made aware** — 作者会同意修?
8. **Impact meaningful + proportionate rigor** — 影响足够 + 严谨度匹配代码库?
> 每条规则的展开详见 `memory/review_sop/review_inline_prompt.txt` §5。
---
## 八、措辞八规范
1. **Why-first** — 第一句给原因。
2. **严重度准确** — 不要把 P2 写得像 P0。
3. **简洁**`evidence` / `impact` / `fix` 各 ≤ 1 段。
4. **少贴大段代码**`evidence` 代码 ≤ 5 行,超过用 `file:line-line` 引用。
5. **触发条件显式**`impact` 首句必带场景 / 输入 / 环境。
6. **不卑不亢** — 直陈事实,无情绪 / 无开场白。
7. **即读即懂** — 核心结论放第一句。
8. **零奉承** — 不写 "Great work, but...", "Thanks for the changes, however..."。
> 展开详见 `memory/review_sop/review_inline_prompt.txt` §6。
---
## 九、输出协议(整段 echo,不落盘)
```
## Scope
<一行一个文件,绝对路径或仓库相对路径>
## Verdict
PASS / CONDITIONAL / FAIL
## Summary
3-6 行散文:整体印象 + 最重要的 1-2 个风险。
## Design Challenge (Q1-Q4)
- **Q1 是不是对的方法**: <证据>
- **Q2 隐藏依赖**: <证据>
- **Q3 边缘 / 敌意输入**: <证据>
- **Q4 故障可观测**: <证据>
## Findings (P0 → P3 顺序)
- **[P0, conf=0.9] file:line-line** 标题(动词开头,≤ 80 字,第一句给原因)
- **Evidence**: 代码片段 ≤ 5 行 或 file:N-M 引用
- **Impact**: 触发场景 + 后果(第一句必带场景)
- **Fix**: 可直接照做的修复思路,≤ 1 段
- **Principle**: 对应 code_review_principles 第 N 条
## Cross-file notes
跨文件耦合 / 命名一致性 / 状态机 / 并发问题。无则 `(none)`。
## Regression tests
3-5 条具体测试点(输入 / 预期 / 边界)。
```
---
## 十、扩展点
- **自定义评审条目**:编辑 `memory/code_review_principles.md`,reviewer 启动时整段注入
- **触发更换**:要把 `/review` 改成别的命令,只动 `frontends/review_cmd.py``install()` 一处
+27
View File
@@ -0,0 +1,27 @@
# 定时任务 SOP
目录:`../sche_tasks/` 放任务定义JSON`../sche_tasks/done/` 放执行报告
## 任务JSON格式(*.json
```json
{"schedule":"08:00", "repeat":"daily", "enabled":true, "prompt":"...", "max_delay_hours":6}
```
repeat可选:daily | weekday | weekly | monthly | once | every_Nh(每N小时)| every_Nd(每N天)
max_delay_hours(可选,默认6):超过schedule多少小时后不再触发,防止开机太晚执行过时任务
## 触发流程
1. scheduler.pyreflect/)每60秒轮询 sche_tasks/*.json
2. 条件全满足才触发:enabled=true + 当前时间≥schedule + 冷却时间已过(基于done/最新报告时间戳)
3. 触发时拼prompt,含报告路径 `../sche_tasks/done/YYYY-MM-DD_任务名.md`
4. **收到任务后第一件事**:用 update_working_checkpoint 记录报告目标文件路径,防止长任务执行中遗忘
5. 执行完毕后将报告写入上述路径(scheduler靠此文件判断今天已执行)
## 日志与监控
- scheduler自动写日志到 `sche_tasks/scheduler.log`(触发/跳过/错误)
- `scheduler.health_check()` 返回所有任务状态列表(HEALTHY/OVERDUE/DISABLED/NEVER_RUN/ERROR
- JSON解析错误、schedule格式错误、未知repeat类型均会记录日志
## 注意
- once类型:执行一次后冷却100年(实际效果为永久跳过)
- 任务文件只管"干什么",报告路径由scheduler自动生成注入prompt
- sche_tasks目录在../,即code root下
+61
View File
@@ -0,0 +1,61 @@
# Subagent 调用 SOP
## 两种模式
### --func 纯函数模式
- `python agentmain.py --func prompt.txt [--llm_no N]`cwd=代码根)
- 读prompt文件→执行→结果写`prompt.out.txt`→退出,主agent读完可删
- 后台启动(print PID),加`--nobg`前台同步等结果
- 适用:单次任务、并行map、不需要追问的场景
### --task 持续协作模式
- `python agentmain.py --task {name} [--input "短文本"] [--llm_no N]`cwd=代码根)
- `--input`自动建目录+清旧output+写input.txt;长文本先手动写input.txt再启动(不带--input)
- **不要--nobg**(会卡在等reply循环),只能后台启动
- 通信:output.txt(`[ROUND END]`=轮完成) → 写reply.txt继续 → 不写10min退出。reply后输出为output1/2/3.txt
- 干预文件:`_stop`(当轮结束) | `_keyinfo`(注入working memory) | `_intervene`(追加指令)
- [[可选fork]]:将变量history(str)写入task目录下`_history.json`继承对话上下文
- [[可选监察者]]:主agent空闲时读output观察进度,必要时干预文件纠偏。加`--verbose`可审查原始数据
## 共通规则
- 所有agent的cwd=temp,方便文件共享
- input:目标+约束即可,subagent同等智能。**禁写步骤/过度描述**,大量数据给路径
## 场景1:测试模式 - 行为验证
**用途**:观察agent真实行为,修正RULES/L2/L3/SOP
**流程**:写prompt→启动subagent→轮询结果→验证→清理
**原则**:只给目标,不提示位置/不诱导做法;Insight优先级>SOPsubagent的cwd=temp/
**两种测试**
- 测SOP质量:input指定SOP名,排除导航干扰,失败即SOP问题
- 测导航能力:input只写目标,验证能自主从insight找到正确SOP
## 场景2:Map模式 - 并行处理
**用途**:N个独立同构子任务分发,独立上下文避免交叉污染
**约束**:文件系统共享(优点);键鼠不可共享;浏览器避免同tab
**流程**:准备独立输入文件→每个启动subagent(--func优先)→收集输出汇总
## subagent内部plan_mode使用
**原则**subagent本身是完整agent,接收多步骤任务时应在内部创建plan管理执行
**触发条件**:任务包含3个以上子步骤、子步骤之间有依赖关系、需要checkpoint来恢复执行
**实现方式**
1. **主agent创建subagent时**:在input.txt中说明任务包含多个步骤,建议使用plan_mode
2. **subagent内部执行**:检测到多步骤任务后,创建 `./subagent_plan.md` 并使用plan_mode执行
3. **主agent监控**:只关注最终结果(output*.txt),不需要关心subagent内部如何执行
4. **文件传递机制**:主agent创建subagent时在task_dir中生成 `context.json`,包含所有文件的**绝对路径**
**⚠ subagent启动后第一步必须读取context.json**
**⚠ 所有文件操作必须使用context.json中的绝对路径**
**格式示例**
```json
{
"task": "任务描述",
"work_dir": "/absolute/path/to/plan_dir/",
"input_files": {
"paper_info": "/absolute/path/to/paper_info.txt"
},
"output_files": {
"pdf": "/absolute/path/to/paper.pdf",
"report": "/absolute/path/to/paper_report.md"
},
"dependencies": ["paper_info.txt必须存在"]
}
```
+62
View File
@@ -0,0 +1,62 @@
# 监察者模式 SOP
目标:让用户一次说明任务后,尽量不用多轮纠偏。
核心:**先定行动协议,再盯协议偏离,最后看完成证据。**
## 1. 定位
监察者不是工人。你是挑刺的监工,只负责看 subagent 是否按目标、约束和证据闭环完成任务;有 SOP 按 SOP 把关,无 SOP 按常理和经验把关。
## 2. 红线
- **禁止下场干活**:不替 subagent 操作浏览器、不写代码、不执行任务步骤。
- **只读探测**:可用 `file_read``web_scan``web_execute_js`、只读 `code_run` 辅助判断对象、进度和证据;探测不是代做。
- **沉默为主**:没发现会导致用户纠偏的问题,就不说话。
- **一句话干预**:必须短、硬、具体,像用户直接纠偏,禁长篇教程。
- **说人话红线**:对外 prompt/reply 必须像真实用户临时打断;1句为主,最多2句;禁评测腔/规约腔/教程腔,禁编号、rubric、协议、闭环、chosen/rejected 等内部词。
- **`_keyinfo` 只用于预注入**:在 subagent 到达关键步骤前提醒;已经犯错必须用 `_intervene`
- **训练数据变体**:若用户要求轨迹像真实多轮对话,禁 `_keyinfo/_intervene`,改用 `_stop` + `reply.txt` 续聊;reply 仍短硬像真人。
- **证据纪律**:学生引用记忆/日志/`file:line` 时,必须核对是否有实际 `file_read`/工具覆盖;读到一半却引用未读范围,按“嘴读”纠偏。
## 3. 执行顺序(硬性,不可跳步)
**Step A — 立刻启动 subagent**(纯机械,不需思考)
读完本 SOP 和 `subagent.md` 后,从用户 prompt 中剥离任务,写 input,启动。调用细节见 `subagent.md`;input 只给目标+约束,长文本给路径。
**Step B — 写预期文档**(利用 subagent 启动+读环境的时间)
subagent 在跑前几轮环境探测时,你写一屏预期文档(产出文件),只写三项:
1. **意图与锚点**:目标、必须先看的对象、明显不是目标的对象;只列显性或高代价的账号/身份/归属字段。
2. **行动协议**:正确推进顺序;必须先找/读哪些 SOP 或工具说明,并把其中的禁止/必须/格式要求纳入约束;何时核对对象/路径/账号/窗口/字段;危险动作前验证什么;调研/测试类先跑通最小可复现闭环再扩大。
3. **完成证据**:最后必须有什么证据才能交付:状态、截图、日志、diff、测试结果、报告路径、使用方法等。
**Step C — tail 监控与干预**(预期文档写完后开始)
轮询 `temp/{task_name}/output*.txt`。看到 `[ROUND END]` 后判断是否需要写 `reply.txt` 续聊或用干预文件纠偏。tail 中若发现新信息(真实文件结构、字段、约束、隐藏难点),回头补充/更新预期文档,尤其补全此前想不到的验收指标。tail 时盯这些偏离:
- 没先落到用户锚点,凭记忆/猜测行动。
- 没找应有 SOP/工具说明,或读了却没按其中禁止/必须/格式要求做。
- 对象、路径、窗口、账号、环境、数据源偏了。
- 顺序错,尤其危险/不可逆动作前没验证。
- 粒度错:该先跑通却大设计,该最小改动却重构,该调研测试却泛泛写报告。
- 要交付了,但证据、路径、格式、测试或说明不够。
- 反复试错、泛泛搜索、卡住不读日志/不看 SOP/不收敛到下一步。
干预示例:
```text
停。用户锚点是 X,不是 Y。先看 X。
不要提交。危险动作前还没验证 X。
粒度错了。先跑通最小闭环。
不要交付。还缺 X 证据/路径/格式。
先停。这个任务应先读/遵守 X SOP,把禁止/必须项列进约束。
你漏了 XX 约束,补上后再继续。
连续失败先停,读错误日志再决定。
别继续乱试。先读 X SOP/日志,列出一个最小下一步再做。
```
## 4. 最终验收
按预期文档逐条核对:锚点是否命中、行动协议是否遵守、危险前是否验证、最终产物/结果是否满足用户目标、质量是否够、证据是否足够且路径/格式明确。任一不满足就返工;全部满足才交付用户。
+138
View File
@@ -0,0 +1,138 @@
# TMWebDriver SOP
- 直接用web_scan/web_execute_js工具。本文件只记录特性和坑。
- 底层:`../TMWebDriver.py`通过Chrome扩展接管用户浏览器(保留登录态/Cookie)
- 非Selenium/Playwright,保留用户浏览器登录态
## 通用特性
- ⚠web_execute_js里使用`await`时需**显式`return`**才能拿到返回值(底层async包裹,不写return则返回null
- ✅web_scan自动穿透同源iframe;跨域iframe需CDP或postMessage(见下方章节)
## 限制(isTrusted)
- JS事件`isTrusted=false`,敏感操作(如文件上传/部分按钮)可能被拦截;这类场景首选**CDP桥**
- ⚠JS点击按钮打不开新tab→可能是浏览器弹窗拦截,换CDP点击试试
- Vue3自定义组件(Select/Dropdown):⭐优先vnode实例调用(无视口限制)→见**vue3_component_sop**CDP坐标点击仅适合选项少且可见的场景
- 文件上传:⭐首选**DataTransfer API**(纯JS,无CDP依赖):`new File([content],name,{type}) → new DataTransfer().items.add(file) → input.files=dt.files → dispatch input+change`CDP `DOM.setFileInputFiles` 在tmwd桥环境nodeId跨调用失效,不推荐;备选ljqCtrl物理点击
- 需转物理坐标时:`physX = (screenX + rect中心x) * dpr``physY = (screenY + chromeH + rect中心y) * dpr`;其中 `chromeH = outerHeight - innerHeight`
## 导航
- `web_scan` 仅读当前页不导航,切换网站用 `web_execute_js` + `location.href='url'`
- ⚠导航与后续操作**必须拆成两次调用**:同一段JS内 `location.href` 后继续操作→报错`Inspected target navigated or closed`(页面已换执行上下文销毁)。先导航→等加载→再单独执行操作
## Google图搜
- class名混淆禁硬编码,点击结果用 `[role=button]` div
- web_scan过滤边栏,弹出后用JS:文本`document.body.innerText`,大图遍历img按`naturalWidth`最大取src
- "访问"链接:遍历a找`textContent.includes('访问')`的href
- 缩略图:`img[src^="data:image"]`直接提取;大图src可能截断用`return img.src`
## Chrome下载PDF
场景:PDF链接在浏览器内预览而非下载
```js
fetch('PDF_URL').then(r=>r.blob()).then(b=>{
const a=document.createElement('a');
a.href=URL.createObjectURL(b);
a.download='filename.pdf';
a.click();
});
```
注意:需同源或CORS允许,跨域先导航到目标域再执行
## Chrome后台标签节流
- 后台标签中`setTimeout`被Chrome intensive throttling延迟到≥1min/次,扩展脚本中避免依赖setTimeout轮询
- 某些SPA页面需CDP `Page.bringToFront`切到前台才会加载数据
## CDP桥(tmwd_cdp_bridge扩展) ⭐首选
扩展路径:`assets/tmwd_cdp_bridge/`(需安装,含debugger权限)
⚠TID约定标识:首次运行自动生成到`assets/tmwd_cdp_bridge/config.js`(已gitignore),扩展通过manifest引用
调用:`web_execute_js` script直传JSON字符串(工具层自动识别对象格式,走WS→background.js cmd路由)
```js
// 直接传JSON字符串作为script参数,无需DOM操作
web_execute_js script='{"cmd": "cookies"}'
web_execute_js script='{"cmd": "tabs"}'
web_execute_js script='{"cmd": "cdp", "tabId": N, "method": "...", "params": {...}}'
web_execute_js script='{"cmd": "batch", "commands": [...]}'
// 返回值直接是JSON结果
```
通信方式:⭐JSON字符串直传(首选) | TID DOM方式(TID元素+MutationObserverweb_scan/execute_js底层依赖)
单命令:`{cmd:'tabs'}` | `{cmd:'cookies'}` | `{cmd:'cdp', tabId:N, method:'...', params:{...}}` | `{cmd:'management', method:'list|reload|disable|enable', extId:'...'}`
- managementlist返回所有扩展信息;reload/disable/enable需传extId
- contentSettings`{cmd:'contentSettings', type:'automaticDownloads', pattern:'https://*/*', setting:'allow'}`
- 绕过Chrome"下载多个文件"对话框(该对话框会阻塞整个浏览器JS执行)
- type可选:automaticDownloads/popups/notifications等;settingallow/block/ask
- ⚠CDP的Browser.setDownloadBehavior在扩展中不可用(chrome.debugger仅tab级),此为替代方案
- ⭐batch混合:`{cmd:'batch', commands:[{cmd:'cookies'},{cmd:'tabs'},{cmd:'cdp',...},...]}`
- 返回`{ok:true, results:[...]}`,一次请求多命令,CDP懒attach复用session
- 子命令会自动继承外层batch的tabId(如cookies命令可正确获取当前页面URL)
- `$N.path`引用第N个结果字段(0-indexed),如`"nodeId":"$2.root.nodeId"`
- ⚠batch前序命令失败时,后续`$N`引用会静默变成undefined;要检查results数组中每项的ok状态
- 典型文件上传:getDocument(**depth:1**) → querySelector(`input[type=file]`) → setFileInputFiles
- 思想:
- 同一链路内保持nodeId来源一致,不混用querySelector路径与performSearch路径
- 上传后前端框架可能不感知,必要时JS补发`input`/`change`事件
- 上传前检查`input.accept`;多input时用accept/父容器语义区分
- 等待元素优先用`DOM.performSearch('input[type=file]')`做轻量轮询
- 瞬态input的核心是**缩短发现→setFileInputFiles时间窗**:优先同batch完成;再不行用DOM事件监听;猴子补丁仅作兜底思路
- ⚠tabIdCDP默认sender.tab.id(当前注入页),跨tab需显式tabId或先batch内tabs查
- ⭐跨tab无需前台:指定tabId即可操作后台标签页
## CDP点击完整生命周期(✅已验证)
- 通用点击需**三事件序列**mouseMoved → mousePressed → mouseReleased(间隔50-100ms
- 省略mouseMoved会导致MUI Tooltip/Ant Design Dropdown等hover依赖组件失效
- ⚠autofill释放是特例,只需mousePressed即可(见下方autofill章节)
- ⭐**坐标系结论**:稳定状态下 CDP坐标 = `getBoundingClientRect()` 坐标,**无需修正**
- ⚠**首次attach陷阱**CDP debugger首次attach时Chrome弹出infobar("正在受自动化控制"~20px高),页面内容被推下
- 如果在attach前测量坐标、attach后发送点击 → 坐标偏移!(之前Currency下拉失败的根因)
- ✅**解决**:确保测量坐标在CDP已attach稳定之后(即infobar已出现后再getBoundingClientRect
- 实践:首次CDP操作前先发一个无害的`mouseMoved(0,0)`预热,之后坐标系就稳定了
- ⭐**下拉框(Vue3 oxd-select等)CDP操作流程**
1. 获取select元素rect → CDP点击打开下拉
2. 获取option元素rect → CDP点击选中(option是动态DOM,打开后才能测量)
- 已验证:CDP点击对自定义下拉框有效,无isTrusted问题
- ⚠**限制**:选项多时底部option超出视口,CDP坐标够不着→此时应优先vnode方案(见vue3_component_sop)
- 坐标修正(页面有transform:scale/zoom时):
```js
var scale = window.visualViewport ? window.visualViewport.scale : 1;
var zoom = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
var realX = x * zoom; var realY = y * zoom;
```
- iframe内元素CDP点击:坐标需合成 `finalX = iframeRect.x + elRect.x`
- 跨域iframe拿不到contentDocument
- ⚠`Target.getTargets`/`Target.attachToTarget`在CDP桥中返回"Not allowed"(chrome.debugger权限限制)
- ⭐**已验证方案**`Page.getFrameTree`找iframe frameId → `Page.createIsolatedWorld({frameId})`获取contextId → `Runtime.evaluate({expression, contextId})`在iframe中执行JS
- batch链式引用:`$0.frameTree.childFrames`遍历找url匹配的frame`$1.executionContextId`传给evaluate
- postMessage中继方案仅在content script已注入iframe时有效,第三方支付iframe通常无注入
## CDP文本输入(未验证,BBS#23
- `insertText`快但无key事件;受控组件需补dispatch `input`事件
- 需完整键盘模拟时用`dispatchKeyEvent`逐键派发
## CDP DOM域穿透 closed Shadow DOM(未验证,BBS#24/#25
- `DOM.getDocument({depth:-1, pierce:true})` 穿透所有Shadow边界(含closed
- `DOM.querySelector({nodeId, selector})` 定位 → `DOM.getBoxModel({nodeId})` 取坐标
- getBoxModel返回content八值[x1,y1,...x4,y4],中心用**四点平均**centerX=sum(x)/4, centerY=sum(y)/4
- ⚠不能简化为对角线平均——元素有transform:rotate/skew时四点非矩形
- querySelector**不能跨Shadow边界写组合选择器**,需分步:先找host再在其shadow内找子元素
- ⚠nodeId在DOM变更后失效 → 用`backendNodeId`更稳定,或重新getDocument刷新
## autofill获取与登录
检测:web_scan输出input带`data-autofilled="true"`,value显示为受保护提示(非真实值,Chrome安全保护需点击释放)
- ⚠**前置条件:必须先CDP `Page.bringToFront` 切tab到前台**Chrome仅在前台tab释放autofill保护值,后台tab物理点击无效
- ⭐**一键释放与登录**bringToFront → mousePressed点任一字段(无需Released,一个释放全页) → 等500ms → 补input/change事件 → 点登录
## 验证码/页面视觉截图
- ⭐首选CDP截图:`Page.captureScreenshot`(format:'png')→返回base64,无需前台/后台tab也行,全页高清
- 验证码canvas/imgJS `canvas.toDataURL()` 直接拿base64最干净
## simphtml与TMWebDriver调试
- simphtml调试必须通过`code_run`注入JS到真实浏览器(Python端无法模拟DOM)
- `d=TMWebDriver()`, `d.set_session('url_pattern')`, `d.execute_js(code)` → 返回`{'data': value}`
- simphtml`str(simphtml.optimize_html_for_tokens(html))` — 返回BS4 Tag需str()
## 连不上排查
web_scan失败时按序排查(自动检测优先,用户参与放最后):
①浏览器没开?→检查浏览器进程是否在跑(tasklist/ps),没有则启动并打开正常URL(⚠about:blank等内部页不加载扩展)
②WS后台挂了?→本机18766端口没监听即dead→手动**后台持续运行**`from TMWebDriver import TMWebDriver; TMWebDriver()`起master
③扩展没装?→读Chrome用户目录下`Secure Preferences`→`extensions.settings`中找`path`含`tmwd_cdp_bridge`的条目
找到→扩展已装,排查其他原因;没找到→走web_setup_sop
④以上都正常仍连不上→请求用户协助
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
UI元素检测 - 基于 OmniParser-v2.0 的 icon_detect YOLO 模型 + RapidOCR
首次使用前必须准备对应权重;这是 OmniParser-v2.0 的 icon_detect YOLO 模型。
若缺失模型文件,新用户/AI 应搜索并下载 OmniParser-v2.0 icon_detect YOLO 权重。
用法:
from ui_detect import detect
elements = detect("screenshot.png") # 默认match模式
elements = detect(pil_image) # 支持PIL.Image
elements = detect(img, mode='crop') # crop备选
返回: [{'bbox':[x1,y1,x2,y2], 'type':'icon'|'text', 'label':str|None, 'confidence':float}]
模式: match=YOLO+全图OCR IoU匹配(推荐,1.2s,无文字图标label=None可VLM保底) | crop=拼接crop OCR(备选,更精确,2.3s)
依赖: ultralytics, rapidocr-onnxruntime, pillow, numpy
"""
from pathlib import Path
from PIL import Image, ImageDraw
import numpy as np
import json, urllib.request, subprocess, sys, time
#print('[UI DETECT] 截图分析后必须使用物理坐标,ljqCtrl也使用物理坐标!')
DEFAULT_MODEL = str(Path(__file__).resolve().parent.parent / 'temp' / 'weights' / 'icon_detect' / 'model.pt')
try:
from rapidocr_onnxruntime import RapidOCR
_ocr = RapidOCR()
except ImportError: _ocr = None
_YOLO = None
_YOLO_PORT = 31876
def _yolo_local(image_path, conf=0.25):
global _YOLO
if _YOLO is None:
from ultralytics import YOLO
_YOLO = YOLO(DEFAULT_MODEL)
res = _YOLO(image_path, conf=conf, verbose=False)
boxes = []
for r in res:
for b in r.boxes:
x1, y1, x2, y2 = map(int, b.xyxy[0].cpu().numpy())
boxes.append([x1, y1, x2, y2, float(b.conf[0])])
return boxes
def _ping_yolo_daemon():
try: return urllib.request.urlopen(f'http://127.0.0.1:{_YOLO_PORT}/ping', timeout=0.1).read() == b'ui_detect_yolo'
except Exception: return False
def _yolo(image_path, conf=0.25):
"""YOLO检测 → list of [x1,y1,x2,y2,conf];默认模型走跨进程daemon cache,失败回退本地"""
if not _ping_yolo_daemon():
kw = {'creationflags': getattr(subprocess, 'CREATE_NO_WINDOW', 0)} if sys.platform == 'win32' else {}
subprocess.Popen([sys.executable, __file__, '--yolo-daemon'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kw)
for _ in range(15):
if _ping_yolo_daemon(): break
time.sleep(0.5)
try:
data = json.dumps({'path': str(image_path), 'conf': conf}).encode('utf-8')
req = urllib.request.Request(f'http://127.0.0.1:{_YOLO_PORT}/yolo', data=data, headers={'Content-Type': 'application/json'})
return json.loads(urllib.request.urlopen(req, timeout=3).read().decode('utf-8'))['boxes']
except Exception: return _yolo_local(image_path, conf)
def _ocr_full(image_path):
"""全图OCR → list of [x1,y1,x2,y2,text,conf]"""
if not _ocr: return []
result, _ = _ocr(image_path)
if not result: return []
out = []
for bbox, text, conf in result:
xs = [p[0] for p in bbox]; ys = [p[1] for p in bbox]
out.append([int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys)), text, conf])
return out
def _ocr_crops_batch(img, yolo_boxes):
"""批量OCR:将所有YOLO框crop垂直拼接为一张图,一次OCR,按y坐标映射回各box → {box_idx: text}"""
if not _ocr or not yolo_boxes: return {}
crops, offsets = [], [] # offsets: [(y_off, orig_x1, orig_y1, box_idx)]
max_w, y_cursor = 0, 0
for idx, (x1, y1, x2, y2, _) in enumerate(yolo_boxes):
crop = img.crop((x1, y1, x2, y2))
w, h = crop.size
max_w = max(max_w, w)
crops.append(crop)
offsets.append((y_cursor, x1, y1, idx))
y_cursor += h
if max_w == 0: return {}
# 垂直拼接
stitched = Image.new('RGB', (max_w, y_cursor), (255, 255, 255))
for i, crop in enumerate(crops):
stitched.paste(crop, (0, offsets[i][0]))
result, _ = _ocr(np.array(stitched))
if not result: return {}
# 映射:OCR框中心y → 归属的crop
labels = {}
for bbox, text, _ in result:
cy = sum(p[1] for p in bbox) / len(bbox)
for y_off, ox1, oy1, idx in offsets:
h = yolo_boxes[idx][3] - yolo_boxes[idx][1]
if y_off <= cy < y_off + h:
old = labels.get(idx)
labels[idx] = (old + ' ' + text) if old else text
break
return labels
def _iou(a, b):
"""计算两个bbox的交集占b面积的比例(包含率)"""
x1, y1, x2, y2 = max(a[0],b[0]), max(a[1],b[1]), min(a[2],b[2]), min(a[3],b[3])
inter = max(0, x2-x1) * max(0, y2-y1)
area_b = (b[2]-b[0]) * (b[3]-b[1])
return inter / area_b if area_b > 0 else 0
def detect(image_path, mode='match', conf=0.25, iou_thresh=0.5):
"""
统一检测入口,返回元素列表:
[{'bbox':[x1,y1,x2,y2], 'type':'icon'|'text', 'label':str|None, 'confidence':float}]
mode: 'match' = YOLO+全图OCR空间匹配(推荐, 快) | 'crop' = YOLO+拼接OCR(备选, 更精确)
支持 image_path: str 路径 或 PIL.Image 对象
"""
# 归一化:PIL Image → 临时文件
if isinstance(image_path, Image.Image):
import tempfile, os
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
image_path.save(tmp.name)
image_path = tmp.name
img = Image.open(image_path)
yolo_boxes = _yolo(image_path, conf)
elements = []
if mode == 'crop':
# YOLO元素批量OCR(拼接一次推理)
labels_map = _ocr_crops_batch(img, yolo_boxes)
for idx, (x1, y1, x2, y2, c) in enumerate(yolo_boxes):
elements.append({'bbox': [x1,y1,x2,y2], 'type': 'icon', 'label': labels_map.get(idx), 'confidence': c})
# 补充:全图OCR找未被覆盖的纯文本
for ox1, oy1, ox2, oy2, text, oc in _ocr_full(image_path):
covered = any(_iou([x1,y1,x2,y2,_,__], [ox1,oy1,ox2,oy2]) > iou_thresh
for x1,y1,x2,y2,_,__ in [(b[0],b[1],b[2],b[3],0,0) for b in yolo_boxes])
if not covered:
elements.append({'bbox': [ox1,oy1,ox2,oy2], 'type': 'text', 'label': text, 'confidence': oc})
elif mode == 'match':
ocr_items = _ocr_full(image_path)
matched_ocr = set()
for x1, y1, x2, y2, c in yolo_boxes:
label = None
for i, (ox1, oy1, ox2, oy2, text, oc) in enumerate(ocr_items):
if _iou([x1,y1,x2,y2], [ox1,oy1,ox2,oy2]) > iou_thresh:
label = text; matched_ocr.add(i); break
elements.append({'bbox': [x1,y1,x2,y2], 'type': 'icon', 'label': label, 'confidence': c})
# 未匹配的OCR作为独立text元素
for i, (ox1, oy1, ox2, oy2, text, oc) in enumerate(ocr_items):
if i not in matched_ocr:
elements.append({'bbox': [ox1,oy1,ox2,oy2], 'type': 'text', 'label': text, 'confidence': oc})
#if [x for x in elements if x['label'] is None]: print('[TIPS] crop grid + VLM to identify target no text icon if needed')
print('[TIPS] UI DETECT contains OCR, no need to run OCR again!')
return elements
def visualize_for_debug(image_path, elements, output_path=None):
"""Only use when user wants to DEBUG!"""
from PIL import ImageFont
img = Image.open(image_path)
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype("msyh.ttc", 14)
except:
font = ImageFont.load_default()
for el in elements:
x1, y1, x2, y2 = el['bbox']
color = 'red' if el['type'] == 'icon' else 'blue'
draw.rectangle([x1, y1, x2, y2], outline=color, width=2)
tag = el.get('label') or f"{el['confidence']:.2f}"
draw.text((x1, y1-16), tag[:15], fill=color, font=font)
if output_path: img.save(output_path)
return img
def _serve_yolo_daemon():
from http.server import BaseHTTPRequestHandler, HTTPServer
class H(BaseHTTPRequestHandler):
def log_message(self, *args): pass
def handle_one_request(self): self.server.last=time.time(); return super().handle_one_request()
def do_GET(self):
if self.path == '/ping':
self.send_response(200); self.end_headers(); self.wfile.write(b'ui_detect_yolo')
else:
self.send_response(404); self.end_headers()
def do_POST(self):
try:
d = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0))))
body = json.dumps({'boxes': _yolo_local(d['path'], d.get('conf', 0.25))}).encode('utf-8')
self.send_response(200); self.end_headers(); self.wfile.write(body)
except Exception as e:
body = json.dumps({'error': repr(e)}).encode('utf-8')
self.send_response(500); self.end_headers(); self.wfile.write(body)
s=HTTPServer(('127.0.0.1', _YOLO_PORT), H); s.timeout=60; s.last=time.time()
while time.time()-s.last < 3600: s.handle_request()
if __name__ == '__main__' and '--yolo-daemon' in sys.argv:
_serve_yolo_daemon()
+181
View File
@@ -0,0 +1,181 @@
# GA UltraPlan SOP
## 1. Protocol: start and continue
### What this is
UltraPlan is Python-scripted multi-agent orchestration. The main agent designs phases, prompts, fan-out/fan-in, and stop/continue decisions; subagents do task-facing work.
### Opt-in only
Start UltraPlan only when the user explicitly says `ultraplan`, `UltraPlan`, or `ultraplan mode`. If not opted in, do not start it; at most mention it is available.
### First move
Once opted in, the next substantive action is writing and running the first script.
Before the first `plan(...)`, do not inspect source, tests, logs, imports, file listings, pages, or APIs for the task itself.
Allowed pre-launch work: record objective/constraints, confirm cwd is GA `temp/`, write the minimal script.
### File and cwd contract
Scripts are plain Python files under GA `temp/`; run them with cwd = `temp/`.
Reference repo files from `temp/`, e.g. `../assets/...`; never place UltraPlan scripts in the repo code tree.
Every script starts with the real API contract and a shared artifact directory:
```python
import os, sys
sys.path.append("..")
from assets.ga_ultraplan import plan, phase, parallel, mapchain
RUN_DIR = os.path.abspath("ultraplan_<stable_slug>")
plan(RUN_DIR)
ARTIFACT_DIR = RUN_DIR
```
`plan(...)` must be the first UltraPlan statement; defining `RUN_DIR` before it is allowed. If import/plan fails, diagnose only cwd/path/import/daemon startup, not the user task.
### Same-plan continuation
For one user objective, every later script reuses the exact same `RUN_DIR` and `plan(RUN_DIR)`.
Round 2/3/etc. are new scripts under the same plan/work directory, not new plans. Continuation changes only phases/prompts/archetype.
A finished script is not proof the task is finished: read reducer/report paths, then answer, ask, apply a completed result, or launch the next same-plan script.
### Delegation boundary
Do not solve the task outside UltraPlan. Do not perform task discovery, implementation, review, or verification in main chat.
The main agent may read outputs only to supervise and decide the next script.
Exactly one agent is the UltraPlan orchestrator for one objective. The orchestrator may read this SOP; ordinary workers must not be told to read UltraPlan SOP, start UltraPlan, design phases, or delegate.
If the orchestrator itself is a subagent, give only objective, constraints, output budget, and permission to use UltraPlan; do not paste SOP, prescribe phases, or tell it which SOP files to read. It chooses context reads.
Worker prompts are job tickets, not mini-SOPs: role, exact scope, inputs, allowed/forbidden actions, evidence, short output shape, stop condition.
Every worker prompt must include the boundary when relevant: `Do not start UltraPlan. Do not delegate. If decomposition is needed, report blocker only.`
## 2. Core mental model
### Why orchestrate
Assume a strong single executor can handle long documents, complex code, and coherent multi-file edits. Do not orchestrate merely because work looks large.
Orchestration is mainly omission control: missing items, angles, hypotheses, evidence, checks, or residual improvements. Hunt is the special hard-search case: one direct attempt may hit many dead ends before a viable proof/root cause/solution appears.
### Three decisions
1. Problem class: Explore, Sweep, Hunt, or Improve.
2. Omission risk: unknown-list discovery or known-list ownership.
3. Topology: one executor, parallel width, phase/loop depth, pipeline, barrier, reducer.
### Parallel is only for omission control
Use parallel in exactly two cases:
- Unknown-list discovery: the item list is not known; split by meaningful search lenses, paths, evidence sources, representations, failure modes, or counterexamples. Evidence sources may include local code, logs/tests, live reproduction, user artifacts, and web/Google research when external ecosystem knowledge may reveal known issues, API limits, prior incidents, or platform constraints.
- Known-list ownership: the item list is known, independent, and AI-sized; assign ownership so no item is skipped.
Do not parallelize coherent execution. If the task is clear, bounded, coherent, and in one capable agent's comfort zone, use one executor.
### Width vs depth
Parallel width: independent angles/items may surface different omissions.
Phase/loop depth: later search depends on earlier findings, reduction, verification, or dead ends. Use phases/loops for find -> dedupe/rank -> verify/refute -> search residuals until dry.
### Choose by main risk
Explore: space unknown; risk is missing angles/items.
Sweep: known independent list; risk is missing items/status.
Hunt: cause/solution/proof unknown or hard; risk is wrong path/dead ends.
Improve: existing artifact; risk is residual defects/opportunities after execution.
Design/Integrator are support moves: design contracts prevent divergent parallel output; integrators restore global coherence after parallel work.
## 3. Tool semantics and output discipline
`phase(name, desc="")` is visible structure. Name the current archetype and reducer boundary.
`parallel(tasks, max_workers=None, **data)` runs independent tasks and returns result paths in input order. Default concurrency is engine-chosen; omit `max_workers` in examples unless there is a real reason.
Task forms: tuple/list `(desc, prompt)` or dict with `desc`, `prompt`, `data`, `llm_no`, `timeout`.
Subagent calls return `.out.txt` paths. Later prompts should reference paths and tell workers to read/tail only what they need.
`mapchain(items, step1, step2, ...)` runs steps sequentially per item and items concurrently. `{item}` is original item; `{previous}` is the prior step result path.
A `parallel(...)` between stages is a barrier. Use it only when the next stage needs cross-result dedupe, ranking, shared context, or early-exit. If each item can continue independently, use `mapchain`.
Workers return plain text, not JSON by default, but it must be reducer-readable: stable IDs, evidence paths/quotes, verdict/status, risk, next action.
Brevity rule: be as short as practical. Main chat reports only status, blocker, next action. Workers/reducers/verifiers include necessary evidence but no padding.
Forbid filler: no background essay, copied prompt, SOP recap, chain-of-thought prose, unsupported impression, or vague `done`.
Reducers compare rather than concatenate: accept, reject, dedupe, rank, expose contradictions, state coverage bounds, and recommend stop/continue/next archetype.
## 4. Prompt contract
A worker prompt must be executable without follow-up.
State: role, exact scope, input paths/items, artifact directory, allowed sources/tools, evidence standard, concise output shape, stop condition, and exclusions. If web/Google search is allowed, say so explicitly; require URLs/source names, distinguish sourced facts vs local evidence vs hypotheses, and map every external finding to a task hypothesis, discriminator, mitigation, or verification step.
Tell workers what not to do when overlap is harmful. Tell verifiers whether to confirm, refute, reproduce, compare, or inspect local formatting.
Prefer file paths over pasted long context. Give only the state needed for that worker.
If a worker creates or edits files, require saving them under `ARTIFACT_DIR` and returning paths.
If an operation is risky or irreversible, the prompt must stop before doing it unless the user already approved it.
## 5. Archetypes
### Explore
Use Explore when the space is unknown and choosing one path too early would bias the task.
Fan out by lenses, not by fake dependencies: architecture, failure modes, data/evidence sources, constraints, user intent, external web/official/forum evidence, reproduction route, counterexample route, test surface, style risk. Use web search only when outside knowledge may change the map; forbid generic background research.
Each explorer returns lens, covered area, findings/frontiers, evidence, unknowns, and dead ends.
Reducer builds the map: accepted facts, rejected claims, promising frontiers, missing lenses, contradictions, and next archetype.
Stop Explore when the reducer can name a bounded Execute, Hunt, Improve, or Sweep.
Research/report tasks often start with material collection plus Explore of different research paths; save gathered material as files, then synthesize a report and Improve it. In engineering/debugging tasks, web research is a collector lens feeding a reducer, not the final artifact unless the user asked for a research report.
### Hunt
Use Hunt for uncertain root cause, high-stakes claim validation, or hard solution/proof search.
Typical flow: collect evidence surface -> synthesize facts/timeline/contradictions -> generate diverse hypotheses/approaches -> rank by evidence/value/cost/verifiability -> verify selected candidates.
Fan out by non-overlapping blades or evidence sources: local code/static path, logs/errors/tests, reproduction behavior, recent changes, dependency edges, external web/official/forum evidence, constraints, weird angles, alternate representation, counterexamples. Use web research when known ecosystem/platform failures may be missing from local evidence; it must return cited mechanisms and discriminators, not a background essay.
Each hunter returns candidate/approach ID, evidence, confidence, why distinct, why plausible, how to verify, and dead ends.
Verification becomes Sweep only after there is a known independent hypothesis list.
If all attempts fail, record rejected paths, exclude repeats, change blades/representation, and Hunt again. Dead ends are progress.
### Improve
Use Improve when there is an existing artifact to fix, simplify, optimize, rewrite, polish, or decide among alternatives.
Improve is an outer loop, not one edit: find opportunities/residual risks -> reduce/prioritize -> execute selected change -> verify/test -> search residuals -> repeat.
Do not start Improve with mechanical fixed lenses. Ask what omissions matter for this artifact, then choose search lenses only when each lens can uniquely find something.
Default execution is one AI executor for small/coupled/coherent work. Sweep only when there is a known independent item list; Hunt if cause/option is unknown.
Example: for a single-file/coherent rewrite, use one executor, then run real tests (see Verification shapes: discover existing tests/demos/CLI usage or build minimal real ones, not import-only smoke), then optionally use unknown-list discovery to find remaining regressions, style issues, or missed simplifications.
After execution, verify. If coverage is uncertain, use phase/loop depth: find plausible untested failures -> dedupe/rank -> verify/refute -> search residuals until dry. Use parallel width only for distinct discovery lenses.
For important changes, use adversarial verify: ask workers to refute the result, not merely agree.
Stop only when no material improvement remains or remaining items are tiny/unsafe.
### Sweep
Use Sweep when the item list is known, items are truly independent, and coverage/status matters.
Classic case: download games A/B/C/D. Each item has its own search/download/verify route; parallel ownership prevents forgetting D and isolates blockers.
Use `mapchain` for per-item inspect -> act -> verify when each item can progress without global waiting.
Each item report includes item ID, action/result, evidence, status, unresolved risk, skipped condition, and blocker.
Reducer reports total, covered, omitted, failed, accepted findings, rejected findings, and coverage bounds.
Do not call every large dataset a Sweep. 12,000 correlated rows for analysis usually need one data-analysis executor/script, not 12,000 AI workers. Sweep is for AI-sized independent items, often few-to-dozens; for huge independent batches, sample/pilot then script/shard with explicit bounds.
If several items fail similarly, reduce failures and switch to Hunt for common cause; if one item is hard, make that item a Hunt.
## 6. Composition rules and edge cases
### Design before parallel construction
If independent artifacts require shared style/terminology/format, first Explore/Design a compact contract, then Sweep construction, then one integrator pass.
Example: add Troubleshooting sections to 12 independent docs pages. Contract first; page workers then write under contract; integrator unifies style and catches hallucinations.
But high-coherence artifacts with small per-unit edits usually stay single-executor. Example: add one conclusion sentence to each non-title slide in a 40-slide PPT; narrative continuity is global, so one executor writes. Sweep is suitable only for local checks such as missing sentence, overflow, or layout errors.
### Verification shapes
Verify is not always Sweep. For single coherent artifacts, verification often uses unknown-list discovery: find possible problems, reduce them, verify/refute, then search residuals until no material issue remains.
Use parallel verification only when distinct lenses can find different omissions. Otherwise use one verifier plus real tests.
Decouple verification lenses by evidence source, not by sub-checklists of one method. Splitting one static read into "check API", "check parity", "check side effects" is fake parallelism: same method, same files, overlapping output. Real independent sources are usually: static analysis (read code, no run), real execution (run actual tests), and quality-vs-intent (does it meet the task's goal, e.g. simpler/cleaner). Open a parallel lens only when its evidence source is genuinely different.
Real execution must be genuine, not import-only smoke. Optimize for finding real breakage, not for finishing cheap. The runner first discovers existing entry points (test suites, example/demo scripts, README/CLI usage, callable public APIs) and runs the relevant ones; if none cover the change, it builds minimal but real tests that exercise the changed behavior. Record exact commands and stdout/stderr/stack. Never report pass for behavior that was not actually run; mark it blocked with the concrete reason (e.g. needs live window/GPU/network) and what would unblock it.
Sweep verification fits known local independent checks: each file has required logging format, each slide has no overflow, each downloaded game opens.
High-stakes claims use Hunt-style validation: collect evidence, generate alternatives, verify/refute, and block confident answers if coverage is weak.
### Research/report shape
Research is usually not a primitive archetype. Use collection/exploration to gather materials, parallel paths for distinct search strategies, a reducer/synthesizer for the report, then Improve to remove synthesis scars, gaps, weak evidence, and style problems.
Final chat should summarize what was produced and where files/materials are, not paste huge gathered content.
### Multi-round continuation
Do not write one giant script when the next phase depends on reduced results.
After each script, read only reducer/report outputs needed to decide: answer, ask, apply completed result, or launch the next same-plan script.
The next script's archetype comes from the reducer: Explore if the map is still unknown, Hunt for candidates, Improve for chosen artifact, Sweep for known items, Verify for high-stakes claims.
Never restart outside UltraPlan or rename the plan because the first script finished; rename only for a different user objective.
## 7. Scale, failure, and bounds
Scale to the request. Quick check uses small fan-out; comprehensive audit uses broader blades, stronger verification, and explicit coverage bounds.
Prefer engine-chosen concurrency. More agents are worse when prompts overlap; improve decomposition before tuning execution knobs.
Use `timeout` for risky or slow probes and require workers to report partial progress.
If you sample, top-N, time-box, skip retries, exclude a subsystem, or hit a tool failure, make the bound visible in reducer output and final answer.
If a worker fails, inspect its `.out.txt` or error path, then retry with narrower scope, longer timeout, different tool, or different archetype. Do not repeat a failed prompt unchanged.
If reducers expose contradictions, launch targeted verification to resolve conflicts with evidence.
If coverage is too weak, do not answer confidently; run another same-plan script or ask the user to choose cost/coverage.
## 8. Classic patterns
Use these as recognition anchors, not rigid templates:
1. Improve existing artifact/code: Improve loop. If execution is coherent, one executor changes it; real tests follow (discover existing tests/demos/CLI usage or build minimal real ones, not import-only smoke); use unknown-list discovery only to find residual regressions, missed simplifications, style issues, or weak tests; repeat until dry.
2. Root cause / unsafe conclusion: Hunt. Collect evidence first (single collector if narrow; parallel collectors only for distinct evidence sources) -> synthesize -> find hypotheses/counterexamples -> verify/refute -> record dead ends and continue if unresolved.
3. Many known independent deliverables: Sweep. Example: download A/B/C/D games. Each item gets ownership because sequential work often forgets items; methods may differ per item. Reducer tracks status/blockers.
4. Large correlated data: not Sweep per row. 12000 rows needing analysis is one coherent data-analysis execution; Sweep only independent AI-sized subsets or residual problem cases.
5. Research/report: parallel only for distinct search paths/sources because materials may be missed; synthesize with one writer/integrator; Improve then searches evidence gaps, synthesis scars, style problems, and missing perspectives.
6. Simple coherent code change across modest files: one executor may do it; add Sweep only for known local checks such as per-file log format, then optional residual discovery for style/tests.
7. Single file or high-coherence artifact verification: not Sweep. Use tests and unknown-list problem discovery; use parallel only if distinct lenses can find different omissions.
8. Design-then-Sweep construction: when items are independent but style must match, first Explore/Design a contract, then Sweep item work, then one integrator pass.
9. PPT/narrative conclusion edits: usually one executor for coherence; Sweep may check local layout/format only, not write each page when cross-page flow matters.
## 9. Minimal shapes
```python
BOUNDARY = "Do not start UltraPlan. Do not delegate. If decomposition is needed, report blocker only."
ART = f"Save any artifacts under {ARTIFACT_DIR}; return paths."
with phase("Improve coherent artifact", "single executor -> real tests -> residual search"):
result = parallel([("Executor", f"Apply the focused change. Keep coherence. Run real tests: find existing tests/demos/CLI usage and run them, or build minimal real ones; record commands and output; do not claim pass for unrun behavior. {ART} Return evidence/blockers. Be concise. {BOUNDARY}")])[0]
with phase("Find residuals", "only if coverage is uncertain"):
# Fill only meaningful lenses; leave empty for one verifier.
residual_lenses = []
residuals = parallel(residual_lenses) if residual_lenses else parallel([
("Verifier", f"Inspect {result}. Find blockers/residuals only. {ART} Return evidence and stop/continue. Be concise. {BOUNDARY}")])
with phase("Reduce/decide", "dedupe, verify/refute, continue/stop"):
next_move = parallel([("Reducer", f"Use {result} and {residuals}. Return accepted/rejected, artifact paths, evidence, next action. Be concise. {BOUNDARY}")])[0]
with phase("Hunt", "evidence -> hypotheses -> verification plan"):
evidence = parallel(evidence_collectors) # each collector prompt includes ART and BOUNDARY
hypotheses = parallel(hypothesis_blades) # each hunter prompt includes ART and BOUNDARY
ranked = parallel([("Reducer", f"Use {evidence} and {hypotheses}. Rank candidates and verification steps. Return artifact paths. Be concise. {BOUNDARY}")])[0]
with phase("Sweep known independent items", "per-item ownership and status"):
reports = mapchain(items,
("Inspect {item}", "Inspect only {item}. Save artifacts under {artifact_dir}; return paths, ID, evidence, action, risk. Be concise. " + BOUNDARY),
("Act/verify {previous}", "Use {previous}. Save artifacts under {artifact_dir}; return paths, ID, status, evidence, blocker. Be concise. " + BOUNDARY),
artifact_dir=ARTIFACT_DIR)
```
+120
View File
@@ -0,0 +1,120 @@
import base64, requests, sys, os
from io import BytesIO
from pathlib import Path
# ============ 用户配置区(从 template 拷贝后只需改这里)============
# GA自举提示:
# mykey.py配置变量名不固定;默认值失败时,枚举候选变量名,并用最小vision请求实验探测哪组配置可用。
# 只打印变量名/字段名/model/apibase域名路径/HTTP状态码/错误类型,禁止打印完整dict和apikey/token。
# 常见格式:
# xxx_config = {"apibase": "https://.../v1", "apikey": "...", "model": "...", "proxy": None}
# 不同中转的 apibase/endpoint 可能不同,按实际状态码和响应结构修正。
CLAUDE_CONFIG_KEY = 'claude_config141' # mykey.py 中 Claude 配置的变量名
OPENAI_CONFIG_KEY = 'oai_config1' # mykey.py 中 OpenAI 配置的变量名
MODELSCOPE_API_KEY = '' # 直接填你的 ModelScope token
DEFAULT_BACKEND = 'claude' # 默认后端: 'claude' / 'openai' / 'modelscope'
# =================================================================
MODELSCOPE_API_BASE = 'https://api-inference.modelscope.cn'
MODELSCOPE_MODEL = 'Qwen/Qwen3-VL-235B-A22B-Instruct'
_DIR = os.path.dirname(os.path.abspath(__file__))
for _p in [os.path.join(_DIR, '..'), os.path.join(_DIR, '../..')]:
if _p not in sys.path: sys.path.insert(0, _p)
def ask_vision(image_input, prompt="详细描述这张图片的内容", timeout=60, max_pixels=1440000, backend=DEFAULT_BACKEND):
try:
b64 = _prepare_image(image_input, max_pixels)
except Exception as e:
return f"Error: 图片处理失败 - {type(e).__name__}: {e}"
try:
if backend == 'claude':
return _call_claude(b64, prompt, timeout)
elif backend == 'openai':
mk = _load_config()
cfg = getattr(mk, OPENAI_CONFIG_KEY)
return _call_openai_compat(
b64, prompt, timeout,
apibase=cfg['apibase'], apikey=cfg['apikey'], model=cfg['model'], proxy=cfg.get('proxy')
)
elif backend == 'modelscope':
return _call_openai_compat(
b64, prompt, timeout,
apibase=MODELSCOPE_API_BASE, apikey=MODELSCOPE_API_KEY, model=MODELSCOPE_MODEL, proxy=None
)
else: return f"Error: 未知backend '{backend}',可选: claude, openai, modelscope"
except requests.exceptions.Timeout:
return f"Error: 请求超时 (>{timeout}s)"
except requests.exceptions.RequestException as e:
return f"Error: API请求失败 - {type(e).__name__}: {e}"
except (KeyError, ValueError) as e:
return f"Error: 响应解析失败 - {e}"
# ===================== 以下为内部实现 =====================
def _prepare_image(image_input, max_pixels=1440000):
"""加载+缩放+base64编码,返回b64字符串"""
from PIL import Image
if isinstance(image_input, Image.Image):
img = image_input
elif isinstance(image_input, (str, Path)):
img = Image.open(image_input)
else:
raise TypeError(f"image_input 必须是文件路径或PIL Image,实际: {type(image_input).__name__}")
w, h = img.size
if w * h > max_pixels:
scale = (max_pixels / (w * h)) ** 0.5
new_w, new_h = int(w * scale), int(h * scale)
img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
print(f" 📐 缩放: {w}×{h}{new_w}×{new_h}")
if img.mode in ('RGBA', 'LA', 'P'):
rgb = Image.new('RGB', img.size, (255, 255, 255))
rgb.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
img = rgb
buf = BytesIO()
img.save(buf, format='JPEG', quality=80, optimize=True)
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
print(f" 📦 Base64: {len(buf.getvalue())/1024:.1f}KB")
return b64
def _load_config():
import mykey
return mykey
def _call_claude(b64, prompt, timeout, max_tokens=1024):
mk = _load_config()
cfg = getattr(mk, CLAUDE_CONFIG_KEY)
resp = requests.post(
cfg['apibase'] + '/v1/messages', # endpoint按中转实际情况改:有的apibase已含/v1,或路径不同
json={'model': cfg['model'], 'max_tokens': max_tokens, 'messages': [{
'role': 'user',
'content': [
{'type': 'image', 'source': {'type': 'base64', 'media_type': 'image/jpeg', 'data': b64}},
{'type': 'text', 'text': prompt}
]
}]},
headers={'x-api-key': cfg['apikey'], 'anthropic-version': '2023-06-01', 'content-type': 'application/json'},
timeout=timeout
)
resp.raise_for_status()
return resp.json()['content'][0]['text']
def _call_openai_compat(b64, prompt, timeout, *, apibase, apikey, model, proxy=None):
proxies = {'https': proxy, 'http': proxy} if proxy else None
resp = requests.post(
apibase.rstrip('/') + '/v1/chat/completions', # endpoint按中转实际情况改:有的apibase已含/v1,或路径不同
json={'model': model, 'messages': [{
'role': 'user',
'content': [
{'type': 'text', 'text': prompt},
{'type': 'image_url', 'image_url': {'url': f'data:image/jpeg;base64,{b64}'}}
]
}]},
headers={'Authorization': f"Bearer {apikey}", 'Content-Type': 'application/json'},
proxies=proxies, timeout=timeout
)
resp.raise_for_status()
return resp.json()['choices'][0]['message']['content']
if __name__ == '__main__':
pass
+23
View File
@@ -0,0 +1,23 @@
# Vision API SOP
## ⚠️ 前置规则(必须遵守)
1. **先枚举窗口**:调用 vision 前必须先用 `pygetwindow` 枚举窗口标题,确认目标窗口存在且已激活到前台。窗口不存在就不要截图。
2. **🚫 禁止全屏截图**:必须先利用ljqCtrl截取窗口区域。能截局部(如标题栏)就不截整窗口,能截窗口就绝不全屏。全屏截图在任何场景下都不允许。
3. **能不用 vision 就不用**:如果窗口标题/本地 OCR`ocr_utils.py`)能获取所需信息,就不要调用 vision API,省 token 且更可靠。Vision 是最后手段。
## 快速用法
```python
from vision_api import ask_vision
result = ask_vision(image, prompt="描述图片内容", timeout=60, max_pixels=1_440_000)
# image: 文件路径(str/Path) 或 PIL Image
# backend: 'claude'(默认) | 'openai' | 'modelscope'
# 返回 str:成功为模型回复,失败为 'Error: ...'
```
## 如果没有 `vision_api.py`,初次构建vision能力
1. 复制 `memory/vision_api.template.py``memory/vision_api.py`
2. 只改头部"用户配置区":去 `mykey.py` 里扫描变量名(⚠️ 只看名字,禁止输出 apikey 值),尝试找能用配置名填入 `CLAUDE_CONFIG_KEY` / `OPENAI_CONFIG_KEY``DEFAULT_BACKEND` 选后端,并测试
3. 保底:没有可用 config 时去 `https://modelscope.cn/my/myaccesstoken` 申请 token 填入 `MODELSCOPE_API_KEY`
+200
View File
@@ -0,0 +1,200 @@
# Vue 3 自定义组件 JS 操作 SOP
## 问题
Vue 3 自定义组件(如 OxdSelect)通过 `addEventListener` 绑定事件,JS `dispatchEvent` 产生的事件 `isTrusted: false`,组件不响应。
- `element.click()` 无效(组件可能绑定 mousedown 而非 click
- `dispatchEvent(new MouseEvent('mousedown'))` 无效(isTrusted:false
- `element.focus()` 不触发 Vue 绑定的 focus handler
## 解决方案:直接操作 Vue 组件实例
### 1. 获取 Vue 3 根入口
```javascript
const rootVnode = document.getElementById('app')._vnode;
```
### 2. 遍历 vnode 树匹配 DOM 元素
```javascript
function findCompByEl(vnode, targetEl, depth = 0) {
if (depth > 50 || !vnode) return null;
const comp = vnode.component;
if (comp) {
if (comp.vnode?.el === targetEl || comp.subTree?.el === targetEl) return comp;
if (comp.vnode?.el?.contains?.(targetEl)) {
const result = findCompByEl(comp.subTree, targetEl, depth + 1);
if (result) return result;
return comp;
}
const subResult = findCompByEl(comp.subTree, targetEl, depth + 1);
if (subResult) return subResult;
}
if (vnode.children && Array.isArray(vnode.children)) {
for (const child of vnode.children) {
const result = findCompByEl(child, targetEl, depth + 1);
if (result) return result;
}
}
if (vnode.dynamicChildren) {
for (const child of vnode.dynamicChildren) {
const result = findCompByEl(child, targetEl, depth + 1);
if (result) return result;
}
}
return null;
}
```
### 3. 调用组件方法
```javascript
// 目标DOM的parentElement通常是组件根元素
const comp = findCompByEl(rootVnode, targetElement.parentElement);
const ctx = comp.proxy;
// 查看可用方法
Object.keys(ctx).filter(k => !k.startsWith('_') && !k.startsWith('$'));
// Select 类组件:直接调用 onSelect
ctx.onSelect({id: 'USD', label: 'United States Dollar'});
// 获取选项列表
ctx.computedOptions; // [{id, label, _selected}, ...]
```
## 组件层级注意
- **展示层**(如 OxdSelectText):只有 onToggle/onFocus/onBlur,调用无实际效果
- **逻辑层**(如 OxdSelectInput,是展示层的父组件):有 openDropdown/onSelect/computedOptions/onCloseDropdown
- 定位逻辑层:用 `targetElement.parentElement` 而非 targetElement 本身
### 弹窗内 Select 同样纯 JS 优先(已验证)
- 弹窗(`.oxd-dialog-sheet`)内的 `.oxd-select-text` 用循环向上查找同样能命中 `OxdSelectInput``onSelect` 正常工作。
- 不需要 CDP 兜底。仅当循环 8 层仍找不到组件时才考虑 CDP 打开+JS 点 option。
### 循环向上查找模式(推荐)
单层 `parentElement` 可能不够,用循环更健壮:
```javascript
function findSelectComp(selectTextEl) {
for (let el = selectTextEl, up = 0; el && up < 8; el = el.parentElement, up++) {
const comp = findCompByEl(rootVnode, el);
if (comp?.proxy?.onSelect && comp.proxy.computedOptions?.length) return comp;
}
return null; // 找不到再考虑CDP兜底
}
```
## 普通 Input/Textarea 操作(nativeSetter
Vue 3 的 `v-model` 监听 input 事件,直接 `el.value = x` 不触发响应式。需用原型 setter:
```javascript
// Input
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
setter.call(inputEl, '新值');
inputEl.dispatchEvent(new Event('input', {bubbles: true}));
inputEl.dispatchEvent(new Event('change', {bubbles: true}));
// Textarea
const taSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
taSetter.call(textareaEl, '内容');
textareaEl.dispatchEvent(new Event('input', {bubbles: true}));
```
### Date Input 特殊处理
日期组件通常有 blur 校验,需要 focus→赋值→blur 完整链:
```javascript
dateInput.focus();
setter.call(dateInput, '2026-08-05');
dateInput.dispatchEvent(new Event('input', {bubbles: true}));
dateInput.dispatchEvent(new Event('change', {bubbles: true}));
dateInput.dispatchEvent(new Event('blur', {bubbles: true}));
```
### Button
普通 `.click()` 即可,Vue 3 不检查 button click 的 isTrusted。
### File Upload (input[type="file"])
浏览器安全模型禁止JS直接 `input.value='path'`,但可用 DataTransfer API 构造 FileList
```javascript
const fileInput = document.querySelector('input[type="file"]');
const content = '文件内容';
const file = new File([content], 'filename.txt', { type: 'text/plain', lastModified: Date.now() });
const dt = new DataTransfer();
dt.items.add(file);
fileInput.files = dt.files; // Chrome 62+ 支持
fileInput.dispatchEvent(new Event('input', { bubbles: true }));
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
```
- 适用于任何框架(非Vue3特有),纯浏览器API
- 可构造任意类型文件(Blob/ArrayBuffer均可传入File构造器)
- ⚠ CDP `DOM.setFileInputFiles` 只设files属性不触发事件(Chrome通用行为),DataTransfer+dispatch是唯一纯JS方案
- ⚠ 确保弹窗/容器已打开再querySelector,否则input不在DOM中
## 泛化到其他 Vue3 站点(未逐一验证,思路层面)
本 SOP 的核心方法(根 vnode → findCompByEl → proxy)是 Vue3 通用的,但具体方法名/属性名因 UI 库而异。
面对陌生 Vue3 站点的探测思路:
1. **确认是 Vue3**`document.getElementById('app')?.__vue_app__` 存在即可
2. **定位目标 DOM** — 用选择器找到要操作的元素(如某个 select wrapper
3. **从 DOM 反查组件** — 用 findCompByEl 从目标元素及其父级向上找,拿到 component
4. **探测组件能力** — 拿到 comp 后查看:
- `Object.keys(comp.proxy.$options.methods || {})` → 组件方法名
- `Object.keys(comp.props || {})` → props
- `Object.keys(comp.setupState || {})` → setup 暴露的响应式数据和函数
- 重点找类似 onSelect/handleSelect/select/setValue 的方法,以及 options/items/computedOptions 之类的选项列表
5. **试调** — 找到疑似选中方法后,传入选项对象试调,观察 DOM 是否更新
6. **选项格式** — 不同库的 option 结构不同(可能是 `{id, label}` 也可能是 `{value, text}` 或纯字符串),从选项列表数据中取一个完整对象传入即可
注意事项:
- 有些库用 `emits` 而非 methods,选中逻辑可能在父组件而非子组件
- 有些库 prod build 会 minify 方法名,此时 setupState 里的 key 可能是短名,需结合行为猜测
- Composition API 组件的逻辑主要在 setupState 而非 $options.methods
- 如果 proxy 上找不到方法,试试 `comp.exposed``<script setup>` 用 defineExpose 暴露的)
## Vue 富文本编辑器操作
### 核心原则
1. **禁止只改 DOM**`innerHTML` 不触发编辑器内部 model 更新,提交时数据丢失
2. **优先找编辑器实例调原生 API** — 唯一稳路径:
- Quill: `el.__quill.setText()` / `.clipboard.dangerouslyPasteHTML()`
- Tiptap: `el.__tiptap.commands.setContent()` 或 Vue ref `.editor.commands.setContent()`
- TinyMCE: `tinymce.get(id).setContent()``tinymce.activeEditor.setContent()`
- WangEditor: `el.__wangEditor.setHtml()` 或 Vue ref `.editorRef.setHtml()`
- CKEditor: `editor.setData()`
3. **次选 `innerHTML + InputEvent`** — 对简单 Vue wrapper 有效(wrapper 监听 input 并 emit),复杂编辑器不保证
4. **兜底 CDP `Input.insertText`** — 绕过 `isTrusted` 检查,等同物理输入
5. **验证标准是"提交对了"不是"看到了"** — 拦截 fetch/XHR 看 payload,或读 `editor.getHTML()`
### 编辑器实例查找路径(按优先级)
1. DOM 私有字段: `el.__quill`, `el.__tiptap`, `el.cmView`(CodeMirror)
2. Vue 组件 setupState/exposed: `comp.setupState.editor`, `comp.exposed.editor`
3. 全局变量: `window.editor`, `tinymce.editors[0]`
4. Quill 静态方法: `Quill.find(el)`
### 编辑器类型识别
- `.ql-editor` → Quill
- `.ProseMirror` → Tiptap / ProseMirror
- `.tox-edit-area` / `iframe` → TinyMCE
- `.w-e-text-container` → WangEditor
- `.ck-editor__editable` → CKEditor 5
- `.cm-editor` → CodeMirror 6
### 避坑
- Element Plus Select 选项被 Teleport 到 body,不在组件 DOM 子树内,要从 `document.querySelectorAll('.el-select-dropdown__item')` 全局找
- 编辑器可能在 iframe 内(TinyMCE 默认),需 `iframe.contentDocument.body` 操作
- 提交时数据来源可能不是 Vue state,而是编辑器实例现取 `getHTML()`,所以必须改编辑器 model
- debounce:有些 wrapper 用 debounce 同步到 v-model,改完后等 300-500ms 再验证
- Pinia/Vuex:表单数据可能在 store 里而非组件 data,需找到 store 直接赋值
## 适用场景
- Vue 3 自定义 Select/Dropdown/Autocomplete 组件 → vnode 实例方法
- Vue 3 普通 Input/Textareav-model)→ nativeSetter + input 事件
- Date 组件 → nativeSetter + focus/blur 链
- File Upload → DataTransfer + change 事件
- 需要绕过 isTrusted 检查的场景
- **Vue 3 富文本编辑器(Quill/Tiptap/TinyMCE/WangEditor/CKEditor)→ 编辑器实例 API**
## 验证于
- OrangeHRM (opensource-demo.orangehrmlive.com) Vue 3 + OXD 组件库
- 本地 Vue3 + Element Plus + 模拟 Quill/Tiptap 富文本靶场 (2026-05-09)
- 2026-05-08
+25
View File
@@ -0,0 +1,25 @@
# Web 工具链初始化执行 SOP
若 web_scan 和 web_execute_js 已测试可用,无需执行此 SOP。
仅供初始安装时,code_run 可用但 web 工具尚未配置的场景。
## 目标
在仅具备系统级权限(code_run)时,建立 Web 交互能力(web_scan / web_execute_js)。
## 前置:检测浏览器
## 安装 tmwd_cdp_bridge 扩展
扩展路径: `../assets/tmwd_cdp_bridge/`MV3 Chrome 扩展,含 CDP debugger + scripting + cookie 能力)
### 自动打开扩展管理页
`chrome://extensions` 无法通过命令行或 JS 打开,需用剪贴板+地址栏方案
### 安装步骤(chrome扩展页难以自动化)
1. 打开扩展管理页,开启「开发者模式」
2. 点击「加载已解压的扩展程序」,选择 `assets/tmwd_cdp_bridge/` 目录,或让用户直接拖入
3. 显示“错误”不用管,一般只是因为还没连上GA
## 验证
⚠ web_scan 显示「没有可用标签页」不一定是扩展没装好,可能是浏览器未打开或只有 blank 页。
此时禁止乱试,先用 `start "" "https://www.baidu.com"` 打开一个正常页面,再 `web_scan` 确认。
若仍不可用,无法自动探测默认浏览器是哪个、插件装在了哪个浏览器、或是否已安装——此时请求用户协助。