Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 107 additions & 69 deletions docs/session-persistence-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,106 +2,144 @@

## 模块职责与边界

- `internal/session` 是会话领域模型、存储抽象与 JSON 持久化实现的唯一归属层
- `internal/runtime` 负责决定保存时机、恢复会话状态和编排主循环,不承载文件存储细节
- `internal/tui` 只消费 runtime 暴露的会话数据,不直接读写会话文件
- `internal/session` 是会话领域模型、SQLite 存储实现和资产持久化的唯一归属层
- `internal/runtime` 只决定何时创建会话、追加消息、更新会话头和替换 transcript,不关心底层表结构
- `internal/tui` 只消费 runtime 暴露的会话数据,不直接读取数据库或资产文件

## 存储策略

NeoCode 当前使用本地 JSON 文件持久化会话,以保持实现简单、可调试且跨平台可移植
NeoCode 当前使用工作区级 SQLite 数据库持久化会话,不再使用 `session.json` 文件

- 默认目录按工作区隔离:`~/.neocode/projects/<workspace-hash>/sessions/`
- 工作区哈希基于启动阶段确定的工作区根目录生成
- `session.Workdir` 表示会话最近一次运行实际使用的目录,由启动 `workdir` 或请求级覆盖值写回,但不参与分桶
- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读
- 数据库路径:`~/.neocode/projects/<workspace-hash>/session.db`
- 资产目录:`~/.neocode/projects/<workspace-hash>/assets/<session-id>/<asset-id>.bin`
- 工作区哈希基于启动时确定的工作区根目录生成
- `session.Workdir` 记录该会话最近一次运行实际使用的目录,但不参与分桶
- 开发阶段遗留的旧 `sessions/` JSON 目录不迁移、不回读、不兼容

SQLite 初始化固定使用以下 PRAGMA:

- `journal_mode = WAL`
- `synchronous = NORMAL`
- `foreign_keys = ON`
- `busy_timeout = 5000`
- `user_version = 1`

## 数据模型

`internal/session.Session` 持久化以下核心字段:
### sessions

会话头保存摘要和 durable 状态:

- `schema_version`
- `id`、`title`
- `provider`、`model`
- `created_at`、`updated_at`
- `id`
- `title`
- `created_at_ms`
- `updated_at_ms`
- `provider`
- `model`
- `workdir`
- `task_state`
- `todos`
- `messages`
- `task_state_json`
- `todos_json`
- `activated_skills_json`
- `token_input_total`
- `token_output_total`
- `last_seq`
- `message_count`

其中:
### messages

- `schema_version` 为开发期强校验字段;当前实现只接受当前版本,不兼容旧 session 文件
- `provider` / `model` 记录最近一次成功运行会话时使用的配置,供 compact 等流程优先复用
- `task_state` 是会话级 durable task state,由 runtime 维护、session 持久化、context 只读投影
- `token_input_total` / `token_output_total` 分别表示会话累计输入与输出 token
- token 字段仍使用 `omitempty`,但不再承担旧版 session JSON 兼容职责
消息正文按行存储,一条消息对应一行:

`internal/session.Summary` 只保留会话列表渲染所需的轻量字段,不加载完整消息历史。
- `session_id`
- `seq`
- `role`
- `parts_json`
- `tool_calls_json`
- `tool_call_id`
- `is_error`
- `tool_metadata_json`
- `created_at_ms`

`task_state` 固定包含以下字段:
### session_assets

- `goal`
- `progress`
- `open_items`
- `next_step`
- `blockers`
- `key_artifacts`
- `decisions`
- `user_constraints`
- `last_updated_at`
资产元数据入库,二进制内容落盘:

`todos` 固定包含以下要点:
- `id`
- `content`
- `status`
- `dependencies`
- `created_at`
- `updated_at`
- 可选 `priority`
- `session_id`
- `mime_type`
- `size_bytes`
- `relative_path`
- `created_at_ms`

## 运行时读写语义

### 创建会话

runtime 在新会话开始时调用 `CreateSession`,只写入一条空会话头,不写消息正文。

### 追加消息

runtime 在以下时机调用 `AppendMessages`:

