Claude-Mem Branch Memory Phase 01:为每条 Observation 持久化 Git 分支与 Commit 元数据的完整实现方案
本文基于 Claude-Mem 仓库中的 Branch Memory 实施计划文档 BRANCH-MEMORY-01.md 撰写,系统讲解 Phase 01 的完整技术内容:如何通过一次数据库 schema 迁移、一个 Git 分支检测工具函数,把 branch 名与 commit SHA 沿「Hook 层 → Worker → SQLite 存储」整条 Observation 管线串起来。读完后你将掌握:Claude-Mem 的 SQLite 迁移模式如何落地新列、Hook 层如何安全调用 git 命令而不破坏 stderr 纪律,以及为什么内容去重哈希必须纳入 branch 维度——这些是后续所有分支感知检索功能的地基。
需要说明一个概念区分:这里的 “Branch Memory” 指的是给 observation(工具调用记忆)打上用户代码仓库的 git 分支元数据,与仓库文档 branches.mdx 中描述的 Claude-Mem 自身三条发布分支(main / core-dev / community-edge)是两回事,不要混淆。
背景与目标:为什么 Observation 需要分支元数据
Claude-Mem 的核心工作流是:Agent 会话期间的工具调用由 Hook 捕获,经 Worker 压缩后写入 SQLite(observations 表),再在未来会话中按需注入上下文。原始计划文档开宗明义地指出 Phase 01 的目标:
By the end, every new observation will be stored with its git branch name and commit SHA. This is the critical foundation that all subsequent phases build on.
也就是说,在 Phase 01 完成之前,存储层只知道“这个项目、这个会话里发生过什么”,却不知道“它发生在哪个分支、哪个提交上”。而真实开发中,同一个项目经常同时在 main、特性分支、detached HEAD 上工作,不同分支上的代码状态、甚至同名文件的内容都完全不同。若记忆不带分支坐标,跨分支检索时就会把 A 分支的结论错误注入 B 分支的上下文。Phase 01 因此被定位为“后续所有阶段的地基”:它不引入任何新的检索能力,只保证每条新 observation 落库时自带 branch 和 commit_sha 两个坐标。
从源码结构看,这条数据链路涉及四段代码:Hook CLI(src/cli/hook-command.ts、src/cli/handlers/observation.ts)、Worker 路由与 SessionManager(src/services/worker/http/routes/SessionRoutes.ts)、以及存储层(src/services/sqlite/observations/store.ts 与 src/services/sqlite/SessionStore.ts)。Phase 01 的五个任务恰好逐段覆盖这条链路。
任务一:Schema 迁移与 Observation 类型更新
迁移 24:为 observations 表增加两列
计划文档要求的新迁移方法 addObservationBranchColumns() 必须严格复刻仓库既有的迁移模式。当前代码中该模式的基准是 SessionStore.ts 中的 addSessionCustomTitleColumn()(migration 23),其结构是:
private addSessionCustomTitleColumn(): void {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(23);
const tableInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all();
const hasColumn = tableInfo.some(col => col.name === 'custom_title');
if (applied && hasColumn) return;
if (!hasColumn) {
this.db.run('ALTER TABLE sdk_sessions ADD COLUMN custom_title TEXT');
}
if (!applied) {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)')
.run(23, new Date().toISOString());
}
}
可以看到既有模式的三要素:版本号查询(schema_versions 表)、PRAGMA table_info 列存在性守卫(保证重复执行幂等)、先补列再记版本。计划文档要求 addObservationBranchColumns() 完全照此办理,版本号用 24,守卫对象换成 observations 表:
- 查
schema_versions是否已有 version 24; - 用
PRAGMA table_info(observations)分别守卫branch与commit_sha两列; ALTER TABLE observations ADD COLUMN branch TEXT;ALTER TABLE observations ADD COLUMN commit_sha TEXT;- 最后
INSERT OR IGNOREversion 24 进schema_versions; - 并在
runAllMigrations()的最后一个位置追加this.addObservationBranchColumns();调用。
调用位置“放在最后”并非随意:从当前 SessionStore 构造函数 的迁移链可以看到,各迁移严格按顺序串行执行(ensureWorkerPortColumn → … → addObservationContentHashColumn → addSessionCustomTitleColumn → …),新迁移追加到链尾可确保它看到前面迁移产生的全部结构,也避免打乱既有版本号的语义。
一点差异说明:计划文档把迁移 runner 定位在
src/services/sqlite/migrations/runner.ts;而在当前main线 checkout 中,迁移链实际内聚在 SessionStore.ts 的构造函数里(src/services/sqlite/migrations/目录并不存在)。实现时应以当前代码的实际组织为准,把新方法挂进现有迁移链。
类型层改动:三处接口各加字段
计划文档要求在 observation 类型文件中做三处更新(文档指定路径为 src/services/sqlite/observations/types.ts;当前 checkout 中 observation 模块由 store.ts、get.ts、recent.ts 等文件构成,类型定义的具体落点以实际代码为准):
| 类型 | 新增字段 | 说明 |
|---|---|---|
ObservationInput |
branch?: string、commit_sha?: string |
写入路径的入参,单值即可,一条 observation 只属于一个分支坐标 |
GetObservationsByIdsOptions |
branch?: string | string[]、commit_sha?: string | string[] |
查询选项。计划文档特别注明:数组支持是为后续阶段基于祖先关系做 IN 子句过滤预留的——例如一次传入某分支的所有祖先 commit SHA,即可圈定“该分支历史窗口内”的 observation |
AllRecentObservationRow |
branch?: string | null、commit_sha?: string | null |
近期 observation 行的返回类型,null 表示历史数据(迁移前写入的行)无分支信息 |
GetObservationsByIdsOptions 中“标量或数组”的二态设计是这条链路里最值得记住的一点:写入侧永远单值,读取侧允许集合,类型在入口处就把两种查询形态都表达了出来。
任务二:Git 分支检测工具 git-branch.ts
计划文档指定新建 src/services/integrations/git-branch.ts(该集成目录当前位于 src/services/integrations/,已有 11 个集成文件,git 检测工具放这里符合既有组织方式),对外导出:
export interface BranchInfo {
branch: string | null;
commitSha: string | null;
}
export async function detectCurrentBranch(cwd: string): Promise<BranchInfo>
函数内部执行两条 git 命令,都带 { cwd } 选项,把命令限定在会话所在仓库目录内:
- 分支名:
git rev-parse --abbrev-ref HEAD - 提交 SHA:
git rev-parse HEAD
计划文档对实现方式的要求是“先搜索现有代码库的 spawn 模式(Bun.spawn 或 child_process 用法),与项目约定保持一致”。这一点有明确的工程理由:Hook 进程的生命周期极短且 stdout/stderr 有严格纪律约束,src/cli/handlers/observation.ts 文件头部的注释即声明了这条纪律——
this handler is PURE. It returns a HookResult and MUST NOT call process.stderr.write / process.stdout.write / console.* / process.exit
四条硬性实现约束:
- 整体 try/catch,任何失败返回
{ branch: null, commitSha: null }。这是计划文档标注为 “Critical” 的一条:非 git 仓库、未安装 git、权限异常等任何情况都只能静默降级,绝不能让分支检测把 Hook 进程打崩。Hook 层 stderr 被抑制,一个未捕获异常会直接破坏整个 observation 上报。 - detached HEAD 特判:
git rev-parse --abbrev-ref HEAD在 detached HEAD 下会输出字面量"HEAD"。此时把branch置为null,但仍然捕获 commit SHA——detached 状态下 commit 坐标依然有效,只是没有分支名。 - 输出 trim:两条命令的 stdout 通常带尾部换行,必须去除空白后再赋值。
cwd参数化:不依赖调用进程自身的工作目录,由 Hook 输入的input.cwd传入,保证在 Claude Code 等宿主进程 cwd 与目标仓库不一致时依然检测正确。
这四条约束合起来使 detectCurrentBranch 成为一个“永不抛错、永不阻塞、结果可为空”的探测函数,恰好匹配 Hook 环境的容错要求。
任务三:Hook 层 → Worker POST 体的元数据透传
分支元数据进入链路的入口在 CLI Hook 侧,共三步:
第一步,扩展 Hook 输入类型。 在代码库中找到 interface NormalizedHookInput(当前 checkout 中定义于 src/cli/types.ts),新增两个可选字段 branch?: string 和 commitSha?: string。
第二步,在 Hook 命令入口执行检测。 在 src/cli/hook-command.ts 中,input.platform = platform; 赋值之后追加分支检测:
import { detectCurrentBranch } from '../services/integrations/git-branch.js';
// input.cwd 有值时才检测
if (input.cwd) {
const { branch, commitSha } = await detectCurrentBranch(input.cwd);
input.branch = branch;
input.commitSha = commitSha;
}
input.cwd 为真值才调用,与工具函数“永不抛错”的约定叠加,构成双保险:cwd 缺失时根本不触发 git 子进程。
第三步,把字段写进 POST 请求体。 在 src/cli/handlers/observation.ts 中,观察上报走 POST /api/sessions/observations。当前请求体字段为(见 dispatchToWorker):
{
contentSessionId: input.sessionId,
platformSource,
tool_name: input.toolName,
tool_input: input.toolInput,
tool_response: input.toolResponse,
cwd: input.cwd,
agentId: input.agentId,
agentType: input.agentType,
}
计划文档要求在此追加 branch: input.branch ?? null 和 commit_sha: input.commitSha ?? null。注意这里统一转为 ?? null 而非直接透传 undefined:JSON 序列化会丢弃 undefined 字段,而显式 null 能让 Worker 侧和存储层区分“检测失败/非 git 环境”(null)与“未检测到字段”。
任务四:Worker 内部透传 → 数据库落库
Worker 侧的改动分路由、链路与存储三层。
路由层:SessionRoutes.ts 的 handleObservationsByClaudeId 方法中,从 req.body 取出 branch 与 commit_sha,随数据对象一并传给 sessionManager.queueObservation()(作为第二个参数的数据结构中新增这两个字段)。
链路层:从 sessionManager.queueObservation 一路追踪到真正调用 storeObservation() 的位置,逐个中间文件(SessionManager → Agent/observation 处理器)在对应数据结构与接口上补齐 branch、commit_sha。计划文档在此强调了一条边界:branch 与 commit_sha 是元数据,应伴随 observation 内容传递,不得进入 SDK Agent 的 prompt——它们不参与 AI 压缩/摘要的语义生成,只是随行的检索坐标。
存储层:在 src/services/sqlite/observations/store.ts 中修改 storeObservation():
- 函数签名在
discoveryTokens参数之后追加branch?: string与commitSha?: string; - INSERT 的列清单从 15 列扩到 17 列,
branch, commit_sha加在created_at_epoch之后; - VALUES 的绑定数组追加
branch ?? null、commitSha ?? null,占位符同步增加两个?; - 最后核对占位符数量与列数严格一致——这是 SQLite 参数化查询最常见的低级错误点,计划文档特意单列了一条任务项。
内容去重哈希:为什么必须纳入 branch
同文件中还有一个容易被忽视但计划文档专门点名的小函数 computeObservationContentHash,当前实现是:
export function computeObservationContentHash(
memorySessionId: string,
title: string | null,
narrative: string | null
): string {
return createHash('sha256')
.update([memorySessionId || '', title || '', narrative || ''].join('\x00'))
.digest('hex')
.slice(0, 16);
}
它用 \x00 连接 memorySessionId、title、narrative 后取 SHA-256 前 16 位十六进制作为 observation 的内容指纹(数据库中另有 addObservationContentHashColumn 与唯一内容哈希索引支撑去重)。计划文档要求把哈希输入改为:
(memorySessionId || '') + (branch || '') + (title || '') + (narrative || '')
即 branch 插入到 memorySessionId 之后。动机写在任务描述里:防止跨分支去重误杀——同一个 memory session 中,开发者切分支后产生了内容完全相同的 observation(相同的标题与叙述文本),在 30 秒去重窗口内,若哈希不含 branch,第二条会被静默丢弃,其携带的“新分支坐标”随之丢失。把 branch 编入哈希后,不同分支上的相同内容各自入库,去重语义从“内容去重”细化为“内容 + 分支坐标去重”。
这个改动同时是“便宜的”:branch 为 null 时 '' + '' 不改变输入形态,历史行为在单分支场景下保持不变。
任务五:构建与验证
计划文档最后一步给出完整的验证闭环,可直接复制执行:
# 1. 编译全部 TypeScript 并同步到本地插件市场(同时重启 worker)
npm run build-and-sync
build-and-sync 是仓库既有的构建同步命令——branches.mdx 中运行非稳定分支时使用的正是它,它会构建、同步到本地 Claude Code 插件市场并重启 worker。构建中若出现 TypeScript 报错,先修复编译错误再谈验证。
# 2. 验证迁移实际生效:observations 表应出现两个新列
sqlite3 ~/.claude-mem/claude-mem.db "PRAGMA table_info(observations);"
预期输出中能看到 branch 与 commit_sha 两列(TEXT 类型)。最后确认 worker 能正常启动,排除回归。这三步分别验证了“编译正确”“schema 正确”“运行时正确”三个层面,对应 Phase 01 三个交付物。
当前仓库实现状态核对
为避免读者误以为本功能已在当前代码库生效,用仓库实际内容对照如下(以当前 checkout 为准):
| 计划项 | 当前 checkout 实际状态 | 证据路径 |
|---|---|---|
observations 表 branch/commit_sha 列(migration 24) |
未实现:当前迁移链中 version 24 已被 addSessionPlatformSourceColumn(sdk_sessions.platform_source)占用,observations 表尚无分支列 |
SessionStore.ts 迁移链、migration 24 现占用方 |
git-branch.ts 检测工具 |
未实现:src/services/integrations/ 下无该文件 |
src/services/integrations/ |
NormalizedHookInput.branch/commitSha 与 observation POST 体分支字段 |
未实现:POST 体当前仅含 contentSessionId、platformSource、tool_name、tool_input、tool_response、cwd、agentId、agentType |
observation.ts |
computeObservationContentHash 纳入 branch |
未实现:仍为三段输入(memorySessionId、title、narrative) |
store.ts |
对照上表可以推断:该功能在计划文档(全部任务已勾选)与当前 main 线代码之间存在差异,实现可能位于仓库的 core-dev / community-edge 开发分支上(仓库采用 三条发布分支 模型,运行时改动不直接进 main),或者迁移版本号需要在合并时重新分配(当前 version 24 已被占用,落地时应顺延为未占用的新版本号,并同样遵循“PRAGMA 守卫 + schema_versions 记账”的幂等模式)。这也反过来印证了 Phase 01 设计成“纯增量、可独立验证”的价值:它不改动任何既有查询路径,合并冲突面小,且每一步都可用 PRAGMA table_info 这类零成本命令验收。
小结与后续阶段的接口
Phase 01 交付的是一条端到端的元数据管道:detectCurrentBranch 在 Hook 入口以“永不抛错”的方式采集坐标,NormalizedHookInput → POST 体 → queueObservation → storeObservation 逐级透传,最终两列落库,并把 branch 编入内容哈希以修正跨分支去重语义。同时它给后续阶段留好了两个明确接口:GetObservationsByIdsOptions 的数组形态字段支撑分支祖先 IN 过滤,AllRecentObservationRow 的可空返回类型区分新旧数据。对仓库维护者而言,本文的“当前状态核对”表可以直接当作合并前的 diff checklist;对读者而言,这条链路展示的“迁移幂等模式 + Hook 容错纪律 + 元数据不入 prompt”三点做法,是 Claude-Mem 这类本地记忆系统中值得复用的工程范式。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00