Skip to content
Closed
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
- `internal/runtime`:ReAct 主循环、事件流、Prompt 编排、token 累积与自动压缩触发。
- `internal/session`:会话领域模型、存储抽象与 JSON 持久化实现。
- `internal/tools`:工具契约、注册表、参数校验和具体工具实现。
- `internal/workspace`:工作区路径规范化、Git 根目录识别与 `workspaceRoot/workdir` 解析。
- `internal/tui`:Bubble Tea 状态机、渲染层、Slash Command 和事件桥接。
- `docs`:架构、配置、事件流、会话持久化等说明文档。

Expand All @@ -38,6 +39,7 @@
- `runtime` 负责会话编排、消息上下文流转、tool schema 传递、tool call 识别、tool result 回灌、token 累积、事件派发和停止条件。runtime 不替 context 做压缩决策。
- `session` 负责会话领域模型与 JSON 持久化,包括 token 累计值的持久化。
- `tools` 负责统一的 `schema + execute + result` 协议,以及参数校验、错误包装和输出格式收敛。
- `workspace` 负责统一的工作区解析语义,优先识别 Git 顶层仓库并输出稳定的 `workspaceRoot`。
- `tui` 只消费 runtime 事件并负责展示,不直接调用 provider,不直接执行 tools。

## 4. AI 修改代码时的执行流程
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ go run ./cmd/neocode
- **`internal/runtime`** — ReAct 主循环与事件流编排(不直接承载会话存储实现;不再导出会话模型与存储类型)
- **`internal/session`** — 会话模型、会话存储抽象与 JSON 持久化实现(统一对外暴露 `Session` / `Summary` / `Store`)
- **`internal/tools`** — 工具注册表与具体工具实现
- **`internal/workspace`** — 统一工作区解析与 Git 根目录识别
- **`internal/tui`** — 终端 UI、交互体验、事件桥接
- **`internal/app`** — 应用装配与依赖注入

Expand Down Expand Up @@ -204,7 +205,8 @@ go run ./cmd/neocode --workdir /path/to/workspace
说明:

- `--workdir` 只影响当前进程,不会写回 `config.yaml`
- 当前工作区会同时用于工具执行根目录与 session 存储分桶
- 启动路径会先解析为 `workdir`,再优先向上回溯 Git 顶层仓库作为 `workspaceRoot`
- 工具沙箱和 session 存储分桶固定绑定到 `workspaceRoot`,不会因 `/cwd` 切到子目录而缩小边界
- session 历史现在按工作区隔离存储,不同工作区默认互不可见