- 用户消息提交后
- assistant 完整回复后
- 每个 tool result 完成后

一次调用会在同一事务内完成两件事:

- 追加 1..N 条消息
- 更新会话头上的 `updated_at`、`provider`、`model`、`workdir`、token 增量和消息计数

因此常规写入不再与历史消息总量线性耦合。

### 更新会话头

runtime 在以下场景调用 `UpdateSessionState`:

- workdir 变更
- task_state 变更
- todo 列表变更
- skill 激活状态变更
- assistant 本轮没有正文,但 provider/model 或 token 统计发生变化

该操作不写消息,只覆盖会话头字段。

其中 `status` 当前固定为:
- `pending`
- `in_progress`
- `completed`
### 替换 transcript

同时,当 session JSON 缺失 `todos` 字段时,`Load` 会按空 Todo 列表兼容加载。
compact 成功后,runtime 调用 `ReplaceTranscript`,在单事务内:

## 读写行为
- 删除该会话原有全部消息
- 按新顺序写回 compact 后的消息
- 同步更新 `task_state`、token 统计、provider/model/workdir 和消息计数

- `Save` 使用“临时文件 + 原子替换”写入完整会话 JSON
- `Load` 在用户真正进入某个会话时读取完整历史,并严格要求 `schema_version` 与 `task_state` 字段存在
- `ListSummaries` 只解析摘要字段,并按 `updated_at` 倒序返回;不合法的旧 session 文件会被直接跳过
这是低频路径,允许重写整段 transcript。

## Token 计数持久化
### 加载会话

- runtime 在 provider 调用完成后更新 session 的累计 token 字段
- 会话保存时,token 计数随 session 一起持久化
- 会话重新加载时,runtime 从 session 恢复累计 token
- 自动 compact 成功后,runtime 会重置累计 token,并将重置后的值持久化
- `ListSummaries` 只查询 `sessions` 表,并按 `updated_at` 倒序返回摘要
- `LoadSession` 先读取会话头,再按 `seq` 顺序加载消息并组装完整 `Session`

## TaskState 与 compact
## Token 持久化

- `TaskState` 是继续执行多轮任务时的唯一 durable truth,不依赖聊天消息本身长期保存
- compact 成功后,runtime 会同时回写 `session.TaskState` 和压缩后的 `session.Messages`
- `messages` 中的 `[compact_summary]` 只是展示层,不再是唯一续航载体
- context 构建时会优先注入 `TaskState`,再注入 memo、最近消息和必要工具结果
- 只有当 `TaskState` 已建立后,读时 micro compact 才允许清理旧的可重建 tool payload
- runtime 在 assistant 调用完成后累计输入和输出 token
- `AppendMessages` 可以原子地追加消息并累加 token
- `UpdateSessionState` 和 `ReplaceTranscript` 可以直接覆盖 token 总量
- compact 成功后,runtime 会将 token 总量重置为 0 并持久化

## 保存时机
## TaskState 与 Todo

- 用户消息提交后保存
- assistant 完整回复后保存
- 每个工具结果完成后保存
- 避免在高频 UI 刷新路径中直接做磁盘 I/O
- `TaskState` 是 compact 与多轮续航依赖的 durable summary
- `Todo` 是结构化任务状态,独立持久化在 `sessions.todos_json`
- 二者都属于会话头,不写入 `messages` 表
- context 构建时优先读取 `TaskState`、`Todo`、最近消息和必要工具结果

## 并发约束

- `internal/session` 的存储实现自行保护共享访问
- 保存时机统一由 runtime 决定,TUI 不直接触发磁盘写入
- SQLite 负责单工作区数据库的一致性和事务边界
- runtime 继续通过会话锁串行化同一 session 的关键写入路径
- 不同 session 可以并行运行

## 演进约束

