基于 Git 的实体记忆:未实现提案#
**实现状态:**本文是实体记忆的提议设计。下文的 Session-Git 和 Project-Git 生命周期尚未在当前 Memory runtime 实现。当前实现是
overview.md所述的 Source/Topic/Core workspace。
本文统一维护生命周期、回放和一致性提案。下文路径、hook 与 UI 入口均属于未实现设计,不是当前使用说明。存储草案统一使用 history/ 与 context/;旧 messages/ 与依赖 SQLite 的草图不作为当前存储合同。当前持久化见会话存储。项目自动提交必须能识别 agent 所属改动;如果用户并发编辑,仅凭开始时工作区干净不能证明改动归属。
概念#
实体记忆是不可变的真实历史记录,基于 git 存储。"实体"= 真实发生过的事,可逐步回溯。
两种实体:
| 类型 | 粒度 | 存储位置 |
|---|---|---|
| Session-Git | 每次对话,每 turn 一 commit | <state>/sessions/<id>/ 或 <project>/.openprogram/sessions/<id>/ |
| Project-Git | 绑定的用户工作目录 | <user-workdir>/.git/(复用已有) |
Session-Git 记录对话过程(user → LLM → tool 调用链)。Project-Git 记录 agent 对用户代码/文档的实际修改。两者互补:session 存"说了什么",project 存"改了什么"。
存储布局#
~/.openprogram/ ← get_state_dir()
├── sessions/
│ ├── <session_id>/ ← 一个 Session-Git repo
│ │ ├── .git/
│ │ ├── meta.json title, agent_id, project_id, created_at, ...
│ │ ├── history/ DAG 节点文件
│ │ │ ├── 000001-u-<id>.json user message
│ │ │ ├── 000002-a-<id>.json assistant message
│ │ │ ├── 000003-t-<id>.json tool result (called_by = assistant)
│ │ │ └── ...
│ │ ├── context/ per-turn LLM context 物化视图
│ │ │ └── commits/<commit_id>.json
│ │ └── workdir/ 此会话的临时工作目录
│ │
│ └── locations.json ← 索引:项目内 session → 真实路径
│
├── projects/
│ └── projects.json project 注册表
│
└── memory/ ← 抽象记忆层(见 virtual-memory.md)
<用户工作目录>/
├── .git/ ← Project-Git(复用已有,或 auto-init)
└── .openprogram/sessions/<id>/ ← 绑定此项目的 session repo
Session-Git 生命周期#
创建 (Create)#
触发时机:第一条消息写入时 lazy-init(SessionStore._open(id, create_if_missing=True))。
创建产物:
git init→.git/- 写入
meta.json(title, agent_id, project_id, created_at) - 创建
history/,context/,workdir/目录
归属:每个 session 创建时绑定一个 project:
- 指定了工作目录 → 绑定真实 Project-Git,session repo 落在
<project>/.openprogram/sessions/<id>/ - 未指定 → 绑定默认项目(纯逻辑标签
project_id="default"),session repo 落在 home 根
合法性 (Validity)#
一个目录被视为有效 session,当且仅当:
- 该目录存在
- 目录下存在
meta.json文件 meta.json可解析为有效 JSON
不满足以上条件的目录:跳过,不列入列表,不报错。这涵盖了:
- 测试残留(只有
steering/子目录,无 meta.json) - 手动创建的无关目录
- 损坏的 session(meta.json 不可解析)
Title 规则#
Session title 是用户在列表中识别对话的主要标识。
生成策略(优先级从高到低)#
-
用户手动命名:用户通过
/rename或 UI 右键 rename 设置的 title,永远优先,不被覆盖。 -
LLM 生成摘要(首选自动方式):第一轮对话结束后(assistant 回复完成),后台线程调用 LLM 生成 3-7 词的描述性标题。
- 触发时机:第一轮 turn 的
finalize_turn之后,异步执行 - 输入:user message 前 500 字符 + assistant response 前 500 字符
- Prompt:"Generate a short, descriptive title (3-7 words) for this conversation. Return ONLY the title, no quotes, no prefix."
- 模型选择:当前 session 使用的模型(已建立连接,无额外开销);如果不可用,用系统最便宜的可用模型
- 参数:
max_tokens=50,temperature=0.3(确定性高) - 后处理:去引号、去 "Title:" 前缀、截断到 80 字符
- 非阻塞:后台 daemon 线程执行,失败只记日志不影响用户
- 幂等:
meta.json中_titled=True标记,已生成过不再触发
- 触发时机:第一轮 turn 的
-
Fallback — 第一条消息截取:LLM 不可用或调用失败时,取第一条 user message 的第一行,截断到 50 字符 + "…"。
-
展示层 Fallback:如果以上都没有触发(session 通过非 dispatcher 入口创建,如 harness),列举时 title 仍为空/"New conversation"/"Untitled" → 用 preview(第一条 user message 前 80 字符)替代显示。
-
不过滤空占位:所有合法 session 都显示在侧边栏,包括刚创建、title/preview 尚未就绪的会话。New chat 不建 session,会话只在发第一条消息时由后端延迟创建,所以列出来的会话必然有内容,这类过滤没有可藏的对象。
时序#
用户发送第一条消息
→ dispatcher 处理 turn
→ finalize_turn:
1. 立即设 title = 第一行前 50 字符(Fallback, 确保侧边栏不空)
2. 启动后台线程 → LLM 生成摘要 → 成功则覆盖 title + 标记 _titled
→ 用户看到侧边栏立即显示截取标题
→ 几秒后 LLM 标题就绪 → 广播 session_updated → 侧边栏更新为摘要标题
设计决策#
- 为什么不等 LLM 再显示? LLM 调用需要 1-5 秒,用户切换到别的 session 时侧边栏不能是空的。先用截取占位,再异步更新。
- 为什么用当前模型? 避免额外的 API key / 连接开销。title 生成的 prompt 很短(< 1200 tokens),对任何模型都是微不足道的开销。
- 为什么只触发一次? 避免对话深入后标题来回变。第一轮最能代表用户意图。
- 为什么 temperature=0.3? 稍有创意但基本确定性。同样的对话开头重跑不会得到完全不同的标题。
发现与列举 (Discovery)#
列举所有 session 时,来源有两个:
- 全局目录扫描:遍历
<state>/sessions/下所有子目录,逐一做合法性校验 - locations.json 索引:记录了落在项目目录内的 session 路径,逐一做合法性校验
两个来源合并去重后,按 updated_at 降序排列。
展示规则:
- 合法性校验通过 → 显示(包括 title/preview 尚未就绪的刚创建会话;没有空占位过滤)
- 合法性校验不通过 → 跳过
侧边栏和 Chats 页面使用同一套展示规则。
读取与写入 (Read/Write)#
写入:
append_message(session_id, msg)→ 同步写history/NNNN-<role>-<id>.json+ 更新内存索引commit_turn(session_id, message)→ turn 结束时一次 git commit(不是每条消息一次)
读取:
get_branch(session_id, head_id)→ 按 parent_id 边遍历 DAG,返回渲染后的消息列表get_nodes(session_id)→ 原始Call对象(含工具调用细节)session_commits(session_id)→ git log(turn 粒度)
分支/重试:
- DAG 的 retry → git branch(
retry-<assistant_id>) - 切换 DAG head ↔ git checkout
管理 (Management)#
元数据更新:update_session(session_id, title=..., project_id=..., ...) → 写 meta.json + 更新内存索引
缓存:
- 内存中维护
OrderedDict[session_id → (GitSession, SessionMemoryIndex)] - LRU,cap=256(env
OPENPROGRAM_SESSION_CACHE_CAP可配) - 驱逐无损:下次访问时从磁盘重建
- 线程安全:per-session lock + 全局 store lock
Project 绑定:
meta.json的project_id字段- 绑定了真实项目的 session 落在项目目录内(
locations.json记录路径)
删除 (Deletion)#
手动删除:delete_session(session_id) →
- 从内存缓存中移除
- 关闭关联的 runtime(如有)
shutil.rmtree()整个 session 目录(包括.git/)- 如在
locations.json中有记录,移除该条目
级联影响:
- 抽象记忆中引用该 session 的 provenance 指针变为 dangling
- 查询时通过合法性检查自动跳过(session 目录不存在 → 返回 None)
- 不需要显式清理虚拟层记录
前端入口:
- 侧边栏右键菜单 → Delete
- Chats 页面(待补充:右键删除功能)
GC 策略 (Garbage Collection)#
| 场景 | 处理 |
|---|---|
无 meta.json 的目录 |
列举时跳过(§3.2 合法性校验) |
| 有 meta.json 但 history 为空且 title 为默认值 | 列入列表,使用标题或预览兜底,不过滤有效会话 |
| 长期不活跃的 session | 不自动删除(用户数据,由用户决定) |
设计决策:不设自动 TTL。理由:session 是用户的对话历史,属于用户数据,不应被系统自动清理。如果未来需要空间回收,由 policy 层(用户配置)决定,不在 store 层实现。
Project-Git 生命周期#
创建#
触发:resolve_project(path, name) — 用户在 UI 绑定工作目录时,或 session 指定 workdir 时。
行为:
- 目录已有
.git/→ 复用 - 目录无
.git/→git init - 注册到
projects.json
写入(Auto-commit)#
Turn 结束时,如果 session 绑了真实 project 且 agent 改过文件:
if working_tree_clean_before_agent:
git add -A
git commit -c user.name="agent (<model> via OpenProgram)"
-m "[agent <session_id>] turn <N>: <user msg first 60 chars>"
else:
skip + UI 警告(不污染用户未提交的改动)
Agent commit 用覆盖的 user.name/email 标识,跟用户手动 commit 区分。
读取#
ProjectGit.log(limit)→ agent-attributed commitsproject_commits(project_id)→ provenance 层的读原语
删除#
取消项目绑定 ≠ 删除 git 历史。用户的 .git/ 里包含用户自己的 commit,不可由 OpenProgram 删除。
取消绑定时:
- 从
projects.json移除注册 - 关联 session 的
project_id不变(historical pointer) - Session repo 仍在项目目录内(不搬回 home 根)
跟抽象记忆的关系#
实体记忆是抽象记忆的唯一数据源。提炼管道读 session-git 的 DAG 节点 + project-git 的 commit 历史,从中抽取事件和关系,写入抽象记忆的 timeline/graph。
每条抽象记忆都带 Provenance 指针回指实体层:
@dataclass
class Provenance:
project_id: str
session_id: str
node_ids: tuple[str, ...]
commit: str | None
event_time: float
ingestion_time: float
轮次提交、回放与一致性#
Commit 时机#
每个 turn 结束时一次 commit, 不是每条消息一次 —— 按消息切太碎, 没有实用价值. turn 结束 = dispatcher.process_user_turn() 返回 TurnResult 时.
一个 turn 包含:
- 1 个 user message
- 1 个 assistant placeholder → 最终带 content
- N 个 tool result (caller = assistant)
commit message:
turn <N>: <first 60 chars of user msg>
assistant: <first 80 chars of reply>
tools: read, grep × 3, list
[meta: turn took 12.3s, 18 tools, 4521 tokens]
回溯 UI#
chat 顶部 / 历史区右侧的 prev / next 控件:
[← prev turn] Turn 7 / 12 [next turn →] [view full history]
- prev:
git checkout HEAD~1+ 重放 UI 到那时状态 - next: 反向走 reflog
- view full history: 弹一个 timeline (每个 commit 一条, 点开看 message 内容)
实现层面: WS action git_history(session_id) 返回 commit log, git_checkout(session_id, commit_sha) 切到某个状态, dispatcher 下一轮 user message 续在那个 commit 上.
双写一致性#
主路径: dispatcher 写 DAG. 镜像: turn 完成后, 异步把这一 turn 的所有节点序列化到 session repo + commit.
异步是因为 git commit ~100-500ms, 不该卡用户. 用 threading.Thread 后台跑, 失败只记日志不阻塞.
冲突可能性极低 (一个 session 一个 repo, 串行 commit), 用文件锁兜底.
老 session 迁移#
启动时扫现有会话存储, 给没 repo 的 session 跑一次性 backfill: 按 seq 遍历节点, 逐 turn commit 出来. 一次跑完, 之后增量.
项目元数据与展示#
关联#
class Project:
id: str
name: str
workdir: str # 绝对路径, 用户文件系统目录
sessions: list[str] # session id 列表 (谁在这 project 里干活)
status: "active" | "paused" | "done"
created_at: float
# 反向关联:
Session.metadata["project_id"] = "proj_xxx" # 加到 sessions 表
session 可以独立存在 (没 project). 有 project 时, agent 修改文件触发的 commit 落在 project repo.
项目面板#
左 sidebar 的 "Projects" section:
─ Projects ─────────
● Wiki Agent Refactor 2 sessions ● active
○ DAG Visualization 1 session done
○ ...
+ New Project
点击 project → 进 project detail page: 名字 / workdir / 关联 sessions / 提交历史 / 抽象记忆入口.
新建 project 流程: 选个目录 → 起名 → 创建 / 复用 git repo → 把当前 session 关联进去 (可选).
Chat 顶部 project 指示#
如果当前 session 关联了 project, 顶部 status 区显示 project 名字 + workdir 简写, 点击进 project page.
关键不变式#
- DAG 是当前真源, Git 是镜像. Git 失败不影响 DAG. 反过来不行 — git 落盘但 DAG 没写就是脏数据.
- Session-git 一 turn 一 commit, 不按消息细分.
- Project-git 干净优先: 用户 working tree 不能被 agent 污染. 有未提交改动时 agent 跳过 commit.
- 回溯不破坏 DAG: checkout 是只读视图, 用户发新消息会基于该 commit 在 DAG 上 fork 新分支.
- 抽象记忆建立在实体记忆之上, 而不是反过来.
风险点#
- Git 异步 commit 失败: 用户看不到, 静默漏数据. 缓解: 后台线程失败重试 + 启动时校验 DAG seq vs git commit 数, 不对就触发 backfill.
- Project workdir 不是 git 仓: agent 第一次 commit 时自动
git init. 用户已经有 git 的仓: 直接复用. - 多 session 并发改同一 project: file lock 串行化 project commit. 极端情况退化到队列.
- 回溯 + 继续聊天的语义: 用户回到 turn 5 后发新消息, 结果是 fork 新分支, 而不是覆盖之后的内容 (DAG retry 已有这个概念), git 自然映射到 branch.
跟现有 commit chain 的关系#
不冲突. commit chain 是"LLM 看到的 context view", 跟 git 是"实际发生过的 history" 是两个层:
- DAG 节点 (raw 真源) → git commit (持久化镜像)
- ContextCommit chain (LLM 视角) → 不入 git (派生, 可重算)
commit 可以选择性 export 到 git (e.g. 用户想看"那时 LLM 看到啥"), 但不是强制.
外部比较边界#
原提案比较了 Claude Code 的回退体验,但没有经过核实的实现证据,不能据此判断其会话存储与恢复方式。本提案明确使用 Git log/diff 暴露历史;外部能力比较需要在实施前另行核实。
附录: 提议的构建顺序#
工作拆成五块, 每块独立可验证:
- Session-git 基础设施 — 模块
openprogram/memory/session_git/(init / commit / log / checkout 包装);dispatcher.process_user_turn末尾 hook, 后台线程跑 commit; backfill 脚本把老 session 转成 git repo; WS actiongit_session_log,git_session_checkout. - Project schema + UI — 项目注册表
projects(id, name, workdir, status, …); sessions 表加project_id; WSlist_projects,create_project,add_session_to_project; 左 sidebar 加 Projects section. - Project-git auto-commit — 已绑
project_id的会话在 turn 后跑 project commit hook; 干净时 commit, 有 dirty 就警告; UI 警告 banner. - 回溯 UI — chat 顶部 prev/next + timeline view; WS action 调 git_checkout 重放历史.
- 数据迁移 — 现有 session 全部跑一遍 backfill; 已有 git 项目 import 成 Project.