Files
AgentCompany/builtin-skills/document-process/my-writing-style/scripts/observe.py
T
ZacharyZhang-NY e74dafcd36 Silence render-disposed spam, unbreak bundled skills, drop board wake
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.
2026-04-20 08:13:48 -04:00

310 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()