内容
# sofar
基于事件溯源的编码代理内存。
真相存储在每个倡议的只追加事件日志中(`.sofar/initiatives/<slug>/events.jsonl`)。会话读取的所有内容 — `plan.md`、`decisions.md`、每个会话的摘要、会话开始时注入的状态块 — 都是该日志的生成投影,每次追加时重新生成,从不手动编辑。会话从记录中定位,记录决策和任务变更为事件,同时工作,并写入摘要 + 下一步动作,然后结束 — 以便下一个会话(任何工具、任何模型、零上下文)无需询问即可继续。
## 安装
```bash
npm install -g @alignlabs/sofar # 安装 `sofar` 命令
```
或一次性:`npx @alignlabs/sofar <command>`。需要 Node ≥ 18。该软件包安装时没有任何运行时依赖项 — `dist/cli.js` 是完全捆绑的。
使用 `sofar upgrade` 更新现有的安装(`--check` 预览,`--dry-run` 打印确切的命令)。它从正在运行的二进制文件解析实际安装前缀,因此它会更新实际位于您的 `PATH` 中的副本 — 即使普通的 `npm install -g` 也会错过自定义前缀。
从检出开始:
```bash
npm install
npm run build
cd packages/engine
npm pack
npm install -g ./alignlabs-sofar-<version>.tgz
```
## 快速入门
```bash
cd your-repo
sofar init # 搭建 .sofar/,安装钩子和注册
sofar new my-feature --goal "..." # 创建一个倡议,绑定到当前分支
sofar status # 折叠日志:目标、进度、阶段树、下一个动作
```
每个会话的工作循环:
1. 会话开始 → 从注入的状态上下文(或 `sofar status`)定位。
2. 工作期间 → 追加事件:任务状态变更、决策、笔记;文件/命令事件通过钩子机械地落地。
3. 结束前 → 写回 `session_ended`(摘要 + 下一步动作)。在 Claude Code 下,停止钩子会阻止跳过此步骤的会话。
## 团队
```bash
# 每个人,一次:
sofar init
git add .sofar .gitattributes .claude .mcp.json CLAUDE.md AGENTS.md
git commit -m "采用 sofar"
# 每个开发人员:
npm install -g @alignlabs/sofar
git pull
sofar status # 共享记录,在本地折叠
```
无需服务,无需服务器:记录是 git 中的文件,每个克隆都折叠相同的日志到相同状态。
- **合并不能在真相上冲突。** `init` 在 `.gitattributes` 中标记事件日志 `merge=union`,因此同时向一个倡议追加的分支可以干净地合并。联合合并(保留双方的线路,顺序任意)是安全的,因为日志是只追加的,并且折叠按 ulid id 顺序重放事件 — 文件中的线路顺序不携带任何意义,因此对相同事件的任何合并都折叠到相同状态。生成的投影可能仍会在文本上冲突;取任意一方 — 它们将在下一次追加时从日志中重建。
- **事件携带其作者。** 每个事件都带有 `user` 从 `git config user.email` 在追加时的时间戳,因此归因在合并和导入中存活;没有配置电子邮件的机器只省略该字段。
## 同步(sofar-cloud)
可选,开启前:通过 [api.sofar.sh](https://sofar.sh) 推送/拉取记录,而不是(或与)git 一起。
```bash
sofar login # 设备流:代码 + 浏览器批准
sofar link --org <org> # 绑定此仓库;写入 .sofar/remote.json — 提交它
sofar push [--all] # 发送事件(ulid 顺序,按 id幂等)
sofar pull [--all|--watch] # 获取事件;--watch 在门铃上持续拉取
```
本地工作从未阻塞同步:当 API 无法访问时,`push` 礼貌地失败,日志的未确认尾部简单地等待 — 下一次推送用零损失和无重复的事件排空它(事件在双方按 id 去重)。`.sofar/remote.json` 是唯一属于 git 的同步文件;凭证存储在 `~/.config/sofar/credentials.json`(0600)和 `~/.local/state/sofar/` 下的每个克隆光标。
## 集成表面
`sofar init` 连接所有三个;工具使用它能用的那个。
**MCP 服务器**(Claude Code,任何 MCP 客户端)。在 `.mcp.json` 中注册为 `sofar mcp`(stdio)。工具:`sofar_get_state`、`sofar_start_session`、`sofar_end_session`、`sofar_update_task`、`sofar_log_decision`、`sofar_update_plan`、`sofar_add_note`。每个工具都是验证有效载荷 → 追加事件 → 再生投影;除了通过事件外,状态不发生突变。
更喜欢连接到正在运行的守护进程而不是为每个会话启动服务器?`sofar serve` 还通过流式 HTTP 在 `/mcp` (仅限 localhost)公开相同的七个工具。可以通过替换 `.mcp.json` 中的 stdio 条目来选择:
```json
{
"mcpServers": {
"sofar": { "type": "http", "url": "http://127.0.0.1:4173/mcp" }
}
}
```
stdio 仍然是默认的 — `sofar init` 始终注册它,两个传输返回相同的结果。如果守护进程没有运行,HTTP 连接会立即被拒绝(从不挂起):启动 `sofar serve`(例如,在进程管理器下)或切换回 stdio 条目。
**钩子垫片**(Claude Code)。安装到 `.claude/hooks/` 的独立脚本,并在 `.claude/settings.json` 中注册;它们不包含逻辑 — 每个调用 CLI。
- `SessionStart` — 在日志中注册会话,并打印注入的上下文(硬限制 10,000 字符)作为状态投影,包括来自 `.sofar/repo.md` 的预算“存储库内存”部分(如果有内容)。在冷会话的 `--resume` 上(记录在最长缓存 TTL 后空闲,记录值得重新预热),一行建议的行在块命名估计的重新预热成本和更便宜的重新启动替代方案之前。
- `UserPromptSubmit` — 批量完成提示:当 ≥5 记录事件在最后一次写回后累积时,每个新提示都带有建议 `sofar_end_session` 的一行上下文,同时上下文温暖,以便停止门成为回退而不是强制额外回合。
- `PostToolUse` — 为 Edit/Write/MultiEdit/Bash 调用追加机械的 `file_touched` / `command_run` 事件。
- `Stop` — 写回门:如果会话未追加 `session_ended`,退出 2 并阻止完成,直到完成(通过 `stop_hook_active` 循环保护)。
- `SessionEnd` — 机械关闭标记,仅作为回退。
**AGENTS.md 方言**(没有 MCP 或钩子的工具 — OpenCode、Codex、普通 shell)。`init` 在 `AGENTS.md` 中安装一个协议块,通过 CLI 单独驱动相同的循环:使用 `sofar status` 定位,然后每个写入一个 `sofar event append --type <event_type> --payload '<json>'` — 会话开始、任务更改、决策和强制 `session_ended` 写回。没有钩子可以阻止这些工具,因此约定将写回作为强制的。
**库**(在此格式上构建的服务和工具)。在同一个包上键入 ESM 子路径导出 — 导入它们不会运行 CLI 代码:
```ts
import { validateEnvelope, type EventEnvelope } from '@alignlabs/sofar/schema'
import { foldLines, exportNDJSON } from '@alignlabs/sofar/engine'
import { pushStream, pullStream, runDoorbell } from '@alignlabs/sofar/client'
```
`/schema` 是格式层:v1 信封类型、宽容的 `validateEnvelope` 保护(验证 — 从不抛出或修复)、`makeEvent` 和每个事件有效载荷类型 + 验证器。`/engine` 是状态层:确定性的 ulid 顺序折叠(与 CLI 使用的完全相同 — 消费者的状态始终与相同日志上的 `sofar status` 匹配)、`InitiativeState` 及其派生类,以及光标基元(`exportNDJSON` / `importNDJSON` — 同步接口)。`/client` 是 CLI 自身运行的 sofar-cloud 同步客户端 — 设备流登录、链接、推送/拉取、门铃 — 用于 shell 和以编程方式同步的应用程序。类型是自包含的;使用捆绑器样式的模块解析。
## 记录布局
```
.sofar/
repo.md # 存储库范围的内存(手动编写,非生成)
bindings.json # { "<git-branch-or-worktree>": "<slug>" }
initiatives/<slug>/
events.jsonl # 真相 — 只追加
plan.md # 生成的投影
decisions.md # 生成的投影
sessions/<session-id>.md # 生成的每个会话摘要
```
事件是不可变的单行 JSON,具有稳定的信封(ulid id,原子 O_APPEND 写入);修正是引用目标 id 的新事件,而不是重写。投影是原子写入的(临时文件 + 重命名),因此读取者永远不会看到半写入的文件。
## CLI
```bash
sofar init [--statusline] 使存储库准备好 sofar(幂等);--statusline 连接租赁表
sofar new <slug> [--goal] 创建一个倡议,绑定当前分支
sofar switch <slug> 重新绑定当前分支
sofar status [slug] 折叠并打印整个倡议树
sofar export [slug] [--since] 事件日志为 NDJSON(同步光标基元)
sofar import <file|-> [slug] 追加缺少的事件,按 id 去重
sofar login [--scopes read] 登录到 api.sofar.sh(设备流),存储机器令牌
sofar link --org <org> 将存储库绑定到云组织/存储库(.sofar/remote.json,可提交)
sofar push [slug|--all] 将事件推送到链接的存储库(幂等,离线安全)
sofar pull [slug|--all] 从光标以来拉取事件;--watch 跟随门铃
sofar event append ... 验证的单事件追加(对接表面)
sofar statusline Claude Code 状态行的租赁表(stdin JSON → 一行)
sofar serve [--port] 本地 JSON 状态 + SSE 更改 + /mcp(仅限 127.0.0.1)
sofar mcp [--root] stdio MCP 服务器(默认注册;/mcp 在 serve 上是可选的)
sofar upgrade [version] 自更新全局安装(--check,--dry-run)
```
## 合同
权威合同 — 信封、事件类型、状态形状、工具签名、钩子行为、验收标准 — 位于 [docs/SPEC.md](docs/SPEC.md)。磁盘记录格式 — 一个符合要求的第三方读取器或写入器 `.sofar/` 必须实现,引擎不是必需的 — 在 [docs/FORMAT.md](docs/FORMAT.md) 中指定。该存储库使用相同的协议跟踪其自身开发 — 在其自己的 `.sofar/` 记录中自我托管。
## 代币经济学
两个不同的问题控制了记录对代理的成本,sofar 将它们视为两个杠杆:
**杠杆 A — 价格和延迟:提示缓存。** 缓存是代理运行时的作业,但 sofar 构建良好,适合缓存前缀。状态块在会话开始时注入,一次,七个 MCP 工具定义总共约 1k 个代币 — 两者都落在前缀中,并在第一次请求后停止计费。要保持这种状态:一次从摘要中定位,当在会话中途需要历史记录时,从磁盘读取生成的投影(`plan.md`、`decisions.md`、`sessions/<id>.md`)而不是将状态转储重新拉入对话。
**杠杆 B — 上下文窗口占用。** 缓存不会减少模型必须关注的代币,因此每个 sofar 读取表面都经过预算:
- `sofar_get_state` 默认情况下为摘要密集型摘要(~1-2k 代币);完整的折叠状态(通常比成熟记录上的 10 倍)通过 `view: "full"` 仍然可用。
- 注入的状态块带有 10,000 字符的硬上限,每个部分都有预算和计数上限;已完成阶段折叠为一行摘要。
- 细节从不被删除,只被重新定位 — 完整的记录在事件日志及其投影中存活。
有意从不修剪的一件事:**理由**。摘要保留最近的决策及其选择/超过/因为,加上被拒绝的接近日志,列出每个决策被拒绝的替代方案。恢复测试表明,删除被拒绝的替代方案正是使新鲜代理自信地重新提出记录已经排除的死端 — 区块中最便宜的代币是那些停止重复工作的代币。
**让成本显现。** 两个表面将不可见的运行成本转化为可见的时刻,两个表面都是只读的,并且在没有单个模型调用的情况下计算(一个命名的不变式 — sofar 持有 no API 密钥并且不向任何地方发送任何内容):
- *冷恢复建议*(自动):恢复一个记录已经闲置超过最长缓存 TTL 的会话,会以全额输入价格重新预热整个记录。SessionStart 注入说明了这一点 — 具有估计令牌成本和新鲜启动替代方案的一行 — 并且在温暖或小型恢复时保持沉默。
- *租赁表*(可选):`sofar statusline` 渲染模型 + 目录 ⎇ 分支(Claude Code 默认状态行显示的段 — 自定义状态行取代默认状态行,因此 sofar 将它们向前传递),记录进度,会话成本和缓存读取份额的输入代币 — 运行健康稳定前缀的会话通常为 50-80%(绿色 `✓`);低于 30%(红色 `⚠`)意味着某些东西正在搅拌您的提示前缀;上下文 % 通过黄色(≥70%)变为红色(≥90%)随着压缩接近。默认为样式化的状态栏;`--no-color` 提供纯文本行。将它与 `sofar init --statusline` 连接(仅当 `.claude/settings.json` 没有 statusLine 时合并 — 现有的状态行,例如您的个人 `~/.claude` 状态行,闪耀着光芒),或手动:
```json
{ "statusLine": { "type": "command", "command": "sofar statusline" } }
```
## 构建工具卫生
记录(`.sofar/`)是存储库中提交的散文 — 计划、决策、笔记。扫描整个树以查找内容的工具可以摄取它。已知案例:
**Tailwind v4** 自动扫描每个非 gitignore 的文件以查找类候选项,并可以从类似代码的散文中生成无效 CSS。 在 `globals.css` 中排除记录:
```css
@source not "../.sofar";
```
相同的原理适用于具有树范围 glob 的任何扫描器:将它指向远离 `.sofar/`。