☰
OpenChamber Project Context 源码解读:Notes、Todos 与 Plans 的服务端存储架构
2026/9/25 5:37:27 网站建设 项目流程
  • AI Agent
  • 人工智能
  • 代码智能体
  • 交互助手

【免费下载链接】openchamber

Agentic Development Environment based on OpenCode AI agent

项目地址:https://gitcode.com/gh_mirrors/op/openchamber
点击查看免费下载

本文以 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>.jsonpackages/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.jsonpackages/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_LENGTH3000单条笔记正文长度上限
PROJECT_NOTE_MAX_ITEMS200每个项目最多笔记条数
PROJECT_TODO_TEXT_MAX_LENGTH120单条待办文本长度上限
PROJECT_TODO_MAX_ITEMS500每个项目最多待办条数
PROJECT_PLAN_TITLE_MAX_LENGTH160计划标题长度上限
PROJECT_PLAN_BODY_MAX_LENGTH200_000计划正文(raw 文档)长度上限
PROJECT_PLAN_MAX_ITEMS500每个项目最多计划链接数

清洗时对超长内容执行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)的命名规则为:

  1. baseName =${createdAt}-${slugifyPlanTitle(title)};
  2. 目标文件 =${baseName}.md;
  3. 若与已有个人计划重名,则追加-1、-2数字后缀;
  4. 先写 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 中验证):

MethodRouteNotes
GET/api/project-context/:projectId完整上下文;文件缺失时返回200空数据
PUT/api/project-context/:projectId/todos整体替换 todo 列表;返回提交后的上下文
POST/api/project-context/:projectId/notes201;请求体{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/plans201;请求体{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。

不变式:九条让存储层可靠运行的铁律

模块文档列出的不变式是理解整套设计的钥匙,每条都能在源码中找到对应实现:

  1. 缺失不等于畸形。context.json缺失是权威的空数据;无法解析的 JSON 是故障,以500传播——这样客户端保留已有内容,而不是在磁盘上完好的数据上渲染空面板(readStoredContext见 runtime.js,测试见 runtime.test.js)。
  2. 写入按项目串行化。通过进程内锁(withWriteLock,runtime.js,以 projectId 为键的 Promise 链)串行化,并以写临时文件 + rename(writeJsonAtomic,runtime.js)落盘,崩溃不会留下半写文件。
  3. readContext永不取锁。每个修改者在已持锁状态下调用它,若在此加锁必然死锁。它能触发的遗留迁移在无锁下也安全:迁移的两次写入都是同内容的原子 rename,并发迁移收敛而不是交错(测试 runtime.test.js 用三个并发读验证收敛)。
  4. 计划创建先写 Markdown 再写 manifest;删除先删 manifest 再删文件。任一方向的半失败都只留下一个未被引用的 Markdown 文件(惰性无害);反过来则会留下一个渲染为计划却打不开的 manifest 条目。
  5. 计划更新接受整个 raw 文档,而不是 title + body。编辑器以原样持有文件;从解析出的部件重组会重写标题、重新格式化用户输入。保存后 manifest 标题从内容重新推导,且文件名不随标题变化——它是链接背后的稳定身份(测试 runtime.test.js 验证了重写标题后文件名不变)。
  6. 计划更新拒绝重建已删除的文件。若编辑器打开期间 Markdown 消失,链接已死;写入会复活用户以为已丢弃的内容,因此返回404(updatePlan先access探测文件存在性,见 runtime.js,测试见 runtime.test.js)。
  7. 笔记补丁只碰它点名的字段(详见前文,测试见 runtime.test.js)。
  8. 笔记正文可截断但不可清空;每项目上限 200 条,超出响亮失败(测试 runtime.test.js)。
  9. 逐条目清洗不会让整个读取失败。畸形 todo 或计划链接被丢弃,其余上下文照常加载(测试 runtime.test.js 验证了无 id 的 todo、../escape.md、无扩展名的 plan 被丢弃而正常条目保留)。

遗留迁移:从客户端配置文件搬入 context.json

projectNotes、projectTodos、projectPlanFiles三个键原本存放在<projectId>.json(有界名,见所有权表)。首次读取且没有context.json时,migrateFromLegacyConfig(runtime.js)执行一次性迁移:

  1. 检查客户端拥有的文件是否含这三个键,不含则直接跳过;
  2. 迁移 notes(字符串转单条manualnote)与 todos;
  3. 计划链接原来携带绝对路径:转换为 base name——已在 plans 目录内的文件就地使用;指向别处(早期项目 id 留下的过期路径)的文件复制进 plans 目录而非丢弃;Markdown 完全找不到的链接直接丢弃(反正也打不开);
  4. 先将context.json持久写入,之后才删除客户端文件中的三个遗留键——任何失败都只是让迁移下次读取时重跑,重复与并发读取收敛到相同内容;
  5. 其余键(如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

项目地址:https://gitcode.com/gh_mirrors/op/openchamber
点击查看免费下载

相关推荐

上一篇:SlackPirate代码解析:Python实现Slack API敏感信息提取的原理
下一篇:Cursor 接入 OpenViking:一条命令为 AI 编程助手装上跨会话长期记忆

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询