capture_learning.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """跨工具「学习捕获」hook 草案(PostToolUse / UserPromptSubmit 通用)。
  4. 借鉴 elf-improving-agent 的 activator.sh + error-detector.sh 设计理念,
  5. 改写为 Python 以兼容 Windows 与所有 Agent 工具(CodeBuddy / Trae / Cursor /
  6. Claude Code / Codex / OpenClaw),不依赖单一平台的 bash 运行时。
  7. 设计原则(与 dev-expert 现有 15 个防护 hook 一致):
  8. - 跨工具兼容:stdin 事件字段名多源容错(tool_input/filePath/file_path/path、
  9. tool_response/tool_output/output/result、prompt/query/user_input)。
  10. - 非阻塞:默认 exit 0(仅回显提醒文本,交 Agent 判断);--strict 时命中 exit 1(强提醒)。
  11. - 不自动写文件:遵循 elf「提醒而非写入」+ error-ledger「不经确认不写入」禁忌,
  12. 仅输出「建议记录」提醒,由 Agent 决定是否落盘到 .ai-memory/。
  13. - 去重提示:扫描 error_index.md 判定 Recurrence,复现时提示更新 Recurrence-Count。
  14. 触发信号:
  15. - 错误信号(工具/命令输出含关键词):elf 16 个 + dev-expert 语境扩展。
  16. - 纠错信号(用户输入含句式):中英文清单,仅在 --mode correction 或事件含用户输入时检测。
  17. 退出码:
  18. 0 = 无信号 / 仅提醒(非阻塞默认)
  19. 1 = 命中信号且 --strict(强提醒,仍不阻断已完成的写入/执行)
  20. """
  21. import sys
  22. import os
  23. import json
  24. import re
  25. import argparse
  26. # ---- 错误关键词(命令/工具输出扫描)----
  27. ERROR_PATTERNS = [
  28. "error:", "Error:", "ERROR:", "failed", "FAILED",
  29. "command not found", "No such file", "Permission denied",
  30. "fatal:", "Exception", "Traceback", "npm ERR!",
  31. "ModuleNotFoundError", "SyntaxError", "TypeError",
  32. "exit code", "non-zero",
  33. # dev-expert 语境扩展
  34. "Parse error", "Fatal error", "PHP Fatal", "PHP Warning",
  35. "断言失败", "assert", "测试失败", "未通过", "GATE-CHECK 不足",
  36. ]
  37. # ---- 用户纠错句式(用户输入扫描)----
  38. CORRECTION_PATTERNS = [
  39. "不对", "错了", "搞混", "实际上", "应该是", "你理解", "你误会",
  40. "纠正", "混淆", "你之前",
  41. "no, that", "actually", "you're wrong", "that's outdated", "not right",
  42. "i said",
  43. ]
  44. def read_event():
  45. """容错读取 hook 事件 JSON;任何解析失败返回空 dict(静默 exit 0)。"""
  46. try:
  47. raw = sys.stdin.read()
  48. except Exception:
  49. return {}
  50. raw = raw.lstrip("\ufeff") # 容忍 UTF-8 BOM(项目 .md 常见)
  51. if not raw.strip():
  52. return {}
  53. try:
  54. return json.loads(raw)
  55. except Exception:
  56. return {}
  57. def pick(data, *keys, default=""):
  58. """从多源字段名中取第一个非空字符串。"""
  59. for k in keys:
  60. v = data.get(k)
  61. if isinstance(v, str) and v.strip():
  62. return v
  63. return default
  64. def get_output(data):
  65. return pick(data, "tool_response", "tool_output", "output", "result", "stdout", default="")
  66. def get_prompt(data):
  67. return pick(data, "prompt", "query", "user_input", "input", default="")
  68. def get_tool_name(data):
  69. return pick(data, "tool_name", "toolName", "name", default="")
  70. def scan_error(text):
  71. """返回命中的首个错误关键词,或 None。"""
  72. for p in ERROR_PATTERNS:
  73. if p in text:
  74. return p
  75. return None
  76. def scan_correction(text):
  77. """返回命中的首个纠错句式,或 None。"""
  78. low = text.lower()
  79. for p in CORRECTION_PATTERNS:
  80. if p.lower() in low:
  81. return p
  82. return None
  83. def find_recurrence(index_path, keyword):
  84. """扫描 error_index.md,返回含该关键词的已有 ERR-ID 列表(去重提示用)。"""
  85. if not index_path or not os.path.isfile(index_path):
  86. return []
  87. try:
  88. with open(index_path, encoding="utf-8") as f:
  89. content = f.read()
  90. except OSError:
  91. return []
  92. hits = []
  93. for line in content.splitlines():
  94. if "|" not in line:
  95. continue
  96. if keyword.lower() in line.lower():
  97. m = re.search(r"ERR-\d+", line)
  98. if m and m.group(0) not in hits:
  99. hits.append(m.group(0))
  100. return hits
  101. def main():
  102. parser = argparse.ArgumentParser(description="跨工具学习捕获 hook(草案)")
  103. parser.add_argument(
  104. "--mode", choices=["auto", "correction"], default="auto",
  105. help="auto=扫描输出+输入; correction=仅扫描用户输入纠错",
  106. )
  107. parser.add_argument(
  108. "--strict", action="store_true",
  109. help="命中信号时 exit 1(强提醒,仍不阻断)",
  110. )
  111. parser.add_argument(
  112. "--index", default="",
  113. help="error_index.md 路径(默认:从 PROJECT_ROOT 推断 .ai-memory/error_index.md)",
  114. )
  115. args = parser.parse_args()
  116. data = read_event()
  117. if not data:
  118. return 0
  119. tool_name = get_tool_name(data)
  120. output = get_output(data)
  121. prompt = get_prompt(data)
  122. signal_kind = None
  123. signal_word = None
  124. snippet = ""
  125. if args.mode == "correction":
  126. c = scan_correction(prompt)
  127. if c:
  128. signal_kind, signal_word, snippet = "correction", c, prompt[:200]
  129. else:
  130. e = scan_error(output)
  131. if e:
  132. signal_kind, signal_word, snippet = "error", e, output[:200]
  133. else:
  134. c = scan_correction(prompt)
  135. if c:
  136. signal_kind, signal_word, snippet = "correction", c, prompt[:200]
  137. if not signal_kind:
  138. return 0
  139. # 去重 / Recurrence 提示
  140. index_path = args.index
  141. if not index_path:
  142. proj = os.environ.get("PROJECT_ROOT", "")
  143. if proj:
  144. cand = os.path.join(proj, ".ai-memory", "error_index.md")
  145. if os.path.isfile(cand):
  146. index_path = cand
  147. hits = find_recurrence(index_path, signal_word) if index_path else []
  148. print("[LEARN-CAPTURE] 检测到可提炼信号(建议记录到 error-ledger)")
  149. print(" 类型: %s | 关键词: %s" % (signal_kind, signal_word))
  150. print(" 工具: %s" % (tool_name or "unknown"))
  151. print(" 片段: %s" % snippet.replace("\n", " ").strip())
  152. if hits:
  153. print(" 复现提示: 已记录 %s,建议更新其 Recurrence-Count" % ", ".join(hits))
  154. else:
  155. print(" 复现提示: 未见历史记录,建议新建 ERR-XXX 并附 Recurrence-Count: 1")
  156. print(" 动作: 在 .ai-memory/error_index.md 追加索引 + errors/ERR-XXX.md 单条(遵循 error-ledger 禁忌)")
  157. return 1 if args.strict else 0
  158. if __name__ == "__main__":
  159. sys.exit(main())