| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """PreCompact hook (GLOBAL): 上下文压缩前写 ≤2KB 交接快照到项目记忆根。
- 落点:{PROJECT_ROOT}/.ai-memory/_handoff_snapshot.md
- (PROJECT_ROOT 按 project-memory-management「路径根目录探测协议」解析,绝
- 不写死 .codebuddy 等其它 IDE 约定路径;记忆跟随项目,跨 IDE 通用。)
- hook 拿不到对话内容,故快照基于文件系统派生(压缩后最实用的重定位信息):
- - 近 4 小时内被改动的项目文件(排除备份/依赖/缓存/记忆目录),按时间倒序取前 25
- - 当前 Plan/*.md 的状态行(## 状态)
- - 最新一份每日 memory 文件名
- """
- import sys
- import os
- import json
- import time
- CUTOFF_HOURS = 4
- MAX_FILES = 25
- EXCLUDE = {"backup", "vendor", "node_modules", "uploads",
- ".git", ".codebuddy", ".ai-memory", "ecachefiles",
- "__pycache__", "dist", "build", "cache", "tmp", "temp", "logs", "runtime"}
- # 排除备份/临时文件扩展名(避免 backup_on_write 的 .bak/.bak.1/.bak.2 污染快照)
- EXCLUDE_EXT = {".bak", ".tmp", ".log", ".cache", ".pyc", ".swp", ".swo"}
- def _find_git_root(start):
- """向上找最近的 .git 目录,返回其所在目录(仓库根),找不到返回 None。"""
- d = os.path.abspath(start)
- while True:
- if os.path.isdir(os.path.join(d, ".git")):
- return d
- nd = os.path.dirname(d)
- if nd == d:
- return None
- d = nd
- def resolve_project_root(root):
- """按技能「路径根目录探测协议」解析 PROJECT_ROOT:
- 1. 环境变量 PROJECT_ROOT(跨 IDE 通用,优先):绝对路径直接用,相对路径按 root 拼接。
- 2. Git 仓库根目录:向上找含 .git 的目录。
- 3. IDE 工作区根目录:仅当 cwd 自身已含 .ai-memory(确为项目记忆根)时才回退。
- 4. 以上均未命中:hook 无法询问用户,返回 None(由调用方跳过,严禁自行创建目录)。
- """
- env = os.environ.get("PROJECT_ROOT")
- if env:
- return env if os.path.isabs(env) else os.path.join(root, env)
- git_root = _find_git_root(root)
- if git_root:
- return git_root
- if os.path.isdir(os.path.join(root, ".ai-memory")):
- return root
- return None
- def main():
- try:
- sys.stdin.read()
- except Exception:
- pass
- cwd = os.getcwd()
- proj_root = resolve_project_root(cwd)
- if not proj_root:
- # 项目根不可解析(如运行在 skill 目录、或记忆在数据库的运行时不落文件系统):跳过落盘
- return 0
- mem_dir = os.path.join(proj_root, ".ai-memory")
- root = proj_root # 所有扫描与相对路径均以解析出的项目根为基准
- cutoff = time.time() - CUTOFF_HOURS * 3600
- recent = []
- for dirpath, dirnames, filenames in os.walk(root):
- dirnames[:] = [d for d in dirnames
- if d.lower() not in EXCLUDE and not d.startswith(".")]
- for fn in filenames:
- # 过滤备份/临时文件扩展名
- ext = os.path.splitext(fn)[1].lower()
- if ext in EXCLUDE_EXT:
- continue
- fp = os.path.join(dirpath, fn)
- try:
- mtime = os.path.getmtime(fp)
- except OSError:
- continue
- if mtime >= cutoff:
- recent.append((mtime, fp))
- recent.sort(reverse=True)
- recent = recent[:MAX_FILES]
- def rel(p):
- return os.path.relpath(p, root).replace("\\", "/")
- lines = ["# 压缩交接快照 (PreCompact 自动生成)", ""]
- lines.append("生成时间: " + time.strftime("%Y-%m-%d %H:%M:%S"))
- lines.append("工作区: " + root)
- lines.append("")
- # 输出顺序:Plan 状态 → 最新记忆 → 改动文件列表(最重要的放前面,避免截断丢失)
- # Plan 状态(兼容 Plan/plan 目录名大小写)
- lines.append("## 进行中计划 (Plan/*.md 状态)")
- plan_dir = None
- for name in ("Plan", "plan"):
- candidate = os.path.join(root, name)
- if os.path.isdir(candidate):
- plan_dir = candidate
- break
- if plan_dir:
- for fn in sorted(os.listdir(plan_dir)):
- if not fn.endswith("_plan.md"):
- continue
- p = os.path.join(plan_dir, fn)
- status = ""
- try:
- with open(p, encoding="utf-8", errors="ignore") as f:
- for line in f:
- if line.strip().startswith("## 状态"):
- status = line.strip()
- break
- except OSError:
- pass
- lines.append("- %s %s" % (fn, status))
- else:
- lines.append("- (无 Plan 目录)")
- lines.append("")
- # 最新 memory(按 mtime 排序,避免非日期格式文件名排序不准)
- lines.append("## 最新记忆")
- if os.path.isdir(mem_dir):
- mds = []
- for f in os.listdir(mem_dir):
- if not f.endswith(".md"):
- continue
- fp = os.path.join(mem_dir, f)
- try:
- mds.append((os.path.getmtime(fp), f))
- except OSError:
- continue
- mds.sort(reverse=True) # 按 mtime 降序,最新的在前
- rel_mem = os.path.relpath(mem_dir, root).replace("\\", "/")
- if mds:
- lines.append("- 最新: %s/%s" % (rel_mem, mds[0][1]))
- else:
- lines.append("- (无)")
- else:
- lines.append("- (无 memory 目录)")
- lines.append("")
- # 改动文件列表(放最后,截断时只丢失文件列表,不丢失 Plan/记忆)
- lines.append("## 近 %d 小时改动文件 (前 %d)" % (CUTOFF_HOURS, MAX_FILES))
- if recent:
- for mtime, fp in recent:
- lines.append("- %s (%s)" % (rel(fp),
- time.strftime("%m-%d %H:%M", time.localtime(mtime))))
- else:
- lines.append("- (无)")
- out = "\n".join(lines) + "\n"
- b = out.encode("utf-8")
- if len(b) > 2048:
- # 按字节截断并在字符边界安全解码,避免切断多字节中文
- b = b[:2048]
- out = b.decode("utf-8", errors="ignore")
- try:
- os.makedirs(mem_dir, exist_ok=True)
- snap = os.path.join(mem_dir, "_handoff_snapshot.md")
- tmp = snap + ".tmp"
- # 二进制写入:避免 Windows 文本模式把 \n 转 \r\n 撑大文件致超 2048
- with open(tmp, "wb") as f:
- f.write(out.encode("utf-8"))
- try:
- os.replace(tmp, snap) # 原子替换,避免目标被读锁占用时写失败
- except OSError:
- # 极少数情况目标被锁:退回直接覆盖写
- with open(snap, "wb") as f:
- f.write(out.encode("utf-8"))
- try:
- os.remove(tmp)
- except OSError:
- pass
- except OSError:
- return 0
- return 0
- if __name__ == "__main__":
- sys.exit(main())
|