Files
goclaw/internal/tools/memory_interceptor.go
T
2c1efa29ec fix(memory): prevent silent data loss on memory file overwrites (#279)
* 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>
2026-03-19 17:42:03 +07:00

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
}