"""Project Mode plugin — 零核心代码改动实现 GA 项目模式。 机制:注册 agent_before hook(agent_loop.py 中每个用户轮触发一次)。 当项目模式激活时,把 L1 层(规则 + 记忆文件指针 + 收尾纪律)追加到最后一条 user message(str 直接拼接,多模态 list 追加 text block)。 两层设计:L1 每轮全量注入(轻量、稳定);L2 = project_memory.md 全文不注入, 由模型按 L1 中的指针与线索(行数/大小)自行判断是否用 file 工具读取。 利用 messages 是 list 引用的事实,直接 mutate 即反映到真正发给 LLM 的内容。 激活态载体 = 文件锚 temp/.active_project.<宿主pid>(存当前项目名)。PID 键控: - 锚只对写它的那个 GA 进程有效 → 多开 GA 各自激活不同项目,互不可见 - GA 关闭即自动失活(重启后 pid 变,旧锚作废);重新激活需经用户确认(SOP 指示) - 进入:agent 读 project_mode_sop,经用户确认后写锚(code_run 中 os.getppid() 即宿主 pid) - 退出:删除该文件。插件加载时清扫旧版无后缀锚与自己 pid 的前世残留(不碰他进程的锚) 目录约定: temp/projects/<项目名>/project_memory.md 单文件全文注入的项目记忆 temp/projects/<项目名>/ 项目私域文件(todo 等),解决多项目覆盖 """ import os import plugins.hooks as hooks _PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _TEMP = os.path.join(_PROJECT_ROOT, 'temp') _ANCHOR = os.path.join(_TEMP, f'.active_project.{os.getpid()}') def _cleanup_stale_anchors(): """清扫无主锚:仅删两类能纯靠文件名判定的——旧版无后缀的、自己 pid 的(刚启动 不可能写过锚,必是 pid 复用的前世残留)。他进程的锚一律不碰:故意不做存活探测, os.kill(pid, 0) 在 Windows 上语义是终止进程而非探活,任何平台分支都不值得为 删除无害残留文件引入杀进程风险(激活判定只认自己 pid 的锚,残留不影响行为)。""" import glob for path in glob.glob(os.path.join(_TEMP, '.active_project*')): pid = path.rsplit('.', 1)[-1] if path != _ANCHOR and pid.isdigit(): continue # 他进程的锚(含已死进程的),不碰 try: os.remove(path) except OSError: pass _cleanup_stale_anchors() def _active_project(ctx=None): """返回当前激活的项目名;未激活返回 None。 兼容策略: - 新 TUI 多会话可在当前 GenericAgent 实例上设置 _ga_project_mode_name。 只要该属性存在,就以它为准;值为 None/空串表示该 agent 普通模式。 - 其它 UI / 旧 SOP 不设置该属性,继续读取 pid 键控文件锚。 异常不在此捕获——hooks.trigger 统一捕获并打印,保持可观测。""" parent = None if isinstance(ctx, dict): handler = ctx.get('handler') parent = getattr(handler, 'parent', None) if parent is not None and hasattr(parent, '_ga_project_mode_name'): return getattr(parent, '_ga_project_mode_name', None) or None if not os.path.isfile(_ANCHOR): return None return open(_ANCHOR, encoding='utf-8').read().strip() or None def _project_dir(name): return os.path.join(_TEMP, 'projects', name) def _mem_path(name): return os.path.join(_project_dir(name), 'project_memory.md') def _memory_stat(name): """返回 project_memory.md 的 (存在, 行数, 字节数),供 L1 指针给模型判断依据。""" path = _mem_path(name) if os.path.isfile(path): data = open(path, encoding='utf-8').read() return True, len(data.splitlines()), len(data.encode('utf-8')) return False, 0, 0 def _build_injection(name): """构造追加到 user message 末尾的内容(两层设计的 L1 层)。 L1(每轮全量注入):规范/规则/操作说明 + 记忆文件指针(含行数/大小线索)。 L2(按需):project_memory.md 全文不注入,由模型自行判断是否用 file 工具去读。 """ pdir = _project_dir(name) mem_path = _mem_path(name) exists, lines, nbytes = _memory_stat(name) if exists and nbytes > 0: mem_hint = ( f"项目全量记忆在 {mem_path}({lines} 行 / {nbytes} 字节)。" f"本轮任务若涉及项目上下文(接续工作、查约定、避坑),先读它再动手;" f"若与项目认知无关(闲聊、独立小事),可不读。自行判断。" ) else: mem_hint = f"项目记忆 {mem_path} 暂为空(本项目尚无沉淀),无需读取。" return ( f"\n\n---\n" f"[PROJECT MODE: {name}]\n" f"你正在「{name}」项目模式中。\n\n" f"## 规则\n" f"- 项目私域目录:{pdir}(todo、草稿、产物一律放这里,勿放 temp 根目录)\n" f"- {mem_hint}\n\n" f"## 收尾纪律\n" f"干完本轮活后自问一个问题:「记忆归零、重新接手本项目的我,缺了本轮哪条信息会重复付出认知代价" f"——再踩一次坑、再摸索一次、再问一次用户?」会的,就用 file 工具把那条追加进 {mem_path}," f"写成未来的自己能直接复用的一句话;不会的,一个字都不写。\n" f"---" ) @hooks.register('agent_before') def inject_project_context(ctx): """每个用户轮起始时,若项目模式激活,把项目上下文追加到 user message。""" name = _active_project(ctx) if not name: return # 未激活,普通模式,什么都不做 # 从尾部找最后一条 user message(不依赖 messages[1] 的位置约定) um = next((m for m in reversed(ctx.get('messages') or []) if isinstance(m, dict) and m.get('role') == 'user'), None) if um is None: return content = um.get('content') if isinstance(content, str): um['content'] = content + _build_injection(name) elif isinstance(content, list): # 多模态:追加 text block content.append({'type': 'text', 'text': _build_injection(name)})