Three independently ugly bugs that all fed the same log flood:
* Electron's `WebFrameMain.send` writes every "Render frame was disposed"
to stderr via `console.error`. When the worker fans a second
agent-event past a reloading renderer, we produced thousands of
identical lines and stalled the main loop on stderr I/O. Added a
`mainFrame.detached` guard so the send is short-circuited before
Electron's own catch block fires, and installed a targeted filter in
`suppress-warnings.ts` (now actually imported from the entry point)
so the specific `console.error("Error sending from webFrameMain:"…)`
noise never reaches the terminal. Any other payload still surfaces.
* `dispatchAgentMessage` accepted the human-reader sentinel as a wake
target when an agent replied to `__board__` — `wakeAgentIfPossible`
then called `db.getAgent("__board__")`, threw, and the dispatcher
kept repeating on every send. Strip `HUMAN_SENDER_ID` at
`resolveMessageWakeTargets`, and keep a defense-in-depth guard in the
heartbeat so a stray caller (future automation fan-out) cannot
reintroduce the crash.
* Pi's skills loader rejects any SKILL.md whose frontmatter `name`
diverges from its parent directory. The four bundled `my-writing-style`
copies declared `name: writing-style-skill`, so every install emitted
a warning and dropped the skill. They also lived as orphan gitlinks
(mode 160000 without .gitmodules), and the global `*.md` gitignore
hid every other SKILL.md from version control. Fixed the frontmatter
to match the dir, converted the dangling submodules to plain trees,
and allowlisted `builtin-skills/**/*.md` so a fresh clone carries the
catalogue instead of silently losing it.
310 lines
9.9 KiB
Python
310 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Self-Improving Skill — Observation Layer
|
||
|
||
通用的 original → final 记录器。适用于任何 "AI 生成 → 人类修改" 的循环。
|
||
|
||
用法:
|
||
python3 observe.py record-original article.md [--text "..."] [--account X] [--content-type article]
|
||
python3 observe.py record-final article.md [--match <hash>] [--text "..."]
|
||
python3 observe.py pending
|
||
python3 observe.py stats
|
||
|
||
环境变量:
|
||
SKILL_LOG_DIR — 日志目录(默认 ~/clawd/memory/skill-runs/default/)
|
||
"""
|
||
|
||
import sys
|
||
import json
|
||
import os
|
||
import argparse
|
||
from pathlib import Path
|
||
from datetime import datetime, timedelta
|
||
import hashlib
|
||
|
||
# 日志目录 — 自动检测 OpenClaw / Claude Code / standalone
|
||
def _detect_base():
|
||
if os.environ.get("SKILL_BASE_DIR"):
|
||
return Path(os.environ["SKILL_BASE_DIR"])
|
||
candidates = [
|
||
Path.home() / "clawd" / "memory",
|
||
Path.home() / ".openclaw" / "memory",
|
||
Path.home() / ".claude" / "memory",
|
||
]
|
||
for c in candidates:
|
||
if c.parent.exists():
|
||
return c
|
||
return Path.home() / ".self-improving" / "memory"
|
||
|
||
DEFAULT_LOG_DIR = _detect_base() / "skill-runs" / "default"
|
||
|
||
|
||
def get_log_dir(args=None):
|
||
"""按优先级决定日志目录:--log-dir > SKILL_LOG_DIR > default"""
|
||
if args and hasattr(args, 'log_dir') and args.log_dir:
|
||
d = Path(args.log_dir)
|
||
elif os.environ.get("SKILL_LOG_DIR"):
|
||
d = Path(os.environ["SKILL_LOG_DIR"])
|
||
elif args and hasattr(args, 'skill') and args.skill:
|
||
skill_name = Path(args.skill).name
|
||
d = Path.home() / "clawd" / "memory" / "skill-runs" / skill_name
|
||
else:
|
||
d = DEFAULT_LOG_DIR
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
return d
|
||
|
||
|
||
def get_log_file(log_dir, date_str=None):
|
||
if date_str is None:
|
||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||
return log_dir / f"{date_str}.jsonl"
|
||
|
||
|
||
def compute_hash(content):
|
||
return hashlib.md5(content.encode()).hexdigest()[:8]
|
||
|
||
|
||
def get_content(args):
|
||
if hasattr(args, 'text') and args.text:
|
||
return args.text
|
||
if hasattr(args, 'stdin') and args.stdin:
|
||
return sys.stdin.read()
|
||
if hasattr(args, 'file') and args.file:
|
||
p = Path(args.file)
|
||
if not p.exists():
|
||
print(f"❌ 文件不存在: {args.file}")
|
||
sys.exit(1)
|
||
return p.read_text()
|
||
return None
|
||
|
||
|
||
def read_log_entries(log_file):
|
||
if not log_file.exists():
|
||
return []
|
||
entries = []
|
||
with log_file.open("r") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if line:
|
||
try:
|
||
entries.append(json.loads(line))
|
||
except json.JSONDecodeError:
|
||
continue
|
||
return entries
|
||
|
||
|
||
def find_unmatched(log_dir, days=14):
|
||
"""找到有 original 但没有 final 的记录(兼容旧 edited/no-change type)"""
|
||
all_originals = {}
|
||
all_matched = set()
|
||
|
||
for i in range(days):
|
||
date = (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d")
|
||
entries = read_log_entries(get_log_file(log_dir, date))
|
||
for e in entries:
|
||
if e["type"] == "original":
|
||
e["_date"] = date
|
||
all_originals[e["content_hash"]] = e
|
||
elif e["type"] in ("final", "edited", "no-change"):
|
||
all_matched.add(e["content_hash"])
|
||
|
||
return {h: o for h, o in all_originals.items() if h not in all_matched}
|
||
|
||
|
||
def record_original(args):
|
||
content = get_content(args)
|
||
if not content:
|
||
print("❌ 需要提供内容(文件路径、--text 或 --stdin)")
|
||
sys.exit(1)
|
||
|
||
log_dir = get_log_dir(args)
|
||
content_hash = compute_hash(content)
|
||
|
||
context = {}
|
||
if hasattr(args, 'account') and args.account:
|
||
context["account"] = args.account
|
||
if hasattr(args, 'content_type') and args.content_type:
|
||
context["content_type"] = args.content_type
|
||
|
||
log_entry = {
|
||
"timestamp": datetime.now().isoformat(),
|
||
"type": "original",
|
||
"content_hash": content_hash,
|
||
"file": str(args.file) if hasattr(args, 'file') and args.file else None,
|
||
"content": content,
|
||
"context": context,
|
||
"char_count": len(content),
|
||
}
|
||
|
||
log_file = get_log_file(log_dir)
|
||
with log_file.open("a") as f:
|
||
f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
|
||
|
||
print(f"✅ 记录原稿: {content_hash}")
|
||
print(f"📝 字数: {len(content)}")
|
||
print(f"📁 日志: {log_file}")
|
||
return content_hash
|
||
|
||
|
||
def record_final(args):
|
||
content = get_content(args)
|
||
if not content:
|
||
print("❌ 需要提供最终版内容")
|
||
sys.exit(1)
|
||
|
||
log_dir = get_log_dir(args)
|
||
target_hash = getattr(args, 'match', None)
|
||
|
||
if target_hash:
|
||
# 指定 hash,跨天查找
|
||
original = None
|
||
for i in range(14):
|
||
date = (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d")
|
||
entries = read_log_entries(get_log_file(log_dir, date))
|
||
for e in entries:
|
||
if e["type"] == "original" and e["content_hash"] == target_hash:
|
||
original = e
|
||
break
|
||
if original:
|
||
break
|
||
if not original:
|
||
print(f"❌ 找不到 hash {target_hash} 的原稿")
|
||
sys.exit(1)
|
||
else:
|
||
# 自动匹配最近的未配对 original
|
||
unmatched = find_unmatched(log_dir, 14)
|
||
if not unmatched:
|
||
print("❌ 没有待配对的原稿")
|
||
sys.exit(1)
|
||
target_hash = list(unmatched.keys())[-1]
|
||
original = unmatched[target_hash]
|
||
|
||
is_same = content.strip() == original["content"].strip()
|
||
|
||
log_entry = {
|
||
"timestamp": datetime.now().isoformat(),
|
||
"type": "final",
|
||
"content_hash": target_hash,
|
||
"original_content": original["content"],
|
||
"final_content": content,
|
||
"original_char_count": len(original["content"]),
|
||
"final_char_count": len(content),
|
||
"context": original.get("context", {}),
|
||
"no_change": is_same,
|
||
}
|
||
|
||
log_file = get_log_file(log_dir)
|
||
with log_file.open("a") as f:
|
||
f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
|
||
|
||
if is_same:
|
||
print(f"✅ 记录最终版: {target_hash} (无修改 — 正反馈)")
|
||
else:
|
||
diff_pct = ((len(content) - len(original["content"])) / max(len(original["content"]), 1)) * 100
|
||
print(f"✅ 记录最终版: {target_hash}")
|
||
print(f"📝 原稿: {len(original['content'])} 字 → 最终: {len(content)} 字 ({diff_pct:+.1f}%)")
|
||
print(f"📁 日志: {log_file}")
|
||
|
||
|
||
def show_pending(args):
|
||
log_dir = get_log_dir(args)
|
||
unmatched = find_unmatched(log_dir, 14)
|
||
|
||
if not unmatched:
|
||
print("✅ 全部已配对")
|
||
return
|
||
|
||
print(f"\n⏳ {len(unmatched)} 条待配对:\n")
|
||
for h, entry in unmatched.items():
|
||
preview = entry["content"][:80].replace("\n", " ")
|
||
ctx = entry.get("context", {})
|
||
account = ctx.get("account", "?")
|
||
date = entry.get("_date", "?")
|
||
print(f" 📄 {h} | {date} | {account}")
|
||
print(f" {preview}...")
|
||
print()
|
||
|
||
|
||
def show_stats(args):
|
||
log_dir = get_log_dir(args)
|
||
all_files = sorted(log_dir.glob("*.jsonl"))
|
||
|
||
total_originals = 0
|
||
total_finals = 0
|
||
total_no_change = 0
|
||
total_changed = 0
|
||
|
||
for log_file in all_files:
|
||
entries = read_log_entries(log_file)
|
||
total_originals += sum(1 for e in entries if e["type"] == "original")
|
||
finals = [e for e in entries if e["type"] in ("final", "edited")]
|
||
total_finals += len(finals)
|
||
total_no_change += sum(1 for e in finals if e.get("no_change"))
|
||
total_changed += sum(1 for e in finals if not e.get("no_change"))
|
||
|
||
pending = find_unmatched(log_dir, 14)
|
||
|
||
print(f"\n📊 Self-Improving Skill 观察统计")
|
||
print(f"{'='*40}")
|
||
print(f"📁 日志天数: {len(all_files)}")
|
||
print(f"📝 原稿: {total_originals}")
|
||
print(f"✅ 已配对: {total_finals}")
|
||
print(f" ├ 有修改: {total_changed}")
|
||
print(f" └ 无修改: {total_no_change}")
|
||
print(f"⏳ 待配对: {len(pending)}")
|
||
if total_finals > 0:
|
||
print(f"📈 修改率: {total_changed}/{total_finals} = {total_changed/total_finals*100:.0f}%")
|
||
|
||
|
||
def add_common_args(parser):
|
||
"""添加通用参数"""
|
||
parser.add_argument("--skill", help="目标 skill 目录名(用于定位日志目录)")
|
||
parser.add_argument("--log-dir", help="自定义日志目录")
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Self-Improving Skill — Observation Layer")
|
||
subparsers = parser.add_subparsers(dest="action")
|
||
|
||
# record-original
|
||
p_orig = subparsers.add_parser("record-original", help="记录 AI 原稿")
|
||
p_orig.add_argument("file", nargs="?", help="文件路径")
|
||
p_orig.add_argument("--text", help="直接传入文本")
|
||
p_orig.add_argument("--stdin", action="store_true", help="从 stdin 读取")
|
||
p_orig.add_argument("--account", help="发布账号")
|
||
p_orig.add_argument("--content-type", help="内容类型")
|
||
add_common_args(p_orig)
|
||
|
||
# record-final
|
||
p_final = subparsers.add_parser("record-final", help="记录最终版")
|
||
p_final.add_argument("file", nargs="?", help="文件路径")
|
||
p_final.add_argument("--text", help="直接传入文本")
|
||
p_final.add_argument("--stdin", action="store_true", help="从 stdin 读取")
|
||
p_final.add_argument("--match", help="匹配的原稿 hash")
|
||
add_common_args(p_final)
|
||
|
||
# pending
|
||
p_pending = subparsers.add_parser("pending", help="查看待配对原稿")
|
||
add_common_args(p_pending)
|
||
|
||
# stats
|
||
p_stats = subparsers.add_parser("stats", help="总体统计")
|
||
add_common_args(p_stats)
|
||
|
||
args = parser.parse_args()
|
||
if not args.action:
|
||
parser.print_help()
|
||
sys.exit(1)
|
||
|
||
actions = {
|
||
"record-original": record_original,
|
||
"record-final": record_final,
|
||
"pending": show_pending,
|
||
"stats": show_stats,
|
||
}
|
||
actions[args.action](args)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|