From 867d1c379cb05fd65901af2587975800e3bc9e7a Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 25 Mar 2026 20:38:15 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E6=8F=90=E5=8F=96=E5=99=A8=E9=85=8D=E7=BD=AE=E5=B9=B6?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=8F=90=E4=BE=9B=E6=96=B9=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 + config.example.yaml | 13 +- configs/app_config.go | 137 +++++++++++------- configs/app_config_test.go | 54 ++++++- internal/server/infra/provider/registry.go | 2 +- .../server/infra/provider/registry_test.go | 4 +- 6 files changed, 154 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 51867cb2..45d62733 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,9 @@ memory: max_prompt_chars: 1800 max_items: 1000 storage_path: "./data/memory_rules.json" + extractor: "rule" + extractor_model: "" + extractor_timeout_seconds: 20 persist_types: - "user_preference" - "project_rule" diff --git a/config.example.yaml b/config.example.yaml index b96c2618..6de1f470 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,10 +1,10 @@ -# 应用配置文件示例 +# Example application configuration app: name: "NeoCode" version: "1.0.0" ai: - provider: "openll" # modelscope | deepseek | openll | siliconflow | 豆包大模型 | openai + provider: "openll" # modelscope | deepseek | openll | siliconflow | doubao | openai api_key: "AI_API_KEY" # environment variable name; falls back to AI_API_KEY when empty model: "gpt-5.4" @@ -14,6 +14,15 @@ memory: max_prompt_chars: 1800 max_items: 1000 storage_path: "./data/memory_rules.json" + project_files: + - "AGENTS.md" + - "CLAUDE.md" + - ".neocode/memory.md" + - "NEOCODE.md" + project_prompt_chars: 2400 + extractor: "rule" # rule | llm | auto + extractor_model: "" # optional; defaults to ai.model when empty + extractor_timeout_seconds: 20 persist_types: - "user_preference" - "project_rule" diff --git a/configs/app_config.go b/configs/app_config.go index b5681684..b4c5242b 100644 --- a/configs/app_config.go +++ b/configs/app_config.go @@ -24,12 +24,17 @@ type AppConfiguration struct { } `yaml:"ai"` Memory struct { - TopK int `yaml:"top_k"` - MinMatchScore float64 `yaml:"min_match_score"` - MaxPromptChars int `yaml:"max_prompt_chars"` - MaxItems int `yaml:"max_items"` - StoragePath string `yaml:"storage_path"` - PersistTypes []string `yaml:"persist_types"` + TopK int `yaml:"top_k"` + MinMatchScore float64 `yaml:"min_match_score"` + MaxPromptChars int `yaml:"max_prompt_chars"` + MaxItems int `yaml:"max_items"` + StoragePath string `yaml:"storage_path"` + PersistTypes []string `yaml:"persist_types"` + ProjectFiles []string `yaml:"project_files"` + ProjectPromptChars int `yaml:"project_prompt_chars"` + Extractor string `yaml:"extractor"` + ExtractorModel string `yaml:"extractor_model"` + ExtractorTimeoutSecond int `yaml:"extractor_timeout_seconds"` } `yaml:"memory"` History struct { @@ -48,7 +53,7 @@ type AppConfiguration struct { var GlobalAppConfig *AppConfiguration -// DefaultAppConfig 返回内置的应用默认配置。 +// DefaultAppConfig returns the built-in application defaults. func DefaultAppConfig() *AppConfiguration { cfg := &AppConfiguration{} cfg.App.Name = "NeoCode" @@ -62,6 +67,11 @@ func DefaultAppConfig() *AppConfiguration { cfg.Memory.MaxItems = 1000 cfg.Memory.StoragePath = "./data/memory_rules.json" cfg.Memory.PersistTypes = []string{"user_preference", "project_rule", "code_fact", "fix_recipe"} + cfg.Memory.ProjectFiles = []string{"AGENTS.md", "CLAUDE.md", ".neocode/memory.md", "NEOCODE.md"} + cfg.Memory.ProjectPromptChars = 2400 + cfg.Memory.Extractor = "rule" + cfg.Memory.ExtractorModel = "" + cfg.Memory.ExtractorTimeoutSecond = 20 cfg.History.ShortTermTurns = 6 cfg.History.MaxToolContextMessages = 3 cfg.History.MaxToolContextOutputSize = 4000 @@ -72,7 +82,7 @@ func DefaultAppConfig() *AppConfiguration { return cfg } -// LoadAppConfig 加载运行时配置并保存到全局变量。 +// LoadAppConfig loads runtime config and stores it globally. func LoadAppConfig(filePath string) error { cfg, err := LoadBootstrapConfig(filePath) if err != nil { @@ -85,17 +95,16 @@ func LoadAppConfig(filePath string) error { return nil } -// LoadBootstrapConfig 加载不依赖运行时密钥的基础配置。 +// LoadBootstrapConfig loads config without requiring runtime secrets. func LoadBootstrapConfig(filePath string) (*AppConfiguration, error) { data, err := os.ReadFile(filePath) if err != nil { - return nil, fmt.Errorf("读取配置文件时出错: %w", err) + return nil, fmt.Errorf("read config file: %w", err) } cfg := DefaultAppConfig() - //解析data数据覆盖到cfg上 if err := yaml.Unmarshal(data, cfg); err != nil { - return nil, fmt.Errorf("解析yaml信息失败: %w", err) + return nil, fmt.Errorf("parse yaml config: %w", err) } if err := cfg.ValidateBase(); err != nil { return nil, err @@ -103,13 +112,13 @@ func LoadBootstrapConfig(filePath string) (*AppConfiguration, error) { return cfg, nil } -// EnsureConfigFile 加载已有配置文件,或在缺失时写入默认配置。 +// EnsureConfigFile loads an existing config or writes defaults when missing. func EnsureConfigFile(filePath string) (*AppConfiguration, bool, error) { if _, err := os.Stat(filePath); err == nil { cfg, loadErr := LoadBootstrapConfig(filePath) return cfg, false, loadErr } else if !errors.Is(err, os.ErrNotExist) { - return nil, false, fmt.Errorf("文件不存在: %w", err) + return nil, false, fmt.Errorf("stat config file: %w", err) } cfg := DefaultAppConfig() @@ -119,87 +128,99 @@ func EnsureConfigFile(filePath string) (*AppConfiguration, bool, error) { return cfg, true, nil } -// WriteAppConfig 将应用配置写入磁盘。 +// WriteAppConfig persists application config to disk. func WriteAppConfig(filePath string, cfg *AppConfiguration) error { if cfg == nil { - return fmt.Errorf("应用配置不能为空") + return fmt.Errorf("app configuration cannot be nil") } cfgCopy := *cfg cfgCopy.AI.APIKey = strings.TrimSpace(cfgCopy.AI.APIKey) data, err := yaml.Marshal(&cfgCopy) if err != nil { - return fmt.Errorf("序列化yaml信息时错误: %w", err) + return fmt.Errorf("marshal yaml config: %w", err) } if err := os.WriteFile(filePath, data, 0o644); err != nil { - return fmt.Errorf("向yaml文件写入配置信息时错误: %w", err) + return fmt.Errorf("write yaml config: %w", err) } return nil } -// Validate 检查配置是否满足运行时要求。 +// Validate checks runtime config requirements. func (c *AppConfiguration) Validate() error { return c.ValidateRuntime() } -// ValidateBase 检查不包含密钥的基础配置是否合法。 +// ValidateBase checks config values that do not require secrets. func (c *AppConfiguration) ValidateBase() error { if c == nil { - return fmt.Errorf("应用配置不能为空") + return fmt.Errorf("app configuration cannot be nil") } + providerName := normalizeProviderName(c.AI.Provider) if providerName == "" { - return fmt.Errorf("配置无效:需要 ai.provider") + return fmt.Errorf("invalid config: ai.provider is required") } if !isSupportedProvider(providerName) { - return fmt.Errorf("配置无效:不支持的 ai.provider %q", strings.TrimSpace(c.AI.Provider)) + return fmt.Errorf("invalid config: unsupported ai.provider %q", strings.TrimSpace(c.AI.Provider)) } c.AI.Provider = providerName + if strings.TrimSpace(c.AI.Model) == "" { - return fmt.Errorf("配置无效:需要 ai.model") + return fmt.Errorf("invalid config: ai.model is required") } if c.Memory.TopK <= 0 { - return fmt.Errorf("配置无效:memory.top_k 必须大于 0") + return fmt.Errorf("invalid config: memory.top_k must be greater than 0") } if c.Memory.MinMatchScore < 0 { - return fmt.Errorf("配置无效:memory.min_match_score 不能为负数") + return fmt.Errorf("invalid config: memory.min_match_score cannot be negative") } if c.Memory.MaxPromptChars <= 0 { - return fmt.Errorf("配置无效:memory.max_prompt_chars 必须大于 0") + return fmt.Errorf("invalid config: memory.max_prompt_chars must be greater than 0") } if c.Memory.MaxItems <= 0 { - return fmt.Errorf("配置无效:memory.max_items 必须大于 0") + return fmt.Errorf("invalid config: memory.max_items must be greater than 0") } if strings.TrimSpace(c.Memory.StoragePath) == "" { - return fmt.Errorf("配置无效:需要 memory.storage_path") + return fmt.Errorf("invalid config: memory.storage_path is required") + } + if c.Memory.ProjectPromptChars <= 0 { + return fmt.Errorf("invalid config: memory.project_prompt_chars must be greater than 0") + } + c.Memory.Extractor = normalizeMemoryExtractorMode(c.Memory.Extractor) + if c.Memory.Extractor == "" { + return fmt.Errorf("invalid config: memory.extractor must be one of rule, llm, auto") + } + if c.Memory.ExtractorTimeoutSecond <= 0 { + return fmt.Errorf("invalid config: memory.extractor_timeout_seconds must be greater than 0") } if c.History.ShortTermTurns <= 0 { - return fmt.Errorf("配置无效:history.short_term_turns 必须大于 0") + return fmt.Errorf("invalid config: history.short_term_turns must be greater than 0") } if c.History.MaxToolContextMessages < 0 { - return fmt.Errorf("配置无效:history.max_tool_context_messages 不能为负数") + return fmt.Errorf("invalid config: history.max_tool_context_messages cannot be negative") } if c.History.MaxToolContextOutputSize <= 0 { - return fmt.Errorf("配置无效:history.max_tool_context_output_size 必须大于 0") + return fmt.Errorf("invalid config: history.max_tool_context_output_size must be greater than 0") } if c.History.PersistSessionState && strings.TrimSpace(c.History.WorkspaceStateDir) == "" { - return fmt.Errorf("配置无效:history.workspace_state_dir 不能为空") + return fmt.Errorf("invalid config: history.workspace_state_dir cannot be empty") } return nil } -// ValidateRuntime 检查配置字段和运行时必需的环境变量。 +// ValidateRuntime checks config and required environment variables. func (c *AppConfiguration) ValidateRuntime() error { if err := c.ValidateBase(); err != nil { return err } envVarName := c.APIKeyEnvVarName() if c.RuntimeAPIKey() == "" { - return fmt.Errorf("运行时无效:需要 %s 环境变量", envVarName) + return fmt.Errorf("invalid runtime config: missing %s environment variable", envVarName) } return nil } -// APIKeyEnvVarName 返回当前配置使用的 API Key 环境变量名。 +// APIKeyEnvVarName returns the configured environment variable name for the API key. func (c *AppConfiguration) APIKeyEnvVarName() string { if c == nil { return DefaultAPIKeyEnvVar @@ -210,12 +231,12 @@ func (c *AppConfiguration) APIKeyEnvVarName() string { return DefaultAPIKeyEnvVar } -// RuntimeAPIKey 返回配置指向的环境变量中的 API Key,并去掉首尾空白。 +// RuntimeAPIKey returns the actual API key from environment variables. func (c *AppConfiguration) RuntimeAPIKey() string { return strings.TrimSpace(os.Getenv(c.APIKeyEnvVarName())) } -// RuntimeAPIKeyEnvVarName 返回全局配置当前使用的 API Key 环境变量名。 +// RuntimeAPIKeyEnvVarName returns the active API key environment variable name. func RuntimeAPIKeyEnvVarName() string { if GlobalAppConfig != nil { return GlobalAppConfig.APIKeyEnvVarName() @@ -223,7 +244,7 @@ func RuntimeAPIKeyEnvVarName() string { return DefaultAPIKeyEnvVar } -// RuntimeAPIKey 返回全局配置指向的环境变量中的 API Key,并去掉首尾空白。 +// RuntimeAPIKey returns the active API key from the global config. func RuntimeAPIKey() string { if GlobalAppConfig != nil { return GlobalAppConfig.RuntimeAPIKey() @@ -236,32 +257,42 @@ func normalizeProviderName(name string) string { if trimmed == "" { return "" } - if strings.EqualFold(trimmed, "modelscope") { + switch { + case strings.EqualFold(trimmed, "modelscope"): return "modelscope" - } - if strings.EqualFold(trimmed, "deepseek") { + case strings.EqualFold(trimmed, "deepseek"): return "deepseek" - } - if strings.EqualFold(trimmed, "openll") { + case strings.EqualFold(trimmed, "openll"): return "openll" - } - if strings.EqualFold(trimmed, "siliconflow") { + case strings.EqualFold(trimmed, "siliconflow"): return "siliconflow" - } - if strings.EqualFold(trimmed, "openai") { + case strings.EqualFold(trimmed, "openai"): return "openai" + case strings.EqualFold(trimmed, "doubao"): + return "doubao" + default: + return trimmed } - if trimmed == "豆包大模型" { - return "豆包大模型" - } - return trimmed } func isSupportedProvider(name string) bool { switch normalizeProviderName(name) { - case "modelscope", "deepseek", "openll", "siliconflow", "豆包大模型", "openai": + case "modelscope", "deepseek", "openll", "siliconflow", "openai", "doubao": return true default: return false } } + +func normalizeMemoryExtractorMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", "rule": + return "rule" + case "llm": + return "llm" + case "auto": + return "auto" + default: + return "" + } +} diff --git a/configs/app_config_test.go b/configs/app_config_test.go index 82d58c91..e0a70321 100644 --- a/configs/app_config_test.go +++ b/configs/app_config_test.go @@ -76,11 +76,22 @@ func TestAppConfigurationValidateBaseRejectsUnsupportedProvider(t *testing.T) { cfg.AI.Provider = "unknown" err := cfg.ValidateBase() - if err == nil || !strings.Contains(err.Error(), "不支持的 ai.provider") { + if err == nil || !strings.Contains(err.Error(), "unsupported ai.provider") { t.Fatalf("expected unsupported provider error, got: %v", err) } } +func TestAppConfigurationValidateRejectsUnsupportedMemoryExtractor(t *testing.T) { + t.Setenv(DefaultAPIKeyEnvVar, "env-chat-key") + cfg := validConfig() + cfg.Memory.Extractor = "unknown" + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "memory.extractor") { + t.Fatalf("expected memory.extractor validation error, got: %v", err) + } +} + func TestLoadAppConfig(t *testing.T) { t.Setenv("CUSTOM_CHAT_KEY", "env-chat-key") dir := t.TempDir() @@ -98,6 +109,13 @@ memory: max_prompt_chars: 1800 max_items: 1000 storage_path: "./data/memory_rules.json" + project_files: + - "AGENTS.md" + - ".neocode/memory.md" + project_prompt_chars: 2400 + extractor: "auto" + extractor_model: "gpt-5.4-mini" + extractor_timeout_seconds: 15 history: short_term_turns: 6 max_tool_context_messages: 3 @@ -126,6 +144,12 @@ persona: if got := GlobalAppConfig.RuntimeAPIKey(); got != "env-chat-key" { t.Fatalf("expected runtime api key from custom env, got %q", got) } + if GlobalAppConfig.Memory.Extractor != "auto" { + t.Fatalf("expected memory extractor auto, got %q", GlobalAppConfig.Memory.Extractor) + } + if GlobalAppConfig.Memory.ExtractorModel != "gpt-5.4-mini" { + t.Fatalf("expected extractor model gpt-5.4-mini, got %q", GlobalAppConfig.Memory.ExtractorModel) + } } func TestAppConfigurationValidateMissingMemoryStoragePath(t *testing.T) { @@ -182,6 +206,9 @@ func TestWriteAppConfigRoundTrip(t *testing.T) { if got.Memory.StoragePath != want.Memory.StoragePath { t.Fatalf("expected storage path %q, got %q", want.Memory.StoragePath, got.Memory.StoragePath) } + if got.Memory.Extractor != want.Memory.Extractor { + t.Fatalf("expected extractor %q, got %q", want.Memory.Extractor, got.Memory.Extractor) + } } func validConfig() *AppConfiguration { @@ -195,6 +222,11 @@ func validConfig() *AppConfiguration { cfg.Memory.MaxItems = 1000 cfg.Memory.StoragePath = "./data/memory_rules.json" cfg.Memory.PersistTypes = []string{"user_preference", "project_rule", "code_fact", "fix_recipe"} + cfg.Memory.ProjectFiles = []string{"AGENTS.md", ".neocode/memory.md"} + cfg.Memory.ProjectPromptChars = 2400 + cfg.Memory.Extractor = "rule" + cfg.Memory.ExtractorModel = "" + cfg.Memory.ExtractorTimeoutSecond = 20 cfg.History.ShortTermTurns = 6 cfg.History.MaxToolContextMessages = 3 cfg.History.MaxToolContextOutputSize = 4000 @@ -210,6 +242,26 @@ func TestDefaultAppConfigUsesCheckedInPersonaPath(t *testing.T) { if cfg.Persona.FilePath != DefaultPersonaFilePath { t.Fatalf("expected default persona path %q, got %q", DefaultPersonaFilePath, cfg.Persona.FilePath) } + if cfg.Memory.Extractor != "rule" { + t.Fatalf("expected default memory extractor to be rule, got %q", cfg.Memory.Extractor) + } + if cfg.Memory.ProjectPromptChars != 2400 { + t.Fatalf("expected default project prompt chars 2400, got %d", cfg.Memory.ProjectPromptChars) + } + if cfg.Memory.ExtractorTimeoutSecond != 20 { + t.Fatalf("expected default extractor timeout 20, got %d", cfg.Memory.ExtractorTimeoutSecond) + } +} + +func TestAppConfigurationValidateRejectsInvalidProjectPromptChars(t *testing.T) { + t.Setenv(DefaultAPIKeyEnvVar, "env-chat-key") + cfg := validConfig() + cfg.Memory.ProjectPromptChars = 0 + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "memory.project_prompt_chars") { + t.Fatalf("expected project prompt chars validation error, got: %v", err) + } } func TestResolvePersonaFilePathFallsBackToCheckedInFile(t *testing.T) { diff --git a/internal/server/infra/provider/registry.go b/internal/server/infra/provider/registry.go index 5c20741c..dcab0647 100644 --- a/internal/server/infra/provider/registry.go +++ b/internal/server/infra/provider/registry.go @@ -20,7 +20,7 @@ var providerSpecs = []ProviderSpec{ {Name: "deepseek", BaseURL: "https://api.deepseek.com/chat/completions", DefaultModel: "deepseek-chat"}, {Name: "openll", BaseURL: "https://www.openll.top/v1/chat/completions", DefaultModel: "gpt-5.4"}, {Name: "siliconflow", BaseURL: "https://api.siliconflow.cn/v1/chat/completions", DefaultModel: "zai-org/GLM-4.6"}, - {Name: "豆包大模型", BaseURL: "https://ark.cn-beijing.volces.com/api/v3/chat/completions", DefaultModel: "doubao-pro-v1"}, + {Name: "doubao", BaseURL: "https://ark.cn-beijing.volces.com/api/v3/chat/completions", DefaultModel: "doubao-pro-v1"}, {Name: "openai", BaseURL: "https://api.openai.com/v1/chat/completions", DefaultModel: "gpt-5.4"}, } diff --git a/internal/server/infra/provider/registry_test.go b/internal/server/infra/provider/registry_test.go index 0f521b3d..56c61328 100644 --- a/internal/server/infra/provider/registry_test.go +++ b/internal/server/infra/provider/registry_test.go @@ -17,7 +17,7 @@ func TestNormalizeProviderName(t *testing.T) { "DEEPSEEK": "deepseek", "OPENLL": "openll", "siliconflow": "siliconflow", - "豆包大模型": "豆包大模型", + "doubao": "doubao", "openai": "openai", } @@ -38,7 +38,7 @@ func TestDefaultModelForProvider(t *testing.T) { "deepseek": "deepseek-chat", "openll": "gpt-5.4", "siliconflow": "zai-org/GLM-4.6", - "豆包大模型": "doubao-pro-v1", + "doubao": "doubao-pro-v1", "openai": "gpt-5.4", } From c0a9f0c6a4227844bac42853b0262e71182c897d Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 25 Mar 2026 20:39:35 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=E6=8A=BD=E8=B1=A1=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E6=8F=90=E5=8F=96=E5=99=A8=E5=B9=B6=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E8=AE=B0=E5=BF=86=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/domain/memory.go | 19 +- internal/server/domain/project_memory.go | 15 + .../server/service/llm_memory_extractor.go | 223 ++++++++++++ .../service/llm_memory_extractor_test.go | 93 +++++ .../service/memory_extractor_factory.go | 54 +++ internal/server/service/memory_service.go | 339 ++++-------------- .../server/service/project_memory_service.go | 136 +++++++ .../service/project_memory_service_test.go | 60 ++++ .../service/rule_based_memory_extractor.go | 284 +++++++++++++++ .../rule_based_memory_extractor_test.go | 138 +++++++ 10 files changed, 1086 insertions(+), 275 deletions(-) create mode 100644 internal/server/domain/project_memory.go create mode 100644 internal/server/service/llm_memory_extractor.go create mode 100644 internal/server/service/llm_memory_extractor_test.go create mode 100644 internal/server/service/memory_extractor_factory.go create mode 100644 internal/server/service/project_memory_service.go create mode 100644 internal/server/service/project_memory_service_test.go create mode 100644 internal/server/service/rule_based_memory_extractor.go create mode 100644 internal/server/service/rule_based_memory_extractor_test.go diff --git a/internal/server/domain/memory.go b/internal/server/domain/memory.go index 0ae4e9e6..8c9328d2 100644 --- a/internal/server/domain/memory.go +++ b/internal/server/domain/memory.go @@ -49,6 +49,11 @@ type MemoryService interface { ClearSession(ctx context.Context) error } +// MemoryExtractor extracts structured memory items from one conversation turn. +type MemoryExtractor interface { + Extract(ctx context.Context, userInput, assistantReply string) ([]MemoryItem, error) +} + type MemoryStats struct { PersistentItems int SessionItems int @@ -59,7 +64,7 @@ type MemoryStats struct { ByType map[string]int } -// IsPersistentType 判断记忆类型是否应该长期持久化。 +// IsPersistentType reports whether the memory type should be stored long-term. func IsPersistentType(itemType string) bool { switch strings.TrimSpace(itemType) { case TypeUserPreference, TypeProjectRule, TypeCodeFact, TypeFixRecipe: @@ -69,7 +74,7 @@ func IsPersistentType(itemType string) bool { } } -// Normalized 为记忆项补齐缺失字段并应用默认值。 +// Normalized fills missing fields and applies defaults to a memory item. func (i MemoryItem) Normalized() MemoryItem { normalized := i if normalized.Type == "" { @@ -116,7 +121,7 @@ func (i MemoryItem) Normalized() MemoryItem { return normalized } -// SearchText 根据记忆项字段构建用于检索的文本。 +// SearchText builds a text blob used for retrieval. func (i MemoryItem) SearchText() string { parts := make([]string, 0, 4) if strings.TrimSpace(i.Type) != "" { @@ -137,7 +142,7 @@ func (i MemoryItem) SearchText() string { return strings.TrimSpace(strings.Join(parts, "\n")) } -// PromptBlock 将记忆项格式化为可注入提示词的文本块。 +// PromptBlock formats a memory item for prompt injection. func (i MemoryItem) PromptBlock() string { parts := []string{ "Type: " + i.Type, @@ -153,7 +158,7 @@ func (i MemoryItem) PromptBlock() string { return strings.Join(parts, "\n") } -// SummarizeText 按指定最大长度截断文本。 +// SummarizeText truncates text to the given maximum length. func SummarizeText(text string, maxLen int) string { trimmed := strings.TrimSpace(text) if len(trimmed) <= maxLen { @@ -165,7 +170,7 @@ func SummarizeText(text string, maxLen int) string { return trimmed[:maxLen-3] + "..." } -// InferTags 从自由文本中推断简要主题标签。 +// InferTags derives short topic tags from free-form text. func InferTags(text string) []string { trimmed := strings.ToLower(text) tags := make([]string, 0, 6) @@ -198,7 +203,7 @@ func InferTags(text string) []string { return tags } -// Keywords 从文本中提取去重后的小写关键词。 +// Keywords extracts unique lowercase keywords from text. func Keywords(text string) []string { text = strings.ToLower(text) replacer := strings.NewReplacer( diff --git a/internal/server/domain/project_memory.go b/internal/server/domain/project_memory.go new file mode 100644 index 00000000..612c7aa9 --- /dev/null +++ b/internal/server/domain/project_memory.go @@ -0,0 +1,15 @@ +package domain + +import "context" + +// ProjectMemorySource represents an explicit memory file loaded from the workspace. +type ProjectMemorySource struct { + Path string + Content string +} + +// ProjectMemoryService loads explicit project memory files and formats them for prompt injection. +type ProjectMemoryService interface { + BuildContext(ctx context.Context) (string, error) + ListSources(ctx context.Context) ([]ProjectMemorySource, error) +} diff --git a/internal/server/service/llm_memory_extractor.go b/internal/server/service/llm_memory_extractor.go new file mode 100644 index 00000000..e1b6a57d --- /dev/null +++ b/internal/server/service/llm_memory_extractor.go @@ -0,0 +1,223 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "go-llm-demo/internal/server/domain" +) + +const llmMemoryExtractorPrompt = `You are a memory extraction component for a coding assistant. +Return JSON only. Do not include markdown fences, prose, or explanations. + +Extract at most 5 memory items from the conversation turn. +Only store memories that are useful for future coding assistance. +Prefer fewer, higher-quality items. + +Allowed types: +- user_preference: explicit durable user preferences such as language, formatting, workflow, or response style +- project_rule: concrete project conventions, commands, config locations, or repository rules backed by evidence +- code_fact: concrete codebase facts like file responsibility, symbol purpose, or implementation location +- fix_recipe: a problem and the fix that resolved it +- session_memory: temporary but useful coding context for the current session + +Rules: +- Do not store tool protocol payloads or raw tool JSON. +- Do not store generic pleasantries or assistant filler. +- Do not invent facts. +- Only create user_preference when the user clearly expresses a durable preference. +- Only create project_rule when the turn contains concrete repository or configuration evidence. +- Only create code_fact when the turn contains concrete file, symbol, or module evidence and an explanatory relationship. +- If nothing is worth storing, return {"items":[]}. + +JSON schema: +{ + "items": [ + { + "type": "user_preference | project_rule | code_fact | fix_recipe | session_memory", + "scope": "user | project | session", + "summary": "short summary", + "details": "optional details", + "confidence": 0.0 + } + ] +}` + +type llmMemoryExtractor struct { + provider domain.ChatProvider + timeout time.Duration + fallback domain.MemoryExtractor +} + +type llmMemoryExtractionEnvelope struct { + Items []llmMemoryCandidate `json:"items"` +} + +type llmMemoryCandidate struct { + Type string `json:"type"` + Scope string `json:"scope"` + Summary string `json:"summary"` + Details string `json:"details"` + Confidence float64 `json:"confidence"` +} + +// NewLLMMemoryExtractor returns an extractor backed by a chat model. +func NewLLMMemoryExtractor(provider domain.ChatProvider, opts LLMMemoryExtractorOptions) domain.MemoryExtractor { + timeout := opts.Timeout + if timeout <= 0 { + timeout = 20 * time.Second + } + return &llmMemoryExtractor{ + provider: provider, + timeout: timeout, + fallback: opts.Fallback, + } +} + +// Extract uses an LLM to classify and structure memory candidates. +func (e *llmMemoryExtractor) Extract(ctx context.Context, userInput, assistantReply string) ([]domain.MemoryItem, error) { + if shouldSkipConversationMemory(userInput, assistantReply) { + return nil, nil + } + if e.provider == nil { + return e.fallbackExtract(ctx, userInput, assistantReply, fmt.Errorf("llm memory extractor provider is nil")) + } + + reqCtx := ctx + cancel := func() {} + if e.timeout > 0 { + reqCtx, cancel = context.WithTimeout(ctx, e.timeout) + } + defer cancel() + + raw, err := e.runExtractionPrompt(reqCtx, userInput, assistantReply) + if err != nil { + return e.fallbackExtract(ctx, userInput, assistantReply, err) + } + + items, err := e.parseExtraction(raw, userInput, assistantReply) + if err != nil { + return e.fallbackExtract(ctx, userInput, assistantReply, err) + } + return items, nil +} + +func (e *llmMemoryExtractor) runExtractionPrompt(ctx context.Context, userInput, assistantReply string) (string, error) { + prompt := fmt.Sprintf("Conversation turn:\nUser:\n%s\n\nAssistant:\n%s", strings.TrimSpace(userInput), strings.TrimSpace(assistantReply)) + messages := []domain.Message{ + {Role: "system", Content: llmMemoryExtractorPrompt}, + {Role: "user", Content: prompt}, + } + + out, err := e.provider.Chat(ctx, messages) + if err != nil { + return "", err + } + + var builder strings.Builder + for { + select { + case <-ctx.Done(): + return "", ctx.Err() + case chunk, ok := <-out: + if !ok { + result := strings.TrimSpace(builder.String()) + if result == "" { + return "", fmt.Errorf("llm memory extractor returned an empty response") + } + return result, nil + } + builder.WriteString(chunk) + } + } +} + +func (e *llmMemoryExtractor) parseExtraction(raw, userInput, assistantReply string) ([]domain.MemoryItem, error) { + jsonText := extractJSONObject(raw) + if jsonText == "" { + return nil, fmt.Errorf("llm memory extractor returned non-json content") + } + + var envelope llmMemoryExtractionEnvelope + if err := json.Unmarshal([]byte(jsonText), &envelope); err != nil { + return nil, fmt.Errorf("decode llm memory extraction: %w", err) + } + + now := time.Now().UTC() + items := make([]domain.MemoryItem, 0, len(envelope.Items)) + for _, candidate := range envelope.Items { + item, ok := normalizeLLMMemoryCandidate(candidate, now, userInput, assistantReply) + if !ok { + continue + } + items = append(items, item) + } + return dedupeMemoryItems(items), nil +} + +func (e *llmMemoryExtractor) fallbackExtract(ctx context.Context, userInput, assistantReply string, cause error) ([]domain.MemoryItem, error) { + if e.fallback == nil { + return nil, cause + } + return e.fallback.Extract(ctx, userInput, assistantReply) +} + +func normalizeLLMMemoryCandidate(candidate llmMemoryCandidate, now time.Time, userInput, assistantReply string) (domain.MemoryItem, bool) { + itemType := strings.TrimSpace(candidate.Type) + switch itemType { + case domain.TypeUserPreference, domain.TypeProjectRule, domain.TypeCodeFact, domain.TypeFixRecipe, domain.TypeSessionMemory: + default: + return domain.MemoryItem{}, false + } + + summary := strings.TrimSpace(candidate.Summary) + if summary == "" { + return domain.MemoryItem{}, false + } + + scope := strings.TrimSpace(candidate.Scope) + if scope == "" { + scope = domain.MemoryItem{Type: itemType}.Normalized().Scope + } + + confidence := candidate.Confidence + if confidence <= 0 || confidence > 1 { + confidence = 0.78 + } + + return newConversationMemoryItem( + now, + itemType, + scope, + summary, + strings.TrimSpace(candidate.Details), + userInput, + assistantReply, + conversationText(userInput, assistantReply), + confidence, + ), true +} + +func extractJSONObject(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + if strings.HasPrefix(trimmed, "```") { + trimmed = strings.TrimPrefix(trimmed, "```json") + trimmed = strings.TrimPrefix(trimmed, "```JSON") + trimmed = strings.TrimPrefix(trimmed, "```") + trimmed = strings.TrimSuffix(trimmed, "```") + trimmed = strings.TrimSpace(trimmed) + } + + start := strings.Index(trimmed, "{") + end := strings.LastIndex(trimmed, "}") + if start == -1 || end == -1 || end < start { + return "" + } + return trimmed[start : end+1] +} diff --git a/internal/server/service/llm_memory_extractor_test.go b/internal/server/service/llm_memory_extractor_test.go new file mode 100644 index 00000000..4d68a4a3 --- /dev/null +++ b/internal/server/service/llm_memory_extractor_test.go @@ -0,0 +1,93 @@ +package service + +import ( + "context" + "testing" + "time" + + "go-llm-demo/internal/server/domain" +) + +type stubLLMChatProvider struct { + response string + err error +} + +func (s stubLLMChatProvider) GetModelName() string { + return "stub" +} + +func (s stubLLMChatProvider) Chat(context.Context, []domain.Message) (<-chan string, error) { + if s.err != nil { + return nil, s.err + } + out := make(chan string, 1) + go func() { + defer close(out) + out <- s.response + }() + return out, nil +} + +func TestLLMMemoryExtractorParsesStructuredResponse(t *testing.T) { + extractor := NewLLMMemoryExtractor(stubLLMChatProvider{ + response: `{"items":[{"type":"user_preference","scope":"user","summary":"Use Chinese for future replies","details":"The user asked for Chinese responses going forward.","confidence":0.93}]}`, + }, LLMMemoryExtractorOptions{Timeout: time.Second}) + + items, err := extractor.Extract(context.Background(), "以后请用中文回复。", "好的,后续我会用中文。") + if err != nil { + t.Fatalf("extract: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected one extracted item, got %+v", items) + } + if items[0].Type != domain.TypeUserPreference { + t.Fatalf("expected user_preference, got %+v", items[0]) + } +} + +func TestLLMMemoryExtractorFallsBackToRuleExtractorOnInvalidJSON(t *testing.T) { + extractor := NewLLMMemoryExtractor(stubLLMChatProvider{ + response: `not-json`, + }, LLMMemoryExtractorOptions{ + Timeout: time.Second, + Fallback: NewRuleBasedMemoryExtractor(), + }) + + items, err := extractor.Extract(context.Background(), "以后回答中文,命令和说明都用中文。", "好的,后续我会统一使用中文回复。") + if err != nil { + t.Fatalf("extract with fallback: %v", err) + } + + var hasPreference bool + for _, item := range items { + if item.Type == domain.TypeUserPreference { + hasPreference = true + } + } + if !hasPreference { + t.Fatalf("expected fallback rule extractor to produce user_preference, got %+v", items) + } +} + +func TestBuildMemoryExtractorAutoFallsBackToRuleWhenProviderMissing(t *testing.T) { + extractor, err := BuildMemoryExtractor(MemoryExtractorModeAuto, nil, LLMMemoryExtractorOptions{}) + if err != nil { + t.Fatalf("build extractor: %v", err) + } + + items, err := extractor.Extract(context.Background(), "What does memory_repository.go do?", "internal/server/infra/repository/memory_repository.go is responsible for persistent memory storage.") + if err != nil { + t.Fatalf("extract: %v", err) + } + + var hasCodeFact bool + for _, item := range items { + if item.Type == domain.TypeCodeFact { + hasCodeFact = true + } + } + if !hasCodeFact { + t.Fatalf("expected auto mode without provider to use rule extractor, got %+v", items) + } +} diff --git a/internal/server/service/memory_extractor_factory.go b/internal/server/service/memory_extractor_factory.go new file mode 100644 index 00000000..313093b0 --- /dev/null +++ b/internal/server/service/memory_extractor_factory.go @@ -0,0 +1,54 @@ +package service + +import ( + "fmt" + "strings" + "time" + + "go-llm-demo/internal/server/domain" +) + +const ( + MemoryExtractorModeRule = "rule" + MemoryExtractorModeLLM = "llm" + MemoryExtractorModeAuto = "auto" +) + +type LLMMemoryExtractorOptions struct { + Timeout time.Duration + Fallback domain.MemoryExtractor +} + +// BuildMemoryExtractor constructs the configured extractor strategy. +func BuildMemoryExtractor(mode string, llmProvider domain.ChatProvider, opts LLMMemoryExtractorOptions) (domain.MemoryExtractor, error) { + switch normalizeExtractorMode(mode) { + case MemoryExtractorModeRule: + return NewRuleBasedMemoryExtractor(), nil + case MemoryExtractorModeLLM: + if llmProvider == nil { + return nil, fmt.Errorf("llm memory extractor requires a chat provider") + } + return NewLLMMemoryExtractor(llmProvider, opts), nil + case MemoryExtractorModeAuto: + if llmProvider == nil { + return NewRuleBasedMemoryExtractor(), nil + } + opts.Fallback = NewRuleBasedMemoryExtractor() + return NewLLMMemoryExtractor(llmProvider, opts), nil + default: + return nil, fmt.Errorf("unsupported memory extractor mode %q", strings.TrimSpace(mode)) + } +} + +func normalizeExtractorMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", MemoryExtractorModeRule: + return MemoryExtractorModeRule + case MemoryExtractorModeLLM: + return MemoryExtractorModeLLM + case MemoryExtractorModeAuto: + return MemoryExtractorModeAuto + default: + return "" + } +} diff --git a/internal/server/service/memory_service.go b/internal/server/service/memory_service.go index e04b91fe..0d6a39d4 100644 --- a/internal/server/service/memory_service.go +++ b/internal/server/service/memory_service.go @@ -2,12 +2,9 @@ package service import ( "context" - "encoding/json" "fmt" "sort" - "strconv" "strings" - "time" "go-llm-demo/internal/server/domain" ) @@ -15,6 +12,7 @@ import ( type memoryServiceImpl struct { persistentRepo domain.MemoryRepository sessionRepo domain.MemoryRepository + extractor domain.MemoryExtractor topK int minScore float64 maxPromptChars int @@ -27,7 +25,7 @@ type Match struct { Score float64 } -// NewMemoryService 使用长期存储和会话存储创建记忆服务。 +// NewMemoryService creates a memory service with the default rule-based extractor. func NewMemoryService( persistentRepo domain.MemoryRepository, sessionRepo domain.MemoryRepository, @@ -37,9 +35,37 @@ func NewMemoryService( path string, persistTypes []string, ) domain.MemoryService { + return NewMemoryServiceWithExtractor( + persistentRepo, + sessionRepo, + NewRuleBasedMemoryExtractor(), + topK, + minScore, + maxPromptChars, + path, + persistTypes, + ) +} + +// NewMemoryServiceWithExtractor creates a memory service with a pluggable extractor. +func NewMemoryServiceWithExtractor( + persistentRepo domain.MemoryRepository, + sessionRepo domain.MemoryRepository, + extractor domain.MemoryExtractor, + topK int, + minScore float64, + maxPromptChars int, + path string, + persistTypes []string, +) domain.MemoryService { + if extractor == nil { + extractor = NewRuleBasedMemoryExtractor() + } + return &memoryServiceImpl{ persistentRepo: persistentRepo, sessionRepo: sessionRepo, + extractor: extractor, topK: topK, minScore: minScore, maxPromptChars: maxPromptChars, @@ -48,7 +74,7 @@ func NewMemoryService( } } -// BuildContext 为当前输入返回得分最高的记忆片段。 +// BuildContext returns the most relevant memory snippets for the current input. func (s *memoryServiceImpl) BuildContext(ctx context.Context, userInput string) (string, error) { persistentItems, err := s.persistentRepo.List(ctx) if err != nil { @@ -63,18 +89,15 @@ func (s *memoryServiceImpl) BuildContext(ctx context.Context, userInput string) sessionMatches := Search(sessionItems, userInput, s.topK, s.minScore) matches := MergeMatches(s.topK, persistentMatches, sessionMatches) - // 新增:进行最终的分数过滤,防止低分项进入上下文 var filteredMatches []Match for _, match := range matches { - if match.Score >= s.minScore { // 确保分数不低于阈值 + if match.Score >= s.minScore { filteredMatches = append(filteredMatches, match) } } - // 如果过滤后没有符合条件的记忆,则直接返回空 if len(filteredMatches) == 0 { return "", nil } - // 使用过滤后的结果 matches = filteredMatches var builder strings.Builder @@ -100,9 +123,13 @@ func (s *memoryServiceImpl) BuildContext(ctx context.Context, userInput string) return builder.String(), nil } -// Save 从一轮对话中提取记忆项并保存。 +// Save extracts memory items from a conversation turn and persists them. func (s *memoryServiceImpl) Save(ctx context.Context, userInput, reply string) error { - items := deriveMemoryItems(userInput, reply) + items, err := s.extractor.Extract(ctx, userInput, reply) + if err != nil { + return err + } + for _, item := range items { if item.Type == domain.TypeSessionMemory { if err := s.sessionRepo.Add(ctx, item); err != nil { @@ -122,7 +149,7 @@ func (s *memoryServiceImpl) Save(ctx context.Context, userInput, reply string) e return nil } -// GetStats 返回记忆服务的数量统计和检索配置。 +// GetStats returns memory counts and retrieval settings. func (s *memoryServiceImpl) GetStats(ctx context.Context) (*domain.MemoryStats, error) { persistentItems, err := s.persistentRepo.List(ctx) if err != nil { @@ -144,17 +171,17 @@ func (s *memoryServiceImpl) GetStats(ctx context.Context) (*domain.MemoryStats, return stats, nil } -// Clear 清空所有长期记忆项。 +// Clear removes all persistent memory items. func (s *memoryServiceImpl) Clear(ctx context.Context) error { return s.persistentRepo.Clear(ctx) } -// ClearSession 清空所有会话级记忆项。 +// ClearSession removes all session-scoped memory items. func (s *memoryServiceImpl) ClearSession(ctx context.Context) error { return s.sessionRepo.Clear(ctx) } -// Search 对记忆项打分并返回与查询最相关的结果。 +// Search scores memory items and returns the most relevant matches. func Search(items []domain.MemoryItem, query string, topK int, minScore float64) []Match { trimmedQuery := strings.TrimSpace(query) if topK <= 0 || trimmedQuery == "" { @@ -182,7 +209,7 @@ func Search(items []domain.MemoryItem, query string, topK int, minScore float64) return matches } -// MergeMatches 对多个匹配结果分组去重并重新排序。 +// MergeMatches merges and resorts multiple match groups. func MergeMatches(topK int, groups ...[]Match) []Match { merged := make([]Match, 0) seen := map[string]Match{} @@ -342,242 +369,6 @@ func matchKey(item domain.MemoryItem) string { return normalized.Type + "::" + normalized.Scope + "::" + normalized.Summary } -func buildMemoryText(userInput, assistantReply string) string { - return strings.TrimSpace(userInput) + "\n" + strings.TrimSpace(assistantReply) -} - -func deriveMemoryItems(userInput, assistantReply string) []domain.MemoryItem { - if shouldSkipMemoryCapture(userInput, assistantReply) { - return nil - } - - now := time.Now().UTC() - items := make([]domain.MemoryItem, 0, 4) - - if preferenceItem, ok := extractPreferenceMemory(userInput, assistantReply, now); ok { - items = append(items, preferenceItem) - } - if ruleItem, ok := extractProjectRuleMemory(userInput, assistantReply, now); ok { - items = append(items, ruleItem) - } - if codeFactItem, ok := extractCodeFactMemory(userInput, assistantReply, now); ok { - items = append(items, codeFactItem) - } - if failureItem, ok := extractFixRecipeMemory(userInput, assistantReply, now); ok { - items = append(items, failureItem) - } - if mainItem, ok := extractSessionMemory(userInput, assistantReply, now); ok { - items = append(items, mainItem) - } - - return dedupeMemoryItems(items) -} - -func extractSessionMemory(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { - combined := buildMemoryText(userInput, assistantReply) - if !isCodingRelevant(userInput, assistantReply) || looksLikeStableInstruction(userInput) || looksLikeProjectFact(userInput, assistantReply) { - return domain.MemoryItem{}, false - } - - summary := domain.SummarizeText(userInput, 140) - if summary == "" { - summary = domain.SummarizeText(assistantReply, 140) - } - - 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) { - trimmed := strings.TrimSpace(userInput) - if trimmed == "" || !looksLikeStableInstruction(trimmed) { - return domain.MemoryItem{}, false - } - - summary := domain.SummarizeText(trimmed, 140) - 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) { - combined := strings.ToLower(buildMemoryText(userInput, assistantReply)) - if !looksLikeProjectFact(userInput, assistantReply) { - return domain.MemoryItem{}, false - } - 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, userInput, assistantReply, buildMemoryText(userInput, assistantReply), 0.9), true -} - -func extractCodeFactMemory(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { - combined := buildMemoryText(userInput, assistantReply) - if !looksLikeCodeKnowledge(userInput, assistantReply) { - return domain.MemoryItem{}, false - } - if containsAnyFold(strings.ToLower(userInput), "帮我", "请你", "写一个", "实现一个") && !containsAnyFold(combined, "在 ", "位于", "负责", "调用", "使用", "路径", "文件", "函数", "模块", "返回", "读取", "写入") { - return domain.MemoryItem{}, false - } - summary := domain.SummarizeText(firstNonEmptyLine(assistantReply, userInput), 140) - 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) { - combined := strings.ToLower(buildMemoryText(userInput, assistantReply)) - hasProblem := containsAnyFold(combined, "error", "failed", "panic", "bug", "报错", "失败", "异常") - hasFix := containsAnyFold(combined, "修复", "已通过", "解决", "fixed", "use", "改为", "增加", "remove", "replace") - if !hasProblem || !hasFix { - return domain.MemoryItem{}, false - } - summary := domain.SummarizeText(firstNonEmptyLine(userInput, assistantReply), 140) - details := assistantReply - if details == "" { - details = userInput - } - 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, 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(userInput), - AssistantReply: strings.TrimSpace(assistantReply), - } - return item.Normalized() -} - -func isCodingRelevant(userInput, assistantReply string) bool { - combined := strings.ToLower(buildMemoryText(userInput, assistantReply)) - if containsAnyFold(combined, - "function", "file", "repo", "project", "build", "test", "config", "bug", "error", "fix", - "golang", "go ", "yaml", "json", "memory", "prompt", "cli", "agent", "编程", "项目", "配置", "测试", "构建", "报错", "修复") { - return true - } - trimmedUser := strings.TrimSpace(strings.ToLower(userInput)) - return len(trimmedUser) > 20 && containsAnyFold(trimmedUser, "code", "代码") -} - -func looksLikeProjectFact(userInput, assistantReply string) bool { - combined := strings.ToLower(buildMemoryText(userInput, assistantReply)) - return hasProjectRuleAnchor(combined) && hasProjectRuleSignal(combined) -} - -func looksLikeCodeKnowledge(userInput, assistantReply string) bool { - combined := buildMemoryText(userInput, assistantReply) - if !isCodingRelevant(userInput, assistantReply) { - return false - } - - hasCodeAnchor := containsAnyFold(combined, - ".go", "config.yaml", "main.go", "services/", "memory/", "json", "yaml", - "function", "func", "struct", "interface", "method", "package", "import", - "函数", "文件", "模块", "包", "结构体", "接口", "方法", "字段", "参数", "路径", "目录") - if !hasCodeAnchor { - return false - } - - trimmedUser := strings.ToLower(strings.TrimSpace(userInput)) - trimmedReply := strings.ToLower(strings.TrimSpace(assistantReply)) - hasQuestionIntent := containsAnyFold(trimmedUser, - "什么", "干嘛", "作用", "怎么", "如何", "why", "where", "which", "负责", "在哪", "含义", "区别") - hasExplanation := containsAnyFold(trimmedReply, - "用于", "负责", "位于", "表示", "通过", "调用", "读取", "写入", "返回", "实现", "处理", "对应", "配置", "路径", "字段", "参数") - - return hasQuestionIntent || hasExplanation || len(strings.TrimSpace(assistantReply)) >= 48 -} - -func looksLikeStableInstruction(text string) bool { - trimmed := strings.TrimSpace(strings.ToLower(text)) - if trimmed == "" { - return false - } - if !containsAnyFold(trimmed, "默认", "始终", "以后", "统一", "不要自动", "回答中文", "只用", "只使用", "只需要", "不要再", "固定", "长期") { - return false - } - 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)) { - return true - } - } - return false -} - -func firstNonEmptyLine(values ...string) string { - for _, value := range values { - for _, line := range strings.Split(value, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - return trimmed - } - } - } - return "" -} - func allowedPersistTypes(configured []string) map[string]struct{} { allowed := map[string]struct{}{} for _, itemType := range configured { @@ -621,22 +412,6 @@ func shortPromptBlock(item domain.MemoryItem) string { return strings.Join(parts, "\n") } -func dedupeMemoryItems(items []domain.MemoryItem) []domain.MemoryItem { - if len(items) == 0 { - return nil - } - seen := map[string]domain.MemoryItem{} - for _, item := range items { - key := item.Type + "::" + item.Scope + "::" + strings.ToLower(strings.TrimSpace(item.Summary)) - seen[key] = item - } - result := make([]domain.MemoryItem, 0, len(seen)) - for _, item := range seen { - result = append(result, item) - } - return result -} - func countMemoryTypes(groups ...[]domain.MemoryItem) map[string]int { counts := map[string]int{} for _, group := range groups { @@ -646,3 +421,31 @@ func countMemoryTypes(groups ...[]domain.MemoryItem) map[string]int { } return counts } + +// ListMemoryItems returns both persistent and session memory items for inspection. +func (s *memoryServiceImpl) ListMemoryItems(ctx context.Context) ([]domain.MemoryItem, error) { + persistentItems, err := s.persistentRepo.List(ctx) + if err != nil { + return nil, err + } + sessionItems, err := s.sessionRepo.List(ctx) + if err != nil { + return nil, err + } + + items := make([]domain.MemoryItem, 0, len(persistentItems)+len(sessionItems)) + for _, item := range persistentItems { + items = append(items, item.Normalized()) + } + for _, item := range sessionItems { + items = append(items, item.Normalized()) + } + + sort.Slice(items, func(i, j int) bool { + if items[i].UpdatedAt.Equal(items[j].UpdatedAt) { + return items[i].Summary < items[j].Summary + } + return items[i].UpdatedAt.After(items[j].UpdatedAt) + }) + return items, nil +} diff --git a/internal/server/service/project_memory_service.go b/internal/server/service/project_memory_service.go new file mode 100644 index 00000000..f39d46d4 --- /dev/null +++ b/internal/server/service/project_memory_service.go @@ -0,0 +1,136 @@ +package service + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "go-llm-demo/internal/server/domain" +) + +type projectMemoryServiceImpl struct { + workspaceRoot string + files []string + maxPromptChars int +} + +// NewProjectMemoryService creates a service for loading explicit workspace memory files. +func NewProjectMemoryService(workspaceRoot string, files []string, maxPromptChars int) domain.ProjectMemoryService { + cleaned := make([]string, 0, len(files)) + seen := map[string]struct{}{} + for _, item := range files { + item = strings.TrimSpace(item) + if item == "" { + continue + } + key := strings.ToLower(filepath.Clean(item)) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + cleaned = append(cleaned, item) + } + if maxPromptChars <= 0 { + maxPromptChars = 2400 + } + return &projectMemoryServiceImpl{ + workspaceRoot: strings.TrimSpace(workspaceRoot), + files: cleaned, + maxPromptChars: maxPromptChars, + } +} + +// BuildContext formats explicit project memory files into a prompt block. +func (s *projectMemoryServiceImpl) BuildContext(ctx context.Context) (string, error) { + sources, err := s.ListSources(ctx) + if err != nil { + return "", err + } + if len(sources) == 0 { + return "", nil + } + + header := "Use the following explicit project memory files as authoritative project instructions and conventions. Prefer them over inferred memory when they conflict.\n" + var builder strings.Builder + builder.WriteString(header) + + for _, source := range sources { + block := fmt.Sprintf("Project memory file: %s\n%s\n", source.Path, strings.TrimSpace(source.Content)) + if s.maxPromptChars > 0 && builder.Len()+len(block) > s.maxPromptChars { + remaining := s.maxPromptChars - builder.Len() + if remaining <= 0 { + break + } + builder.WriteString(domain.SummarizeText(block, remaining)) + break + } + builder.WriteString(block) + builder.WriteString("\n") + } + + return strings.TrimSpace(builder.String()), nil +} + +// ListSources returns the workspace memory files that currently exist. +func (s *projectMemoryServiceImpl) ListSources(ctx context.Context) ([]domain.ProjectMemorySource, error) { + _ = ctx + if strings.TrimSpace(s.workspaceRoot) == "" || len(s.files) == 0 { + return nil, nil + } + + sources := make([]domain.ProjectMemorySource, 0, len(s.files)) + for _, item := range s.files { + fullPath, ok := s.resolveProjectFile(item) + if !ok { + continue + } + + data, err := os.ReadFile(fullPath) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, err + } + + content := strings.TrimSpace(string(data)) + if content == "" { + continue + } + + relPath, err := filepath.Rel(s.workspaceRoot, fullPath) + if err != nil || strings.HasPrefix(relPath, "..") { + relPath = fullPath + } + relPath = filepath.ToSlash(relPath) + sources = append(sources, domain.ProjectMemorySource{ + Path: relPath, + Content: content, + }) + } + + return sources, nil +} + +func (s *projectMemoryServiceImpl) resolveProjectFile(item string) (string, bool) { + if strings.TrimSpace(s.workspaceRoot) == "" { + return "", false + } + + root := filepath.Clean(s.workspaceRoot) + if filepath.IsAbs(item) { + cleaned := filepath.Clean(item) + if cleaned == root || strings.HasPrefix(strings.ToLower(cleaned), strings.ToLower(root+string(filepath.Separator))) { + return cleaned, true + } + return "", false + } + + fullPath := filepath.Clean(filepath.Join(root, item)) + if fullPath == root || strings.HasPrefix(strings.ToLower(fullPath), strings.ToLower(root+string(filepath.Separator))) { + return fullPath, true + } + return "", false +} diff --git a/internal/server/service/project_memory_service_test.go b/internal/server/service/project_memory_service_test.go new file mode 100644 index 00000000..a3d364ba --- /dev/null +++ b/internal/server/service/project_memory_service_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestProjectMemoryServiceLoadsConfiguredFiles(t *testing.T) { + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "AGENTS.md"), []byte("Use go test ./... before PR."), 0o644); err != nil { + t.Fatalf("write AGENTS.md: %v", err) + } + if err := os.MkdirAll(filepath.Join(workspace, ".neocode"), 0o755); err != nil { + t.Fatalf("mkdir .neocode: %v", err) + } + if err := os.WriteFile(filepath.Join(workspace, ".neocode", "memory.md"), []byte("Prefer Chinese explanations for teammates."), 0o644); err != nil { + t.Fatalf("write .neocode/memory.md: %v", err) + } + + svc := NewProjectMemoryService(workspace, []string{"AGENTS.md", ".neocode/memory.md", "missing.md"}, 2400) + + sources, err := svc.ListSources(context.Background()) + if err != nil { + t.Fatalf("list sources: %v", err) + } + if len(sources) != 2 { + t.Fatalf("expected 2 project memory sources, got %+v", sources) + } + + ctxText, err := svc.BuildContext(context.Background()) + if err != nil { + t.Fatalf("build context: %v", err) + } + if !strings.Contains(ctxText, "AGENTS.md") || !strings.Contains(ctxText, ".neocode/memory.md") { + t.Fatalf("expected project memory paths in context, got %q", ctxText) + } + if !strings.Contains(ctxText, "Use go test ./... before PR.") { + t.Fatalf("expected project memory content in context, got %q", ctxText) + } +} + +func TestProjectMemoryServiceIgnoresPathsOutsideWorkspace(t *testing.T) { + workspace := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside.md") + if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil { + t.Fatalf("write outside file: %v", err) + } + + svc := NewProjectMemoryService(workspace, []string{outside}, 2400) + sources, err := svc.ListSources(context.Background()) + if err != nil { + t.Fatalf("list sources: %v", err) + } + if len(sources) != 0 { + t.Fatalf("expected outside path to be ignored, got %+v", sources) + } +} diff --git a/internal/server/service/rule_based_memory_extractor.go b/internal/server/service/rule_based_memory_extractor.go new file mode 100644 index 00000000..78a59d27 --- /dev/null +++ b/internal/server/service/rule_based_memory_extractor.go @@ -0,0 +1,284 @@ +package service + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "time" + + "go-llm-demo/internal/server/domain" +) + +type ruleBasedMemoryExtractor struct{} + +var ( + durablePreferenceCues = []string{ + "默认", "始终", "以后", "后续", "统一", "记住", "长期", + "always", "from now on", "by default", "going forward", "remember", + } + durablePreferenceTargets = []string{ + "中文", "英文", "命令", "说明", "提交", "commit", "command", "style", "配置", "config", + } + projectRuleAnchors = []string{ + "config.yaml", "readme", "go test", "go build", + "cmd/", "internal/", "configs/", "services/", "memory/", "main.go", + "data/", "workspace", "工作区", "根目录", "主配置文件", "文件", "路径", + } + projectRuleSignals = []string{ + "约定", "规则", "配置", "结构", "目录", "命令", + "默认", "统一", "必须", "需要", "测试命令", "构建命令", + "convention", "rule", "config", "structure", "directory", "command", "default", "must", "should", + } + codeAnchors = []string{ + ".go", "config.yaml", "main.go", "services/", "memory/", "json", "yaml", + "function", "func", "struct", "interface", "method", "package", "import", + "函数", "文件", "模块", "包", "结构体", "接口", "方法", "字段", "参数", "路径", "目录", + } + codeQuestionCues = []string{ + "什么", "干啥", "作用", "怎么", "如何", "为什么", "在哪", "含义", "区别", + "what", "why", "where", "which", + } + codeExplanationCues = []string{ + "用于", "负责", "位于", "表示", "通过", "调用", "读取", "写入", "返回", "实现", "处理", "对应", "配置", "路径", "字段", "参数", + "used for", "responsible for", "located in", "reads", "writes", "returns", "defines", "implements", + } + codingSignals = []string{ + "function", "file", "repo", "project", "build", "test", "config", "bug", "error", "fix", + "golang", "go ", "yaml", "json", "memory", "prompt", "cli", "agent", + "编码", "项目", "配置", "测试", "构建", "报错", "修复", "代码", + } + problemCues = []string{"error", "failed", "panic", "bug", "报错", "失败", "异常"} + fixCues = []string{"修复", "已通过", "解决", "fixed", "use", "改为", "增加", "remove", "replace"} + taskRequestCues = []string{"帮我", "请你", "写一个", "实现一个"} + factAnswerCues = []string{"在", "位于", "负责", "调用", "使用", "路径", "文件", "函数", "模块", "返回", "读取", "写入", "responsible", "located", "returns", "reads", "writes"} +) + +// NewRuleBasedMemoryExtractor returns the default deterministic extractor. +func NewRuleBasedMemoryExtractor() domain.MemoryExtractor { + return &ruleBasedMemoryExtractor{} +} + +// Extract converts a conversation turn into structured memory items. +func (e *ruleBasedMemoryExtractor) Extract(_ context.Context, userInput, assistantReply string) ([]domain.MemoryItem, error) { + if shouldSkipConversationMemory(userInput, assistantReply) { + return nil, nil + } + + now := time.Now().UTC() + items := make([]domain.MemoryItem, 0, 5) + + if item, ok := e.extractUserPreference(userInput, assistantReply, now); ok { + items = append(items, item) + } + if item, ok := e.extractProjectRule(userInput, assistantReply, now); ok { + items = append(items, item) + } + if item, ok := e.extractCodeFact(userInput, assistantReply, now); ok { + items = append(items, item) + } + if item, ok := e.extractFixRecipe(userInput, assistantReply, now); ok { + items = append(items, item) + } + if item, ok := e.extractSessionMemory(userInput, assistantReply, now); ok { + items = append(items, item) + } + + return dedupeMemoryItems(items), nil +} + +func (e *ruleBasedMemoryExtractor) extractSessionMemory(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { + combined := conversationText(userInput, assistantReply) + if !isCodingRelevant(userInput, assistantReply) || hasDurablePreferenceIntent(userInput) || looksLikeProjectRuleEvidence(userInput, assistantReply) { + return domain.MemoryItem{}, false + } + + summary := domain.SummarizeText(userInput, 140) + if summary == "" { + summary = domain.SummarizeText(assistantReply, 140) + } + + return newConversationMemoryItem(now, domain.TypeSessionMemory, domain.ScopeSession, summary, assistantReply, userInput, assistantReply, combined, 0.66), true +} + +func (e *ruleBasedMemoryExtractor) extractUserPreference(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { + trimmed := strings.TrimSpace(userInput) + if trimmed == "" || !hasDurablePreferenceIntent(trimmed) { + return domain.MemoryItem{}, false + } + + summary := domain.SummarizeText(trimmed, 140) + return newConversationMemoryItem(now, domain.TypeUserPreference, domain.ScopeUser, summary, assistantReply, userInput, assistantReply, conversationText(userInput, assistantReply), 0.95), true +} + +func (e *ruleBasedMemoryExtractor) extractProjectRule(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { + if hasDurablePreferenceIntent(userInput) { + return domain.MemoryItem{}, false + } + if !looksLikeProjectRuleEvidence(userInput, assistantReply) { + return domain.MemoryItem{}, false + } + + summary := domain.SummarizeText(firstNonEmptyLine(userInput, assistantReply), 140) + return newConversationMemoryItem(now, domain.TypeProjectRule, domain.ScopeProject, summary, assistantReply, userInput, assistantReply, conversationText(userInput, assistantReply), 0.9), true +} + +func (e *ruleBasedMemoryExtractor) extractCodeFact(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { + combined := conversationText(userInput, assistantReply) + if !looksLikeCodeFactEvidence(userInput, assistantReply) { + return domain.MemoryItem{}, false + } + if containsAnyFold(strings.ToLower(userInput), taskRequestCues...) && !containsAnyFold(combined, factAnswerCues...) { + return domain.MemoryItem{}, false + } + + summary := domain.SummarizeText(firstNonEmptyLine(assistantReply, userInput), 140) + return newConversationMemoryItem(now, domain.TypeCodeFact, domain.ScopeProject, summary, assistantReply, userInput, assistantReply, combined, 0.82), true +} + +func (e *ruleBasedMemoryExtractor) extractFixRecipe(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { + combined := strings.ToLower(conversationText(userInput, assistantReply)) + if !containsAnyFold(combined, problemCues...) || !containsAnyFold(combined, fixCues...) { + return domain.MemoryItem{}, false + } + + summary := domain.SummarizeText(firstNonEmptyLine(userInput, assistantReply), 140) + details := assistantReply + if details == "" { + details = userInput + } + return newConversationMemoryItem(now, domain.TypeFixRecipe, domain.ScopeProject, summary, details, userInput, assistantReply, conversationText(userInput, assistantReply), 0.8), true +} + +func newConversationMemoryItem(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(userInput), + AssistantReply: strings.TrimSpace(assistantReply), + } + return item.Normalized() +} + +func conversationText(userInput, assistantReply string) string { + return strings.TrimSpace(userInput) + "\n" + strings.TrimSpace(assistantReply) +} + +func isCodingRelevant(userInput, assistantReply string) bool { + combined := strings.ToLower(conversationText(userInput, assistantReply)) + if containsAnyFold(combined, codingSignals...) { + return true + } + trimmedUser := strings.TrimSpace(strings.ToLower(userInput)) + return len(trimmedUser) > 20 && containsAnyFold(trimmedUser, "code", "代码") +} + +func looksLikeProjectRuleEvidence(userInput, assistantReply string) bool { + combined := strings.ToLower(conversationText(userInput, assistantReply)) + return containsAnyFold(combined, projectRuleAnchors...) && containsAnyFold(combined, projectRuleSignals...) +} + +func looksLikeCodeFactEvidence(userInput, assistantReply string) bool { + combined := conversationText(userInput, assistantReply) + if !isCodingRelevant(userInput, assistantReply) { + return false + } + if !containsAnyFold(combined, codeAnchors...) { + return false + } + + trimmedUser := strings.ToLower(strings.TrimSpace(userInput)) + trimmedReply := strings.ToLower(strings.TrimSpace(assistantReply)) + hasQuestionIntent := containsAnyFold(trimmedUser, codeQuestionCues...) + hasExplanation := containsAnyFold(trimmedReply, codeExplanationCues...) + return hasQuestionIntent || hasExplanation +} + +func hasDurablePreferenceIntent(text string) bool { + trimmed := strings.TrimSpace(strings.ToLower(text)) + if trimmed == "" { + return false + } + return containsAnyFold(trimmed, durablePreferenceCues...) && containsAnyFold(trimmed, durablePreferenceTargets...) +} + +func shouldSkipConversationMemory(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 containsAnyFold(text string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(strings.ToLower(text), strings.ToLower(needle)) { + return true + } + } + return false +} + +func firstNonEmptyLine(values ...string) string { + for _, value := range values { + for _, line := range strings.Split(value, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + return trimmed + } + } + } + return "" +} + +func dedupeMemoryItems(items []domain.MemoryItem) []domain.MemoryItem { + if len(items) == 0 { + return nil + } + seen := map[string]domain.MemoryItem{} + for _, item := range items { + key := item.Type + "::" + item.Scope + "::" + strings.ToLower(strings.TrimSpace(item.Summary)) + seen[key] = item + } + result := make([]domain.MemoryItem, 0, len(seen)) + for _, item := range seen { + result = append(result, item) + } + return result +} diff --git a/internal/server/service/rule_based_memory_extractor_test.go b/internal/server/service/rule_based_memory_extractor_test.go new file mode 100644 index 00000000..16ae2a2b --- /dev/null +++ b/internal/server/service/rule_based_memory_extractor_test.go @@ -0,0 +1,138 @@ +package service + +import ( + "context" + "testing" + + "go-llm-demo/internal/server/domain" + "go-llm-demo/internal/server/infra/repository" +) + +func TestRuleBasedMemoryExtractorExtractsUserPreference(t *testing.T) { + extractor := NewRuleBasedMemoryExtractor() + + items, err := extractor.Extract(context.Background(), "以后回答中文,命令和说明都用中文。", "好的,后续我会统一使用中文回复。") + if err != nil { + t.Fatalf("extract: %v", err) + } + + if len(items) == 0 { + t.Fatalf("expected extracted items, got none") + } + + var preferenceCount, projectRuleCount int + for _, item := range items { + if item.Type == domain.TypeUserPreference { + preferenceCount++ + } + if item.Type == domain.TypeProjectRule { + projectRuleCount++ + } + } + + if preferenceCount != 1 { + t.Fatalf("expected exactly one user_preference, got %d (%+v)", preferenceCount, items) + } + if projectRuleCount != 0 { + t.Fatalf("expected no project_rule for a user preference, got %d (%+v)", projectRuleCount, items) + } +} + +func TestRuleBasedMemoryExtractorSkipsToolPayload(t *testing.T) { + extractor := NewRuleBasedMemoryExtractor() + + items, err := extractor.Extract(context.Background(), "请读取 memory_service.go 看看实现。", `{"tool":"read","params":{"filePath":"internal/server/service/memory_service.go"}}`) + if err != nil { + t.Fatalf("extract: %v", err) + } + if len(items) != 0 { + t.Fatalf("expected tool payload to be skipped, got %+v", items) + } +} + +func TestRuleBasedMemoryExtractorExtractsProjectRuleAndCodeFact(t *testing.T) { + extractor := NewRuleBasedMemoryExtractor() + + projectItems, err := extractor.Extract( + context.Background(), + "The project convention is to run go test ./... from the repo root.", + "Documented: run go test ./... from the repository root as the default test command.", + ) + if err != nil { + t.Fatalf("extract project rule: %v", err) + } + + var hasProjectRule bool + for _, item := range projectItems { + if item.Type == domain.TypeProjectRule { + hasProjectRule = true + } + } + if !hasProjectRule { + t.Fatalf("expected a project_rule item, got %+v", projectItems) + } + + codeItems, err := extractor.Extract( + context.Background(), + "What does memory_repository.go do?", + "internal/server/infra/repository/memory_repository.go is responsible for persistent memory storage and retrieval.", + ) + if err != nil { + t.Fatalf("extract code fact: %v", err) + } + + var hasCodeFact bool + for _, item := range codeItems { + if item.Type == domain.TypeCodeFact { + hasCodeFact = true + } + } + if !hasCodeFact { + t.Fatalf("expected a code_fact item, got %+v", codeItems) + } +} + +type stubMemoryExtractor struct { + items []domain.MemoryItem +} + +func (s stubMemoryExtractor) Extract(context.Context, string, string) ([]domain.MemoryItem, error) { + return s.items, nil +} + +func TestMemoryServiceUsesInjectedExtractor(t *testing.T) { + ctx := context.Background() + path := t.TempDir() + "/memory.json" + + svc := NewMemoryServiceWithExtractor( + repository.NewFileMemoryStore(path, 100), + repository.NewSessionMemoryStore(100), + stubMemoryExtractor{ + items: []domain.MemoryItem{ + { + Type: domain.TypeCodeFact, + Summary: "custom extractor item", + Scope: domain.ScopeProject, + Confidence: 0.9, + }, + }, + }, + 5, + 2.2, + 1800, + path, + []string{"code_fact"}, + ) + + if err := svc.Save(ctx, "ignored", "ignored"); err != nil { + t.Fatalf("save: %v", err) + } + + stats, err := svc.GetStats(ctx) + if err != nil { + t.Fatalf("stats: %v", err) + } + if stats.ByType[domain.TypeCodeFact] != 1 { + t.Fatalf("expected custom extractor item to be persisted, got %+v", stats.ByType) + } +} From f545e29101b6b5f17983cd9fc9792fd658ea2d91 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 25 Mar 2026 20:40:09 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E8=AE=B0=E5=BF=86=E5=B9=B6=E6=89=A9=E5=B1=95=20TUI=20?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/main.go | 57 +++++-- internal/server/service/chat_service.go | 78 +++++---- internal/server/service/chat_service_test.go | 109 +++++++++++++ internal/tui/core/update.go | 76 ++++++++- internal/tui/core/update_test.go | 109 +++++++++++++ internal/tui/services/api_client.go | 159 +++++++++++++++---- internal/tui/services/memory_bridge.go | 7 + 7 files changed, 528 insertions(+), 67 deletions(-) create mode 100644 internal/server/service/chat_service_test.go create mode 100644 internal/tui/services/memory_bridge.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 18290079..fee8429f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,8 +3,11 @@ package main import ( "fmt" "path/filepath" + "strings" + "time" "go-llm-demo/configs" + "go-llm-demo/internal/server/domain" "go-llm-demo/internal/server/infra/provider" "go-llm-demo/internal/server/infra/repository" "go-llm-demo/internal/server/infra/tools" @@ -14,20 +17,20 @@ import ( func main() { workspaceRoot, err := tools.ResolveWorkspaceRoot("") if err != nil { - fmt.Printf("解析工作区失败:%v\n", err) + fmt.Printf("failed to resolve workspace: %v\n", err) return } if err := tools.SetWorkspaceRoot(workspaceRoot); err != nil { - fmt.Printf("设置工作区失败:%v\n", err) + fmt.Printf("failed to set workspace: %v\n", err) return } if err := initializeSecurity(filepath.Join(workspaceRoot, "configs", "security")); err != nil { - fmt.Printf("初始化安全策略失败:%v\n", err) + fmt.Printf("failed to initialize security: %v\n", err) return } if err := configs.LoadAppConfig("config.yaml"); err != nil { - fmt.Printf("加载配置失败:%v\n", err) + fmt.Printf("failed to load config: %v\n", err) return } @@ -35,9 +38,16 @@ func main() { memoryRepo := repository.NewFileMemoryStore(cfg.Memory.StoragePath, cfg.Memory.MaxItems) sessionRepo := repository.NewSessionMemoryStore(cfg.Memory.MaxItems) workingRepo := repository.NewWorkingMemoryStore() - memorySvc := service.NewMemoryService( + + memoryExtractor, err := buildMemoryExtractor(cfg) + if err != nil { + fmt.Printf("failed to initialize memory extractor: %v\n", err) + return + } + memorySvc := service.NewMemoryServiceWithExtractor( memoryRepo, sessionRepo, + memoryExtractor, cfg.Memory.TopK, cfg.Memory.MinMatchScore, cfg.Memory.MaxPromptChars, @@ -45,6 +55,7 @@ func main() { cfg.Memory.PersistTypes, ) workingSvc := service.NewWorkingMemoryService(workingRepo, cfg.History.ShortTermTurns, tools.GetWorkspaceRoot()) + projectMemorySvc := service.NewProjectMemoryService(tools.GetWorkspaceRoot(), cfg.Memory.ProjectFiles, cfg.Memory.ProjectPromptChars) roleRepo := repository.NewFileRoleStore("./data/roles.json") roleSvc := service.NewRoleService(roleRepo, cfg.Persona.FilePath) @@ -54,13 +65,41 @@ func main() { chatProvider, err := provider.NewChatProvider(cfg.AI.Model) if err != nil { - fmt.Printf("初始化 ChatProvider 失败:%v\n", err) + fmt.Printf("failed to initialize chat provider: %v\n", err) return } - chatGateway := service.NewChatService(memorySvc, workingSvc, todoSvc, roleSvc, chatProvider) - fmt.Printf("服务器已初始化并加载服务: %+v\n", chatGateway) - fmt.Println("注意:这是一个占位符。实际的服务器实现将在此处进行.") + chatGateway := service.NewChatService(memorySvc, workingSvc, projectMemorySvc, todoSvc, roleSvc, chatProvider) + fmt.Printf("server dependencies initialized: %+v\n", chatGateway) + fmt.Println("note: cmd/server is still a wiring check entrypoint, not a production server.") +} + +func buildMemoryExtractor(cfg *configs.AppConfiguration) (domain.MemoryExtractor, error) { + if cfg == nil { + return service.BuildMemoryExtractor(service.MemoryExtractorModeRule, nil, service.LLMMemoryExtractorOptions{}) + } + + mode := strings.TrimSpace(cfg.Memory.Extractor) + if mode == "" || strings.EqualFold(mode, service.MemoryExtractorModeRule) { + return service.BuildMemoryExtractor(service.MemoryExtractorModeRule, nil, service.LLMMemoryExtractorOptions{}) + } + + model := strings.TrimSpace(cfg.Memory.ExtractorModel) + if model == "" { + model = strings.TrimSpace(cfg.AI.Model) + } + + extractorProvider, err := provider.NewChatProvider(model) + if err != nil { + if strings.EqualFold(mode, service.MemoryExtractorModeAuto) { + return service.BuildMemoryExtractor(service.MemoryExtractorModeAuto, nil, service.LLMMemoryExtractorOptions{}) + } + return nil, err + } + + return service.BuildMemoryExtractor(mode, extractorProvider, service.LLMMemoryExtractorOptions{ + Timeout: time.Duration(cfg.Memory.ExtractorTimeoutSecond) * time.Second, + }) } func initializeSecurity(configDir string) error { diff --git a/internal/server/service/chat_service.go b/internal/server/service/chat_service.go index 02e3f98a..bf9afb13 100644 --- a/internal/server/service/chat_service.go +++ b/internal/server/service/chat_service.go @@ -9,31 +9,40 @@ import ( ) type chatServiceImpl struct { - memorySvc domain.MemoryService - workingSvc domain.WorkingMemoryService - todoSvc domain.TodoService - roleSvc domain.RoleService - chatProvider domain.ChatProvider + memorySvc domain.MemoryService + workingSvc domain.WorkingMemoryService + projectMemorySvc domain.ProjectMemoryService + todoSvc domain.TodoService + roleSvc domain.RoleService + chatProvider domain.ChatProvider } -// NewChatService 使用记忆、角色、任务清单和模型提供方依赖创建聊天服务。 -func NewChatService(memorySvc domain.MemoryService, workingSvc domain.WorkingMemoryService, todoSvc domain.TodoService, roleSvc domain.RoleService, chatProvider domain.ChatProvider) domain.ChatGateway { +// NewChatService creates a chat service from role, memory, todo, and provider dependencies. +func NewChatService( + memorySvc domain.MemoryService, + workingSvc domain.WorkingMemoryService, + projectMemorySvc domain.ProjectMemoryService, + todoSvc domain.TodoService, + roleSvc domain.RoleService, + chatProvider domain.ChatProvider, +) domain.ChatGateway { return &chatServiceImpl{ - memorySvc: memorySvc, - workingSvc: workingSvc, - todoSvc: todoSvc, - roleSvc: roleSvc, - chatProvider: chatProvider, + memorySvc: memorySvc, + workingSvc: workingSvc, + projectMemorySvc: projectMemorySvc, + todoSvc: todoSvc, + roleSvc: roleSvc, + chatProvider: chatProvider, } } -// Send 为消息补充角色和记忆上下文后发起流式回复。 +// Send injects role, explicit project memory, working memory, and recalled memory before chatting. func (s *chatServiceImpl) Send(ctx context.Context, req *domain.ChatRequest) (<-chan string, error) { messages := req.Messages rolePrompt, err := s.roleSvc.GetActivePrompt(ctx) if err != nil { - fmt.Printf("获取角色提示失败:%v\n", err) + fmt.Printf("failed to load role prompt: %v\n", err) } else if rolePrompt != "" { hasSystem := false for _, msg := range messages { @@ -42,14 +51,20 @@ func (s *chatServiceImpl) Send(ctx context.Context, req *domain.ChatRequest) (<- break } } - if !hasSystem { - // 角色提示总是放在最前面的 system 消息里,便于后续继续叠加记忆上下文。 messages = append([]domain.Message{{Role: "system", Content: rolePrompt}}, messages...) } } userInput := s.latestUserInput(messages) + projectContext := "" + if s.projectMemorySvc != nil { + projectContext, err = s.projectMemorySvc.BuildContext(ctx) + if err != nil { + return nil, err + } + } + workingContext := "" if s.workingSvc != nil { workingContext, err = s.workingSvc.BuildContext(ctx, messages) @@ -57,20 +72,22 @@ func (s *chatServiceImpl) Send(ctx context.Context, req *domain.ChatRequest) (<- return nil, err } } + todoContext := "" if s.todoSvc != nil { todos, _ := s.todoSvc.ListTodos(ctx) todoContext = buildTodoContext(todos) } - blocks := []string{workingContext, todoContext} - if userInput != "" { + blocks := []string{projectContext, workingContext, todoContext} + if userInput != "" && s.memorySvc != nil { memoryContext, ctxErr := s.memorySvc.BuildContext(ctx, userInput) if ctxErr != nil { return nil, ctxErr } blocks = append(blocks, memoryContext) } + combinedContext := joinContextBlocks(blocks...) if combinedContext != "" { messages = injectSystemContext(messages, rolePrompt, combinedContext) @@ -91,18 +108,21 @@ func (s *chatServiceImpl) Send(ctx context.Context, req *domain.ChatRequest) (<- resultChan <- chunk } - if s.latestUserInput(messages) != "" && replyBuilder.Len() > 0 { - if s.workingSvc != nil { - // 工作记忆刷新要基于用户原始消息序列,而不是注入过 system 上下文后的 messages, - // 否则会把内部提示也误当成真实对话历史。 - updatedMessages := append([]domain.Message{}, req.Messages...) - updatedMessages = append(updatedMessages, domain.Message{Role: "assistant", Content: replyBuilder.String()}) - if err := s.workingSvc.Refresh(context.Background(), updatedMessages); err != nil { - fmt.Printf("工作记忆刷新失败:%v\n", err) - } + latestInput := s.latestUserInput(messages) + if latestInput == "" || replyBuilder.Len() == 0 { + return + } + + if s.workingSvc != nil { + updatedMessages := append([]domain.Message{}, req.Messages...) + updatedMessages = append(updatedMessages, domain.Message{Role: "assistant", Content: replyBuilder.String()}) + if err := s.workingSvc.Refresh(context.Background(), updatedMessages); err != nil { + fmt.Printf("failed to refresh working memory: %v\n", err) } - if err := s.memorySvc.Save(context.Background(), s.latestUserInput(messages), replyBuilder.String()); err != nil { - fmt.Printf("记忆保存失败:%v\n", err) + } + if s.memorySvc != nil { + if err := s.memorySvc.Save(context.Background(), latestInput, replyBuilder.String()); err != nil { + fmt.Printf("failed to save memory: %v\n", err) } } }() diff --git a/internal/server/service/chat_service_test.go b/internal/server/service/chat_service_test.go new file mode 100644 index 00000000..9b2044c4 --- /dev/null +++ b/internal/server/service/chat_service_test.go @@ -0,0 +1,109 @@ +package service + +import ( + "context" + "strings" + "testing" + + "go-llm-demo/internal/server/domain" +) + +type stubRoleService struct { + prompt string +} + +func (s stubRoleService) GetActivePrompt(context.Context) (string, error) { return s.prompt, nil } +func (s stubRoleService) SetActive(context.Context, string) error { return nil } +func (s stubRoleService) List(context.Context) ([]domain.Role, error) { return nil, nil } +func (s stubRoleService) Create(context.Context, string, string, string) (*domain.Role, error) { + return nil, nil +} +func (s stubRoleService) Delete(context.Context, string) error { return nil } + +type stubProjectMemoryService struct { + context string +} + +func (s stubProjectMemoryService) BuildContext(context.Context) (string, error) { + return s.context, nil +} + +func (s stubProjectMemoryService) ListSources(context.Context) ([]domain.ProjectMemorySource, error) { + return nil, nil +} + +type stubWorkingMemoryService struct{} + +func (stubWorkingMemoryService) BuildContext(context.Context, []domain.Message) (string, error) { + return "Current task: fix memory module", nil +} +func (stubWorkingMemoryService) Refresh(context.Context, []domain.Message) error { return nil } +func (stubWorkingMemoryService) Clear(context.Context) error { return nil } +func (stubWorkingMemoryService) Get(context.Context) (*domain.WorkingMemoryState, error) { + return nil, nil +} + +type stubMemoryService struct{} + +func (stubMemoryService) BuildContext(context.Context, string) (string, error) { + return "Type: code_fact", nil +} +func (stubMemoryService) Save(context.Context, string, string) error { return nil } +func (stubMemoryService) GetStats(context.Context) (*domain.MemoryStats, error) { + return &domain.MemoryStats{}, nil +} +func (stubMemoryService) Clear(context.Context) error { return nil } +func (stubMemoryService) ClearSession(context.Context) error { return nil } + +type capturingChatProvider struct { + messages []domain.Message + reply string +} + +func (p *capturingChatProvider) GetModelName() string { return "stub" } + +func (p *capturingChatProvider) Chat(_ context.Context, messages []domain.Message) (<-chan string, error) { + p.messages = append([]domain.Message{}, messages...) + out := make(chan string, 1) + go func() { + defer close(out) + out <- p.reply + }() + return out, nil +} + +func TestChatServiceInjectsProjectMemoryBeforeAutoMemory(t *testing.T) { + provider := &capturingChatProvider{reply: "done"} + chatSvc := NewChatService( + stubMemoryService{}, + stubWorkingMemoryService{}, + stubProjectMemoryService{context: "Project memory file: AGENTS.md\nRun go test ./... before PR."}, + nil, + stubRoleService{prompt: "You are NeoCode."}, + provider, + ) + + stream, err := chatSvc.Send(context.Background(), &domain.ChatRequest{ + Messages: []domain.Message{{Role: "user", Content: "help me check memory"}}, + }) + if err != nil { + t.Fatalf("send: %v", err) + } + for range stream { + } + + if len(provider.messages) == 0 || provider.messages[0].Role != "system" { + t.Fatalf("expected injected system prompt, got %+v", provider.messages) + } + + systemPrompt := provider.messages[0].Content + projectIdx := strings.Index(systemPrompt, "Project memory file: AGENTS.md") + workingIdx := strings.Index(systemPrompt, "Current task: fix memory module") + autoIdx := strings.Index(systemPrompt, "Type: code_fact") + if projectIdx == -1 || workingIdx == -1 || autoIdx == -1 { + t.Fatalf("expected project, working, and auto memory in system prompt, got %q", systemPrompt) + } + if !(projectIdx < workingIdx && workingIdx < autoIdx) { + t.Fatalf("expected project memory before working and auto memory, got %q", systemPrompt) + } +} diff --git a/internal/tui/core/update.go b/internal/tui/core/update.go index e4985eba..07de0be9 100644 --- a/internal/tui/core/update.go +++ b/internal/tui/core/update.go @@ -587,10 +587,82 @@ func (m *Model) handleCommand(input string) (tea.Model, tea.Cmd) { return *m, nil } m.chat.MemoryStats = *stats + projectFiles := "none" + if len(stats.ProjectSources) > 0 { + projectFiles = strings.Join(stats.ProjectSources, ", ") + } m.AddMessage("assistant", fmt.Sprintf( - "Memory stats:\n Persistent: %d\n Session: %d\n Total: %d\n TopK: %d\n Min score: %.2f\n File: %s\n Types: %s", - stats.PersistentItems, stats.SessionItems, stats.TotalItems, stats.TopK, stats.MinScore, stats.Path, formatTypeStats(stats.ByType), + "Memory stats:\n Persistent: %d\n Session: %d\n Total: %d\n TopK: %d\n Min score: %.2f\n File: %s\n Project memory files: %s\n Types: %s", + stats.PersistentItems, stats.SessionItems, stats.TotalItems, stats.TopK, stats.MinScore, stats.Path, projectFiles, formatTypeStats(stats.ByType), )) + case "/memory-list": + items, err := m.client.ListMemoryItems(context.Background()) + if err != nil { + m.AddMessage("assistant", fmt.Sprintf("Failed to list memory items: %v", err)) + return *m, nil + } + if len(items) == 0 { + m.AddMessage("assistant", "No memory items stored.") + return *m, nil + } + lines := make([]string, 0, len(items)+1) + lines = append(lines, "Memory items:") + for _, item := range items { + lines = append(lines, fmt.Sprintf("- [%s/%s] %s", item.Type, item.Scope, item.Summary)) + } + m.AddMessage("assistant", strings.Join(lines, "\n")) + case "/project-memory": + sources, err := m.client.GetProjectMemorySources(context.Background()) + if err != nil { + m.AddMessage("assistant", fmt.Sprintf("Failed to load project memory files: %v", err)) + return *m, nil + } + if len(sources) == 0 { + m.AddMessage("assistant", "No explicit project memory files are loaded for this workspace.") + return *m, nil + } + lines := make([]string, 0, len(sources)+1) + lines = append(lines, "Explicit project memory files:") + for _, source := range sources { + lines = append(lines, fmt.Sprintf("- %s", source.Path)) + } + m.AddMessage("assistant", strings.Join(lines, "\n")) + case "/resume": + if provider, ok := m.client.(services.WorkingSessionSummaryProvider); ok { + summary, err := provider.GetWorkingSessionSummary(context.Background()) + if err != nil { + m.AddMessage("assistant", fmt.Sprintf("Failed to load working session summary: %v", err)) + return *m, nil + } + if strings.TrimSpace(summary) == "" { + m.AddMessage("assistant", "No saved working session summary for this workspace.") + return *m, nil + } + m.AddMessage("assistant", summary) + return *m, nil + } + m.AddMessage("assistant", "Working session summary is not available in the current client.") + case "/remember": + if len(args) == 0 { + m.AddMessage("assistant", "Usage: /remember ") + return *m, nil + } + text := strings.TrimSpace(strings.TrimPrefix(input, cmd)) + if err := m.client.Remember(context.Background(), text); err != nil { + m.AddMessage("assistant", fmt.Sprintf("Failed to remember this note: %v", err)) + return *m, nil + } + stats, _ := m.client.GetMemoryStats(context.Background()) + if stats != nil { + m.chat.MemoryStats = *stats + } + m.AddMessage("assistant", "Stored a manual memory note for future coding assistance.") + case "/memory-mode": + mode := m.client.MemoryMode(context.Background()) + if strings.TrimSpace(mode) == "" { + mode = "rule" + } + m.AddMessage("assistant", fmt.Sprintf("Current memory extractor mode: %s", mode)) case "/clear-memory": if len(args) == 0 || args[0] != "confirm" { m.AddMessage("assistant", "This command will clear persistent memory. Use /clear-memory confirm") diff --git a/internal/tui/core/update_test.go b/internal/tui/core/update_test.go index d9b0adae..1e992eb3 100644 --- a/internal/tui/core/update_test.go +++ b/internal/tui/core/update_test.go @@ -19,11 +19,18 @@ type fakeChatClient struct { lastMessages []services.Message lastModel string memoryStats *services.MemoryStats + memoryItems []services.MemoryListItem + projectSources []services.ProjectMemorySource + resumeSummary string nilMemoryStats bool memoryErr error + memoryListErr error + projectMemoryErr error + rememberErr error clearMemoryErr error clearSessionErr error defaultModelName string + memoryMode string todos []services.Todo } @@ -56,6 +63,35 @@ func (f *fakeChatClient) GetMemoryStats(context.Context) (*services.MemoryStats, return &services.MemoryStats{}, nil } +func (f *fakeChatClient) GetWorkingSessionSummary(context.Context) (string, error) { + return f.resumeSummary, nil +} + +func (f *fakeChatClient) GetProjectMemorySources(context.Context) ([]services.ProjectMemorySource, error) { + if f.projectMemoryErr != nil { + return nil, f.projectMemoryErr + } + return append([]services.ProjectMemorySource(nil), f.projectSources...), nil +} + +func (f *fakeChatClient) ListMemoryItems(context.Context) ([]services.MemoryListItem, error) { + if f.memoryListErr != nil { + return nil, f.memoryListErr + } + return append([]services.MemoryListItem(nil), f.memoryItems...), nil +} + +func (f *fakeChatClient) Remember(context.Context, string) error { + return f.rememberErr +} + +func (f *fakeChatClient) MemoryMode(context.Context) string { + if strings.TrimSpace(f.memoryMode) == "" { + return "rule" + } + return f.memoryMode +} + func (f *fakeChatClient) ClearMemory(context.Context) error { return f.clearMemoryErr } @@ -655,6 +691,7 @@ func TestHandleCommandMemorySuccess(t *testing.T) { TopK: 4, MinScore: 1.5, Path: "memory.json", + ProjectSources: []string{"AGENTS.md", ".neocode/memory.md"}, ByType: map[string]int{ services.TypeUserPreference: 1, }, @@ -665,6 +702,78 @@ func TestHandleCommandMemorySuccess(t *testing.T) { updated, _ := m.handleCommand("/memory") got := updated.(Model) assertLastMessageContains(t, got, "memory.json") + assertLastMessageContains(t, got, "AGENTS.md") +} + +func TestHandleCommandMemoryListSuccess(t *testing.T) { + client := &fakeChatClient{memoryItems: []services.MemoryListItem{ + {Type: services.TypeUserPreference, Scope: "user", Summary: "Use Chinese"}, + {Type: services.TypeCodeFact, Scope: "project", Summary: "memory_repository.go stores persistent memory"}, + }} + m := newTestModel(t, client) + m.chat.APIKeyReady = true + + updated, _ := m.handleCommand("/memory-list") + got := updated.(Model) + assertLastMessageContains(t, got, "Use Chinese") + assertLastMessageContains(t, got, "memory_repository.go") +} + +func TestHandleCommandProjectMemorySuccess(t *testing.T) { + client := &fakeChatClient{projectSources: []services.ProjectMemorySource{ + {Path: "AGENTS.md"}, + {Path: ".neocode/memory.md"}, + }} + m := newTestModel(t, client) + m.chat.APIKeyReady = true + + updated, _ := m.handleCommand("/project-memory") + got := updated.(Model) + assertLastMessageContains(t, got, "AGENTS.md") + assertLastMessageContains(t, got, ".neocode/memory.md") +} + +func TestHandleCommandResumeSuccess(t *testing.T) { + client := &fakeChatClient{resumeSummary: "已恢复上次工作现场:\n- 当前目标: 修复记忆模块"} + m := newTestModel(t, client) + m.chat.APIKeyReady = true + + updated, _ := m.handleCommand("/resume") + got := updated.(Model) + assertLastMessageContains(t, got, "修复记忆模块") +} + +func TestHandleCommandRememberRequiresArgument(t *testing.T) { + client := &fakeChatClient{} + m := newTestModel(t, client) + m.chat.APIKeyReady = true + + updated, _ := m.handleCommand("/remember") + got := updated.(Model) + assertLastMessageContains(t, got, "/remember ") +} + +func TestHandleCommandRememberSuccess(t *testing.T) { + client := &fakeChatClient{memoryStats: &services.MemoryStats{TotalItems: 4}} + m := newTestModel(t, client) + m.chat.APIKeyReady = true + + updated, _ := m.handleCommand("/remember 以后都用中文总结改动") + got := updated.(Model) + assertLastMessageContains(t, got, "Stored a manual memory note") + if got.chat.MemoryStats.TotalItems != 4 { + t.Fatalf("expected refreshed stats after remember, got %+v", got.chat.MemoryStats) + } +} + +func TestHandleCommandMemoryModeSuccess(t *testing.T) { + client := &fakeChatClient{memoryMode: "auto"} + m := newTestModel(t, client) + m.chat.APIKeyReady = true + + updated, _ := m.handleCommand("/memory-mode") + got := updated.(Model) + assertLastMessageContains(t, got, "auto") } func TestHandleCommandClearMemoryRequiresConfirm(t *testing.T) { diff --git a/internal/tui/services/api_client.go b/internal/tui/services/api_client.go index 11362b5a..4b4b7138 100644 --- a/internal/tui/services/api_client.go +++ b/internal/tui/services/api_client.go @@ -3,6 +3,7 @@ package services import ( "context" "strings" + "time" "go-llm-demo/configs" "go-llm-demo/internal/server/domain" @@ -31,10 +32,15 @@ var ( ParseTodoPriority = domain.ParseTodoPriority ) -// ChatClient 定义 TUI 侧依赖的最小聊天与记忆接口。 +// ChatClient defines the local interface the TUI depends on. type ChatClient interface { Chat(ctx context.Context, messages []Message, model string) (<-chan string, error) GetMemoryStats(ctx context.Context) (*MemoryStats, error) + GetWorkingSessionSummary(ctx context.Context) (string, error) + GetProjectMemorySources(ctx context.Context) ([]ProjectMemorySource, error) + ListMemoryItems(ctx context.Context) ([]MemoryListItem, error) + Remember(ctx context.Context, text string) error + MemoryMode(ctx context.Context) string ClearMemory(ctx context.Context) error ClearSessionMemory(ctx context.Context) error GetTodoList(ctx context.Context) ([]Todo, error) @@ -44,12 +50,12 @@ type ChatClient interface { DefaultModel() string } -// WorkingSessionSummaryProvider 为支持恢复摘要的客户端扩展接口。 +// WorkingSessionSummaryProvider exposes persisted working session summaries. type WorkingSessionSummaryProvider interface { GetWorkingSessionSummary(ctx context.Context) (string, error) } -// MemoryStats 是 TUI 展示 memory 面板所需的聚合统计信息。 +// MemoryStats contains the memory statistics shown in the TUI. type MemoryStats struct { PersistentItems int SessionItems int @@ -58,17 +64,29 @@ type MemoryStats struct { MinScore float64 Path string ByType map[string]int + ProjectSources []string +} + +type ProjectMemorySource = domain.ProjectMemorySource + +type MemoryListItem struct { + Type string + Scope string + Summary string + UpdatedAt time.Time } type localChatClient struct { - roleSvc domain.RoleService - memorySvc domain.MemoryService - workingSvc domain.WorkingMemoryService - todoSvc domain.TodoService - config *configs.AppConfiguration + roleSvc domain.RoleService + memorySvc domain.MemoryService + workingSvc domain.WorkingMemoryService + projectMemorySvc domain.ProjectMemoryService + todoSvc domain.TodoService + config *configs.AppConfiguration + memoryMode string } -// NewLocalChatClient 将本地服务组装为 TUI 使用的聊天客户端。 +// NewLocalChatClient wires local services for TUI use. func NewLocalChatClient() (ChatClient, error) { cfg := configs.GlobalAppConfig if cfg == nil { @@ -83,17 +101,25 @@ func NewLocalChatClient() (ChatClient, error) { if maxItems <= 0 { maxItems = 1000 } + workspaceRoot := tools.GetWorkspaceRoot() persistentRepo := repository.NewFileMemoryStore(storePath, maxItems) sessionRepo := repository.NewSessionMemoryStore(maxItems) + workingStatePath := "" if cfg.History.PersistSessionState { workingStatePath = BuildWorkspaceStatePath(cfg.History.WorkspaceStateDir, workspaceRoot) } workingRepo := repository.NewWorkingMemoryStore(workingStatePath) - memorySvc := service.NewMemoryService( + + memoryExtractor, err := buildMemoryExtractor(cfg) + if err != nil { + return nil, err + } + memorySvc := service.NewMemoryServiceWithExtractor( persistentRepo, sessionRepo, + memoryExtractor, cfg.Memory.TopK, cfg.Memory.MinMatchScore, cfg.Memory.MaxPromptChars, @@ -101,6 +127,7 @@ func NewLocalChatClient() (ChatClient, error) { cfg.Memory.PersistTypes, ) workingSvc := service.NewWorkingMemoryService(workingRepo, cfg.History.ShortTermTurns, workspaceRoot) + projectMemorySvc := service.NewProjectMemoryService(workspaceRoot, cfg.Memory.ProjectFiles, cfg.Memory.ProjectPromptChars) roleRepo := repository.NewFileRoleStore("./data/roles.json") roleSvc := service.NewRoleService(roleRepo, strings.TrimSpace(cfg.Persona.FilePath)) @@ -109,32 +136,71 @@ func NewLocalChatClient() (ChatClient, error) { todoSvc := service.NewTodoService(todoRepo) tools.GlobalRegistry.Register(tools.NewTodoTool(todoSvc)) - // 当前仍以内进程方式组装服务,后续替换为真实 transport 时可继续复用该接口。 return &localChatClient{ - roleSvc: roleSvc, - memorySvc: memorySvc, - workingSvc: workingSvc, - todoSvc: todoSvc, - config: cfg, + roleSvc: roleSvc, + memorySvc: memorySvc, + workingSvc: workingSvc, + projectMemorySvc: projectMemorySvc, + todoSvc: todoSvc, + config: cfg, + memoryMode: strings.TrimSpace(cfg.Memory.Extractor), }, nil } -// Chat 通过本地聊天服务发送消息。 +func buildMemoryExtractor(cfg *configs.AppConfiguration) (domain.MemoryExtractor, error) { + if cfg == nil { + return service.BuildMemoryExtractor(service.MemoryExtractorModeRule, nil, service.LLMMemoryExtractorOptions{}) + } + + mode := strings.TrimSpace(cfg.Memory.Extractor) + if mode == "" || strings.EqualFold(mode, service.MemoryExtractorModeRule) { + return service.BuildMemoryExtractor(service.MemoryExtractorModeRule, nil, service.LLMMemoryExtractorOptions{}) + } + + model := strings.TrimSpace(cfg.Memory.ExtractorModel) + if model == "" { + model = strings.TrimSpace(cfg.AI.Model) + } + + extractorProvider, err := provider.NewChatProvider(model) + if err != nil { + if strings.EqualFold(mode, service.MemoryExtractorModeAuto) { + return service.BuildMemoryExtractor(service.MemoryExtractorModeAuto, nil, service.LLMMemoryExtractorOptions{}) + } + return nil, err + } + + return service.BuildMemoryExtractor(mode, extractorProvider, service.LLMMemoryExtractorOptions{ + Timeout: time.Duration(cfg.Memory.ExtractorTimeoutSecond) * time.Second, + }) +} + +// Chat sends a chat request through the local services. func (c *localChatClient) Chat(ctx context.Context, messages []Message, model string) (<-chan string, error) { chatProvider, err := provider.NewChatProvider(model) if err != nil { return nil, err } - chatSvc := service.NewChatService(c.memorySvc, c.workingSvc, c.todoSvc, c.roleSvc, chatProvider) + chatSvc := service.NewChatService(c.memorySvc, c.workingSvc, c.projectMemorySvc, c.todoSvc, c.roleSvc, chatProvider) return chatSvc.Send(ctx, &domain.ChatRequest{Messages: messages, Model: model}) } -// GetMemoryStats 返回 TUI 所需的当前记忆统计信息。 +// GetMemoryStats returns memory statistics for the TUI. func (c *localChatClient) GetMemoryStats(ctx context.Context) (*MemoryStats, error) { stats, err := c.memorySvc.GetStats(ctx) if err != nil { return nil, err } + projectSources := make([]string, 0) + if c.projectMemorySvc != nil { + sources, sourceErr := c.projectMemorySvc.ListSources(ctx) + if sourceErr != nil { + return nil, sourceErr + } + for _, source := range sources { + projectSources = append(projectSources, source.Path) + } + } return &MemoryStats{ PersistentItems: stats.PersistentItems, SessionItems: stats.SessionItems, @@ -143,15 +209,54 @@ func (c *localChatClient) GetMemoryStats(ctx context.Context) (*MemoryStats, err MinScore: stats.MinScore, Path: stats.Path, ByType: stats.ByType, + ProjectSources: projectSources, }, nil } -// ClearMemory 通过本地记忆服务清空长期记忆。 +// GetProjectMemorySources returns currently loaded explicit project memory files. +func (c *localChatClient) GetProjectMemorySources(ctx context.Context) ([]ProjectMemorySource, error) { + if c.projectMemorySvc == nil { + return nil, nil + } + return c.projectMemorySvc.ListSources(ctx) +} + +// ListMemoryItems returns memory items for inspection. +func (c *localChatClient) ListMemoryItems(ctx context.Context) ([]MemoryListItem, error) { + if c.memorySvc == nil { + return nil, nil + } + + impl, ok := c.memorySvc.(memoryListProvider) + if ok { + return impl.ListMemoryItems(ctx) + } + + return nil, nil +} + +// Remember stores an explicit durable preference or rule from a manual command. +func (c *localChatClient) Remember(ctx context.Context, text string) error { + if c.memorySvc == nil { + return nil + } + return c.memorySvc.Save(ctx, strings.TrimSpace(text), "Noted. Remember this for future coding assistance.") +} + +// MemoryMode returns the configured memory extraction mode. +func (c *localChatClient) MemoryMode(context.Context) string { + if strings.TrimSpace(c.memoryMode) == "" { + return service.MemoryExtractorModeRule + } + return c.memoryMode +} + +// ClearMemory clears persistent memory. func (c *localChatClient) ClearMemory(ctx context.Context) error { return c.memorySvc.Clear(ctx) } -// ClearSessionMemory 清空会话记忆和工作记忆状态。 +// ClearSessionMemory clears session memory and working memory state. func (c *localChatClient) ClearSessionMemory(ctx context.Context) error { if err := c.memorySvc.ClearSession(ctx); err != nil { return err @@ -162,7 +267,7 @@ func (c *localChatClient) ClearSessionMemory(ctx context.Context) error { return nil } -// GetTodoList 返回当前任务清单。 +// GetTodoList returns the current todo list. func (c *localChatClient) GetTodoList(ctx context.Context) ([]domain.Todo, error) { if c.todoSvc == nil { return nil, nil @@ -170,7 +275,7 @@ func (c *localChatClient) GetTodoList(ctx context.Context) ([]domain.Todo, error return c.todoSvc.ListTodos(ctx) } -// AddTodo 添加一个新任务。 +// AddTodo adds a new todo item. func (c *localChatClient) AddTodo(ctx context.Context, content string, priority domain.TodoPriority) (*domain.Todo, error) { if c.todoSvc == nil { return nil, nil @@ -178,7 +283,7 @@ func (c *localChatClient) AddTodo(ctx context.Context, content string, priority return c.todoSvc.AddTodo(ctx, content, priority) } -// UpdateTodoStatus 更新任务状态。 +// UpdateTodoStatus updates a todo status. func (c *localChatClient) UpdateTodoStatus(ctx context.Context, id string, status domain.TodoStatus) error { if c.todoSvc == nil { return nil @@ -186,7 +291,7 @@ func (c *localChatClient) UpdateTodoStatus(ctx context.Context, id string, statu return c.todoSvc.UpdateTodoStatus(ctx, id, status) } -// RemoveTodo 移除特定任务。 +// RemoveTodo removes a todo item. func (c *localChatClient) RemoveTodo(ctx context.Context, id string) error { if c.todoSvc == nil { return nil @@ -194,12 +299,12 @@ func (c *localChatClient) RemoveTodo(ctx context.Context, id string) error { return c.todoSvc.RemoveTodo(ctx, id) } -// DefaultModel 返回 TUI 使用的默认模型。 +// DefaultModel returns the TUI default model. func (c *localChatClient) DefaultModel() string { return provider.DefaultModelForConfig(c.config) } -// GetWorkingSessionSummary 返回当前工作区上次保存的会话摘要。 +// GetWorkingSessionSummary returns the persisted working session summary for the workspace. func (c *localChatClient) GetWorkingSessionSummary(ctx context.Context) (string, error) { if c.workingSvc == nil || c.config == nil || !c.config.History.ResumeLastSession { return "", nil diff --git a/internal/tui/services/memory_bridge.go b/internal/tui/services/memory_bridge.go new file mode 100644 index 00000000..17708316 --- /dev/null +++ b/internal/tui/services/memory_bridge.go @@ -0,0 +1,7 @@ +package services + +import "context" + +type memoryListProvider interface { + ListMemoryItems(ctx context.Context) ([]MemoryListItem, error) +} From c76ead8da7600be89273e8e5289abed991d1266c Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 25 Mar 2026 20:40:31 +0800 Subject: [PATCH 04/10] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E6=9E=B6=E6=9E=84=E4=B8=8E=E6=8F=90=E5=8F=96=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/memory-architecture.md | 68 +++++++++++++++++++++++++++++++++++++ docs/memory-extractor.md | 27 +++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 docs/memory-architecture.md create mode 100644 docs/memory-extractor.md diff --git a/docs/memory-architecture.md b/docs/memory-architecture.md new file mode 100644 index 00000000..41cec6de --- /dev/null +++ b/docs/memory-architecture.md @@ -0,0 +1,68 @@ +# Memory Architecture + +This document describes the target memory design for NeoCode as an AI coding assistant. + +## Goals + +- Preserve durable project rules without forcing the model to infer them from chat history. +- Preserve useful long-term coding memory such as preferences, code facts, and fix recipes. +- Preserve short-term working state so the assistant can resume where it left off. +- Keep every layer inspectable and replaceable. + +## Layers + +### 1. Explicit Project Memory + +Loaded from workspace files such as: + +- `AGENTS.md` +- `CLAUDE.md` +- `.neocode/memory.md` +- `NEOCODE.md` + +These files are treated as authoritative project instructions and are injected before inferred memory. + +### 2. Structured Auto Memory + +Stored as structured memory items: + +- `user_preference` +- `project_rule` +- `code_fact` +- `fix_recipe` +- `session_memory` + +Extraction can use: + +- `rule` +- `llm` +- `auto` + +### 3. Working Session Memory + +Persisted per workspace and used to resume active work: + +- current task +- last completed action +- current in progress +- next step +- recent files +- recent turns + +## Injection Priority + +Prompt assembly order should be: + +1. role prompt +2. explicit project memory +3. working memory +4. todo context +5. recalled structured memory + +## Validation + +- Explicit project memory files should load only from the active workspace. +- Missing project memory files should not cause failures. +- Working memory should continue to resume per workspace. +- `memory.extractor: auto` should fall back to rule extraction if LLM extraction fails. +- `go test ./...` must pass. diff --git a/docs/memory-extractor.md b/docs/memory-extractor.md new file mode 100644 index 00000000..4af7fa35 --- /dev/null +++ b/docs/memory-extractor.md @@ -0,0 +1,27 @@ +# Memory Extractor + +NeoCode now supports multiple memory extraction strategies through `memory.extractor`. + +## Modes + +- `rule`: deterministic rule-based extraction only. This is the default and adds no extra model call. +- `llm`: uses a chat model to convert a conversation turn into structured memory JSON. +- `auto`: tries the LLM extractor first and falls back to the rule extractor when the model call or JSON parsing fails. + +## Config + +```yaml +memory: + extractor: "rule" + extractor_model: "" + extractor_timeout_seconds: 20 +``` + +- `memory.extractor_model`: optional. When empty, NeoCode reuses `ai.model`. +- `memory.extractor_timeout_seconds`: timeout for a single LLM extraction request. + +## Validation + +- `rule` should preserve current behavior and add no network cost. +- `llm` requires a working chat provider and valid API key. +- `auto` should still write memory when the LLM extractor fails, by falling back to the rule extractor. From 5fb369e9725c17acf66a9c63ec279143fb2dc2bc Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 25 Mar 2026 21:06:24 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E8=AE=B0=E5=BF=86=E5=91=BD=E4=BB=A4=E6=9C=AA=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E8=90=BD=E5=BA=93=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/service/memory_service.go | 83 ++++++++++++++++--- .../rule_based_memory_extractor_test.go | 37 +++++++++ internal/tui/services/api_client.go | 3 + internal/tui/services/memory_bridge.go | 4 + 4 files changed, 115 insertions(+), 12 deletions(-) diff --git a/internal/server/service/memory_service.go b/internal/server/service/memory_service.go index 0d6a39d4..3123347b 100644 --- a/internal/server/service/memory_service.go +++ b/internal/server/service/memory_service.go @@ -4,7 +4,9 @@ import ( "context" "fmt" "sort" + "strconv" "strings" + "time" "go-llm-demo/internal/server/domain" ) @@ -131,24 +133,54 @@ func (s *memoryServiceImpl) Save(ctx context.Context, userInput, reply string) e } for _, item := range items { - if item.Type == domain.TypeSessionMemory { - if err := s.sessionRepo.Add(ctx, item); err != nil { - return err - } - continue - } - if len(s.persistTypes) > 0 { - if _, ok := s.persistTypes[item.Type]; !ok { - continue - } - } - if err := s.persistentRepo.Add(ctx, item); err != nil { + if err := s.storeMemoryItem(ctx, item); err != nil { return err } } return nil } +// SaveManualMemory stores an explicit durable note without routing it back through the extractor. +func (s *memoryServiceImpl) SaveManualMemory(ctx context.Context, text string) error { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return nil + } + + itemType, scope := inferManualMemoryKind(trimmed) + if len(s.persistTypes) > 0 { + if _, ok := s.persistTypes[itemType]; !ok { + for _, fallbackType := range []string{domain.TypeUserPreference, domain.TypeProjectRule, domain.TypeCodeFact, domain.TypeFixRecipe} { + if _, allowed := s.persistTypes[fallbackType]; allowed { + itemType = fallbackType + scope = domain.MemoryItem{Type: fallbackType}.Normalized().Scope + break + } + } + if _, ok := s.persistTypes[itemType]; !ok { + return fmt.Errorf("manual memory type %q is not enabled in memory.persist_types", itemType) + } + } + } + + now := time.Now().UTC() + item := domain.MemoryItem{ + ID: strconv.FormatInt(now.UnixNano(), 10) + "-manual", + Type: itemType, + Scope: scope, + Summary: domain.SummarizeText(trimmed, 140), + Details: domain.SummarizeText(trimmed, 220), + Source: "manual", + Confidence: 0.99, + UserInput: trimmed, + AssistantReply: "Manually saved from /remember.", + Text: trimmed, + CreatedAt: now, + UpdatedAt: now, + } + return s.storeMemoryItem(ctx, item) +} + // GetStats returns memory counts and retrieval settings. func (s *memoryServiceImpl) GetStats(ctx context.Context) (*domain.MemoryStats, error) { persistentItems, err := s.persistentRepo.List(ctx) @@ -181,6 +213,18 @@ func (s *memoryServiceImpl) ClearSession(ctx context.Context) error { return s.sessionRepo.Clear(ctx) } +func (s *memoryServiceImpl) storeMemoryItem(ctx context.Context, item domain.MemoryItem) error { + if item.Type == domain.TypeSessionMemory { + return s.sessionRepo.Add(ctx, item) + } + if len(s.persistTypes) > 0 { + if _, ok := s.persistTypes[item.Type]; !ok { + return nil + } + } + return s.persistentRepo.Add(ctx, item) +} + // Search scores memory items and returns the most relevant matches. func Search(items []domain.MemoryItem, query string, topK int, minScore float64) []Match { trimmedQuery := strings.TrimSpace(query) @@ -422,6 +466,21 @@ func countMemoryTypes(groups ...[]domain.MemoryItem) map[string]int { return counts } +func inferManualMemoryKind(text string) (string, string) { + lower := strings.ToLower(strings.TrimSpace(text)) + projectHints := []string{ + "repo", "repository", "project", "workspace", "file", "path", "config", "command", + "go test", "go build", "readme", "agents.md", "claude.md", "memory.md", + "仓库", "项目", "工作区", "文件", "路径", "配置", "命令", "约定", "规范", "提交", + } + for _, hint := range projectHints { + if strings.Contains(lower, hint) { + return domain.TypeProjectRule, domain.ScopeProject + } + } + return domain.TypeUserPreference, domain.ScopeUser +} + // ListMemoryItems returns both persistent and session memory items for inspection. func (s *memoryServiceImpl) ListMemoryItems(ctx context.Context) ([]domain.MemoryItem, error) { persistentItems, err := s.persistentRepo.List(ctx) diff --git a/internal/server/service/rule_based_memory_extractor_test.go b/internal/server/service/rule_based_memory_extractor_test.go index 16ae2a2b..67e6cd4c 100644 --- a/internal/server/service/rule_based_memory_extractor_test.go +++ b/internal/server/service/rule_based_memory_extractor_test.go @@ -136,3 +136,40 @@ func TestMemoryServiceUsesInjectedExtractor(t *testing.T) { t.Fatalf("expected custom extractor item to be persisted, got %+v", stats.ByType) } } + +func TestMemoryServiceSaveManualMemoryPersistsExplicitNote(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{domain.TypeUserPreference, domain.TypeProjectRule}, + ) + + writer, ok := svc.(interface { + SaveManualMemory(context.Context, string) error + }) + if !ok { + t.Fatal("expected memory service to expose SaveManualMemory") + } + + if err := writer.SaveManualMemory(ctx, "以后默认用中文总结改动"); err != nil { + t.Fatalf("save manual memory: %v", err) + } + + stats, err := svc.GetStats(ctx) + if err != nil { + t.Fatalf("stats: %v", err) + } + if stats.PersistentItems != 1 { + t.Fatalf("expected one persisted manual memory item, got %+v", stats) + } + if stats.ByType[domain.TypeUserPreference] != 1 { + t.Fatalf("expected a user_preference manual memory item, got %+v", stats.ByType) + } +} diff --git a/internal/tui/services/api_client.go b/internal/tui/services/api_client.go index 4b4b7138..472ac94b 100644 --- a/internal/tui/services/api_client.go +++ b/internal/tui/services/api_client.go @@ -240,6 +240,9 @@ func (c *localChatClient) Remember(ctx context.Context, text string) error { if c.memorySvc == nil { return nil } + if impl, ok := c.memorySvc.(manualMemoryWriter); ok { + return impl.SaveManualMemory(ctx, text) + } return c.memorySvc.Save(ctx, strings.TrimSpace(text), "Noted. Remember this for future coding assistance.") } diff --git a/internal/tui/services/memory_bridge.go b/internal/tui/services/memory_bridge.go index 17708316..f8f59c5c 100644 --- a/internal/tui/services/memory_bridge.go +++ b/internal/tui/services/memory_bridge.go @@ -5,3 +5,7 @@ import "context" type memoryListProvider interface { ListMemoryItems(ctx context.Context) ([]MemoryListItem, error) } + +type manualMemoryWriter interface { + SaveManualMemory(ctx context.Context, text string) error +} From f6c78e35b653175c2c9c2acf2fff4cc66709c856 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 25 Mar 2026 21:06:41 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=E5=B8=AE?= =?UTF-8?q?=E5=8A=A9=E9=9D=A2=E6=9D=BF=E5=91=BD=E4=BB=A4=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E6=AD=A3=E5=BE=85=E5=8A=9E=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/components/help.go | 14 ++++++-- .../tui/components/layout_helpers_test.go | 12 ++++++- internal/tui/core/update.go | 12 +++++-- internal/tui/core/update_test.go | 32 +++++++++++++++++++ 4 files changed, 63 insertions(+), 7 deletions(-) diff --git a/internal/tui/components/help.go b/internal/tui/components/help.go index f8367070..eaf069e6 100644 --- a/internal/tui/components/help.go +++ b/internal/tui/components/help.go @@ -26,12 +26,20 @@ func RenderHelp(width int) string { {"/apikey ", "Switch the API key environment variable"}, {"/provider ", "Switch the model provider"}, {"/switch ", "Switch the active model"}, - {"/run ", "Run code"}, - {"/explain ", "Explain code"}, {"/memory", "Show memory stats"}, + {"/memory-list", "List stored memory items"}, + {"/project-memory", "Show loaded project memory files"}, + {"/memory-mode", "Show the current memory extraction mode"}, + {"/remember ", "Store an explicit durable memory note"}, + {"/resume", "Show the saved working session summary"}, {"/clear-memory confirm", "Clear persistent memory"}, {"/clear-context", "Clear the session context"}, - {"/exit", "Exit the app"}, + {"/todo | /todo list", "Open or refresh the todo list"}, + {"/todo add [priority]", "Add a todo item"}, + {"/run ", "Run code"}, + {"/explain ", "Explain code"}, + {"/y | /n", "Approve or reject a pending security prompt"}, + {"/exit | /quit | /q", "Exit the app"}, } cmdStyle := lipgloss.NewStyle(). diff --git a/internal/tui/components/layout_helpers_test.go b/internal/tui/components/layout_helpers_test.go index 9184e086..106134c0 100644 --- a/internal/tui/components/layout_helpers_test.go +++ b/internal/tui/components/layout_helpers_test.go @@ -9,7 +9,17 @@ import ( func TestRenderHelpContainsKeyCommands(t *testing.T) { rendered := RenderHelp(80) - for _, want := range []string{"NeoCode Help", "/help", "/provider ", "Press Esc or /help to close"} { + for _, want := range []string{ + "NeoCode Help", + "/help", + "/provider ", + "/memory-list", + "/project-memory", + "/remember ", + "/resume", + "/memory-mode", + "Press Esc or /help to close", + } { if !strings.Contains(rendered, want) { t.Fatalf("expected help to contain %q, got %q", want, rendered) } diff --git a/internal/tui/core/update.go b/internal/tui/core/update.go index 07de0be9..21a79865 100644 --- a/internal/tui/core/update.go +++ b/internal/tui/core/update.go @@ -689,13 +689,19 @@ func (m *Model) handleCommand(input string) (tea.Model, tea.Cmd) { m.AddMessage("assistant", todo.MsgUsageAdd) return *m, nil } - content := args[1] priority := services.TodoPriorityMedium - if len(args) > 2 { - if p, ok := services.ParseTodoPriority(args[2]); ok { + contentParts := append([]string(nil), args[1:]...) + if len(contentParts) > 1 { + if p, ok := services.ParseTodoPriority(contentParts[len(contentParts)-1]); ok { priority = p + contentParts = contentParts[:len(contentParts)-1] } } + content := strings.TrimSpace(strings.Join(contentParts, " ")) + if content == "" { + m.AddMessage("assistant", todo.MsgUsageAdd) + return *m, nil + } _, err := m.client.AddTodo(context.Background(), content, priority) if err != nil { m.AddMessage("assistant", fmt.Sprintf(todo.MsgAddFailed, err)) diff --git a/internal/tui/core/update_test.go b/internal/tui/core/update_test.go index 1e992eb3..d07911e4 100644 --- a/internal/tui/core/update_test.go +++ b/internal/tui/core/update_test.go @@ -776,6 +776,38 @@ func TestHandleCommandMemoryModeSuccess(t *testing.T) { assertLastMessageContains(t, got, "auto") } +func TestHandleCommandTodoAddSupportsMultiWordContent(t *testing.T) { + client := &fakeChatClient{} + m := newTestModel(t, client) + m.chat.APIKeyReady = true + + updated, _ := m.handleCommand("/todo add 修复 memory 命令") + got := updated.(Model) + assertLastMessageContains(t, got, "修复 memory 命令") + if len(client.todos) != 1 || client.todos[0].Content != "修复 memory 命令" { + t.Fatalf("expected full todo content to be stored, got %+v", client.todos) + } + if client.todos[0].Priority != services.TodoPriorityMedium { + t.Fatalf("expected default medium priority, got %+v", client.todos[0]) + } +} + +func TestHandleCommandTodoAddParsesTrailingPriority(t *testing.T) { + client := &fakeChatClient{} + m := newTestModel(t, client) + m.chat.APIKeyReady = true + + updated, _ := m.handleCommand("/todo add 修复 memory 命令 high") + got := updated.(Model) + assertLastMessageContains(t, got, "修复 memory 命令") + if len(client.todos) != 1 || client.todos[0].Content != "修复 memory 命令" { + t.Fatalf("expected full todo content to be stored, got %+v", client.todos) + } + if client.todos[0].Priority != services.TodoPriorityHigh { + t.Fatalf("expected high priority, got %+v", client.todos[0]) + } +} + func TestHandleCommandClearMemoryRequiresConfirm(t *testing.T) { client := &fakeChatClient{} m := newTestModel(t, client) From 5a5c3185dc65712ef937f74771085852e0eeb5e5 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 26 Mar 2026 08:20:02 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix:=E6=96=87=E6=A1=A3=E5=B7=B2=E8=AF=A5?= =?UTF-8?q?=E4=B8=BA=E4=B8=AD=E6=96=87=EF=BC=8C=E5=B7=B2=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.example.yaml | 10 +++++-- docs/memory-architecture.md | 60 ++++++++++++++++++------------------- docs/memory-extractor.md | 26 ++++++++-------- 3 files changed, 51 insertions(+), 45 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 6de1f470..0e391790 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -14,14 +14,20 @@ memory: max_prompt_chars: 1800 max_items: 1000 storage_path: "./data/memory_rules.json" + # 按顺序扫描工作区中的显式项目记忆文件。 + # 这些文件用于承载团队约定、项目规则或需要优先注入给模型的固定上下文。 project_files: - "AGENTS.md" - "CLAUDE.md" - ".neocode/memory.md" - "NEOCODE.md" + # 显式项目记忆注入到 prompt 时的最大字符数预算。 project_prompt_chars: 2400 - extractor: "rule" # rule | llm | auto - extractor_model: "" # optional; defaults to ai.model when empty + # 记忆提取策略:rule | llm | auto。 + extractor: "rule" + # 仅在 llm/auto 模式下使用;为空时默认复用 ai.model。 + extractor_model: "" + # 单次 LLM 记忆提取请求的超时时间(秒),用于限制额外提取链路的等待时间。 extractor_timeout_seconds: 20 persist_types: - "user_preference" diff --git a/docs/memory-architecture.md b/docs/memory-architecture.md index 41cec6de..e8937555 100644 --- a/docs/memory-architecture.md +++ b/docs/memory-architecture.md @@ -1,30 +1,30 @@ -# Memory Architecture +# 记忆架构 -This document describes the target memory design for NeoCode as an AI coding assistant. +本文档描述 NeoCode 作为 AI 编码助手的目标记忆设计。 -## Goals +## 设计目标 -- Preserve durable project rules without forcing the model to infer them from chat history. -- Preserve useful long-term coding memory such as preferences, code facts, and fix recipes. -- Preserve short-term working state so the assistant can resume where it left off. -- Keep every layer inspectable and replaceable. +- 持久保存项目级规则,避免模型只能从聊天历史中被动猜测。 +- 持久保存有价值的长期编码记忆,例如用户偏好、代码事实和修复经验。 +- 保存短期工作态,让助手能够在中断后继续当前任务。 +- 让每一层记忆都可检查、可替换、可独立演进。 -## Layers +## 分层设计 -### 1. Explicit Project Memory +### 1. 显式项目记忆 -Loaded from workspace files such as: +从工作区中的约定文件加载,例如: - `AGENTS.md` - `CLAUDE.md` - `.neocode/memory.md` - `NEOCODE.md` -These files are treated as authoritative project instructions and are injected before inferred memory. +这类文件被视为项目的显式权威说明,在自动推断记忆之前优先注入上下文。 -### 2. Structured Auto Memory +### 2. 结构化自动记忆 -Stored as structured memory items: +以结构化条目的形式持久化,例如: - `user_preference` - `project_rule` @@ -32,26 +32,26 @@ Stored as structured memory items: - `fix_recipe` - `session_memory` -Extraction can use: +提取策略支持: - `rule` - `llm` - `auto` -### 3. Working Session Memory +### 3. 工作会话记忆 -Persisted per workspace and used to resume active work: +按工作区持久化,用于恢复当前工作状态,例如: -- current task -- last completed action -- current in progress -- next step -- recent files -- recent turns +- 当前任务 +- 最近完成的动作 +- 当前进行中的事项 +- 下一步计划 +- 最近涉及的文件 +- 最近对话轮次 -## Injection Priority +## 上下文注入优先级 -Prompt assembly order should be: +Prompt 拼装顺序应为: 1. role prompt 2. explicit project memory @@ -59,10 +59,10 @@ Prompt assembly order should be: 4. todo context 5. recalled structured memory -## Validation +## 验证要求 -- Explicit project memory files should load only from the active workspace. -- Missing project memory files should not cause failures. -- Working memory should continue to resume per workspace. -- `memory.extractor: auto` should fall back to rule extraction if LLM extraction fails. -- `go test ./...` must pass. +- 显式项目记忆文件只能从当前激活的工作区加载。 +- 项目记忆文件缺失时不应导致流程失败。 +- 工作记忆仍应按工作区维度恢复。 +- 当 `memory.extractor: auto` 的 LLM 提取失败时,应回退到规则提取。 +- `go test ./...` 需要通过。 diff --git a/docs/memory-extractor.md b/docs/memory-extractor.md index 4af7fa35..e011205e 100644 --- a/docs/memory-extractor.md +++ b/docs/memory-extractor.md @@ -1,14 +1,14 @@ -# Memory Extractor +# 记忆提取器 -NeoCode now supports multiple memory extraction strategies through `memory.extractor`. +NeoCode 现在通过 `memory.extractor` 支持多种记忆提取策略。 -## Modes +## 模式说明 -- `rule`: deterministic rule-based extraction only. This is the default and adds no extra model call. -- `llm`: uses a chat model to convert a conversation turn into structured memory JSON. -- `auto`: tries the LLM extractor first and falls back to the rule extractor when the model call or JSON parsing fails. +- `rule`:仅使用确定性的规则提取。这是默认模式,不会增加额外模型调用。 +- `llm`:使用聊天模型将对话内容转换为结构化记忆 JSON。 +- `auto`:优先尝试 LLM 提取;当模型调用失败或 JSON 解析失败时,回退到规则提取。 -## Config +## 配置项 ```yaml memory: @@ -17,11 +17,11 @@ memory: extractor_timeout_seconds: 20 ``` -- `memory.extractor_model`: optional. When empty, NeoCode reuses `ai.model`. -- `memory.extractor_timeout_seconds`: timeout for a single LLM extraction request. +- `memory.extractor_model`:可选项。为空时默认复用 `ai.model`。 +- `memory.extractor_timeout_seconds`:单次 LLM 提取请求的等待上限,仅在 `llm` 或 `auto` 模式下生效。 -## Validation +## 验证要求 -- `rule` should preserve current behavior and add no network cost. -- `llm` requires a working chat provider and valid API key. -- `auto` should still write memory when the LLM extractor fails, by falling back to the rule extractor. +- `rule` 模式应保持当前行为,不增加网络请求成本。 +- `llm` 模式要求底层聊天提供方可用,且 API Key 配置正确。 +- `auto` 模式在 LLM 提取失败时,仍应通过规则提取完成记忆写入。 From 3bc065e235c5eb7200afa3077753c47642cc7df2 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 26 Mar 2026 08:32:33 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix:=E6=9B=B4=E5=8A=A0=E8=AF=A6=E7=BB=86?= =?UTF-8?q?=E4=BF=AE=E6=94=B9config.example.yaml=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.example.yaml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 0e391790..e5d9fb8f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -15,12 +15,11 @@ memory: max_items: 1000 storage_path: "./data/memory_rules.json" # 按顺序扫描工作区中的显式项目记忆文件。 - # 这些文件用于承载团队约定、项目规则或需要优先注入给模型的固定上下文。 project_files: - - "AGENTS.md" - - "CLAUDE.md" - - ".neocode/memory.md" - - "NEOCODE.md" + - "AGENTS.md" # 仓库协作约定文件,通常记录开发规范、目录职责、提交流程等项目规则。 + - "CLAUDE.md" # 兼容 Claude Code 风格的仓库指令文件,用于复用已有的团队约定。 + - ".neocode/memory.md" # NeoCode 自身约定的项目记忆文件,用于沉淀需要长期注入的项目上下文。 + - "NEOCODE.md" # 面向 NeoCode 的仓库说明文件,可作为项目级补充指令或约定入口。 # 显式项目记忆注入到 prompt 时的最大字符数预算。 project_prompt_chars: 2400 # 记忆提取策略:rule | llm | auto。 From f5093c678eceb37fb8d51e6272d6ea03fe0c4505 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 26 Mar 2026 11:47:44 +0800 Subject: [PATCH 09/10] =?UTF-8?q?docs:=E5=A2=9E=E5=8A=A0=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/memory-architecture.md | 197 ++++++++++++++++++++++++++++++++---- docs/memory-extractor.md | 66 +++++++++++- 2 files changed, 242 insertions(+), 21 deletions(-) diff --git a/docs/memory-architecture.md b/docs/memory-architecture.md index e8937555..b70e51fe 100644 --- a/docs/memory-architecture.md +++ b/docs/memory-architecture.md @@ -1,6 +1,6 @@ # 记忆架构 -本文档描述 NeoCode 作为 AI 编码助手的目标记忆设计。 +本文档描述 NeoCode 的记忆系统分层、核心概念和运行行为。目标不是“把对话存起来”,而是把后续编码真正可复用的上下文拆成不同职责的记忆层,并以可检查、可替换的方式注入到 Prompt。 ## 设计目标 @@ -9,45 +9,198 @@ - 保存短期工作态,让助手能够在中断后继续当前任务。 - 让每一层记忆都可检查、可替换、可独立演进。 -## 分层设计 +## 核心概念 + +NeoCode 当前把“记忆”拆成三层,它们的输入来源、存储位置和使用方式不同。 ### 1. 显式项目记忆 -从工作区中的约定文件加载,例如: +显式项目记忆来自工作区中的约定文件,例如: - `AGENTS.md` - `CLAUDE.md` - `.neocode/memory.md` - `NEOCODE.md` -这类文件被视为项目的显式权威说明,在自动推断记忆之前优先注入上下文。 +这类文件是项目显式声明的权威说明,典型内容包括: + +- 仓库协作约定 +- 目录职责 +- 提交和测试要求 +- 项目长期规则 + +行为定义: + +- 输入:当前激活工作区中的约定文件内容。 +- 输出:拼装后的项目级 Prompt 上下文块。 +- 触发时机:每次聊天请求发出前。 +- 冲突策略:当它与自动提取出的结构化记忆冲突时,以显式项目记忆为准。 +- 作用范围:仅当前工作区;不允许越过工作区根目录读取其他位置文件。 ### 2. 结构化自动记忆 -以结构化条目的形式持久化,例如: +结构化自动记忆来自“对话轮次提取”,不是直接把整段聊天历史原样保存,而是把当前轮次中对未来编码仍有价值的信息转换成 `MemoryItem` 条目。 -- `user_preference` -- `project_rule` -- `code_fact` -- `fix_recipe` -- `session_memory` +当前支持的条目类型有: -提取策略支持: +- `user_preference`:稳定的用户偏好,例如回复语言、命令风格、协作习惯。 +- `project_rule`:从仓库文件、配置或明确约定中抽取出的项目规则。 +- `code_fact`:代码事实,例如某个文件/模块/符号的职责与位置。 +- `fix_recipe`:问题与修复方案的组合经验。 +- `session_memory`:只在当前会话有价值的临时编码上下文。 -- `rule` -- `llm` -- `auto` +行为定义: + +- 输入:一轮对话的 `userInput` 和 `assistantReply`。 +- 输出:`[]MemoryItem` 结构化条目列表。 +- 触发时机:模型流式回复完成后,`chat_service` 调用 `memorySvc.Save(...)` 时。 +- 存储策略: + - `user_preference` / `project_rule` / `code_fact` / `fix_recipe` 写入长期存储。 + - `session_memory` 写入会话存储,不进入长期记忆文件。 +- 使用方式:后续请求到来时,`memorySvc.BuildContext(...)` 根据当前用户输入检索最相关条目,再注入 Prompt。 ### 3. 工作会话记忆 -按工作区持久化,用于恢复当前工作状态,例如: +工作会话记忆是“当前任务状态快照”,它解决的是“如何恢复进行中的工作”,而不是“沉淀长期知识”。 + +典型字段包括: - 当前任务 +- 任务摘要 - 最近完成的动作 - 当前进行中的事项 - 下一步计划 +- 未解决问题 - 最近涉及的文件 -- 最近对话轮次 +- 最近若干轮用户/助手对话 + +行为定义: + +- 输入:当前会话消息列表。 +- 输出:`WorkingMemoryState` 快照和对应 Prompt 上下文块。 +- 触发时机:聊天请求前刷新一次,聊天回复完成后再刷新一次。 +- 作用:帮助模型延续“正在做什么”,而不是回答“项目长期规则是什么”。 + +### `session_memory` 与工作会话记忆的区别 + +这两个概念容易混淆,但职责不同: + +- `session_memory` 是一种结构化记忆条目类型,来源于对单轮对话的提取,适合记录“本次会话内暂时有用的编码事实或任务上下文”。 +- 工作会话记忆是完整的工作状态快照,来源于整个消息序列的整理,适合恢复当前任务推进状态。 + +可以把它们理解为: + +- `session_memory` 回答“这一轮里有哪些值得短期记住的信息?” +- 工作会话记忆回答“我现在做到哪一步了?” + +## 结构化自动记忆提取契约 + +结构化自动记忆通过 `memory.extractor` 配置选择提取策略。该策略只影响“如何从当前对话轮次中提取结构化条目”,不影响显式项目记忆和工作会话记忆的构建。 + +### 统一输入与输出 + +无论选择哪种策略,契约都一致: + +- 输入:当前轮次的 `userInput` 与 `assistantReply`。 +- 输出:零个或多个 `MemoryItem`。 +- 跳过条件: + - 用户输入为空。 + - 助手回复为空。 + - 助手回复看起来是工具协议载荷,而不是自然语言答复。 + +### `rule` 策略 + +`rule` 是确定性规则提取器,也是默认模式。 + +行为定义: + +- 执行方式:通过关键字、路径锚点、语义线索匹配,分别尝试提取 `user_preference`、`project_rule`、`code_fact`、`fix_recipe`、`session_memory`。 +- 输出特点:稳定、可预测、无额外模型调用。 +- 失败语义:不会因为外部模型不可用而失败;最多只是“提取不到条目”。 + +适用场景: + +- 默认本地开发。 +- 不希望增加额外模型调用成本。 +- 希望行为稳定、便于测试和回归验证。 + +### `llm` 策略 + +`llm` 使用聊天模型把当前轮次转换为结构化 JSON,再映射为 `MemoryItem`。 + +行为定义: + +- 执行方式:额外发起一次模型调用,请求模型输出符合约束的 JSON。 +- 输出特点:更擅长抽取隐含偏好、复杂修复经验和高层代码事实。 +- 依赖条件: + - 底层聊天提供方可用。 + - API Key 和模型配置正确。 + - 输出能被解析为合法 JSON。 +- 失败语义:如果模型调用失败、超时或返回非 JSON,本次提取失败。 + +适用场景: + +- 希望获得比规则匹配更强的语义抽取能力。 +- 可以接受额外的模型成本与时延。 +- 已具备稳定的模型提供方配置。 + +### `auto` 策略 + +`auto` 是“优先 LLM,失败回退 rule”的组合策略。 + +行为定义: + +- 执行顺序:先尝试 `llm` 提取。 +- 回退条件: + - 未配置可用的 LLM provider。 + - 模型调用失败。 + - 超时。 + - 返回空内容或非 JSON。 + - JSON 解析后没有合法候选项。 +- 回退行为:使用 `rule` 提取器重新处理同一轮对话。 + +适用场景: + +- 既希望获得 LLM 的提取质量,又不希望因为提取链路失败而丢失记忆。 +- 生产环境或默认推荐方案。 + +## 存储与检索行为 + +结构化条目提取完成后,记忆服务按类型决定去向: + +- 长期存储:`user_preference`、`project_rule`、`code_fact`、`fix_recipe` +- 会话存储:`session_memory` + +后续检索行为如下: + +- 输入:当前用户新问题。 +- 过程:对长期记忆和会话记忆分别打分、合并、排序。 +- 排序偏好:先看相关度,再看类型优先级,再看更新时间。 +- 注入方式:只注入 top-k 且分数达到阈值的结构化条目摘要,而不是整库全量注入。 + +当前类型优先级为: + +1. `user_preference` +2. `project_rule` +3. `code_fact` +4. `fix_recipe` +5. `session_memory` + +这意味着当多个条目都相关时,系统会优先保证用户偏好和项目规则先进入 Prompt。 + +## 端到端生命周期 + +一次聊天请求中的记忆链路如下: + +1. 加载 role prompt。 +2. 加载显式项目记忆文件并拼装项目上下文。 +3. 刷新工作会话记忆并生成工作态上下文。 +4. 读取 TODO 上下文。 +5. 根据当前用户输入召回相关结构化自动记忆。 +6. 将上述上下文按顺序注入到系统 Prompt。 +7. 调用聊天模型生成回复。 +8. 回复完成后,用本轮 `userInput + assistantReply` 触发结构化记忆提取并保存。 +9. 同步刷新工作会话记忆快照。 ## 上下文注入优先级 @@ -59,10 +212,20 @@ Prompt 拼装顺序应为: 4. todo context 5. recalled structured memory +这个顺序体现的原则是: + +- 先注入最稳定、最权威的约束。 +- 再注入当前任务状态。 +- 最后注入与当前问题相关的结构化经验。 + ## 验证要求 - 显式项目记忆文件只能从当前激活的工作区加载。 - 项目记忆文件缺失时不应导致流程失败。 -- 工作记忆仍应按工作区维度恢复。 +- `session_memory` 不应误写入长期存储。 +- 工作会话记忆应按工作区维度恢复。 +- `rule` 模式不应增加额外模型调用。 +- `llm` 模式要求模型提供方可用,并能输出可解析 JSON。 - 当 `memory.extractor: auto` 的 LLM 提取失败时,应回退到规则提取。 +- 结构化记忆召回应只注入相关条目,而不是全量条目。 - `go test ./...` 需要通过。 diff --git a/docs/memory-extractor.md b/docs/memory-extractor.md index e011205e..dc35c9f5 100644 --- a/docs/memory-extractor.md +++ b/docs/memory-extractor.md @@ -1,12 +1,68 @@ # 记忆提取器 -NeoCode 现在通过 `memory.extractor` 支持多种记忆提取策略。 +本文档聚焦 `memory.extractor` 的配置语义和运行行为。关于整体分层和三类记忆的职责,请优先参考 `docs/memory-architecture.md`。 + +## 作用范围 + +`memory.extractor` 只负责一件事:把当前这一轮 `userInput + assistantReply` 转成结构化记忆条目。 + +它不负责: + +- 加载 `AGENTS.md` 等显式项目记忆文件 +- 构建工作会话记忆快照 +- 直接决定记忆召回排序 + +## 统一契约 + +所有提取模式共享同一接口: + +- 输入:一轮对话的 `userInput` 与 `assistantReply` +- 输出:`[]MemoryItem` +- 触发时机:聊天回复完成后,由 `memorySvc.Save(...)` 调用 + +提取器会直接跳过以下情况: + +- 用户输入为空 +- 助手回复为空 +- 助手回复看起来是工具调用协议载荷,而不是自然语言答复 ## 模式说明 -- `rule`:仅使用确定性的规则提取。这是默认模式,不会增加额外模型调用。 -- `llm`:使用聊天模型将对话内容转换为结构化记忆 JSON。 -- `auto`:优先尝试 LLM 提取;当模型调用失败或 JSON 解析失败时,回退到规则提取。 +### `rule` + +- 含义:仅使用确定性规则提取。 +- 方式:通过关键字、路径锚点和语义线索匹配生成结构化条目。 +- 优点:稳定、可预测、无额外模型调用。 +- 限制:对隐含语义、复杂修复经验的抽取能力弱于 `llm`。 + +### `llm` + +- 含义:使用聊天模型将对话内容转换为结构化记忆 JSON。 +- 方式:额外发起一次模型调用,请求模型返回限定 schema 的 JSON。 +- 优点:语义抽取能力更强。 +- 限制:依赖模型提供方、API Key、模型稳定性以及 JSON 可解析性。 + +### `auto` + +- 含义:优先尝试 LLM 提取;失败时回退到规则提取。 +- 方式:先执行 `llm`,若 provider 缺失、超时、模型调用失败或 JSON 解析失败,则自动改用 `rule`。 +- 优点:在保证可用性的前提下尽量获得更好的抽取质量。 +- 建议:作为推荐模式使用。 + +## 输出条目类型 + +提取器当前可产出的条目类型为: + +- `user_preference` +- `project_rule` +- `code_fact` +- `fix_recipe` +- `session_memory` + +其中: + +- `session_memory` 写入会话存储,仅服务当前会话。 +- 其余类型写入长期存储,但仍会受 `memory.persist_types` 配置约束。 ## 配置项 @@ -17,6 +73,7 @@ memory: extractor_timeout_seconds: 20 ``` +- `memory.extractor`:提取策略,可选 `rule`、`llm`、`auto`。 - `memory.extractor_model`:可选项。为空时默认复用 `ai.model`。 - `memory.extractor_timeout_seconds`:单次 LLM 提取请求的等待上限,仅在 `llm` 或 `auto` 模式下生效。 @@ -25,3 +82,4 @@ memory: - `rule` 模式应保持当前行为,不增加网络请求成本。 - `llm` 模式要求底层聊天提供方可用,且 API Key 配置正确。 - `auto` 模式在 LLM 提取失败时,仍应通过规则提取完成记忆写入。 +- 提取器失败不应影响主聊天回复已经产生的结果,只影响本轮记忆沉淀质量。 From 79e410e843e3acfd1dff66e61e0d96b96c625aff Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 26 Mar 2026 11:56:13 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix:=E5=A4=9A=E9=85=8D=E7=BD=AE=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=89=A9=E5=B1=95=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.example.yaml | 9 ++++----- configs/app_config.go | 2 +- configs/app_config_test.go | 7 +++++-- docs/memory-architecture.md | 17 +++++++++++------ docs/memory-extractor.md | 2 +- .../server/service/project_memory_service.go | 6 +++--- .../service/project_memory_service_test.go | 19 ++++++++++++------- 7 files changed, 37 insertions(+), 25 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index e5d9fb8f..467a28b0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -14,12 +14,11 @@ memory: max_prompt_chars: 1800 max_items: 1000 storage_path: "./data/memory_rules.json" - # 按顺序扫描工作区中的显式项目记忆文件。 + # 显式项目记忆文件。默认兼容业界通用的 AGENTS.md。 + # 如需兼容仓库里已有的其他约定文件,可继续追加到列表末尾; + # 系统按列表顺序加载,越靠前优先级越高。 project_files: - - "AGENTS.md" # 仓库协作约定文件,通常记录开发规范、目录职责、提交流程等项目规则。 - - "CLAUDE.md" # 兼容 Claude Code 风格的仓库指令文件,用于复用已有的团队约定。 - - ".neocode/memory.md" # NeoCode 自身约定的项目记忆文件,用于沉淀需要长期注入的项目上下文。 - - "NEOCODE.md" # 面向 NeoCode 的仓库说明文件,可作为项目级补充指令或约定入口。 + - "AGENTS.md" # 首选项目级约定文件;建议把仓库协作规则集中维护在这里。 # 显式项目记忆注入到 prompt 时的最大字符数预算。 project_prompt_chars: 2400 # 记忆提取策略:rule | llm | auto。 diff --git a/configs/app_config.go b/configs/app_config.go index b4c5242b..cf890ec9 100644 --- a/configs/app_config.go +++ b/configs/app_config.go @@ -67,7 +67,7 @@ func DefaultAppConfig() *AppConfiguration { cfg.Memory.MaxItems = 1000 cfg.Memory.StoragePath = "./data/memory_rules.json" cfg.Memory.PersistTypes = []string{"user_preference", "project_rule", "code_fact", "fix_recipe"} - cfg.Memory.ProjectFiles = []string{"AGENTS.md", "CLAUDE.md", ".neocode/memory.md", "NEOCODE.md"} + cfg.Memory.ProjectFiles = []string{"AGENTS.md"} cfg.Memory.ProjectPromptChars = 2400 cfg.Memory.Extractor = "rule" cfg.Memory.ExtractorModel = "" diff --git a/configs/app_config_test.go b/configs/app_config_test.go index e0a70321..7250c9a7 100644 --- a/configs/app_config_test.go +++ b/configs/app_config_test.go @@ -111,7 +111,7 @@ memory: storage_path: "./data/memory_rules.json" project_files: - "AGENTS.md" - - ".neocode/memory.md" + - "CLAUDE.md" project_prompt_chars: 2400 extractor: "auto" extractor_model: "gpt-5.4-mini" @@ -222,7 +222,7 @@ func validConfig() *AppConfiguration { cfg.Memory.MaxItems = 1000 cfg.Memory.StoragePath = "./data/memory_rules.json" cfg.Memory.PersistTypes = []string{"user_preference", "project_rule", "code_fact", "fix_recipe"} - cfg.Memory.ProjectFiles = []string{"AGENTS.md", ".neocode/memory.md"} + cfg.Memory.ProjectFiles = []string{"AGENTS.md"} cfg.Memory.ProjectPromptChars = 2400 cfg.Memory.Extractor = "rule" cfg.Memory.ExtractorModel = "" @@ -248,6 +248,9 @@ func TestDefaultAppConfigUsesCheckedInPersonaPath(t *testing.T) { if cfg.Memory.ProjectPromptChars != 2400 { t.Fatalf("expected default project prompt chars 2400, got %d", cfg.Memory.ProjectPromptChars) } + if len(cfg.Memory.ProjectFiles) != 1 || cfg.Memory.ProjectFiles[0] != "AGENTS.md" { + t.Fatalf("expected default project files to contain only AGENTS.md, got %+v", cfg.Memory.ProjectFiles) + } if cfg.Memory.ExtractorTimeoutSecond != 20 { t.Fatalf("expected default extractor timeout 20, got %d", cfg.Memory.ExtractorTimeoutSecond) } diff --git a/docs/memory-architecture.md b/docs/memory-architecture.md index b70e51fe..f0301044 100644 --- a/docs/memory-architecture.md +++ b/docs/memory-architecture.md @@ -15,12 +15,11 @@ NeoCode 当前把“记忆”拆成三层,它们的输入来源、存储位置 ### 1. 显式项目记忆 -显式项目记忆来自工作区中的约定文件,例如: +显式项目记忆默认来自工作区根目录下的 `AGENTS.md`。 -- `AGENTS.md` -- `CLAUDE.md` -- `.neocode/memory.md` -- `NEOCODE.md` +这样做的目标是优先兼容当前 agent 项目里已经普遍存在的约定文件,而不是额外创造新的项目级记忆文件标准。 + +如果某个仓库已经稳定使用了其他生态里的约定文件,也可以通过 `memory.project_files` 显式追加兼容文件,例如 `CLAUDE.md`。这些额外文件属于兼容入口,不是新的默认标准。 这类文件是项目显式声明的权威说明,典型内容包括: @@ -34,7 +33,12 @@ NeoCode 当前把“记忆”拆成三层,它们的输入来源、存储位置 - 输入:当前激活工作区中的约定文件内容。 - 输出:拼装后的项目级 Prompt 上下文块。 - 触发时机:每次聊天请求发出前。 -- 冲突策略:当它与自动提取出的结构化记忆冲突时,以显式项目记忆为准。 +- 多文件规则: + - 默认只读取 `AGENTS.md`。 + - 当用户在 `memory.project_files` 中显式配置多个文件时,系统按配置顺序加载。 + - 不做自动语义合并或去重,只保留有序拼装。 + - 如果多个文件冲突,越靠前的文件优先级越高。 +- 冲突策略:当显式项目记忆与自动提取出的结构化记忆冲突时,以显式项目记忆为准。 - 作用范围:仅当前工作区;不允许越过工作区根目录读取其他位置文件。 ### 2. 结构化自动记忆 @@ -222,6 +226,7 @@ Prompt 拼装顺序应为: - 显式项目记忆文件只能从当前激活的工作区加载。 - 项目记忆文件缺失时不应导致流程失败。 +- 当配置多个显式项目记忆文件时,应保持配置顺序稳定,且前者优先级高于后者。 - `session_memory` 不应误写入长期存储。 - 工作会话记忆应按工作区维度恢复。 - `rule` 模式不应增加额外模型调用。 diff --git a/docs/memory-extractor.md b/docs/memory-extractor.md index dc35c9f5..2ba0fe0e 100644 --- a/docs/memory-extractor.md +++ b/docs/memory-extractor.md @@ -8,7 +8,7 @@ 它不负责: -- 加载 `AGENTS.md` 等显式项目记忆文件 +- 加载 `AGENTS.md` 或其他显式配置的兼容约定文件 - 构建工作会话记忆快照 - 直接决定记忆召回排序 diff --git a/internal/server/service/project_memory_service.go b/internal/server/service/project_memory_service.go index f39d46d4..c71d80e3 100644 --- a/internal/server/service/project_memory_service.go +++ b/internal/server/service/project_memory_service.go @@ -52,12 +52,12 @@ func (s *projectMemoryServiceImpl) BuildContext(ctx context.Context) (string, er return "", nil } - header := "Use the following explicit project memory files as authoritative project instructions and conventions. Prefer them over inferred memory when they conflict.\n" + header := "Use the following explicit project memory files as authoritative project instructions and conventions. Files are listed in precedence order: earlier files override later files when conflicts exist. Prefer explicit project memory over inferred memory.\n" var builder strings.Builder builder.WriteString(header) - for _, source := range sources { - block := fmt.Sprintf("Project memory file: %s\n%s\n", source.Path, strings.TrimSpace(source.Content)) + for idx, source := range sources { + block := fmt.Sprintf("Project memory file #%d (higher priority first): %s\n%s\n", idx+1, source.Path, strings.TrimSpace(source.Content)) if s.maxPromptChars > 0 && builder.Len()+len(block) > s.maxPromptChars { remaining := s.maxPromptChars - builder.Len() if remaining <= 0 { diff --git a/internal/server/service/project_memory_service_test.go b/internal/server/service/project_memory_service_test.go index a3d364ba..384ed27d 100644 --- a/internal/server/service/project_memory_service_test.go +++ b/internal/server/service/project_memory_service_test.go @@ -13,14 +13,11 @@ func TestProjectMemoryServiceLoadsConfiguredFiles(t *testing.T) { if err := os.WriteFile(filepath.Join(workspace, "AGENTS.md"), []byte("Use go test ./... before PR."), 0o644); err != nil { t.Fatalf("write AGENTS.md: %v", err) } - if err := os.MkdirAll(filepath.Join(workspace, ".neocode"), 0o755); err != nil { - t.Fatalf("mkdir .neocode: %v", err) - } - if err := os.WriteFile(filepath.Join(workspace, ".neocode", "memory.md"), []byte("Prefer Chinese explanations for teammates."), 0o644); err != nil { - t.Fatalf("write .neocode/memory.md: %v", err) + if err := os.WriteFile(filepath.Join(workspace, "CLAUDE.md"), []byte("Prefer concise review summaries."), 0o644); err != nil { + t.Fatalf("write CLAUDE.md: %v", err) } - svc := NewProjectMemoryService(workspace, []string{"AGENTS.md", ".neocode/memory.md", "missing.md"}, 2400) + svc := NewProjectMemoryService(workspace, []string{"AGENTS.md", "CLAUDE.md", "missing.md"}, 2400) sources, err := svc.ListSources(context.Background()) if err != nil { @@ -34,12 +31,20 @@ func TestProjectMemoryServiceLoadsConfiguredFiles(t *testing.T) { if err != nil { t.Fatalf("build context: %v", err) } - if !strings.Contains(ctxText, "AGENTS.md") || !strings.Contains(ctxText, ".neocode/memory.md") { + if !strings.Contains(ctxText, "AGENTS.md") || !strings.Contains(ctxText, "CLAUDE.md") { t.Fatalf("expected project memory paths in context, got %q", ctxText) } if !strings.Contains(ctxText, "Use go test ./... before PR.") { t.Fatalf("expected project memory content in context, got %q", ctxText) } + if !strings.Contains(ctxText, "precedence order") { + t.Fatalf("expected precedence guidance in context, got %q", ctxText) + } + agentsIdx := strings.Index(ctxText, "AGENTS.md") + claudeIdx := strings.Index(ctxText, "CLAUDE.md") + if agentsIdx == -1 || claudeIdx == -1 || agentsIdx > claudeIdx { + t.Fatalf("expected configured file order to be preserved, got %q", ctxText) + } } func TestProjectMemoryServiceIgnoresPathsOutsideWorkspace(t *testing.T) {