mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-23 16:21:36 +00:00
* fix(memory): auto-backup + overwrite warning for memory files
When an agent writes to a memory file that already has content:
1. Old content is backed up to memory/.prev/{filename} (best-effort)
2. Write succeeds normally (no blocking)
3. Tool result includes warning with previous content (truncated to
4000 chars) so the agent can notice and merge if needed
Backup files are hidden from list_files and not indexed/embedded.
No database migration needed - uses existing PutDocument.
* fix(memory): support append mode + fix UTF-8 truncation in overwrite warning
- Add appendMode parameter to MemoryInterceptor.WriteFile() so
write_file(append=true) actually appends to memory files instead
of silently replacing content. Uses "\n\n---\n\n" separator.
- Fix UTF-8 truncation in overwrite warning: use []rune instead of
byte slicing to avoid cutting multi-byte characters.
- Fix char count in warning message: use len([]rune()) not len().
- Remove .prev/ backup mechanism (prevBackupPath, isPrevPath, backup
writes, ListFiles filter) in favor of simpler approach: append
mode prevents data loss, warning enables self-correction.
---------
Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
Co-authored-by: viettranx <viettranx@gmail.com>
224 lines
7.1 KiB
Go
224 lines
7.1 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// effectiveWorkspace returns the per-user workspace from ctx if available,
|
|
// falling back to the interceptor's base workspace.
|
|
// This ensures memory path detection and normalization work correctly
|
|
// for per-user workspaces (e.g. workspace/channel/userID/).
|
|
func effectiveWorkspace(ctx context.Context, baseWorkspace string) string {
|
|
if ws := ToolWorkspaceFromCtx(ctx); ws != "" {
|
|
return ws
|
|
}
|
|
return baseWorkspace
|
|
}
|
|
|
|
// isMemoryDir checks if a path refers to the memory directory itself.
|
|
// Handles "memory", "./memory", "/workspace/memory" etc.
|
|
func isMemoryDir(path, workspace string) bool {
|
|
clean := filepath.Clean(path)
|
|
if clean == "memory" {
|
|
return true
|
|
}
|
|
if workspace != "" && filepath.IsAbs(clean) {
|
|
expected := filepath.Join(filepath.Clean(workspace), "memory")
|
|
return clean == expected
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isMemoryPath checks if a path refers to a memory file (MEMORY.md, memory.md, memory/*).
|
|
// Handles both relative and absolute paths (when workspace is provided).
|
|
func isMemoryPath(path, workspace string) bool {
|
|
clean := filepath.Clean(path)
|
|
base := filepath.Base(clean)
|
|
|
|
// Root-level MEMORY.md or memory.md
|
|
dir := filepath.Dir(clean)
|
|
if (dir == "." || dir == "/" || dir == "") && (base == bootstrap.MemoryFile || base == bootstrap.MemoryAltFile) {
|
|
return true
|
|
}
|
|
|
|
// Anything under memory/ directory (relative)
|
|
if strings.HasPrefix(clean, "memory/") || strings.HasPrefix(clean, "memory\\") {
|
|
return true
|
|
}
|
|
|
|
// Absolute path at workspace root or under workspace/memory/
|
|
if workspace != "" && filepath.IsAbs(clean) {
|
|
cleanWS := filepath.Clean(workspace)
|
|
if filepath.Dir(clean) == cleanWS && (base == bootstrap.MemoryFile || base == bootstrap.MemoryAltFile) {
|
|
return true
|
|
}
|
|
memDir := filepath.Join(cleanWS, "memory")
|
|
if strings.HasPrefix(clean, memDir+string(filepath.Separator)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// KGExtractFunc is a callback invoked after a memory write to extract KG entities.
|
|
// agentID, userID, content are passed from the write context.
|
|
type KGExtractFunc func(ctx context.Context, agentID, userID, content string)
|
|
|
|
// MemoryInterceptor routes memory file reads/writes to the MemoryStore.
|
|
// Keeps MEMORY.md and memory/* in Postgres.
|
|
type MemoryInterceptor struct {
|
|
memStore store.MemoryStore
|
|
workspace string
|
|
kgExtractFn KGExtractFunc
|
|
}
|
|
|
|
// NewMemoryInterceptor creates an interceptor backed by the given memory store.
|
|
func NewMemoryInterceptor(ms store.MemoryStore, workspace string) *MemoryInterceptor {
|
|
return &MemoryInterceptor{memStore: ms, workspace: workspace}
|
|
}
|
|
|
|
// SetKGExtractFunc sets the callback for KG extraction after memory writes.
|
|
func (m *MemoryInterceptor) SetKGExtractFunc(fn KGExtractFunc) {
|
|
m.kgExtractFn = fn
|
|
}
|
|
|
|
// ReadFile attempts to read a memory file from the DB.
|
|
// Returns (content, true, nil) if handled, or ("", false, nil) if not a memory path.
|
|
func (m *MemoryInterceptor) ReadFile(ctx context.Context, path string) (string, bool, error) {
|
|
ws := effectiveWorkspace(ctx, m.workspace)
|
|
if !isMemoryPath(path, ws) {
|
|
return "", false, nil
|
|
}
|
|
|
|
agentID := store.AgentIDFromContext(ctx)
|
|
if agentID == uuid.Nil {
|
|
return "", false, nil // no agent context
|
|
}
|
|
|
|
// Normalize absolute path to workspace-relative for DB storage
|
|
relPath := normalizeToRelative(path, ws)
|
|
|
|
userID := store.MemoryUserID(ctx)
|
|
agentStr := agentID.String()
|
|
|
|
// Try per-user first, then global
|
|
content, err := m.memStore.GetDocument(ctx, agentStr, userID, relPath)
|
|
if err != nil && userID != "" {
|
|
content, err = m.memStore.GetDocument(ctx, agentStr, "", relPath)
|
|
}
|
|
if err != nil {
|
|
// Not found is OK — return empty
|
|
slog.Debug("memory interceptor: document not found", "path", path, "agent", agentStr)
|
|
return "", true, nil
|
|
}
|
|
|
|
return content, true, nil
|
|
}
|
|
|
|
// MemoryWriteResult holds the outcome of a memory write operation.
|
|
type MemoryWriteResult struct {
|
|
Handled bool
|
|
KGTriggered bool
|
|
PreviousContent string // non-empty if an existing document was overwritten (non-append)
|
|
}
|
|
|
|
// WriteFile attempts to write a memory file to the DB (+ re-index chunks for .md files).
|
|
// When appendMode is true, new content is appended to the existing document with a separator.
|
|
// When appendMode is false and an existing document is overwritten with different content,
|
|
// PreviousContent is populated in the result to allow callers to warn the agent.
|
|
func (m *MemoryInterceptor) WriteFile(ctx context.Context, path, content string, appendMode bool) (MemoryWriteResult, error) {
|
|
ws := effectiveWorkspace(ctx, m.workspace)
|
|
if !isMemoryPath(path, ws) {
|
|
return MemoryWriteResult{}, nil
|
|
}
|
|
|
|
agentID := store.AgentIDFromContext(ctx)
|
|
if agentID == uuid.Nil {
|
|
return MemoryWriteResult{}, nil // no agent context
|
|
}
|
|
|
|
// Normalize absolute path to workspace-relative for DB storage
|
|
relPath := normalizeToRelative(path, ws)
|
|
|
|
userID := store.MemoryUserID(ctx)
|
|
agentStr := agentID.String()
|
|
|
|
var previousContent string
|
|
|
|
if appendMode {
|
|
// Append: read existing content and merge with separator.
|
|
existing, err := m.memStore.GetDocument(ctx, agentStr, userID, relPath)
|
|
if err == nil && existing != "" {
|
|
content = existing + "\n\n---\n\n" + content
|
|
}
|
|
} else {
|
|
// Replace: capture previous content for overwrite warning.
|
|
oldContent, err := m.memStore.GetDocument(ctx, agentStr, userID, relPath)
|
|
if err == nil && oldContent != "" && oldContent != content {
|
|
previousContent = oldContent
|
|
}
|
|
}
|
|
|
|
// Write document to DB
|
|
if err := m.memStore.PutDocument(ctx, agentStr, userID, relPath, content); err != nil {
|
|
return MemoryWriteResult{Handled: true}, err
|
|
}
|
|
|
|
// Only index .md files (chunk + embed).
|
|
if strings.HasSuffix(relPath, ".md") {
|
|
if err := m.memStore.IndexDocument(ctx, agentStr, userID, relPath); err != nil {
|
|
slog.Warn("memory interceptor: index failed after write", "path", path, "error", err)
|
|
// Non-fatal: document was saved, indexing will catch up
|
|
}
|
|
}
|
|
|
|
// Trigger KG extraction in background if configured
|
|
kgTriggered := false
|
|
if m.kgExtractFn != nil && content != "" {
|
|
go m.kgExtractFn(context.WithoutCancel(ctx), agentStr, userID, content)
|
|
kgTriggered = true
|
|
}
|
|
|
|
return MemoryWriteResult{Handled: true, KGTriggered: kgTriggered, PreviousContent: previousContent}, nil
|
|
}
|
|
|
|
// ListFiles lists memory documents from the DB when path is the memory directory.
|
|
// Returns (listing, true, nil) if handled, or ("", false, nil) if not a memory path.
|
|
func (m *MemoryInterceptor) ListFiles(ctx context.Context, path string) (string, bool, error) {
|
|
ws := effectiveWorkspace(ctx, m.workspace)
|
|
if !isMemoryDir(path, ws) {
|
|
return "", false, nil
|
|
}
|
|
|
|
agentID := store.AgentIDFromContext(ctx)
|
|
if agentID == uuid.Nil {
|
|
return "", false, nil
|
|
}
|
|
|
|
userID := store.MemoryUserID(ctx)
|
|
docs, err := m.memStore.ListDocuments(ctx, agentID.String(), userID)
|
|
if err != nil {
|
|
return "", true, err
|
|
}
|
|
|
|
if len(docs) == 0 {
|
|
return "", true, nil
|
|
}
|
|
|
|
var sb strings.Builder
|
|
for _, doc := range docs {
|
|
fmt.Fprintf(&sb, "[FILE] %s\n", doc.Path)
|
|
}
|
|
return sb.String(), true, nil
|
|
}
|