Skip to content
Merged
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ NeoCode 是一个在终端中运行的 AI 编码助手,采用 ReAct(Reason-A
- 支持上下文压缩(`/compact`),帮助长会话保持可用
- 支持工作区隔离(`--workdir`、`/cwd`)
- 会话持久化与恢复,降低重复沟通成本
- 支持持久记忆查看、显式写入与后台自动提取,保留跨会话偏好与项目事实

## 怎么用(快速开始)

Expand Down
11 changes: 10 additions & 1 deletion docs/runtime-provider-event-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,16 @@
8. 如 provider 返回“上下文过长”错误,则触发一次 `reactive` compact,并仅在同一 turn 内重建一次当前请求。
9. 保存 assistant 完整回复。
10. 执行返回的工具调用,并保存每一个工具结果。
11. 如果仍需继续推理,则进入下一轮;否则结束。
11. 如果最终 assistant 回复后没有后续工具调用,则在 runtime 收口处安排一次后台 memo 自动提取。
12. 如果仍需继续推理,则进入下一轮;否则结束。

### Memo 自动提取调度

- 自动提取只在最终 assistant 回复完成且当前轮没有后续工具调用时调度。
- 如果本次 `Run` 已成功调用 `memo_remember`,则不再安排自动提取,避免与显式写入重复。
- runtime 只负责在结束点调度,不直接执行提取逻辑;实际 debounce、尾随执行与持久化去重由 `internal/memo` 内部处理。
- 调度时会绑定当次 provider/model 快照,后台任务不会重新读取全局当前配置,避免把历史会话消息发送到后续切换后的 provider。
- 自动提取失败只记日志,不额外发出 TUI 事件,也不影响主链路完成。

补充约束:
- 同一 turn 内的 provider retry 只重放冻结后的 turn 快照,不会重新读取配置。
Expand Down
7 changes: 7 additions & 0 deletions docs/tools-and-tui-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@
- `filesystem_edit`
- `bash`
- `webfetch`
- `memo_remember`
- `memo_recall`

## Memo 能力集成
- `memo_remember` 与 `memo_recall` 作为标准工具暴露给模型,沿 `Runtime -> Tool Manager -> internal/tools/memo` 链路执行。
- 自动记忆提取不作为单独工具暴露给模型,也不由 TUI 直接触发;它在 runtime 完成最终回复后由 memo 子系统后台调度。
- TUI 目前只通过 Slash Command 展示和管理 memo(如 `/memo`、`/remember`、`/forget`),不会展示后台自动提取的中间状态。

## TUI 集成方式
- 本地配置操作统一通过 Slash Command 完成,例如 Base URL、API Key 和模型选择
Expand Down
147 changes: 137 additions & 10 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package app

import (
"context"
"fmt"
"log"
"strings"
"time"

Expand All @@ -11,6 +13,7 @@ import (
configstate "neo-code/internal/config/state"
agentcontext "neo-code/internal/context"
"neo-code/internal/memo"
"neo-code/internal/provider"
"neo-code/internal/provider/builtin"
providercatalog "neo-code/internal/provider/catalog"
providertypes "neo-code/internal/provider/types"
Expand Down Expand Up @@ -118,10 +121,11 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er

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

return RuntimeBundle{
Expand Down Expand Up @@ -223,13 +227,136 @@ func buildToolManager(registry *tools.Registry) (tools.Manager, error) {
return tools.NewManager(registry, engine, security.NewWorkspaceSandbox())
}

// memoExtractorAdapter 适配 memo.RuleExtractor + memo.Service 到 runtime.MemoExtractor 接口。
type memoExtractorScheduler interface {
ScheduleWithExtractor(sessionID string, messages []providertypes.Message, extractor memo.Extractor)
}

// memoExtractorAdapter 在调度时绑定 provider 配置快照,避免后台任务读取全局可变配置。
type memoExtractorAdapter struct {
extractor *memo.RuleExtractor
svc *memo.Service
factory agentruntime.ProviderFactory
configManager *config.Manager
scheduler memoExtractorScheduler
}

// newMemoExtractorAdapter 创建绑定当前 provider 选择的记忆提取调度适配器。
func newMemoExtractorAdapter(
factory agentruntime.ProviderFactory,
cm *config.Manager,
scheduler memoExtractorScheduler,
) *memoExtractorAdapter {
return &memoExtractorAdapter{
factory: factory,
configManager: cm,
scheduler: scheduler,
}
}

// Schedule 在当前运行结束时绑定 provider/model 快照,再交给后台调度器延后执行。
func (a *memoExtractorAdapter) Schedule(sessionID string, messages []providertypes.Message) {
if a == nil || a.scheduler == nil {
return
}

cfg := a.configManager.Get()
resolved, err := config.ResolveSelectedProvider(cfg)
if err != nil {
log.Printf("memo: resolve selected provider failed: %v", err)
return
}

extractor := memo.NewLLMExtractor(newProviderTextGenerator(
a.factory,
resolved.ToRuntimeConfig(),
cfg.CurrentModel,
))
a.scheduler.ScheduleWithExtractor(sessionID, messages, extractor)
}

// providerTextGenerator 复用当前 provider 配置快照,向 memo 提供纯文本生成能力。
type providerTextGenerator struct {
factory agentruntime.ProviderFactory
runtimeCfg provider.RuntimeConfig
model string
}

// newProviderTextGenerator 创建绑定固定 provider/model 快照的文本生成适配器。
func newProviderTextGenerator(
factory agentruntime.ProviderFactory,
runtimeCfg provider.RuntimeConfig,
model string,
) *providerTextGenerator {
return &providerTextGenerator{
factory: factory,
runtimeCfg: runtimeCfg,
model: model,
}
}

// ExtractAndStore 实现 runtime.MemoExtractor 接口,从消息中提取记忆并保存。
func (a *memoExtractorAdapter) ExtractAndStore(ctx context.Context, messages []providertypes.Message) {
memo.ExtractAndStore(ctx, a.extractor, a.svc, messages)
// Generate 使用预先绑定的 provider/model 快照发起无工具的独立生成请求。
func (g *providerTextGenerator) Generate(
ctx context.Context,
prompt string,
messages []providertypes.Message,
) (string, error) {
modelProvider, err := g.factory.Build(ctx, g.runtimeCfg)
if err != nil {
return "", err
}

events := make(chan providertypes.StreamEvent, 32)
done := make(chan error, 1)
var builder strings.Builder

go func() {
messageDone := false
var streamErr error
for event := range events {
switch event.Type {
case providertypes.StreamEventTextDelta:
payload, err := event.TextDeltaValue()
if err != nil {
if streamErr == nil {
streamErr = err
}
continue
}
builder.WriteString(payload.Text)
case providertypes.StreamEventMessageDone:
if _, err := event.MessageDoneValue(); err != nil {
if streamErr == nil {
streamErr = err
}
continue
}
messageDone = true
default:
if streamErr == nil {
streamErr = fmt.Errorf("memo: unexpected provider stream event %q", event.Type)
}
}
}
if streamErr == nil && !messageDone {
streamErr = fmt.Errorf("memo: provider stream ended without message_done event")
}
done <- streamErr
}()

err = modelProvider.Generate(ctx, providertypes.GenerateRequest{
Model: g.model,
SystemPrompt: prompt,
Messages: append([]providertypes.Message(nil), messages...),
}, events)
close(events)

streamErr := <-done
if streamErr != nil {
if err != nil {
return "", fmt.Errorf("memo: provider generate failed: %v: %w", streamErr, err)
}
return "", streamErr
}
if err != nil {
return "", err
}
return builder.String(), nil
}
Loading
Loading