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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ data/
.idea/
workspace.xml
.vscode/
.claude/
69 changes: 69 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# CLAUDE.md — Claude Code 项目规则

本文件是 Claude Code 在此仓库中工作时的行为指引。完整的项目协作规则请参见 `AGENTS.md`。

## 项目概览

NeoCode Coding Agent MVP — 一个 Go 实现的 AI 编码助手,主链路为:
`用户输入 -> Agent 推理 -> 调用工具 -> 获取结果 -> 继续推理 -> UI 展示`

## 关键目录

| 目录 | 职责 |
|------|------|
| `cmd/neocode` | CLI 入口 |
| `internal/app` | 应用装配与 bootstrap |
| `internal/config` | 配置模型、YAML 加载、校验 |
| `internal/provider` | 模型厂商适配器(差异收敛在此层) |
| `internal/runtime` | ReAct 主循环、事件流、Prompt 编排 |
| `internal/session` | 会话领域模型与 JSON 持久化 |
| `internal/tools` | 工具契约、注册表与具体实现 |
| `internal/tui` | Bubble Tea TUI 状态机与渲染 |
| `internal/context` | 上下文构建、压缩决策 |
| `docs` | 架构与设计文档 |

## 必须遵守的规则

### 架构边界
- **不跨层直连**:遵循 `TUI -> Runtime -> Provider / Tool Manager` 主链路
- **不泄漏厂商差异**:模型协议差异收敛在 `internal/provider` 内
- **不内嵌工具逻辑**:所有可被模型调用的能力必须进入 `internal/tools`
- **不散落状态**:会话状态、消息历史、工具调用记录由 `runtime` 管理

### 编码规范
- Go 惯用风格,制表符缩进,单行约 120 字符
- `PascalCase` 导出,`camelCase` 未导出
- 新增函数必须附带中文注释,说明职责与关键行为
- 不硬编码路径、URL、模型名、超时等,通过配置或常量注入
- 不硬编码业务语义字符串,收敛到共享常量或类型定义

### 安全
- 不写入明文 API Key
- 配置只保存环境变量名
- `filesystem` 工具限制在工作目录内
- `bash` 工具限制超时与输出长度
- 本地运行数据、会话数据不入库

### 测试
- 整体测试覆盖率以 **100%** 为硬性目标
- 改动必须同步补齐测试:正常路径 + 边界条件 + 异常分支 + 回归场景
- 优先覆盖:配置校验、provider 转换、tool 参数校验、runtime 停止条件、事件派发

### 文档
- 沿用目标文档已有语言(中文为主则续用中文)
- 实现与文档冲突时必须修正至少一个

### 提交前检查
- 确认职责分工未被破坏
- 确认新增能力接到正确层级
- `go build ./...` && `go test ./...` && `gofmt -w ./cmd ./internal`
- 检查 `git status`,确保无密钥或临时文件混入

## 常用命令

```bash
go run ./cmd/neocode # 启动应用
go build ./... # 编译
go test ./... # 运行测试
gofmt -w ./cmd ./internal # 格式化
```
23 changes: 23 additions & 0 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ import (
"neo-code/internal/memo"
"neo-code/internal/provider/builtin"
providercatalog "neo-code/internal/provider/catalog"
providertypes "neo-code/internal/provider/types"
agentruntime "neo-code/internal/runtime"
"neo-code/internal/security"
agentsession "neo-code/internal/session"
"neo-code/internal/tools"
"neo-code/internal/tools/bash"
"neo-code/internal/tools/filesystem"
"neo-code/internal/tools/mcp"
memotool "neo-code/internal/tools/memo"
"neo-code/internal/tools/webfetch"
"neo-code/internal/tui"
)
Expand Down Expand Up @@ -102,6 +104,8 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
}
contextBuilder = agentcontext.NewBuilderWithMemo(toolRegistry, memoSource)
memoSvc = memo.NewService(memoStore, nil, cfg.Memo, sourceInvl)
toolRegistry.Register(memotool.NewRememberTool(memoSvc))
toolRegistry.Register(memotool.NewRecallTool(memoSvc))
}

runtimeSvc := agentruntime.NewWithFactory(
Expand All @@ -112,6 +116,14 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
contextBuilder,
)