[![Contributors](https://hub-io-mcells-projects.vercel.app/r/1024XEngineer/neo-code)](https://github.com/1024XEngineer/neo-code/graphs/contributors)
82 changes: 52 additions & 30 deletions docs/session-persistence-design.md
Original file line number Diff line number Diff line change
@@ -1,44 +1,66 @@
# Session 持久化设计

## 模块职责与收口边界
- `internal/session`:承载会话领域模型、存储抽象与 JSON 持久化实现,是唯一的会话持久化实现归属层
- `internal/runtime`:只依赖 `internal/session` 提供的抽象与模型,负责会话保存时机与主循环编排,不再维护会话存储实现细节
- `internal/tui`:仅消费 runtime 暴露的会话数据,不直接执行会话持久化
## 模块职责与边界

- `internal/session` 是会话领域模型、存储抽象与 JSON 持久化实现的唯一归属层。
- `internal/runtime` 负责决定保存时机、恢复会话状态和编排主循环,不承载文件存储细节。
- `internal/tui` 只消费 runtime 暴露的会话数据,不直接读写会话文件。

## 存储策略
NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优先、易于调试和跨平台可移植。

NeoCode 当前使用本地 JSON 文件持久化会话,以保持实现简单、可调试且跨平台可移植。

- 默认目录按工作区隔离:`~/.neocode/projects/<workspace-hash>/sessions/`
- 工作区哈希基于启动阶段确定的工作区根目录生成
- `session.Workdir` 表示会话当前实际执行命令使用的目录,可被运行期命令修改,但不参与分桶
- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读

## 数据模型
- `Session`:完整消息历史以及 `id`、`title`、`updated_at`、`token_input_total`、`token_output_total` 等元信息
- `Summary`:用于侧边栏的轻量摘要结构(原 `SessionSummary` 命名已统一收口为 `Summary`)

### Token 持久化
- `token_input_total` 和 `token_output_total` 分别记录会话累计输入和输出 token。
- 使用 `omitempty` 标签,确保旧版 JSON 文件正常加载(零值不序列化)。
- runtime 在每次 provider 调用后更新 session 的 token 字段,随 session save 一起持久化。
- 会话加载时,runtime 从 session 恢复 token 计数器;新建会话时计数器清零。
- 自动压缩成功后 token 计数器重置为零,并持久化到 session。
`internal/session.Session` 持久化以下核心字段:

- `id`、`title`
- `provider`、`model`
- `created_at`、`updated_at`
- `workdir`
- `messages`
- `token_input_total`
- `token_output_total`

其中:

- `provider` / `model` 记录最近一次成功运行会话时使用的配置,供 compact 等流程优先复用
- `token_input_total` / `token_output_total` 分别表示会话累计输入与输出 token
- token 字段使用 `omitempty`,以兼容旧版 session JSON 文件

## 加载策略
- `ListSummaries` 只读取渲染侧边栏所需的基础信息
- `Load` 仅在用户真正进入某个会话时读取完整消息历史
- `Save` 通过临时文件原子写入完整 Session
`internal/session.Summary` 只保留会话列表渲染所需的轻量字段,不加载完整消息历史。

## 命名策略
- 新会话默认展示为 `New Session`
- 一旦持久化,runtime 会根据首轮用户消息生成简短标题
## 读写行为

- `Save` 使用“临时文件 + 原子替换”写入完整会话 JSON
- `Load` 在用户真正进入某个会话时读取完整历史
- `ListSummaries` 只解析摘要字段,并按 `updated_at` 倒序返回

## Token 计数持久化

- runtime 在 provider 调用完成后更新 session 的累计 token 字段
- 会话保存时,token 计数随 session 一起持久化
- 会话重新加载时,runtime 从 session 恢复累计 token
- 自动 compact 成功后,runtime 会重置累计 token,并将重置后的值持久化

## 保存时机

- 用户消息提交后保存
- assistant 完整回复后保存
- 每个工具结果完成后保存
- 避免在高频 UI 刷新路径中直接做磁盘 I/O

## 并发约束
- `internal/session` 中的 Store 实现必须自行保护共享访问
- 真正的保存时机由 runtime 决定,TUI 不负责直接触发磁盘写入

## 兼容性与演进说明
- 会话持久化能力已从 runtime 侧实现中彻底收口到 `internal/session`
- 新增会话存储实现时,应优先在 `internal/session` 内扩展并通过接口注入 runtime,避免跨层实现
- `internal/session` 的存储实现自行保护共享访问
- 保存时机统一由 runtime 决定,TUI 不直接触发磁盘写入

## 工作区隔离
## 演进约束

- session 现按工作区隔离存储,目录规则为 `~/.neocode/projects/<workspace-hash>/sessions/`
- 工作区哈希基于启动时确定的工作区根目录生成,而不是基于 `session.Workdir`
- `session.Workdir` 仍表示该会话当前实际执行命令时使用的目录,可被 `/cwd` 修改
- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读
- 新增存储实现时,应优先在 `internal/session` 内扩展并通过接口注入
- 不应把持久化逻辑重新分散到 `runtime`、`tui` 或其他上层模块
84 changes: 56 additions & 28 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ package app
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"

Expand All @@ -22,6 +20,8 @@ import (
"neo-code/internal/tools/filesystem"
"neo-code/internal/tools/webfetch"
"neo-code/internal/tui"
tuibootstrap "neo-code/internal/tui/bootstrap"
agentworkspace "neo-code/internal/workspace"
)

const utf8CodePage = 65001
Expand All @@ -42,6 +42,8 @@ type RuntimeBundle struct {
ConfigManager *config.Manager
Runtime agentruntime.Runtime
ProviderSelection *config.SelectionService
WorkspaceRoot string
Workdir string
}

// EnsureConsoleUTF8 负责在 Windows 控制台中尽量启用 UTF-8 编码。
Expand Down Expand Up @@ -76,8 +78,21 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
}

cfg := manager.Get()
workspaceResolution, err := agentworkspace.Resolve(cfg.Workdir)
if err != nil {
return RuntimeBundle{}, err
}
if cfg.Workdir != workspaceResolution.Workdir {
if err := manager.Update(ctx, func(cfg *config.Config) error {
cfg.Workdir = workspaceResolution.Workdir
return nil
}); err != nil {
return RuntimeBundle{}, err
}
cfg = manager.Get()
}

toolRegistry, err := buildToolRegistry(cfg)
toolRegistry, err := buildToolRegistry(cfg, workspaceResolution.WorkspaceRoot)
if err != nil {
return RuntimeBundle{}, err
}
Expand All @@ -86,9 +101,11 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
return RuntimeBundle{}, err
}

