跳转到内容

04. 领域模型与磁盘布局

Craft Agents 采用“文件系统即工作空间”的路线。理解它的关键不是背路径,而是区分三件事:全局设备配置、可搬运 workspace 内容、每次会话的 provider/runtime 句柄。

erDiagram
GLOBAL_CONFIG ||--o{ WORKSPACE : registers
WORKSPACE ||--o{ SESSION : owns
WORKSPACE ||--o{ PROJECT : owns
WORKSPACE ||--o{ SOURCE : defines
WORKSPACE ||--o{ SKILL : defines
WORKSPACE ||--o{ TASK_SPEC : defines
PROJECT o|--o{ SESSION : groups
SESSION o|--o{ SESSION : parent_or_branch
TASK_SPEC ||--o{ TASK_RUN : instantiates
TASK_RUN ||--o{ SESSION : dispatches_nodes
SESSION o|--o{ MESSAGE : persists

Project → Session 是逻辑绑定,session 文件仍放 workspace 的统一 sessions 目录;TaskRun → Session 也是 metadata 关联,不把 child transcript 嵌进 run log。

中央路径由 CONFIG_DIR 决定:

${CRAFT_CONFIG_DIR:-~/.craft-agent}/
├── config.json 全局 workspace 列表、活动项、连接默认值等
├── credentials.enc 设备绑定的加密凭据
├── .server.lock server 单实例锁
├── docs/ 内置帮助材料
└── workspaces/ 默认 workspace 容器

注意一个实现细节:多数新路径代码尊重 CRAFT_CONFIG_DIR,但少量 storage 文件仍直接从 homedir()/.craft-agent 派生默认路径。二次开发时要搜全局常量,避免多实例开发环境出现“一半读 override、一半读默认目录”的漂移。

全局层保存“这台安装知道哪些 workspace、有哪些 LLM connection、默认使用什么”,不应包含可随 workspace 一起分享的业务内容。

Workspace 是最重要的可搬运边界。它有稳定 ID、slug/name、rootPath,并在任意磁盘位置存在;默认创建在 ~/.craft-agent/workspaces/

workspaces/storage.ts 给出标准目录 helper:

<workspaceRoot>/
├── config.json
├── sessions/
├── projects/
├── sources/
├── skills/
├── tasks/
├── automations.json 或 automation 相关配置
├── statuses/ 状态定义/资源
├── labels/ 标签定义/资源
└── .codex-plugin/ 兼容/插件 manifest

Workspace config 保存默认工作目录、默认 connection/model、permission/thinking、source/feature 设置等。路径写入前转成 portable form,读取后展开,提升跨机器可搬运性。

  • id:内部稳定引用;
  • slug/rootPath:目录和可读定位。

不要用 basename(rootPath) 在所有地方替代 id;部分 legacy/helper 会这样推导,但跨路径移动或重命名时稳定性不同。

Project 是 workspace 内的上下文分组:

projects/<projectSlug>/
├── config.json
├── MEMORY.md
└── assets/

源码:projects/storage.ts

包含 project id/slug/name、可选工作目录、时间戳等。Session 存 projectId 而非 slug,以便项目改名时绑定不失效;加载时通过 id 扫描 project。

这是 agent 维护的“项目经验”,不是全 transcript。加载时:

  • 缺失/空文件返回 null
  • 默认上限 5,000 tokens;
  • 超限保留文件头部并追加截断标记;
  • 提示 Agent 将最新/最重要内容写在顶部。

源码:loadProjectMemory

保存项目级参考资料。Prompt 只注入清单/必要摘要,而不是无条件把所有二进制放入上下文。

每个 session 是独立目录:

sessions/<sessionId>/
├── session.jsonl
├── data/
│ ├── long_responses/
│ └── ...工具生成数据
├── plans/
├── pi-turn-anchors.json Pi 分支锚点 sidecar(按实现需要)
└── ...provider/后台任务附件

权威定义在 sessions/types.ts

session.jsonl 第 1 行:SessionHeader
session.jsonl 第 2 行起:每行一个 StoredMessage

为什么不写一个大 JSON?

  • 列表只读首行即可,不解析所有消息;
  • message 可按行恢复,末尾半写坏行可以容错;
  • diff/导出更自然;
  • header 预计算列表所需字段,性能与透明性兼得。

SessionConfig 是持久 metadata;StoredSession = SessionConfig + messages + tokenUsageSessionHeader 再加入用于列表的预计算值。

SESSION_PERSISTENT_FIELDS 是序列化白名单。新增字段需要:

  1. 加入该数组;
  2. 加入 SessionConfig
  3. 若跨 RPC/UI,再加入 DTO/projection 类型和事件。

白名单比 {...session} 安全,因为 ManagedSession 中的 agent、Promise、Map、callback 不会意外落盘。

分组 代表字段 含义
identity id, workspaceRootPath 产品身份与归属
provider resume sdkSessionId, sdkCwd SDK 恢复位置
display/workflow name, sessionStatus, labels, flag/archive/unread 用户工作管理
runtime config source、permission、working dir、model/connection/thinking 下次 runtime 重建输入
plan pendingPlanExecution 人机 handoff 跨重启状态
branching branchFrom* 父 transcript 与 provider anchor
transfer transferredSessionSummary* 跨服务器一次性恢复上下文
hierarchy projectId, parentSessionId, kanbanColumn UI/工作组织
task conductor taskSlug/runId/nodeId/count/draft DAG 关联

这是最容易混淆的领域概念。

ID 谁生成 生命周期 用途
session.id Craft 产品会话终身 目录名、RPC、UI、父子关系
sdkSessionId Claude/Pi runtime provider transcript resume/fork
turnId provider/API 单次 assistant turn 关联 delta/text/tool
message.id Craft/主进程 持久消息 UI、annotation、branch cutoff
sdkMessageId/anchor provider provider turn entry 严格分支锚点

用户可以迁移 Craft session,但远端机器未必拥有原 provider transcript;此时不能假装 sdkSessionId 可继续,系统需要 transfer summary 或 seeded fresh context。

Session 创建时设置 sdkCwd,之后保持不变;workingDirectory 可由用户或 Agent 改。

原因:Claude 等 SDK 的 transcript 存储按 cwd hash 组织。如果每次 cd 都改变 SDK cwd,旧 session 文件可能找不到,resume/branch 会失效。

所以:

sdkCwd = provider session storage identity(固定)
workingDirectory = 当前 Bash/Read/Write 的工作上下文(可变)

源码注释:SessionConfig.sdkCwd;创建逻辑在 sessions/storage.ts

packages/core/src/types/message.ts 区分 MessageStoredMessage。运行时消息可能包含:

  • isStreamingisPending
  • tool display metadata;
  • base64 attachment/content;
  • UI 临时关联信息。

持久格式保存重建 transcript 所需内容,但会对过大 tool result 做保护,把完整内容移到 session data 文件并在消息里留摘要/路径。

Message role 不止 user/assistant:tool、plan、auth 等产品级消息也需要在 transcript 中表现。真正送给 provider 前还会再做 context conversion,不等于把 JSONL 每行原样传入。

Source 目录约定:

sources/<slug>/
├── config.json
├── guide.md
├── icon.*
└── ...可选模板/辅助文件

主类型 FolderSourceConfig 的 discriminant 是:

  • mcp:stdio、HTTP 或 SSE server;
  • api:把 REST endpoint 动态包装为 MCP-style tools;
  • local:本地目录/能力源。

凭据不放 config.json。配置只声明 auth 形态和非秘密 header;secret 存 credential vault,以 workspace/source identity 查找。

Skill 以 skills/<slug>/SKILL.md 为核心,包含 frontmatter 与说明。它和 Source 的区别:

  • Source 提供“可调用能力/数据”;
  • Skill 提供“如何完成某类工作”的指令与引用;
  • Skill 可以声明 required sources;
  • Prompt 中出现 [skill:slug] 后,prerequisite manager 要求 Agent 先读取对应文件,再开放后续工具。

这是一种渐进上下文加载:不把所有技能全文塞进每个 system prompt。

三者不要混为一谈:

  • sessionStatus:用户可配置 workflow status id,例如 todo/in-progress/needs-review/done;
  • labels:多值分类,可含 id::value 形式;
  • kanbanColumn:任务板列位置,尤其用于 Task child;与 sessionStatus 独立。

代码注释明确 session status 是用户控制的,不应由普通 assistant completion 自动改。TaskRunner 是特殊编排器,会显式驱动 child status/column,以反映 DAG 进度。

tasks/<taskSlug>/
├── task.yaml
└── runs/<runId>/
├── task.yaml 启动时快照
├── run.jsonl append-only run log
└── nodes/<nodeId>/output.*

Task 是声明式 DAG;run 是一次执行实例;每个可执行 node 仍是普通 child Session。这样 task board、历史、权限和消息都复用 session 基础设施。

Automation 配置属于 workspace;history/event log 持久化执行结果。它通常不持有独立 Agent runtime,而是通过回调创建 session、发送 prompt 或 webhook。triggeredBy metadata 把新 session 关联回 automation name/event/timestamp。

凭据独立保存在 ~/.craft-agent/credentials.enc

  • AES-256-GCM;
  • PBKDF2 100,000 次,从机器稳定 ID + salt 派生 32 字节 key;
  • 每次写随机 12 字节 IV;
  • 文件 mode 0600,目录 0700
  • 兼容旧 hostname key,并在成功解密后迁移;
  • 文件损坏/跨机器不能解密时要求重新认证。

源码:secure-storage.tssaveStoreSync

这也意味着 workspace bundle 默认不能“带走凭据”;迁移后要在目标设备重新绑定。

典型 session runtime 值按“最具体覆盖最一般”解析:

显式 create/send 参数
> session 持久值
> project 默认/绑定
> workspace defaults
> global defaults
> 代码内安全默认值

并非每个字段完全相同。例如 connection 一旦 agent 建立会锁定,后续 global default 变化不应悄悄换 provider;working directory 可以动态更新,但 sdkCwd 不跟随。

文件系统优先允许:

  • 用户用编辑器改配置;
  • Agent 通过工具改配置;
  • UI RPC 改配置;
  • watcher 同时观察并更新 runtime。

因此必须有三种防线:

  1. 原子写,避免半文件;
  2. schema validator,避免无效配置直接进入运行时;
  3. signature/merge,避免保存旧内存对象覆盖外部刚改的 metadata。

Craft 的领域模型以普通文件为事实源,但没有把“透明”误解成“随便读写”:JSONL header、持久字段白名单、portable path、原子写、外部变更合并和独立 secret vault,共同把文件系统提升成可靠应用存储。

下一章看内存中的另一半:SessionManager 如何把这些静态文件恢复成拥有 agent、队列、工具资源和事件出口的会话 Actor。