mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
329 lines
9.7 KiB
Go
329 lines
9.7 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/memohai/memoh/internal/config"
|
|
mcpgw "github.com/memohai/memoh/internal/mcp"
|
|
"github.com/memohai/memoh/internal/mcp/providers/container"
|
|
)
|
|
|
|
const (
|
|
manifestPath = "index/manifest.json"
|
|
memoryDirPath = "memory"
|
|
manifestVer = 1
|
|
)
|
|
|
|
// MemoryFS persists memory entries as files inside the bot container via ExecRunner.
|
|
type MemoryFS struct {
|
|
execRunner container.ExecRunner
|
|
workDir string // e.g. "/data"
|
|
logger *slog.Logger
|
|
mu sync.Mutex // serialize manifest updates
|
|
}
|
|
|
|
// Manifest is the index file that records everything needed to rebuild memories.
|
|
type Manifest struct {
|
|
Version int `json:"version"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
Entries map[string]ManifestEntry `json:"entries"`
|
|
}
|
|
|
|
// ManifestEntry records metadata for a single memory entry.
|
|
type ManifestEntry struct {
|
|
Hash string `json:"hash"`
|
|
CreatedAt string `json:"created_at"`
|
|
Lang string `json:"lang,omitempty"`
|
|
Filters map[string]any `json:"filters,omitempty"`
|
|
}
|
|
|
|
// NewMemoryFS creates a MemoryFS that writes through the given ExecRunner.
|
|
func NewMemoryFS(log *slog.Logger, runner container.ExecRunner, workDir string) *MemoryFS {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
if strings.TrimSpace(workDir) == "" {
|
|
workDir = config.DefaultDataMount
|
|
}
|
|
return &MemoryFS{
|
|
execRunner: runner,
|
|
workDir: workDir,
|
|
logger: log.With(slog.String("component", "memoryfs")),
|
|
}
|
|
}
|
|
|
|
// ----- write operations -----
|
|
|
|
// PersistMemories writes .md files for new items and incrementally updates the manifest.
|
|
// Used after Add — does NOT delete existing files.
|
|
func (fs *MemoryFS) PersistMemories(ctx context.Context, botID string, items []MemoryItem, filters map[string]any) error {
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
fs.mu.Lock()
|
|
defer fs.mu.Unlock()
|
|
|
|
// Read existing manifest (or create new one).
|
|
manifest, _ := fs.readManifestLocked(ctx, botID)
|
|
if manifest == nil {
|
|
manifest = &Manifest{
|
|
Version: manifestVer,
|
|
Entries: map[string]ManifestEntry{},
|
|
}
|
|
}
|
|
|
|
for _, item := range items {
|
|
if strings.TrimSpace(item.ID) == "" || strings.TrimSpace(item.Memory) == "" {
|
|
continue
|
|
}
|
|
// Write individual .md file.
|
|
if err := fs.writeMemoryFile(ctx, botID, item); err != nil {
|
|
fs.logger.Warn("write memory file failed", slog.String("id", item.ID), slog.Any("error", err))
|
|
continue
|
|
}
|
|
// Update manifest entry.
|
|
manifest.Entries[item.ID] = ManifestEntry{
|
|
Hash: item.Hash,
|
|
CreatedAt: item.CreatedAt,
|
|
Filters: filters,
|
|
}
|
|
}
|
|
|
|
manifest.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
|
return fs.writeManifest(ctx, botID, manifest)
|
|
}
|
|
|
|
// RebuildFiles does a full replace: deletes all old memory/*.md files, writes new ones,
|
|
// and rewrites manifest from scratch. Used after Compact.
|
|
func (fs *MemoryFS) RebuildFiles(ctx context.Context, botID string, items []MemoryItem, filters map[string]any) error {
|
|
fs.mu.Lock()
|
|
defer fs.mu.Unlock()
|
|
|
|
// Delete old memory dir contents.
|
|
fs.execDeleteDir(ctx, botID, memoryDirPath)
|
|
|
|
manifest := &Manifest{
|
|
Version: manifestVer,
|
|
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Entries: make(map[string]ManifestEntry, len(items)),
|
|
}
|
|
|
|
for _, item := range items {
|
|
if strings.TrimSpace(item.ID) == "" || strings.TrimSpace(item.Memory) == "" {
|
|
continue
|
|
}
|
|
if err := fs.writeMemoryFile(ctx, botID, item); err != nil {
|
|
fs.logger.Warn("rebuild write memory file failed", slog.String("id", item.ID), slog.Any("error", err))
|
|
continue
|
|
}
|
|
manifest.Entries[item.ID] = ManifestEntry{
|
|
Hash: item.Hash,
|
|
CreatedAt: item.CreatedAt,
|
|
Filters: filters,
|
|
}
|
|
}
|
|
|
|
return fs.writeManifest(ctx, botID, manifest)
|
|
}
|
|
|
|
// RemoveMemories removes specific memory files from the FS and updates the manifest.
|
|
func (fs *MemoryFS) RemoveMemories(ctx context.Context, botID string, ids []string) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
fs.mu.Lock()
|
|
defer fs.mu.Unlock()
|
|
|
|
manifest, _ := fs.readManifestLocked(ctx, botID)
|
|
if manifest == nil {
|
|
manifest = &Manifest{Version: manifestVer, Entries: map[string]ManifestEntry{}}
|
|
}
|
|
|
|
for _, id := range ids {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
fs.execDeleteFile(ctx, botID, fmt.Sprintf("%s/%s.md", memoryDirPath, id))
|
|
delete(manifest.Entries, id)
|
|
}
|
|
|
|
manifest.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
|
return fs.writeManifest(ctx, botID, manifest)
|
|
}
|
|
|
|
// RemoveAllMemories deletes all memory files and the manifest.
|
|
func (fs *MemoryFS) RemoveAllMemories(ctx context.Context, botID string) error {
|
|
fs.mu.Lock()
|
|
defer fs.mu.Unlock()
|
|
|
|
fs.execDeleteDir(ctx, botID, memoryDirPath)
|
|
emptyManifest := &Manifest{
|
|
Version: manifestVer,
|
|
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Entries: map[string]ManifestEntry{},
|
|
}
|
|
return fs.writeManifest(ctx, botID, emptyManifest)
|
|
}
|
|
|
|
// ----- read operations -----
|
|
|
|
// ReadManifest reads and parses the manifest.json file.
|
|
func (fs *MemoryFS) ReadManifest(ctx context.Context, botID string) (*Manifest, error) {
|
|
fs.mu.Lock()
|
|
defer fs.mu.Unlock()
|
|
return fs.readManifestLocked(ctx, botID)
|
|
}
|
|
|
|
func (fs *MemoryFS) readManifestLocked(ctx context.Context, botID string) (*Manifest, error) {
|
|
content, err := container.ExecRead(ctx, fs.execRunner, botID, fs.workDir, manifestPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var manifest Manifest
|
|
if err := json.Unmarshal([]byte(content), &manifest); err != nil {
|
|
return nil, fmt.Errorf("parse manifest: %w", err)
|
|
}
|
|
return &manifest, nil
|
|
}
|
|
|
|
// ReadAllMemoryFiles lists and reads all .md files under memory/ and parses their frontmatter.
|
|
func (fs *MemoryFS) ReadAllMemoryFiles(ctx context.Context, botID string) ([]MemoryItem, error) {
|
|
entries, err := container.ExecList(ctx, fs.execRunner, botID, fs.workDir, memoryDirPath, false)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list memory dir: %w", err)
|
|
}
|
|
|
|
var items []MemoryItem
|
|
for _, entry := range entries {
|
|
if entry.IsDir || !strings.HasSuffix(entry.Path, ".md") {
|
|
continue
|
|
}
|
|
filePath := memoryDirPath + "/" + entry.Path
|
|
content, err := container.ExecRead(ctx, fs.execRunner, botID, fs.workDir, filePath)
|
|
if err != nil {
|
|
fs.logger.Warn("read memory file failed", slog.String("path", filePath), slog.Any("error", err))
|
|
continue
|
|
}
|
|
item, err := parseMemoryMD(content)
|
|
if err != nil {
|
|
fs.logger.Warn("parse memory file failed", slog.String("path", filePath), slog.Any("error", err))
|
|
continue
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// ----- internal helpers -----
|
|
|
|
func (fs *MemoryFS) writeMemoryFile(ctx context.Context, botID string, item MemoryItem) error {
|
|
content := formatMemoryMD(item)
|
|
filePath := fmt.Sprintf("%s/%s.md", memoryDirPath, item.ID)
|
|
return container.ExecWrite(ctx, fs.execRunner, botID, fs.workDir, filePath, content)
|
|
}
|
|
|
|
func (fs *MemoryFS) writeManifest(ctx context.Context, botID string, manifest *Manifest) error {
|
|
data, err := json.MarshalIndent(manifest, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal manifest: %w", err)
|
|
}
|
|
return container.ExecWrite(ctx, fs.execRunner, botID, fs.workDir, manifestPath, string(data))
|
|
}
|
|
|
|
// execDeleteDir removes all files inside a directory (but keeps the directory itself).
|
|
func (fs *MemoryFS) execDeleteDir(ctx context.Context, botID, dirPath string) {
|
|
// Use find + rm to avoid shell quoting issues with glob wildcards.
|
|
script := fmt.Sprintf("find %s -type f -delete 2>/dev/null; true", container.ShellQuote(dirPath))
|
|
_, err := fs.execRunner.ExecWithCapture(ctx, mcpgw.ExecRequest{
|
|
BotID: botID,
|
|
Command: []string{"/bin/sh", "-c", script},
|
|
WorkDir: fs.workDir,
|
|
})
|
|
if err != nil {
|
|
fs.logger.Warn("exec delete dir failed", slog.String("path", dirPath), slog.Any("error", err))
|
|
}
|
|
}
|
|
|
|
// execDeleteFile removes a single file.
|
|
func (fs *MemoryFS) execDeleteFile(ctx context.Context, botID, filePath string) {
|
|
script := fmt.Sprintf("rm -f %s", container.ShellQuote(filePath))
|
|
_, err := fs.execRunner.ExecWithCapture(ctx, mcpgw.ExecRequest{
|
|
BotID: botID,
|
|
Command: []string{"/bin/sh", "-c", script},
|
|
WorkDir: fs.workDir,
|
|
})
|
|
if err != nil {
|
|
fs.logger.Warn("exec delete file failed", slog.String("path", filePath), slog.Any("error", err))
|
|
}
|
|
}
|
|
|
|
// ----- .md formatting / parsing -----
|
|
|
|
func formatMemoryMD(item MemoryItem) string {
|
|
var b strings.Builder
|
|
b.WriteString("---\n")
|
|
b.WriteString(fmt.Sprintf("id: %s\n", item.ID))
|
|
if item.Hash != "" {
|
|
b.WriteString(fmt.Sprintf("hash: %s\n", item.Hash))
|
|
}
|
|
if item.CreatedAt != "" {
|
|
b.WriteString(fmt.Sprintf("created_at: %s\n", item.CreatedAt))
|
|
}
|
|
if item.UpdatedAt != "" {
|
|
b.WriteString(fmt.Sprintf("updated_at: %s\n", item.UpdatedAt))
|
|
}
|
|
b.WriteString("---\n")
|
|
b.WriteString(item.Memory)
|
|
b.WriteString("\n")
|
|
return b.String()
|
|
}
|
|
|
|
func parseMemoryMD(content string) (MemoryItem, error) {
|
|
content = strings.TrimSpace(content)
|
|
if !strings.HasPrefix(content, "---") {
|
|
return MemoryItem{}, fmt.Errorf("missing frontmatter")
|
|
}
|
|
// Split on "---" delimiters.
|
|
parts := strings.SplitN(content[3:], "---", 2)
|
|
if len(parts) < 2 {
|
|
return MemoryItem{}, fmt.Errorf("incomplete frontmatter")
|
|
}
|
|
frontmatter := strings.TrimSpace(parts[0])
|
|
body := strings.TrimSpace(parts[1])
|
|
|
|
item := MemoryItem{Memory: body}
|
|
for _, line := range strings.Split(frontmatter, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
key, value, found := strings.Cut(line, ":")
|
|
if !found {
|
|
continue
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
value = strings.TrimSpace(value)
|
|
switch key {
|
|
case "id":
|
|
item.ID = value
|
|
case "hash":
|
|
item.Hash = value
|
|
case "created_at":
|
|
item.CreatedAt = value
|
|
case "updated_at":
|
|
item.UpdatedAt = value
|
|
}
|
|
}
|
|
if item.ID == "" {
|
|
return MemoryItem{}, fmt.Errorf("missing id in frontmatter")
|
|
}
|
|
return item, nil
|
|
}
|