- 新增存储实现时,应优先在 `internal/session` 内扩展并通过接口注入
- 不应把持久化逻辑重新分散到 `runtime`、`tui` 或其他上层模块
- 新增持久化行为时,优先扩展 `internal/session.Store` 的意图型接口
- 不要把 SQL、事务或表结构细节泄漏到 `runtime`、`tui` 或其他上层模块
- 如需进一步优化读路径,应继续在 `internal/session` 内演进,而不是重新引入文件级快照保存
53 changes: 33 additions & 20 deletions docs/session-todo-design.md
Original file line number Diff line number Diff line change
@@ -1,47 +1,60 @@
# Session Todo 设计说明

本文档补充说明 `internal/session` 中 Todo 的数据模型、持久化语义与边界约束
本文档补充说明 `internal/session` 中 Todo 的数据模型、持久化语义和边界约束

## 设计目标

- Todo 归属于 `Session`,不单独引入新的持久化子系统
- Todo 只表示结构化待办状态,不替代现有 `TaskState`
- Todo 的校验、规范化和基础增删改查统一收敛在 `internal/session`
- Todo 归属于 `Session`,不单独引入新的持久化子系统
- Todo 只表示结构化待办状态,不替代 `TaskState`
- Todo 的校验、规范化和基础增删改查统一收敛在 `internal/session`

## 数据模型

`Session` 新增 `todos` 字段,对应 `[]TodoItem`。
`Session` 包含 `Todos []TodoItem` 字段

单个 `TodoItem` 目前包含
单个 `TodoItem` 当前包含

- `id`
- `content`
- `status`
- `dependencies`
- `priority`
- `owner_type`
- `owner_id`
- `artifacts`
- `failure_reason`
- `revision`
- `created_at`
- `updated_at`
- 可选 `priority`

其中 `status` 固定为以下三个值
其中 `status` 当前固定为

- `pending`
- `in_progress`
- `completed`
- `failed`

## 持久化语义

- Todo 跟随 `Session` 一起通过现有 JSONStore 保存和加载。
- `Save` 前会对 Todo 执行统一规范化与校验:
- `id`、`content` 去空白
- 空状态默认收敛为 `pending`
- `dependencies` 去空白、去重、保持顺序
- 拒绝重复 ID
- 拒绝自依赖
- 拒绝引用不存在的依赖项
- `Load` 允许 session JSON 缺失 `todos` 字段,并按空 Todo 列表处理。
- Todo 跟随会话头一起保存在 SQLite `sessions.todos_json`
- runtime 修改 Todo 时只调用 `UpdateSessionState`,不会写入 `messages` 表
- `LoadSession` 时会把 `todos_json` 还原为完整 `[]TodoItem`

## 规范化与校验

写入前会统一执行 Todo 校验和规范化,包括:

- `id`、`content` 去空白
- 空状态收敛为 `pending`
- `dependencies` 去空白、去重并保持顺序
- 拒绝重复 ID
- 拒绝自依赖
- 拒绝引用不存在的依赖项
- 使用 `revision` 保障更新时的乐观并发校验

## 与 TaskState 的关系

- `TaskState` 仍是 runtime/context 用于 compact 与续航的 durable summary。
- `Todo` 是更细粒度的结构化状态,不直接注入 context,不写入消息历史。
- 如果未来需要收敛两者关系,应通过单独演进,让 `TaskState` 从 `Todo` 派生摘要,而不是直接复用同一字段。
- `TaskState` 仍是 runtime/context 用于 compact 和续航的 durable summary
- `Todo` 是更细粒度的结构化执行状态
- `Todo` 不直接拼入模型消息历史
- 如需让 `TaskState` 汇总 Todo,应在 runtime/context 层显式投影,而不是复用同一个字段
13 changes: 10 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,28 @@ module neo-code
go 1.25.0

require (
github.com/Microsoft/go-winio v0.6.2
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/creativeprojects/go-selfupdate v1.5.2
github.com/prometheus/client_golang v1.23.2
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
golang.design/x/clipboard v0.7.1
golang.org/x/net v0.52.0
golang.org/x/sys v0.42.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.48.2
)

require (
code.gitea.io/sdk/gitea v0.22.1 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
Expand All @@ -44,6 +46,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/go-github/v74 v74.0.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
Expand All @@ -59,11 +62,12 @@ require (
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
Expand All @@ -88,4 +92,7 @@ require (
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
Loading
Loading