- AI Agent
- 人工智能
- 代码智能体
- 交互助手
【免费下载链接】openchamber
Agentic Development Environment based on OpenCode AI agent
本文以 OpenChamber 仓库中packages/web/server/lib/project-context模块为核心,系统讲解项目上下文(Project Context)的服务端存储设计:它如何为 Project Notes 面板提供自由笔记(notes)、待办(todos)与计划(plans)三类 Markdown 数据,如何通过"分文件 + 独占写权限 + 进程内锁 + 原子写"避免跨进程读写竞争,如何支持个人计划与团队共享计划的切换,以及整套 REST 路由与迁移机制的底层实现。读完本文,你将能理解该模块的每一个路径、字段、接口与不变式,并可直接对照源码进行二次开发或排障。
模块定位:服务端独占的项目上下文存储
Project Context 是 OpenChamber 服务端为 Project Notes 界面提供的存储层,承载三类自由格式数据:notes(笔记)、todos(待办)、plans(计划 Markdown 文件)。
一个关键的设计前提是:被托管的 Chats 根目录(~/.config/openchamber/chats)本身也是一个上下文拥有者。其下每个按日期组织的会话目录都会解析到该根目录,因此 Notes、Todo、Plans、置顶知识与项目记忆可以在普通会话之间共享,而无需把 Chats 注册为用户项目。这意味着 Project Context 的存储模型要同时服务"用户项目"与"会话根目录"两类场景。
从源码结构看,模块只包含四个文件:
- packages/web/server/lib/project-context/runtime.js —— 存储读写核心(锁、原子写、清洗、迁移、计划生命周期);
- packages/web/server/lib/project-context/routes.js —— REST 路由注册与参数校验;
- packages/web/server/lib/project-context/runtime.test.js —— 存储层单元测试;
- packages/web/server/lib/project-context/routes.http.test.js —— 端到端 HTTP 路由测试。
所有权模型:谁写哪个文件,一清二楚
该模块最关键的设计决策是严格划分每个存储文件的写入者。模块文档给出了一张权威的所有权表:
| 路径 | 拥有者 | 内容 |
|---|---|---|
<projectsDir>/<projectId>.json | packages/web/server/lib/projects(project-setup.js 负责/api/projects/:projectId/config背后的客户端所属键;project-config.js 负责version/scheduledTasks),两者共用一个写锁 | worktree 设置、draft starters、项目动作、定时任务 |
<projectsDir>/<projectId>/context.json | 本模块,独占 | notes、todos、plan manifest |
<projectsDir>/<projectId>/plans/*.md | 本模块,独占 | 计划正文 |
<projectsDir>/<projectId>/memory.json | packages/web/server/lib/agent-memory | 代理选择记住的关于项目的内容 |
<repo>/<plansDir>/*.md | 本模块(读、编辑、删除、移动),当团队配置指定了plansDir时;文件夹属于团队,任何工具都可写入 | 共享计划正文 |
为什么要拆分文件
文档明确指出:"拆分本身就是重点(The split is the point)"。历史上这两类数据同存于一个文件,由客户端做整文件读-改-写。如果服务端再加入该文件的写入,那么无关功能(项目动作、draft starters)就会在跨进程场景下覆盖笔记,而且没有任何一把锁能同时跨越客户端与服务端两侧。拆成独立文件等于消除了共享资源,而不是试图协调对它的访问。
在 runtime.js 的头部注释中同样强调:"服务端是<projectsDir>/<projectId>/context.json的唯一写入者";其兄弟文件<projectsDir>/<projectId>.json保持客户端拥有,仅在version/scheduledTasks上由服务端写入。模块还约定:本模块之外任何代码都不得写context.json或plans目录。
projectId 的有界命名:长路径不再触发 ENAMETOOLONG
所有权表中<projectId>并不是原始 id,而是由 project-id.js 的projectConfigFileStemOf给出的有界 stem:
export const projectConfigFileStemOf = (projectId) => { if (projectId.length <= MAX_PROJECT_CONFIG_FILE_STEM_LENGTH) return projectId; const digest = crypto.createHash('sha256').update(projectId, 'utf8').digest('hex'); return `${HASHED_PROJECT_CONFIG_FILE_STEM_PREFIX}${digest}`; };规则如下:
- id 本身最多 200 字符(
MAX_PROJECT_CONFIG_FILE_STEM_LENGTH = 200),在此范围内,文件夹名、配置文件与记忆文件直接以 id 命名; - 超过 200 字符(例如深度嵌套 checkout 产生的
path_<base64url>形式 id)则映射为path_sha256_<sha256 十六进制摘要>; - 文件夹、配置文件、记忆文件共享同一个 stem,本模块从不使用原始 id 拼接路径。
这个设计的必要性在于:path_<base64url>形式的 id 会随 checkout 路径变长,一个深嵌套项目会得到文件系统拒绝的文件名(ENAMETOOLONG)。文档特别强调:遗留迁移读取的是有界配置文件,原因相同——如果去读<raw id>.json,会以 ENAMETOOLONG 失败,从而把一个空项目变成错误。
MAX_PROJECT_CONFIG_FILE_STEM_LENGTH = 200的取值还有更细致的考虑:最长 stem.json必须能在 255 字节文件名限制内,为原子写临时后缀.tmp-<pid>-<ms>-<random>和.json.lock兄弟文件留出空间(id 是 ASCII,字符即字节)。测试 runtime.test.js 构造了长度超过 240 的 id,验证其映射到path_sha256_前缀且长度小于 100 的文件夹,并让 notes/todos/plans 在该有界文件夹中完整往返。
存储格式:Notes 是条目集合,不是一个大 blob
context.json使用 version 2 格式,文档给出了权威示例:
{ "version": 2, "notes": [{ "id": "", "body": "", "createdAt": 0, "updatedAt": 0, "source": "manual | selection | agent", "origin": { "sessionId": "", "messageId": "" } }], "todos": [{ "id": "", "text": "", "completed": false, "createdAt": 0 }], "plans": [{ "id": "", "file": "1700000000-title.md", "title": "", "createdAt": 0 }] }字段语义与清洗规则
- notes:每个 note 是独立条目。
source记录来源,仅允许manual、selection、agent三个取值(runtime.js 的NOTE_SOURCES集合,路由层 routes.js 的isValidNoteSource同样校验);origin把笔记回溯到被提炼的会话消息——{ sessionId, messageId },缺少sessionId的 origin 会被丢弃(sanitizeNoteOrigin,见 runtime.js)。 - todos:
text为待办文本,completed为完成标记。 - plans:
file存的是纯文件名(base name),绝不存路径(详见下文 Plans 小节)。
Version 1 → Version 2 的就地转换
Version 1 的 notes 是单个字符串。转换逻辑放在读取路径中而非独立迁移通道(sanitizeNotes,见 runtime.js):
- 字符串转换为单条
manualnote(id 形如note_legacy_<now>,createdAt/updatedAt为当前时间,pinned: false); - 空字符串转换为零条 notes;
- 这样做保证任何读取者——包括与写入者竞争的读取者——都看到同一形状。
测试 runtime.test.js 验证了这两种转换。旧文件中的遗留pinned字段可能残留,但会被忽略;附件所有权归属各会话的 metadata,不在这里。
读写时的长度与数量上限
runtime.js 顶部定义了一组硬性常量,清洗(sanitize)与创建逻辑都以此约束:
| 常量 | 值 | 作用对象 |
|---|---|---|
PROJECT_NOTE_BODY_MAX_LENGTH | 3000 | 单条笔记正文长度上限 |
PROJECT_NOTE_MAX_ITEMS | 200 | 每个项目最多笔记条数 |
PROJECT_TODO_TEXT_MAX_LENGTH | 120 | 单条待办文本长度上限 |
PROJECT_TODO_MAX_ITEMS | 500 | 每个项目最多待办条数 |
PROJECT_PLAN_TITLE_MAX_LENGTH | 160 | 计划标题长度上限 |
PROJECT_PLAN_BODY_MAX_LENGTH | 200_000 | 计划正文(raw 文档)长度上限 |
PROJECT_PLAN_MAX_ITEMS | 500 | 每个项目最多计划链接数 |
清洗时对超长内容执行clampLength截断(如测试 runtime.test.js 验证 5000 字符笔记被截为 3000)。排序上,notes 与 plans 均按createdAt降序(最新在前)。
Notes 与 Todos 分离写入:防止互相覆盖
Notes 与 todos 通过独立路由写入,这是一个刻意设计:
- todo 勾选操作不会把用户正在输入、尚未成形的笔记一起持久化;
- 代理(agent)写的笔记不会覆盖并发的 todo 变更。
从实现看,saveTodos在写锁内读取当前上下文,仅替换todos字段后整体写回(runtime.js);createNote/updateNote/deleteNote同样只触碰 notes 列表。测试 runtime.test.js 明确验证"保存 todos 不干扰 notes 与 plans"。
笔记补丁(PATCH)遵循只更新它点名的字段:
- 置顶只发送
pinned,因此不可能回滚两个请求之间刚落地的编辑;编辑也只发送body,不会重置置顶状态; - 编辑会递增
updatedAt,置顶不会——置顶不是对笔记内容的改变(updateNote见 runtime.js,测试见 runtime.test.js)。
另外两条笔记铁律:
- 正文可以被截断,但绝不能被清空:空正文直接拒绝而非存储,因为内容为空的笔记与用户没有发起的删除无法区分;
- 每个项目笔记上限 200 条:超出时创建会响亮地失败(抛
at most 200 notes),而不是静默淘汰最旧条目。
Plans:以文件名为身份标识
引用只存 base name
计划链接(plan link)在 manifest 中只存 base name,从不存路径。理由:
- 文件永远位于
<projectId>/plans/,因此移动项目存储目录不会让引用失效; - 调用方永远无法寻址目录之外的文件(配合
PLAN_FILE_PATTERN = /^[a-zA-Z0-9._-]+\.md$/校验,见 runtime.js)。
title被反规范化(denormalize)进 manifest,让"列出计划"只需一次读取而不是每条计划一次读取;而readPlan返回的是从文件解析出的标题——当两者不一致时,文件标题胜出。
文件命名:时间戳 + slug
createPlan(runtime.js)的命名规则为:
- baseName =
${createdAt}-${slugifyPlanTitle(title)}; - 目标文件 =
${baseName}.md; - 若与已有个人计划重名,则追加
-1、-2数字后缀; - 先写 Markdown 文件,再写 manifest 条目(顺序详见"不变式"小节)。
slugifyPlanTitle会把标题转为小写,去掉`*_#>[\](){}.!?,:;"'等字符、空白转连字符、压缩连续连字符,非法字符一律转-,最终结果为空则用plan。测试 runtime.test.js 验证了^\d+-my-plan\.md$命名、同毫秒并发创建不撞文件名等场景。
Markdown 标题解析
parsePlanMarkdown(runtime.js)负责从 raw 文档提取标题:
- 优先匹配文件开头的
#一级标题(支持 CRLF 归一化),标题截断到 160 字符,失败则回退为'Plan'; - 无标题时取首个非空行作为标题(去掉
#+前缀); - 空输入得到默认标题
Plan、空正文。
formatPlanMarkdown(创建时用)会把title/body重新拼成# 标题\n\n正文的标准形状。
共享计划(Shared Plans):团队文件夹与个人计划的双向流转
团队共享文件夹从哪来
仓库内计划文件夹的默认位置是.openchamber/plans(DEFAULT_PLANS_DIR,见 project-setup.js),团队可通过<repo>/.openchamber/project.json(SHARED_CONFIG_RELATIVE_PATH,见 project-setup.js)中的plansDir字段覆盖它。解析逻辑在 project-config.js 的resolveSharedPlansDir:
const resolveSharedPlansDir = async (projectID) => { const personalRaw = await readRawProjectConfigFromDisk(projectID); const projectPath = projectPathOf(projectID, personalRaw); if (!projectPath) return null; const shared = await readSharedProjectConfig(projectID, personalRaw); const relative = shared.status === 'ok' && shared.config.plansDir ? shared.config.plansDir : DEFAULT_PLANS_DIR; return path.join(projectPath, ...relative.split('/')); };要点:
- 自定义文件夹整体取代默认值(默认
.openchamber/plans不再被读取),在两个目录间移动文件是用户自己的职责; plansDir必须是仓库内的相对路径(normalizePlansDir拒绝绝对路径、盘符、..段,见 project-setup.js);- checkout 无法定位时返回
null,此时共享计划整体不可用。
共享计划的读取与寻址
readContext(runtime.js)把共享计划追加在个人计划之后:
- 共享文件夹中的每个
.md文件都是一份计划,id 形如shared:<file>(SHARED_PLAN_ID_PREFIX),除非 manifest 条目已认领该文件; - 共享计划标记
source: "shared",个人计划标记source: "personal"; - 共享计划的
createdAt取文件mtimeMs,标题每次列表都从文件解析(其他工具写的计划没有 manifest 条目); - 响应中报告
sharedPlansDir字段,值为共享文件夹的绝对路径或null。
listSharedPlans(runtime.js)只认PLAN_FILE_PATTERN匹配的普通文件,跳过被 manifest 认领(claimed)的文件,按 mtime 降序排列。
对共享计划的读写直接作用于文件本身:
readPlan('shared:<file>')/updatePlan/deletePlan都绕过 manifest,直接在共享文件夹操作;- update 原样写入 raw 文档,因此另一个工具写的计划能保持原有形状;
setPlanPinned对shared:计划一律返回404(共享计划没有 pin 状态)。
share / unshare:保持 id 的文件夹迁移
sharePlan(runtime.js)把个人计划移入共享文件夹:
- 计划保留原 id:manifest 条目仍在,只是打上
shared: true标记(记录文件在哪个文件夹),因此已附加该计划的会话依然能找到它; - 文件在共享文件夹中以原 id 列出,而非
shared:<file>; - 目标文件夹存在同名文件时,用
freeFileNameIn追加数字后缀; - 移动用
moveFile(runtime.js):优先rename,跨设备(EXDEV)时回退为copyFile+ 删除源; - 只有 checkout 无法定位(无共享文件夹)时才拒绝共享,此时抛
shared plans folder is required(400)。
unsharePlan(runtime.js)是反向操作:
- 用户移过的计划(带 id)移回并清除
shared标记; - 只活在团队文件夹的计划(
shared:<file>)在移入时获得一个 manifest 条目和新 id; - 个人文件夹出现同名文件时同样加后缀;
- 测试 runtime.test.js 完整覆盖了"share 后 id 存活、unshare 带回、同名加后缀、外来计划收养获得 id"等场景。
REST 路由:逐路由挂载 JSON 解析器的教训
完整路由表如下(状态码与语义均可在 routes.js 与 routes.http.test.js 中验证):
| Method | Route | Notes |
|---|---|---|
| GET | /api/project-context/:projectId | 完整上下文;文件缺失时返回200空数据 |
| PUT | /api/project-context/:projectId/todos | 整体替换 todo 列表;返回提交后的上下文 |
| POST | /api/project-context/:projectId/notes | 201;请求体{body, source?, origin?} |
| PATCH | /api/project-context/:projectId/notes/:noteId | 补丁body;遗留pinned输入被会话知识忽略;未知返回404 |
| DELETE | /api/project-context/:projectId/notes/:noteId | 未知返回404 |
| PATCH | /api/project-context/:projectId/plans/:planId | 仅遗留项目置顶状态({pinned: boolean});会话附加使用会话知识;未知返回404 |
| GET | /api/project-context/:projectId/plans/:planId | 链接或其 Markdown 消失时返回404 |
| POST | /api/project-context/:projectId/plans | 201;请求体{title, body},绝不接受路径 |
| PUT | /api/project-context/:projectId/plans/:planId | 接受整个{raw}文档;链接或 Markdown 消失返回404 |
| DELETE | /api/project-context/:projectId/plans/:planId | 未知返回404 |
| POST | /api/project-context/:projectId/plans/:planId/share | 将计划移入共享文件夹;无共享文件夹返回400,未知返回404 |
| POST | /api/project-context/:projectId/plans/:planId/unshare | 将shared:计划移回;未知返回404 |
没有全局 JSON 解析器
文档记录了一个真实的踩坑教训:body 解析按路由逐个挂载。该服务没有全局express.json(),因为通用的 OpenCode 代理需要保留未读的请求流——core-routes只解析一份/api路径前缀白名单,其余/api请求原样放行。后果是:任何写路由忘了express.json(),req.body就是undefined,所有请求都会被当成畸形 body 拒绝——这正是它曾经真实上线过的故障。
routes.js 的做法是定义const parseJsonBody = express.json({ limit: '1mb' }),然后显式挂到每个写路由上。而 routes.http.test.js 特意把路由挂载到裸 express 应用(不添加全局 JSON 解析器),"与生产环境完全一致"——这样缺失 body 解析器的失败会在测试套件里暴露,而不是落到用户头上。测试文件头部注释明确记载了这段历史:早期单测直接调用 handler,能覆盖状态码映射却看不见中间件,盲区导致真实 bug 上线。
projectId 校验与错误码映射
projectId必须匹配/^[a-zA-Z0-9._:-]+$/(runtime.js),拒绝分隔符与路径穿越。测试 runtime.test.js 验证../escape、a/b均被拒绝,空 id 报projectId is required。
错误映射规则(routes.js):
- 校验类错误(消息含
is required或unsupported characters)→400; - 畸形存储数据与 I/O 故障 →
500。
routes.http.test.js验证了这些映射:穿越 projectId 返回 400(第 273-281 行)、畸形存储上下文返回 500 而不是空数据(第 262-271 行)、未知 note/plan 的各种 404、非法 source/pinned/raw 的各种 400。
不变式:九条让存储层可靠运行的铁律
模块文档列出的不变式是理解整套设计的钥匙,每条都能在源码中找到对应实现:
- 缺失不等于畸形。
context.json缺失是权威的空数据;无法解析的 JSON 是故障,以500传播——这样客户端保留已有内容,而不是在磁盘上完好的数据上渲染空面板(readStoredContext见 runtime.js,测试见 runtime.test.js)。 - 写入按项目串行化。通过进程内锁(
withWriteLock,runtime.js,以 projectId 为键的 Promise 链)串行化,并以写临时文件 + rename(writeJsonAtomic,runtime.js)落盘,崩溃不会留下半写文件。 readContext永不取锁。每个修改者在已持锁状态下调用它,若在此加锁必然死锁。它能触发的遗留迁移在无锁下也安全:迁移的两次写入都是同内容的原子 rename,并发迁移收敛而不是交错(测试 runtime.test.js 用三个并发读验证收敛)。- 计划创建先写 Markdown 再写 manifest;删除先删 manifest 再删文件。任一方向的半失败都只留下一个未被引用的 Markdown 文件(惰性无害);反过来则会留下一个渲染为计划却打不开的 manifest 条目。
- 计划更新接受整个 raw 文档,而不是 title + body。编辑器以原样持有文件;从解析出的部件重组会重写标题、重新格式化用户输入。保存后 manifest 标题从内容重新推导,且文件名不随标题变化——它是链接背后的稳定身份(测试 runtime.test.js 验证了重写标题后文件名不变)。
- 计划更新拒绝重建已删除的文件。若编辑器打开期间 Markdown 消失,链接已死;写入会复活用户以为已丢弃的内容,因此返回
404(updatePlan先access探测文件存在性,见 runtime.js,测试见 runtime.test.js)。 - 笔记补丁只碰它点名的字段(详见前文,测试见 runtime.test.js)。
- 笔记正文可截断但不可清空;每项目上限 200 条,超出响亮失败(测试 runtime.test.js)。
- 逐条目清洗不会让整个读取失败。畸形 todo 或计划链接被丢弃,其余上下文照常加载(测试 runtime.test.js 验证了无 id 的 todo、
../escape.md、无扩展名的 plan 被丢弃而正常条目保留)。
遗留迁移:从客户端配置文件搬入 context.json
projectNotes、projectTodos、projectPlanFiles三个键原本存放在<projectId>.json(有界名,见所有权表)。首次读取且没有context.json时,migrateFromLegacyConfig(runtime.js)执行一次性迁移:
- 检查客户端拥有的文件是否含这三个键,不含则直接跳过;
- 迁移 notes(字符串转单条
manualnote)与 todos; - 计划链接原来携带绝对路径:转换为 base name——已在 plans 目录内的文件就地使用;指向别处(早期项目 id 留下的过期路径)的文件复制进 plans 目录而非丢弃;Markdown 完全找不到的链接直接丢弃(反正也打不开);
- 先将
context.json持久写入,之后才删除客户端文件中的三个遗留键——任何失败都只是让迁移下次读取时重跑,重复与并发读取收敛到相同内容; - 其余键(如
projectPath、setup-worktree、projectActions)原样保留。
测试 runtime.test.js 覆盖了:三个键移出且其余保留、外部路径文件被回收进 plans 目录、markdown 已消失的链接被丢弃、无上下文键时不执行、重复读幂等、并发读收敛;runtime.test.js 还验证了有界配置文件中遗留键的迁移。
跨模块契约:settings-runtime 的项目 id 变更合并
项目 id 变更时,packages/web/server/lib/opencode/settings-runtime.js 的mergeProjectContextFiles负责合并项目存储:
- 它必须先于
moveDirectoryContents执行,因为后者只把文件改名进空闲目标,若目标已有context.json,旧目录的内容会被静默丢弃; - 合并按身份(id)合并每个列表,两侧都不丢条目;notes 的合并还处理一侧仍是 version 1 字符串的情况(保留列表侧、两侧皆字符串时优先目标侧);
- 它刻意不把 version 1 字符串 note 转成条目:这个转换属于 project-context 模块的所有权,在两处实现等于同一迁移的两种定义。
同时,mergeProjectConfigData仍然合并遗留的projectNotes/projectTodos/projectPlanFiles键——这是刻意为之:尚未迁移的项目数据仍在<projectId>.json中,迁移会在合并后的目标上随后接手。settings-runtime.js中还有migrateRawIdStorageFolder(第 319-339 行),负责把早期构建用裸 id 建立的存储文件夹(201-255 字符)迁入有界文件夹。
测试体系与运维启示
模块测试分两层,定位互补:
- runtime.test.js —— 存储层:清洗、迁移、锁、计划生命周期、共享计划流转、长 id 边界;
- routes.http.test.js —— 路由层:状态码映射、payload 校验、故障浮现,特意在无全局 JSON 解析器的裸 express 上运行。
值得运维与二次开发者注意的实践结论:
- 排查笔记/待办/计划异常时,先分清"文件缺失(权威空)"与"JSON 畸形(500)"两种状态;
- 任何对该模块的写路径改动,都应保持"锁内读取 + 临时文件 rename"的原子写模式;
- 新增写路由时,必须显式挂
parseJsonBody,否则会复现"req.body 为 undefined、一切写请求 400"的历史故障; - 计划文件名的稳定性是链接语义的基础,不要在标题变化时重命名文件。
整个模块的边界一句话总结:notes/todos/plans 是服务端独占的项目上下文,通过文件拆分、锁与原子写消除跨进程竞争,通过 base-name 引用与共享文件夹机制支撑个人与团队计划的协同。对照本仓库的 DOCUMENTATION.md、runtime.js、routes.js 与两份测试,即可完整还原这套存储架构的设计与实现全貌。
- AI Agent
- 人工智能
- 代码智能体
- 交互助手
【免费下载链接】openchamber
Agentic Development Environment based on OpenCode AI agent
相关推荐
OpenChamber 项目上下文面板(Project Context Panel)深度解析:Notes、Todos、Plans 与 Agent Memory 的工程实现
OpenChamber 项目上下文面板(Project Context Panel)深度解析:Notes、Todos、Plans 与 Agent Memory
AI Agent人工智能代码智能体交互助手Gitpod 源码库的 Active Context 解读:从 Memory Bank 体系看服务端架构、稳定性改造与开发工作流
Gitpod 源码库的 Active Context 解读:从 Memory Bank 体系看服务端架构、稳定性改造与开发工作流 导读 memory bank/
开发工具后端云原生Firefox Send后端服务:Express.js与多存储引擎架构
Firefox Send后端服务:Express.js与多存储引擎架构 Firefox Send的后端服务采用了基于Express.js的高度模块化架构设计,结
后端前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考