// 注入记忆提取钩子:当 AutoExtract 启用且 memoSvc 可用时,ReAct 循环完成后异步提取记忆。
if memoSvc != nil && cfg.Memo.AutoExtract {
runtimeSvc.SetMemoExtractor(&memoExtractorAdapter{
extractor: memo.NewRuleExtractor(),
svc: memoSvc,
})
}

return RuntimeBundle{
Config: cfg,
ConfigManager: manager,
Expand Down Expand Up @@ -210,3 +222,14 @@ func buildToolManager(registry *tools.Registry) (tools.Manager, error) {
}
return tools.NewManager(registry, engine, security.NewWorkspaceSandbox())
}

// memoExtractorAdapter 适配 memo.RuleExtractor + memo.Service 到 runtime.MemoExtractor 接口。
type memoExtractorAdapter struct {
extractor *memo.RuleExtractor
svc *memo.Service
}

// ExtractAndStore 实现 runtime.MemoExtractor 接口,从消息中提取记忆并保存。
func (a *memoExtractorAdapter) ExtractAndStore(ctx context.Context, messages []providertypes.Message) {
memo.ExtractAndStore(ctx, a.extractor, a.svc, messages)
}
93 changes: 93 additions & 0 deletions internal/memo/extractor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package memo

import (
"context"
"strings"

providertypes "neo-code/internal/provider/types"
)

// signalPhrases 包含规则提取器识别的显式记忆信号词。
var signalPhrases = []string{
"记住", "记下来", "以后都这样", "以后都这样",
"我偏好", "我喜欢", "我习惯", "我希望",
"别再", "不要再", "不要使用", "避免",
"always", "never", "prefer", "avoid",
"remember", "make sure", "from now on",
}

// RuleExtractor 基于规则的轻量记忆提取器,检测用户消息中的显式信号词。
// 无外部依赖,适合作为默认提取器。
type RuleExtractor struct{}

// NewRuleExtractor 创建规则提取器实例。
func NewRuleExtractor() *RuleExtractor {
return &RuleExtractor{}
}

// Extract 扫描最近的消息,检测含信号词的用户输入并构造记忆条目。
// 仅提取用户最后一条消息,避免重复。
func (r *RuleExtractor) Extract(ctx context.Context, messages []providertypes.Message) ([]Entry, error) {
if err := ctx.Err(); err != nil {
return nil, err
}

// 找到用户发送的最后一条消息
var lastUserMsg string
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == providertypes.RoleUser {
lastUserMsg = messages[i].Content
break
}
}
if lastUserMsg == "" {
return nil, nil
}

if !containsSignal(lastUserMsg) {
return nil, nil
}

// 截断过长内容作为标题
title := NormalizeTitle(lastUserMsg)
if len(title) > 150 {

This comment was marked as outdated.

title = title[:147] + "..."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里按字节截断字符串(title[:147]),对 UTF-8 多字节字符可能切断 rune,导致标题出现非法编码/替换字符。建议按 rune 数截断。

}

return []Entry{
{
Type: TypeUser,
Title: title,
Content: lastUserMsg,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自动提取会将整条用户消息原文持久化到记忆。结合较宽泛的信号词(如 always/prefer),容易把敏感信息误存为长期数据。建议在入库前增加敏感信息过滤/脱敏策略,或缩窄自动提取触发条件。

Source: SourceAutoExtract,
},
}, nil
}

// containsSignal 检查文本是否包含任意信号词。
func containsSignal(text string) bool {
lower := strings.ToLower(text)
for _, phrase := range signalPhrases {
if strings.Contains(lower, strings.ToLower(phrase)) {
return true
}
}
return false
}

// ExtractAndStore 从消息中提取记忆并保存到 Service。
// 提取失败静默处理,不影响主循环。
func ExtractAndStore(ctx context.Context, extractor Extractor, svc *Service, messages []providertypes.Message) {
if extractor == nil || svc == nil {
return
}

entries, err := extractor.Extract(ctx, messages)
if err != nil || len(entries) == 0 {
return
}

for _, entry := range entries {
_ = svc.Add(ctx, entry) // 提取失败不影响主循环,静默忽略
}
}
Loading
Loading