diff --git a/AGENTS.md b/AGENTS.md index 6a353366..95cc5441 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ ## Commit & Pull Request Guidelines - 保持提交小而具有描述性;优先使用约定的前缀,例如 `feat:`、`fix:`、`docs:` 或 `refactor:`,后跟简要摘要(例如 `feat: add model switch command`)。 +- 每次提交新功能、修复行为问题或调整配置/命令/接口时,都必须检查是否有相关文档需要同步更新;如有影响,应在同一变更中一并更新 `README.md`、`docs/`、配置示例或其他相关说明,避免代码与文档脱节。 - 包含简洁的PR描述,列出关联的问题(如果有),并注明已运行的测试命令。 - 在请求审查之前,运行 `git status`,确保已应用gofmt,并仔细检查是否有秘密值泄露到被跟踪的文件中(例如 `.env`)。 diff --git a/README.md b/README.md index ab72a172..51867cb2 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,11 @@ memory: history: short_term_turns: 6 + max_tool_context_messages: 3 + max_tool_context_output_size: 4000 + persist_session_state: true + workspace_state_dir: "./data/workspaces" + resume_last_session: true persona: file_path: "./persona.txt" @@ -140,6 +145,9 @@ persona: - `memory.min_match_score`:最低召回分数 - `memory.max_prompt_chars`:记忆注入 prompt 的总字符上限 - `history.short_term_turns`:保留最近多少轮上下文 +- `history.persist_session_state`:是否按 workspace 持久化当前工作现场 +- `history.workspace_state_dir`:workspace 会话状态文件保存目录 +- `history.resume_last_session`:启动时是否展示恢复摘要 - `persona.file_path`:启动时加载的人设文件 ## Memory 设计 @@ -154,6 +162,8 @@ persona: 召回顺序会优先考虑长期记忆中的用户偏好、项目规则、代码事实、修复经验,再补充 session memory。 +此外,working memory 现已支持按 workspace 保存会话快照。程序会记录当前目标、最近完成动作、下一步、最近文件和最近几轮对话,并在下次进入同一工作区时恢复这份现场。 + ## 运行 ```bash @@ -183,6 +193,7 @@ go run ./cmd/server - `config.yaml`:主配置文件 - `config.example.yaml`:配置模板 - `data/memory_rules.json`:长期结构化记忆文件 +- `data/workspaces//session_state.json`:按工作区保存的当前工作现场 - `persona.txt`:人设内容 ## 安全与本地文件 diff --git a/config.example.yaml b/config.example.yaml index 27cb312b..b96c2618 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -22,6 +22,11 @@ memory: history: short_term_turns: 6 + max_tool_context_messages: 3 + max_tool_context_output_size: 4000 + persist_session_state: true + workspace_state_dir: "./data/workspaces" + resume_last_session: true persona: file_path: "./configs/persona.txt" diff --git a/configs/app_config.go b/configs/app_config.go index 045eef33..b5681684 100644 --- a/configs/app_config.go +++ b/configs/app_config.go @@ -33,9 +33,12 @@ type AppConfiguration struct { } `yaml:"memory"` History struct { - ShortTermTurns int `yaml:"short_term_turns"` - MaxToolContextMessages int `yaml:"max_tool_context_messages"` - MaxToolContextOutputSize int `yaml:"max_tool_context_output_size"` + ShortTermTurns int `yaml:"short_term_turns"` + MaxToolContextMessages int `yaml:"max_tool_context_messages"` + MaxToolContextOutputSize int `yaml:"max_tool_context_output_size"` + PersistSessionState bool `yaml:"persist_session_state"` + WorkspaceStateDir string `yaml:"workspace_state_dir"` + ResumeLastSession bool `yaml:"resume_last_session"` } `yaml:"history"` Persona struct { @@ -62,6 +65,9 @@ func DefaultAppConfig() *AppConfiguration { cfg.History.ShortTermTurns = 6 cfg.History.MaxToolContextMessages = 3 cfg.History.MaxToolContextOutputSize = 4000 + cfg.History.PersistSessionState = true + cfg.History.WorkspaceStateDir = "./data/workspaces" + cfg.History.ResumeLastSession = true cfg.Persona.FilePath = DefaultPersonaFilePath return cfg } @@ -175,6 +181,9 @@ func (c *AppConfiguration) ValidateBase() error { if c.History.MaxToolContextOutputSize <= 0 { return fmt.Errorf("配置无效:history.max_tool_context_output_size 必须大于 0") } + if c.History.PersistSessionState && strings.TrimSpace(c.History.WorkspaceStateDir) == "" { + return fmt.Errorf("配置无效:history.workspace_state_dir 不能为空") + } return nil } diff --git a/configs/app_config_test.go b/configs/app_config_test.go index 0adddbaa..82d58c91 100644 --- a/configs/app_config_test.go +++ b/configs/app_config_test.go @@ -100,6 +100,11 @@ memory: storage_path: "./data/memory_rules.json" history: short_term_turns: 6 + max_tool_context_messages: 3 + max_tool_context_output_size: 4000 + persist_session_state: true + workspace_state_dir: "./data/workspaces" + resume_last_session: true persona: file_path: "./configs/persona.txt" `) @@ -193,6 +198,9 @@ func validConfig() *AppConfiguration { cfg.History.ShortTermTurns = 6 cfg.History.MaxToolContextMessages = 3 cfg.History.MaxToolContextOutputSize = 4000 + cfg.History.PersistSessionState = true + cfg.History.WorkspaceStateDir = "./data/workspaces" + cfg.History.ResumeLastSession = true cfg.Persona.FilePath = DefaultPersonaFilePath return cfg } diff --git a/internal/server/domain/working_memory.go b/internal/server/domain/working_memory.go index 5658630a..052c9c3f 100644 --- a/internal/server/domain/working_memory.go +++ b/internal/server/domain/working_memory.go @@ -1,6 +1,9 @@ package domain -import "context" +import ( + "context" + "time" +) // WorkingMemoryTurn 表示一轮可复用的用户/助手对话。 type WorkingMemoryTurn struct { @@ -11,11 +14,15 @@ type WorkingMemoryTurn struct { // WorkingMemoryState 表示当前会话的工作记忆快照。 // 第一阶段只保留任务摘要、最近对话、待解决问题和最近文件引用。 type WorkingMemoryState struct { - CurrentTask string - TaskSummary string - RecentTurns []WorkingMemoryTurn - OpenQuestions []string - RecentFiles []string + CurrentTask string + TaskSummary string + LastCompletedAction string + CurrentInProgress string + NextStep string + RecentTurns []WorkingMemoryTurn + OpenQuestions []string + RecentFiles []string + UpdatedAt time.Time } // WorkingMemoryRepository 定义工作记忆的存取接口。 @@ -30,4 +37,5 @@ type WorkingMemoryService interface { BuildContext(ctx context.Context, messages []Message) (string, error) Refresh(ctx context.Context, messages []Message) error Clear(ctx context.Context) error + Get(ctx context.Context) (*WorkingMemoryState, error) } diff --git a/internal/server/infra/repository/working_memory_repository.go b/internal/server/infra/repository/working_memory_repository.go index e898df54..53cff577 100644 --- a/internal/server/infra/repository/working_memory_repository.go +++ b/internal/server/infra/repository/working_memory_repository.go @@ -2,6 +2,11 @@ package repository import ( "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" "sync" "go-llm-demo/internal/server/domain" @@ -12,18 +17,30 @@ import ( type WorkingMemoryStore struct { mu sync.RWMutex state *domain.WorkingMemoryState + path string } // NewWorkingMemoryStore 创建一个进程内工作记忆存储。 -func NewWorkingMemoryStore() *WorkingMemoryStore { - return &WorkingMemoryStore{} +func NewWorkingMemoryStore(path ...string) *WorkingMemoryStore { + storePath := "" + if len(path) > 0 { + storePath = strings.TrimSpace(path[0]) + } + return &WorkingMemoryStore{path: storePath} } // Get 返回当前工作记忆快照的拷贝。 func (s *WorkingMemoryStore) Get(ctx context.Context) (*domain.WorkingMemoryState, error) { _ = ctx - s.mu.RLock() - defer s.mu.RUnlock() + s.mu.Lock() + defer s.mu.Unlock() + if s.state == nil && strings.TrimSpace(s.path) != "" { + state, err := s.readLocked() + if err != nil { + return nil, err + } + s.state = state + } if s.state == nil { return &domain.WorkingMemoryState{}, nil } @@ -36,7 +53,10 @@ func (s *WorkingMemoryStore) Save(ctx context.Context, state *domain.WorkingMemo s.mu.Lock() defer s.mu.Unlock() s.state = cloneWorkingMemoryState(state) - return nil + if strings.TrimSpace(s.path) == "" { + return nil + } + return s.writeLocked(s.state) } // Clear 清空已保存的工作记忆快照。 @@ -45,16 +65,75 @@ func (s *WorkingMemoryStore) Clear(ctx context.Context) error { s.mu.Lock() defer s.mu.Unlock() s.state = nil + if strings.TrimSpace(s.path) == "" { + return nil + } + if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } return nil } +func (s *WorkingMemoryStore) readLocked() (*domain.WorkingMemoryState, error) { + data, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &domain.WorkingMemoryState{}, nil + } + return nil, err + } + if len(data) == 0 { + return &domain.WorkingMemoryState{}, nil + } + + var state domain.WorkingMemoryState + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + return cloneWorkingMemoryState(&state), nil +} + +func (s *WorkingMemoryStore) writeLocked(state *domain.WorkingMemoryState) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(s.path), "working-memory-*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, s.path); err == nil { + return nil + } + if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return os.Rename(tmpPath, s.path) +} + func cloneWorkingMemoryState(state *domain.WorkingMemoryState) *domain.WorkingMemoryState { if state == nil { return &domain.WorkingMemoryState{} } cloned := &domain.WorkingMemoryState{ - CurrentTask: state.CurrentTask, - TaskSummary: state.TaskSummary, + CurrentTask: state.CurrentTask, + TaskSummary: state.TaskSummary, + LastCompletedAction: state.LastCompletedAction, + CurrentInProgress: state.CurrentInProgress, + NextStep: state.NextStep, + UpdatedAt: state.UpdatedAt, } if len(state.RecentTurns) > 0 { cloned.RecentTurns = make([]domain.WorkingMemoryTurn, len(state.RecentTurns)) diff --git a/internal/server/infra/repository/working_memory_repository_test.go b/internal/server/infra/repository/working_memory_repository_test.go new file mode 100644 index 00000000..37897ab5 --- /dev/null +++ b/internal/server/infra/repository/working_memory_repository_test.go @@ -0,0 +1,50 @@ +package repository + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "go-llm-demo/internal/server/domain" +) + +func TestWorkingMemoryStorePersistsStateToDisk(t *testing.T) { + path := filepath.Join(t.TempDir(), "workspace", "session_state.json") + store := NewWorkingMemoryStore(path) + + state := &domain.WorkingMemoryState{ + CurrentTask: "修复记忆模块", + LastCompletedAction: "已完成规则修复", + NextStep: "补测试", + RecentFiles: []string{"internal/server/service/memory_service.go"}, + UpdatedAt: time.Now().UTC(), + } + if err := store.Save(context.Background(), state); err != nil { + t.Fatalf("save state: %v", err) + } + + reloaded := NewWorkingMemoryStore(path) + got, err := reloaded.Get(context.Background()) + if err != nil { + t.Fatalf("get state: %v", err) + } + if got.CurrentTask != state.CurrentTask || got.NextStep != state.NextStep { + t.Fatalf("expected persisted state, got %+v", got) + } +} + +func TestWorkingMemoryStoreClearRemovesPersistedFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "workspace", "session_state.json") + store := NewWorkingMemoryStore(path) + if err := store.Save(context.Background(), &domain.WorkingMemoryState{CurrentTask: "task"}); err != nil { + t.Fatalf("save state: %v", err) + } + if err := store.Clear(context.Background()); err != nil { + t.Fatalf("clear state: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected persisted file to be removed, got %v", err) + } +} diff --git a/internal/server/service/memory_service.go b/internal/server/service/memory_service.go index ab06dc51..e04b91fe 100644 --- a/internal/server/service/memory_service.go +++ b/internal/server/service/memory_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "sort" "strconv" @@ -346,6 +347,10 @@ func buildMemoryText(userInput, assistantReply string) string { } func deriveMemoryItems(userInput, assistantReply string) []domain.MemoryItem { + if shouldSkipMemoryCapture(userInput, assistantReply) { + return nil + } + now := time.Now().UTC() items := make([]domain.MemoryItem, 0, 4) @@ -379,7 +384,7 @@ func extractSessionMemory(userInput, assistantReply string, now time.Time) (doma summary = domain.SummarizeText(assistantReply, 140) } - return newMemoryItem(now, domain.TypeSessionMemory, domain.ScopeSession, summary, assistantReply, combined, 0.66), true + return newMemoryItem(now, domain.TypeSessionMemory, domain.ScopeSession, summary, assistantReply, userInput, assistantReply, combined, 0.66), true } func extractPreferenceMemory(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { @@ -389,7 +394,7 @@ func extractPreferenceMemory(userInput, assistantReply string, now time.Time) (d } summary := domain.SummarizeText(trimmed, 140) - return newMemoryItem(now, domain.TypeUserPreference, domain.ScopeUser, summary, assistantReply, buildMemoryText(userInput, assistantReply), 0.95), true + return newMemoryItem(now, domain.TypeUserPreference, domain.ScopeUser, summary, assistantReply, userInput, assistantReply, buildMemoryText(userInput, assistantReply), 0.95), true } func extractProjectRuleMemory(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { @@ -397,11 +402,14 @@ func extractProjectRuleMemory(userInput, assistantReply string, now time.Time) ( if !looksLikeProjectFact(userInput, assistantReply) { return domain.MemoryItem{}, false } - if !containsAnyFold(combined, "config.yaml", "readme", "go test", "go build", "命令", "约定", "配置", "目录", "结构", "仓库") { + if looksLikeStableInstruction(userInput) && !hasProjectRuleAnchor(combined) { + return domain.MemoryItem{}, false + } + if !hasProjectRuleSignal(combined) { return domain.MemoryItem{}, false } summary := domain.SummarizeText(firstNonEmptyLine(userInput, assistantReply), 140) - return newMemoryItem(now, domain.TypeProjectRule, domain.ScopeProject, summary, assistantReply, buildMemoryText(userInput, assistantReply), 0.9), true + return newMemoryItem(now, domain.TypeProjectRule, domain.ScopeProject, summary, assistantReply, userInput, assistantReply, buildMemoryText(userInput, assistantReply), 0.9), true } func extractCodeFactMemory(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { @@ -413,7 +421,7 @@ func extractCodeFactMemory(userInput, assistantReply string, now time.Time) (dom return domain.MemoryItem{}, false } summary := domain.SummarizeText(firstNonEmptyLine(assistantReply, userInput), 140) - return newMemoryItem(now, domain.TypeCodeFact, domain.ScopeProject, summary, assistantReply, combined, 0.82), true + return newMemoryItem(now, domain.TypeCodeFact, domain.ScopeProject, summary, assistantReply, userInput, assistantReply, combined, 0.82), true } func extractFixRecipeMemory(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { @@ -428,23 +436,24 @@ func extractFixRecipeMemory(userInput, assistantReply string, now time.Time) (do if details == "" { details = userInput } - return newMemoryItem(now, domain.TypeFixRecipe, domain.ScopeProject, summary, details, buildMemoryText(userInput, assistantReply), 0.8), true + return newMemoryItem(now, domain.TypeFixRecipe, domain.ScopeProject, summary, details, userInput, assistantReply, buildMemoryText(userInput, assistantReply), 0.8), true } -func newMemoryItem(now time.Time, itemType, scope, summary, details, text string, confidence float64) domain.MemoryItem { +func newMemoryItem(now time.Time, itemType, scope, summary, details, userInput, assistantReply, text string, confidence float64) domain.MemoryItem { item := domain.MemoryItem{ - ID: strconv.FormatInt(now.UnixNano(), 10) + "-" + itemType, - Type: itemType, - Summary: strings.TrimSpace(summary), - Details: domain.SummarizeText(details, 220), - Scope: scope, - Tags: domain.InferTags(summary + "\n" + details), - Source: "conversation", - Confidence: confidence, - Text: strings.TrimSpace(text), - CreatedAt: now, - UpdatedAt: now, - UserInput: strings.TrimSpace(summary), + ID: strconv.FormatInt(now.UnixNano(), 10) + "-" + itemType, + Type: itemType, + Summary: strings.TrimSpace(summary), + Details: domain.SummarizeText(details, 220), + Scope: scope, + Tags: domain.InferTags(summary + "\n" + details), + Source: "conversation", + Confidence: confidence, + Text: strings.TrimSpace(text), + CreatedAt: now, + UpdatedAt: now, + UserInput: strings.TrimSpace(userInput), + AssistantReply: strings.TrimSpace(assistantReply), } return item.Normalized() } @@ -462,7 +471,7 @@ func isCodingRelevant(userInput, assistantReply string) bool { func looksLikeProjectFact(userInput, assistantReply string) bool { combined := strings.ToLower(buildMemoryText(userInput, assistantReply)) - return containsAnyFold(combined, "config.yaml", "readme", "go test", "go build", "项目", "仓库", "约定", "配置", "结构", "命令", "services/", "memory/", "main.go") + return hasProjectRuleAnchor(combined) && hasProjectRuleSignal(combined) } func looksLikeCodeKnowledge(userInput, assistantReply string) bool { @@ -500,6 +509,54 @@ func looksLikeStableInstruction(text string) bool { return containsAnyFold(trimmed, "config.yaml", ".env", "中文", "提交", "命令", "风格", "配置") } +func shouldSkipMemoryCapture(userInput, assistantReply string) bool { + trimmedUser := strings.TrimSpace(userInput) + trimmedReply := strings.TrimSpace(assistantReply) + if trimmedUser == "" || trimmedReply == "" { + return true + } + return looksLikeToolCallPayload(trimmedReply) +} + +func looksLikeToolCallPayload(text string) bool { + trimmed := strings.TrimSpace(text) + if trimmed == "" || !strings.HasPrefix(trimmed, "{") { + return false + } + + var payload map[string]json.RawMessage + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return false + } + + toolValue, hasTool := payload["tool"] + paramsValue, hasParams := payload["params"] + if !hasTool || !hasParams { + return false + } + + var toolName string + if err := json.Unmarshal(toolValue, &toolName); err != nil { + return false + } + + var params map[string]interface{} + return strings.TrimSpace(toolName) != "" && json.Unmarshal(paramsValue, ¶ms) == nil +} + +func hasProjectRuleAnchor(text string) bool { + return containsAnyFold(text, + "config.yaml", "readme", "go test", "go build", + "cmd/", "internal/", "configs/", "services/", "memory/", "main.go", + "data/", "workspace", "工作区", "根目录", "主配置文件", "文件", "路径") +} + +func hasProjectRuleSignal(text string) bool { + return containsAnyFold(text, + "项目", "仓库", "约定", "配置", "结构", "目录", "命令", + "默认", "统一", "必须", "需要", "测试命令", "构建命令") +} + func containsAnyFold(text string, needles ...string) bool { for _, needle := range needles { if strings.Contains(strings.ToLower(text), strings.ToLower(needle)) { diff --git a/internal/server/service/memory_service_test.go b/internal/server/service/memory_service_test.go new file mode 100644 index 00000000..621393da --- /dev/null +++ b/internal/server/service/memory_service_test.go @@ -0,0 +1,97 @@ +package service + +import ( + "context" + "strings" + "testing" + + "go-llm-demo/internal/server/infra/repository" +) + +func TestMemoryServiceSavesAndRecallsPersistentMemory(t *testing.T) { + ctx := context.Background() + path := t.TempDir() + "/memory.json" + + svc := NewMemoryService( + repository.NewFileMemoryStore(path, 100), + repository.NewSessionMemoryStore(100), + 5, + 2.2, + 1800, + path, + []string{"user_preference", "project_rule", "code_fact", "fix_recipe"}, + ) + + if err := svc.Save(ctx, "以后回答中文,命令和说明都用中文。", "好的,后续我会统一使用中文回复。"); err != nil { + t.Fatalf("save preference: %v", err) + } + if err := svc.Save(ctx, "memory_repository.go 是做什么的?", "internal/server/infra/repository/memory_repository.go 负责长期记忆的文件存储与读写。"); err != nil { + t.Fatalf("save code fact: %v", err) + } + + stats, err := svc.GetStats(ctx) + if err != nil { + t.Fatalf("get stats: %v", err) + } + if stats.PersistentItems != 2 { + t.Fatalf("expected 2 persistent items, got %+v", stats) + } + if stats.ByType["user_preference"] != 1 { + t.Fatalf("expected 1 user_preference, got %+v", stats.ByType) + } + if stats.ByType["code_fact"] != 1 { + t.Fatalf("expected 1 code_fact, got %+v", stats.ByType) + } + if stats.ByType["project_rule"] != 0 { + t.Fatalf("expected no project_rule for preference-only input, got %+v", stats.ByType) + } + + reloaded := NewMemoryService( + repository.NewFileMemoryStore(path, 100), + repository.NewSessionMemoryStore(100), + 5, + 2.2, + 1800, + path, + []string{"user_preference", "project_rule", "code_fact", "fix_recipe"}, + ) + + prompt, err := reloaded.BuildContext(ctx, "请继续用中文,并看看 memory_repository.go 的职责") + if err != nil { + t.Fatalf("build context: %v", err) + } + if !strings.Contains(prompt, "user_preference") { + t.Fatalf("expected recalled user_preference in prompt, got %q", prompt) + } + if !strings.Contains(prompt, "code_fact") { + t.Fatalf("expected recalled code_fact in prompt, got %q", prompt) + } +} + +func TestMemoryServiceSkipsToolCallPayload(t *testing.T) { + ctx := context.Background() + path := t.TempDir() + "/memory.json" + + svc := NewMemoryService( + repository.NewFileMemoryStore(path, 100), + repository.NewSessionMemoryStore(100), + 5, + 2.2, + 1800, + path, + []string{"user_preference", "project_rule", "code_fact", "fix_recipe"}, + ) + + reply := `{"tool":"read","params":{"filePath":"internal/server/service/memory_service.go"}}` + if err := svc.Save(ctx, "请读取 memory_service.go 看看记忆模块怎么实现的。", reply); err != nil { + t.Fatalf("save tool payload: %v", err) + } + + stats, err := svc.GetStats(ctx) + if err != nil { + t.Fatalf("get stats: %v", err) + } + if stats.TotalItems != 0 { + t.Fatalf("expected tool payload to be skipped, got %+v", stats) + } +} diff --git a/internal/server/service/working_memory_service.go b/internal/server/service/working_memory_service.go index cb940202..7298beeb 100644 --- a/internal/server/service/working_memory_service.go +++ b/internal/server/service/working_memory_service.go @@ -5,6 +5,7 @@ import ( "fmt" "regexp" "strings" + "time" "go-llm-demo/internal/server/domain" ) @@ -41,7 +42,7 @@ func (s *workingMemoryServiceImpl) BuildContext(ctx context.Context, messages [] if err := s.Refresh(ctx, messages); err != nil { return "", err } - state, err := s.repo.Get(ctx) + state, err := s.Get(ctx) if err != nil { return "", err } @@ -59,19 +60,30 @@ func (s *workingMemoryServiceImpl) Clear(ctx context.Context) error { return s.repo.Clear(ctx) } +// Get 返回当前工作记忆快照。 +func (s *workingMemoryServiceImpl) Get(ctx context.Context) (*domain.WorkingMemoryState, error) { + return s.repo.Get(ctx) +} + func (s *workingMemoryServiceImpl) buildState(messages []domain.Message) *domain.WorkingMemoryState { turns := collectRecentTurns(messages) if len(turns) > s.maxRecentTurns { turns = turns[len(turns)-s.maxRecentTurns:] } + currentTask := latestUserMessage(messages) + openQuestions := collectOpenQuestions(messages, s.maxOpenQuestions) state := &domain.WorkingMemoryState{ - RecentTurns: turns, + CurrentTask: currentTask, + TaskSummary: buildTaskSummary(turns, currentTask), + LastCompletedAction: inferLastCompletedAction(messages), + CurrentInProgress: inferCurrentInProgress(messages, currentTask), + NextStep: inferNextStep(messages, openQuestions, currentTask), + RecentTurns: turns, + OpenQuestions: openQuestions, + RecentFiles: collectRecentFiles(messages, s.maxRecentFiles), + UpdatedAt: time.Now().UTC(), } - state.CurrentTask = latestUserMessage(messages) - state.TaskSummary = buildTaskSummary(turns) - state.OpenQuestions = collectOpenQuestions(messages, s.maxOpenQuestions) - state.RecentFiles = collectRecentFiles(messages, s.maxRecentFiles) return state } @@ -111,7 +123,10 @@ func latestUserMessage(messages []domain.Message) string { return "" } -func buildTaskSummary(turns []domain.WorkingMemoryTurn) string { +func buildTaskSummary(turns []domain.WorkingMemoryTurn, currentTask string) string { + if strings.TrimSpace(currentTask) != "" { + return domain.SummarizeText(currentTask, 160) + } if len(turns) == 0 { return "" } @@ -125,6 +140,72 @@ func buildTaskSummary(turns []domain.WorkingMemoryTurn) string { return "" } +func inferLastCompletedAction(messages []domain.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] + if msg.Role != "assistant" { + continue + } + for _, line := range strings.Split(msg.Content, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if containsAnyFold(line, "已完成", "已修复", "已经", "完成了", "已处理", "修复了", "implemented", "fixed", "updated", "added", "created") { + return domain.SummarizeText(line, 140) + } + } + } + return "" +} + +func inferCurrentInProgress(messages []domain.Message, currentTask string) string { + trimmedTask := strings.TrimSpace(currentTask) + if trimmedTask == "" { + return "" + } + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] + if msg.Role != "assistant" { + continue + } + content := strings.TrimSpace(msg.Content) + if content == "" { + continue + } + if containsAnyFold(content, "正在", "继续", "接下来", "当前", "处理中", "working on", "next", "continue") { + return domain.SummarizeText(content, 140) + } + break + } + return domain.SummarizeText(trimmedTask, 140) +} + +func inferNextStep(messages []domain.Message, openQuestions []string, currentTask string) string { + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] + if msg.Role != "assistant" { + continue + } + for _, line := range strings.Split(msg.Content, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if containsAnyFold(line, "下一步", "接下来", "建议", "可以继续", "后续", "next step", "next", "follow-up") { + return domain.SummarizeText(line, 140) + } + } + } + if len(openQuestions) > 0 { + return "先解决: " + domain.SummarizeText(openQuestions[0], 110) + } + if strings.TrimSpace(currentTask) != "" { + return "继续推进: " + domain.SummarizeText(currentTask, 110) + } + return "" +} + func collectOpenQuestions(messages []domain.Message, limit int) []string { questions := make([]string, 0, limit) seen := map[string]struct{}{} @@ -197,12 +278,24 @@ func formatWorkingMemoryContext(state *domain.WorkingMemoryState, workspaceRoot if state.TaskSummary != "" { parts = append(parts, "Task summary: "+state.TaskSummary) } + if state.LastCompletedAction != "" { + parts = append(parts, "Last completed action: "+state.LastCompletedAction) + } + if state.CurrentInProgress != "" { + parts = append(parts, "Current in progress: "+state.CurrentInProgress) + } + if state.NextStep != "" { + parts = append(parts, "Next step: "+state.NextStep) + } if len(state.OpenQuestions) > 0 { parts = append(parts, "Open questions: "+strings.Join(state.OpenQuestions, " | ")) } if len(state.RecentFiles) > 0 { parts = append(parts, "Recent file refs: "+strings.Join(state.RecentFiles, ", ")) } + if !state.UpdatedAt.IsZero() { + parts = append(parts, "State updated at: "+state.UpdatedAt.Format(time.RFC3339)) + } if len(state.RecentTurns) > 0 { turnLines := make([]string, 0, len(state.RecentTurns)) for idx, turn := range state.RecentTurns { diff --git a/internal/server/service/working_memory_service_test.go b/internal/server/service/working_memory_service_test.go new file mode 100644 index 00000000..317f8a03 --- /dev/null +++ b/internal/server/service/working_memory_service_test.go @@ -0,0 +1,49 @@ +package service + +import ( + "context" + "strings" + "testing" + + "go-llm-demo/internal/server/domain" + "go-llm-demo/internal/server/infra/repository" +) + +func TestWorkingMemoryServiceBuildsCheckpointFields(t *testing.T) { + svc := NewWorkingMemoryService(repository.NewWorkingMemoryStore(), 6, "D:/neo-code") + messages := []domain.Message{ + {Role: "user", Content: "请修复 internal/server/service/memory_service.go 的记忆问题"}, + {Role: "assistant", Content: "已完成工具 JSON 过滤,接下来补 working memory 测试。"}, + {Role: "user", Content: "下一步应该先验证哪些场景?"}, + } + + if err := svc.Refresh(context.Background(), messages); err != nil { + t.Fatalf("refresh state: %v", err) + } + state, err := svc.Get(context.Background()) + if err != nil { + t.Fatalf("get state: %v", err) + } + if state.CurrentTask == "" || state.NextStep == "" || state.LastCompletedAction == "" { + t.Fatalf("expected checkpoint fields to be populated, got %+v", state) + } + if len(state.RecentFiles) == 0 || state.RecentFiles[0] != "internal/server/service/memory_service.go" { + t.Fatalf("expected recent files to be collected, got %+v", state.RecentFiles) + } +} + +func TestWorkingMemoryServiceFormatsExtendedContext(t *testing.T) { + state := &domain.WorkingMemoryState{ + CurrentTask: "修复记忆模块", + LastCompletedAction: "已修复持久化问题", + CurrentInProgress: "正在补恢复测试", + NextStep: "继续验证跨 workspace 隔离", + } + + got := formatWorkingMemoryContext(state, "D:/neo-code") + for _, want := range []string{"Current task:", "Last completed action:", "Current in progress:", "Next step:"} { + if !strings.Contains(got, want) { + t.Fatalf("expected %q in formatted context, got %q", want, got) + } + } +} diff --git a/internal/tui/core/model.go b/internal/tui/core/model.go index 6cabe863..9131ca6a 100644 --- a/internal/tui/core/model.go +++ b/internal/tui/core/model.go @@ -2,6 +2,7 @@ package core import ( "context" + "strings" "sync" "time" @@ -38,6 +39,8 @@ type Model struct { todoCursor int } +const resumeSummaryPrefix = "[RESUME_SUMMARY]" + // NewModel 创建 TUI 状态模型。 // historyTurns 用于限制发送给后端的短期对话轮数,避免原始消息无限增长。 func NewModel(client services.ChatClient, persona string, historyTurns int, configPath, workspaceRoot string) Model { @@ -71,7 +74,7 @@ func NewModel(client services.ChatClient, persona string, historyTurns int, conf vp := viewport.New(0, 0) vp.SetContent("") - return Model{ + model := Model{ ui: state.UIState{ Mode: state.ModeChat, Focused: "input", @@ -95,6 +98,16 @@ func NewModel(client services.ChatClient, persona string, historyTurns int, conf copyToClipboard: clipboard.WriteAll, mu: &sync.Mutex{}, } + if provider, ok := client.(services.WorkingSessionSummaryProvider); ok { + if summary, err := provider.GetWorkingSessionSummary(context.Background()); err == nil && strings.TrimSpace(summary) != "" { + model.chat.Messages = append(model.chat.Messages, state.Message{ + Role: "system", + Content: resumeSummaryPrefix + "\n" + summary, + Timestamp: time.Now(), + }) + } + } + return model } func (m *Model) mutex() *sync.Mutex { @@ -177,3 +190,7 @@ func (m *Model) TrimHistory(maxTurns int) { m.chat.Messages = append(system, others...) } + +func isResumeSummaryMessage(content string) bool { + return strings.HasPrefix(strings.TrimSpace(content), resumeSummaryPrefix) +} diff --git a/internal/tui/core/model_test.go b/internal/tui/core/model_test.go index f5550964..f1e34ad9 100644 --- a/internal/tui/core/model_test.go +++ b/internal/tui/core/model_test.go @@ -1,6 +1,7 @@ package core import ( + "context" "testing" "go-llm-demo/configs" @@ -65,6 +66,23 @@ func TestInitReturnsNonNilCmd(t *testing.T) { } } +func TestNewModelAddsResumeSummaryMessageWhenSupported(t *testing.T) { + restoreCoreGlobals(t) + + client := &resumeSummaryClient{ + fakeChatClient: fakeChatClient{defaultModelName: "demo-model"}, + summary: "已恢复上次工作现场:\n- 当前目标: 修复记忆模块", + } + + m := NewModel(client, "persona", 4, "config.yaml", "D:/neo-code") + if len(m.chat.Messages) != 1 { + t.Fatalf("expected one resume summary message, got %+v", m.chat.Messages) + } + if m.chat.Messages[0].Role != "system" || !isResumeSummaryMessage(m.chat.Messages[0].Content) { + t.Fatalf("expected resume summary system message, got %+v", m.chat.Messages[0]) + } +} + func TestTrimHistoryKeepsSystemMessagesAndLatestTurns(t *testing.T) { m := Model{} m.chat.Messages = []state.Message{ @@ -89,3 +107,12 @@ func TestTrimHistoryKeepsSystemMessagesAndLatestTurns(t *testing.T) { t.Fatalf("expected only latest turns to remain, got %+v", m.chat.Messages) } } + +type resumeSummaryClient struct { + fakeChatClient + summary string +} + +func (c *resumeSummaryClient) GetWorkingSessionSummary(context.Context) (string, error) { + return c.summary, nil +} diff --git a/internal/tui/core/update.go b/internal/tui/core/update.go index 17a2f308..e4985eba 100644 --- a/internal/tui/core/update.go +++ b/internal/tui/core/update.go @@ -789,6 +789,9 @@ func (m *Model) buildMessages() []services.Message { // 按照消息的原始时间顺序进行迭代 for idx, msg := range m.chat.Messages { + if msg.Role == "system" && isResumeSummaryMessage(msg.Content) { + continue + } if msg.Role == "system" && isTransientToolStatusMessage(msg.Content) { continue } diff --git a/internal/tui/core/view.go b/internal/tui/core/view.go index 82366c08..c7f96ceb 100644 --- a/internal/tui/core/view.go +++ b/internal/tui/core/view.go @@ -98,7 +98,7 @@ func (m Model) toComponentMessages() []components.Message { for i, msg := range m.chat.Messages { messages[i] = components.Message{ Role: msg.Role, - Content: msg.Content, + Content: displayMessageContent(msg.Role, msg.Content), Timestamp: msg.Timestamp, Streaming: msg.Streaming, } @@ -106,6 +106,13 @@ func (m Model) toComponentMessages() []components.Message { return messages } +func displayMessageContent(role, content string) string { + if role == "system" && isResumeSummaryMessage(content) { + return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(content), resumeSummaryPrefix)) + } + return content +} + func minInt(a, b int) int { if a < b { return a diff --git a/internal/tui/services/api_client.go b/internal/tui/services/api_client.go index 5816bd82..11362b5a 100644 --- a/internal/tui/services/api_client.go +++ b/internal/tui/services/api_client.go @@ -44,6 +44,11 @@ type ChatClient interface { DefaultModel() string } +// WorkingSessionSummaryProvider 为支持恢复摘要的客户端扩展接口。 +type WorkingSessionSummaryProvider interface { + GetWorkingSessionSummary(ctx context.Context) (string, error) +} + // MemoryStats 是 TUI 展示 memory 面板所需的聚合统计信息。 type MemoryStats struct { PersistentItems int @@ -78,9 +83,14 @@ func NewLocalChatClient() (ChatClient, error) { if maxItems <= 0 { maxItems = 1000 } + workspaceRoot := tools.GetWorkspaceRoot() persistentRepo := repository.NewFileMemoryStore(storePath, maxItems) sessionRepo := repository.NewSessionMemoryStore(maxItems) - workingRepo := repository.NewWorkingMemoryStore() + workingStatePath := "" + if cfg.History.PersistSessionState { + workingStatePath = BuildWorkspaceStatePath(cfg.History.WorkspaceStateDir, workspaceRoot) + } + workingRepo := repository.NewWorkingMemoryStore(workingStatePath) memorySvc := service.NewMemoryService( persistentRepo, sessionRepo, @@ -90,7 +100,7 @@ func NewLocalChatClient() (ChatClient, error) { storePath, cfg.Memory.PersistTypes, ) - workingSvc := service.NewWorkingMemoryService(workingRepo, cfg.History.ShortTermTurns, tools.GetWorkspaceRoot()) + workingSvc := service.NewWorkingMemoryService(workingRepo, cfg.History.ShortTermTurns, workspaceRoot) roleRepo := repository.NewFileRoleStore("./data/roles.json") roleSvc := service.NewRoleService(roleRepo, strings.TrimSpace(cfg.Persona.FilePath)) @@ -188,3 +198,42 @@ func (c *localChatClient) RemoveTodo(ctx context.Context, id string) error { func (c *localChatClient) DefaultModel() string { return provider.DefaultModelForConfig(c.config) } + +// GetWorkingSessionSummary 返回当前工作区上次保存的会话摘要。 +func (c *localChatClient) GetWorkingSessionSummary(ctx context.Context) (string, error) { + if c.workingSvc == nil || c.config == nil || !c.config.History.ResumeLastSession { + return "", nil + } + state, err := c.workingSvc.Get(ctx) + if err != nil || state == nil { + return "", err + } + return formatWorkingSessionSummary(state), nil +} + +func formatWorkingSessionSummary(state *domain.WorkingMemoryState) string { + if state == nil { + return "" + } + lines := make([]string, 0, 6) + if strings.TrimSpace(state.CurrentTask) != "" { + lines = append(lines, "已恢复上次工作现场:") + lines = append(lines, "- 当前目标: "+domain.SummarizeText(state.CurrentTask, 120)) + } + if strings.TrimSpace(state.LastCompletedAction) != "" { + lines = append(lines, "- 上次完成: "+domain.SummarizeText(state.LastCompletedAction, 120)) + } + if strings.TrimSpace(state.CurrentInProgress) != "" { + lines = append(lines, "- 当前进行中: "+domain.SummarizeText(state.CurrentInProgress, 120)) + } + if strings.TrimSpace(state.NextStep) != "" { + lines = append(lines, "- 下一步: "+domain.SummarizeText(state.NextStep, 120)) + } + if len(state.RecentFiles) > 0 { + lines = append(lines, "- 最近文件: "+strings.Join(state.RecentFiles, ", ")) + } + if len(lines) == 0 { + return "" + } + return strings.Join(lines, "\n") +} diff --git a/internal/tui/services/workspace_state_path.go b/internal/tui/services/workspace_state_path.go new file mode 100644 index 00000000..c465594b --- /dev/null +++ b/internal/tui/services/workspace_state_path.go @@ -0,0 +1,20 @@ +package services + +import ( + "crypto/sha1" + "encoding/hex" + "path/filepath" + "strings" +) + +// BuildWorkspaceStatePath 返回当前工作区的会话状态文件路径。 +func BuildWorkspaceStatePath(baseDir, workspaceRoot string) string { + baseDir = strings.TrimSpace(baseDir) + workspaceRoot = strings.TrimSpace(workspaceRoot) + if baseDir == "" || workspaceRoot == "" { + return "" + } + + hash := sha1.Sum([]byte(strings.ToLower(workspaceRoot))) + return filepath.Join(baseDir, hex.EncodeToString(hash[:]), "session_state.json") +} diff --git a/internal/tui/services/workspace_state_path_test.go b/internal/tui/services/workspace_state_path_test.go new file mode 100644 index 00000000..93c97a58 --- /dev/null +++ b/internal/tui/services/workspace_state_path_test.go @@ -0,0 +1,14 @@ +package services + +import "testing" + +func TestBuildWorkspaceStatePathUsesStableHash(t *testing.T) { + baseDir := "./data/workspaces" + root := "D:/neo-code" + + first := BuildWorkspaceStatePath(baseDir, root) + second := BuildWorkspaceStatePath(baseDir, root) + if first == "" || first != second { + t.Fatalf("expected stable workspace state path, got %q and %q", first, second) + } +}