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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)。

Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 设计
Expand All @@ -154,6 +162,8 @@ persona:

召回顺序会优先考虑长期记忆中的用户偏好、项目规则、代码事实、修复经验,再补充 session memory。

此外,working memory 现已支持按 workspace 保存会话快照。程序会记录当前目标、最近完成动作、下一步、最近文件和最近几轮对话,并在下次进入同一工作区时恢复这份现场。

## 运行

```bash
Expand Down Expand Up @@ -183,6 +193,7 @@ go run ./cmd/server
- `config.yaml`:主配置文件
- `config.example.yaml`:配置模板
- `data/memory_rules.json`:长期结构化记忆文件
- `data/workspaces/<workspace-hash>/session_state.json`:按工作区保存的当前工作现场
- `persona.txt`:人设内容

## 安全与本地文件
Expand Down
5 changes: 5 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 12 additions & 3 deletions configs/app_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}

Expand Down
8 changes: 8 additions & 0 deletions configs/app_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
`)
Expand Down Expand Up @@ -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
}
Expand Down
20 changes: 14 additions & 6 deletions internal/server/domain/working_memory.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package domain

import "context"
import (
"context"
"time"
)

// WorkingMemoryTurn 表示一轮可复用的用户/助手对话。
type WorkingMemoryTurn struct {
Expand All @@ -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 定义工作记忆的存取接口。
Expand All @@ -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)
}
93 changes: 86 additions & 7 deletions internal/server/infra/repository/working_memory_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ package repository

import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"

"go-llm-demo/internal/server/domain"
Expand All @@ -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
}
Expand All @@ -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 清空已保存的工作记忆快照。
Expand All @@ -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))
Expand Down
50 changes: 50 additions & 0 deletions internal/server/infra/repository/working_memory_repository_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading