xworkmate-bridge/internal/memory/provider.go
2026-04-09 09:49:48 +08:00

235 lines
6.6 KiB
Go

package memory
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type Source struct {
Path string
Scope string
}
type Preferences struct {
PreferredRoute string
PreferredModel string
PreferredSkills []string
Provider string
}
type LoadResult struct {
MergedText string
Sources []Source
Preferences Preferences
ProjectFiles []string
}
type SuccessEntry struct {
ResolvedExecutionTarget string
ResolvedProviderID string
ResolvedModel string
ResolvedSkills []string
Summary string
}
type Service struct {
HomeDir string
}
func NewService(homeDir string) Service {
return Service{HomeDir: strings.TrimSpace(homeDir)}
}
func (s Service) Load(workingDirectory string) LoadResult {
projectName := projectNameFromWorkingDirectory(workingDirectory)
paths := []Source{
{Path: filepath.Join(s.HomeDir, "self-improving", "memory.md"), Scope: "global"},
{Path: filepath.Join(s.HomeDir, "self-improving", "projects", projectName+".md"), Scope: "project-home"},
{Path: filepath.Join(strings.TrimSpace(workingDirectory), ".xworkmate", "memory.md"), Scope: "project-local"},
}
merged := make([]string, 0, len(paths))
sources := make([]Source, 0, len(paths))
prefs := Preferences{}
projectFiles := make([]string, 0, 2)
for _, source := range paths {
if strings.TrimSpace(source.Path) == "" {
continue
}
content, err := os.ReadFile(source.Path)
if err != nil {
continue
}
text := sanitizeMemoryText(string(content))
if strings.TrimSpace(text) == "" {
continue
}
sources = append(sources, source)
merged = append(merged, fmt.Sprintf("## %s\n%s", source.Scope, text))
mergePreferences(&prefs, parsePreferences(text))
if source.Scope != "global" {
projectFiles = append(projectFiles, source.Path)
}
}
return LoadResult{
MergedText: strings.TrimSpace(strings.Join(merged, "\n\n")),
Sources: sources,
Preferences: prefs,
ProjectFiles: projectFiles,
}
}
func (s Service) RecordSuccess(workingDirectory string, entry SuccessEntry) error {
workingDirectory = strings.TrimSpace(workingDirectory)
if workingDirectory == "" {
return nil
}
projectName := projectNameFromWorkingDirectory(workingDirectory)
if projectName == "" {
return nil
}
target := s.projectWriteTarget(workingDirectory, projectName)
if target == "" {
return nil
}
block := formatSuccessEntry(entry)
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
if _, err := file.WriteString(block); err != nil {
_ = file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
return nil
}
func (s Service) projectWriteTarget(
workingDirectory string,
projectName string,
) string {
repoLocalDir := filepath.Join(workingDirectory, ".xworkmate")
if err := os.MkdirAll(repoLocalDir, 0o755); err == nil {
return filepath.Join(repoLocalDir, "memory.md")
}
return filepath.Join(s.HomeDir, "self-improving", "projects", projectName+".md")
}
func formatSuccessEntry(entry SuccessEntry) string {
lines := []string{
"",
fmt.Sprintf("## Auto route %s", time.Now().Format(time.RFC3339)),
fmt.Sprintf("preferred-route: %s", strings.TrimSpace(entry.ResolvedExecutionTarget)),
}
if strings.TrimSpace(entry.ResolvedModel) != "" {
lines = append(lines, fmt.Sprintf("preferred-model: %s", strings.TrimSpace(entry.ResolvedModel)))
}
if len(entry.ResolvedSkills) > 0 {
lines = append(lines, fmt.Sprintf("preferred-skills: %s", strings.Join(entry.ResolvedSkills, ", ")))
}
if strings.TrimSpace(entry.ResolvedProviderID) != "" {
lines = append(lines, fmt.Sprintf("provider: %s", strings.TrimSpace(entry.ResolvedProviderID)))
}
if summary := sanitizeMemoryText(entry.Summary); strings.TrimSpace(summary) != "" {
lines = append(lines, "summary:")
for _, line := range strings.Split(summary, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
lines = append(lines, fmt.Sprintf("- %s", trimmed))
}
}
return strings.Join(lines, "\n") + "\n"
}
func parsePreferences(text string) Preferences {
prefs := Preferences{}
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(strings.ToLower(trimmed), "preferred-route:"):
prefs.PreferredRoute = normalizePreferredRoute(
strings.TrimSpace(strings.TrimPrefix(trimmed, "preferred-route:")),
)
case strings.HasPrefix(strings.ToLower(trimmed), "preferred-model:"):
prefs.PreferredModel = strings.TrimSpace(strings.TrimPrefix(trimmed, "preferred-model:"))
case strings.HasPrefix(strings.ToLower(trimmed), "preferred-skills:"):
raw := strings.TrimSpace(strings.TrimPrefix(trimmed, "preferred-skills:"))
for _, item := range strings.Split(raw, ",") {
value := strings.TrimSpace(item)
if value != "" {
prefs.PreferredSkills = append(prefs.PreferredSkills, value)
}
}
case strings.HasPrefix(strings.ToLower(trimmed), "provider:"):
prefs.Provider = strings.TrimSpace(strings.TrimPrefix(trimmed, "provider:"))
}
}
return prefs
}
func mergePreferences(dst *Preferences, src Preferences) {
if strings.TrimSpace(src.PreferredRoute) != "" {
dst.PreferredRoute = strings.TrimSpace(src.PreferredRoute)
}
if strings.TrimSpace(src.PreferredModel) != "" {
dst.PreferredModel = strings.TrimSpace(src.PreferredModel)
}
if len(src.PreferredSkills) > 0 {
dst.PreferredSkills = append([]string(nil), src.PreferredSkills...)
}
if strings.TrimSpace(src.Provider) != "" {
dst.Provider = strings.TrimSpace(src.Provider)
}
}
func sanitizeMemoryText(text string) string {
lines := strings.Split(text, "\n")
filtered := make([]string, 0, len(lines))
for _, line := range lines {
normalized := strings.ToLower(strings.TrimSpace(line))
if normalized == "" {
filtered = append(filtered, "")
continue
}
if strings.Contains(normalized, "token") ||
strings.Contains(normalized, "password") ||
strings.Contains(normalized, "secret") ||
strings.Contains(normalized, "api_key") ||
strings.Contains(normalized, "apikey") ||
strings.Contains(normalized, "api key") {
continue
}
filtered = append(filtered, line)
}
return strings.TrimSpace(strings.Join(filtered, "\n"))
}
func projectNameFromWorkingDirectory(workingDirectory string) string {
cleaned := strings.TrimSpace(workingDirectory)
if cleaned == "" {
return ""
}
return strings.TrimSpace(filepath.Base(cleaned))
}
func normalizePreferredRoute(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "gateway-chat":
return "gateway"
default:
return strings.TrimSpace(value)
}
}