sessionStore := agentsession.NewStore(loader.BaseDir(), cfg.Workdir)
runtimeSvc := agentruntime.NewWithFactory(
sessionStore := agentsession.NewStore(loader.BaseDir(), workspaceResolution.WorkspaceRoot)
runtimeSvc := agentruntime.NewWithWorkspace(
manager,
workspaceResolution.WorkspaceRoot,
workspaceResolution.Workdir,
toolManager,
sessionStore,
providerRegistry,
Expand All @@ -100,6 +117,8 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
ConfigManager: manager,
Runtime: runtimeSvc,
ProviderSelection: providerSelection,
WorkspaceRoot: workspaceResolution.WorkspaceRoot,
Workdir: workspaceResolution.Workdir,
}, nil
}

Expand All @@ -110,7 +129,28 @@ func NewProgram(ctx context.Context, opts BootstrapOptions) (*tea.Program, error
return nil, err
}

tuiApp, err := tui.New(&bundle.Config, bundle.ConfigManager, bundle.Runtime, bundle.ProviderSelection)
tuiApp, err := tui.NewWithBootstrap(tuibootstrap.Options{
Config: &bundle.Config,
ConfigManager: bundle.ConfigManager,
Runtime: bundle.Runtime,
ProviderService: bundle.ProviderSelection,
WorkspaceRoot: bundle.WorkspaceRoot,
Workdir: bundle.Workdir,
RebuildWorkspace: func(ctx context.Context, requestedPath string) (tuibootstrap.WorkspaceBinding, error) {
rebuilt, err := BuildRuntime(ctx, BootstrapOptions{Workdir: requestedPath})
if err != nil {
return tuibootstrap.WorkspaceBinding{}, err
}
return tuibootstrap.WorkspaceBinding{
Config: rebuilt.Config,
ConfigManager: rebuilt.ConfigManager,
Runtime: rebuilt.Runtime,
ProviderService: rebuilt.ProviderSelection,
WorkspaceRoot: rebuilt.WorkspaceRoot,
Workdir: rebuilt.Workdir,
}, nil
},
})
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -139,36 +179,24 @@ func bootstrapDefaultConfig(opts BootstrapOptions) (*config.Config, error) {

// resolveBootstrapWorkdir 将 CLI 传入的工作区解析为存在的绝对目录。
func resolveBootstrapWorkdir(workdir string) (string, error) {
trimmed := strings.TrimSpace(workdir)
if trimmed == "" {
if strings.TrimSpace(workdir) == "" {
return "", fmt.Errorf("app: workdir is empty")
}

absolute, err := filepath.Abs(trimmed)
resolved, err := agentworkspace.Resolve(workdir)
if err != nil {
return "", fmt.Errorf("app: resolve workdir %q: %w", workdir, err)
}
absolute = filepath.Clean(absolute)

info, err := os.Stat(absolute)
if err != nil {
return "", fmt.Errorf("app: resolve workdir %q: %w", workdir, err)
}
if !info.IsDir() {
return "", fmt.Errorf("app: workdir %q is not a directory", absolute)
}

return absolute, nil
return resolved.Workdir, nil
}

func buildToolRegistry(cfg config.Config) (*tools.Registry, error) {
func buildToolRegistry(cfg config.Config, workspaceRoot string) (*tools.Registry, error) {
toolRegistry := tools.NewRegistry()
toolRegistry.Register(filesystem.New(cfg.Workdir))
toolRegistry.Register(filesystem.NewWrite(cfg.Workdir))
toolRegistry.Register(filesystem.NewGrep(cfg.Workdir))
toolRegistry.Register(filesystem.NewGlob(cfg.Workdir))
toolRegistry.Register(filesystem.NewEdit(cfg.Workdir))
toolRegistry.Register(bash.New(cfg.Workdir, cfg.Shell, time.Duration(cfg.ToolTimeoutSec)*time.Second))
toolRegistry.Register(filesystem.New(workspaceRoot))
toolRegistry.Register(filesystem.NewWrite(workspaceRoot))
toolRegistry.Register(filesystem.NewGrep(workspaceRoot))
toolRegistry.Register(filesystem.NewGlob(workspaceRoot))
toolRegistry.Register(filesystem.NewEdit(workspaceRoot))
toolRegistry.Register(bash.New(workspaceRoot, cfg.Shell, time.Duration(cfg.ToolTimeoutSec)*time.Second))
toolRegistry.Register(webfetch.New(webfetch.Config{
Timeout: time.Duration(cfg.ToolTimeoutSec) * time.Second,
MaxResponseBytes: cfg.Tools.WebFetch.MaxResponseBytes,
Expand Down
Loading
Loading