ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

OpenChamber Project Context 源码解读:Notes、Todos 与 Plans 的服务端存储架构

OpenChamber Project Context 源码解读:Notes、Todos 与 Plans 的服务端存储架构 AI Agent人工智能代码智能体交互助手【免费下载链接】openchamberAgentic 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/projectsproject-setup.js 负责/api/projects/:projectId/config背后的客户端所属键project-config.js 负责version/scheduledTasks两者共用一个写锁worktree 设置、draft starters、项目动作、定时任务projectsDir/projectId/context.json本模块独占notes、todos、plan manifestprojectsDir/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给出的有界 stemexport 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 是条目集合不是一个大 blobcontext.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。todostext为待办文本completed为完成标记。plansfile存的是纯文件名base name绝不存路径详见下文 Plans 小节。Version 1 → Version 2 的就地转换Version 1 的 notes 是单个字符串。转换逻辑放在读取路径中而非独立迁移通道sanitizeNotes见 runtime.js字符串转换为单条manualnoteid 形如note_legacy_nowcreatedAt/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.jscreateNote/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返回的是从文件解析出的标题——当两者不一致时文件标题胜出。文件命名时间戳 slugcreatePlanruntime.js的命名规则为baseName ${createdAt}-${slugifyPlanTitle(title)}目标文件 ${baseName}.md若与已有个人计划重名则追加-1、-2数字后缀先写 Markdown 文件再写 manifest 条目顺序详见不变式小节。slugifyPlanTitle会把标题转为小写去掉*_#[\](){}.!?,:;等字符、空白转连字符、压缩连续连字符非法字符一律转-最终结果为空则用plan。测试 runtime.test.js 验证了^\d-my-plan\.md$命名、同毫秒并发创建不撞文件名等场景。Markdown 标题解析parsePlanMarkdownruntime.js负责从 raw 文档提取标题优先匹配文件开头的#一级标题支持 CRLF 归一化标题截断到 160 字符失败则回退为Plan无标题时取首个非空行作为标题去掉#前缀空输入得到默认标题Plan、空正文。formatPlanMarkdown创建时用会把title/body重新拼成# 标题\n\n正文的标准形状。共享计划Shared Plans团队文件夹与个人计划的双向流转团队共享文件夹从哪来仓库内计划文件夹的默认位置是.openchamber/plansDEFAULT_PLANS_DIR见 project-setup.js团队可通过repo/.openchamber/project.jsonSHARED_CONFIG_RELATIVE_PATH见 project-setup.js中的plansDir字段覆盖它。解析逻辑在 project-config.js 的resolveSharedPlansDirconst 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.jscheckout 无法定位时返回null此时共享计划整体不可用。共享计划的读取与寻址readContextruntime.js把共享计划追加在个人计划之后共享文件夹中的每个.md文件都是一份计划id 形如shared:fileSHARED_PLAN_ID_PREFIX除非 manifest 条目已认领该文件共享计划标记source: shared个人计划标记source: personal共享计划的createdAt取文件mtimeMs标题每次列表都从文件解析其他工具写的计划没有 manifest 条目响应中报告sharedPlansDir字段值为共享文件夹的绝对路径或null。listSharedPlansruntime.js只认PLAN_FILE_PATTERN匹配的普通文件跳过被 manifest 认领claimed的文件按 mtime 降序排列。对共享计划的读写直接作用于文件本身readPlan(shared:file)/updatePlan/deletePlan都绕过 manifest直接在共享文件夹操作update 原样写入 raw 文档因此另一个工具写的计划能保持原有形状setPlanPinned对shared:计划一律返回404共享计划没有 pin 状态。share / unshare保持 id 的文件夹迁移sharePlanruntime.js把个人计划移入共享文件夹计划保留原 idmanifest 条目仍在只是打上shared: true标记记录文件在哪个文件夹因此已附加该计划的会话依然能找到它文件在共享文件夹中以原 id 列出而非shared:file目标文件夹存在同名文件时用freeFileNameIn追加数字后缀移动用moveFileruntime.js优先rename跨设备EXDEV时回退为copyFile 删除源只有 checkout 无法定位无共享文件夹时才拒绝共享此时抛shared plans folder is required400。unsharePlanruntime.js是反向操作用户移过的计划带 id移回并清除shared标记只活在团队文件夹的计划shared:file在移入时获得一个 manifest 条目和新 id个人文件夹出现同名文件时同样加后缀测试 runtime.test.js 完整覆盖了share 后 id 存活、unshare 带回、同名加后缀、外来计划收养获得 id等场景。REST 路由逐路由挂载 JSON 解析器的教训完整路由表如下状态码与语义均可在 routes.js 与 routes.http.test.js 中验证MethodRouteNotesGET/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输入被会话知识忽略未知返回404DELETE/api/project-context/:projectId/notes/:noteId未知返回404PATCH/api/project-context/:projectId/plans/:planId仅遗留项目置顶状态{pinned: boolean}会话附加使用会话知识未知返回404GET/api/project-context/:projectId/plans/:planId链接或其 Markdown 消失时返回404POST/api/project-context/:projectId/plans201请求体{title, body}绝不接受路径PUT/api/project-context/:projectId/plans/:planId接受整个{raw}文档链接或 Markdown 消失返回404DELETE/api/project-context/:projectId/plans/:planId未知返回404POST/api/project-context/:projectId/plans/:planId/share将计划移入共享文件夹无共享文件夹返回400未知返回404POST/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。写入按项目串行化。通过进程内锁withWriteLockruntime.js以 projectId 为键的 Promise 链串行化并以写临时文件 renamewriteJsonAtomicruntime.js落盘崩溃不会留下半写文件。readContext永不取锁。每个修改者在已持锁状态下调用它若在此加锁必然死锁。它能触发的遗留迁移在无锁下也安全迁移的两次写入都是同内容的原子 rename并发迁移收敛而不是交错测试 runtime.test.js 用三个并发读验证收敛。计划创建先写 Markdown 再写 manifest删除先删 manifest 再删文件。任一方向的半失败都只留下一个未被引用的 Markdown 文件惰性无害反过来则会留下一个渲染为计划却打不开的 manifest 条目。计划更新接受整个 raw 文档而不是 title body。编辑器以原样持有文件从解析出的部件重组会重写标题、重新格式化用户输入。保存后 manifest 标题从内容重新推导且文件名不随标题变化——它是链接背后的稳定身份测试 runtime.test.js 验证了重写标题后文件名不变。计划更新拒绝重建已删除的文件。若编辑器打开期间 Markdown 消失链接已死写入会复活用户以为已丢弃的内容因此返回404updatePlan先access探测文件存在性见 runtime.js测试见 runtime.test.js。笔记补丁只碰它点名的字段详见前文测试见 runtime.test.js。笔记正文可截断但不可清空每项目上限 200 条超出响亮失败测试 runtime.test.js。逐条目清洗不会让整个读取失败。畸形 todo 或计划链接被丢弃其余上下文照常加载测试 runtime.test.js 验证了无 id 的 todo、../escape.md、无扩展名的 plan 被丢弃而正常条目保留。遗留迁移从客户端配置文件搬入 context.jsonprojectNotes、projectTodos、projectPlanFiles三个键原本存放在projectId.json有界名见所有权表。首次读取且没有context.json时migrateFromLegacyConfigruntime.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人工智能代码智能体交互助手【免费下载链接】openchamberAgentic Development Environment based on OpenCode AI agent项目地址https://gitcode.com/gh_mirrors/op/openchamber点击查看免费下载相关推荐OpenChamber 项目上下文面板Project Context Panel深度解析Notes、Todos、Plans 与 Agent Memory 的工程实现OpenChamber 项目上下文面板Project Context Panel深度解析Notes、Todos、Plans 与 Agent MemoryAI 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的高度模块化架构设计结后端前端上一篇SlackPirate代码解析Python实现Slack API敏感信息提取的原理下一篇Cursor 接入 OpenViking一条命令为 AI 编程助手装上跨会话长期记忆创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表