Ctrl K
ComunidadGuías…

internal/memory/stores.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package memory

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"sync"

	"github.com/yeomyeonggeori/blueclaw/internal/policy"
	"github.com/yeomyeonggeori/blueclaw/internal/security"
	"github.com/yeomyeonggeori/bluememo"
)

const (
	ScopePerson    = "person"
	ScopeCircle    = "circle"
	ScopeWorkspace = "workspace"
)

// memoryFileMode lets the subject's group read a memory and nobody but the
// service write it: a subject's memory is kept on their behalf.
const (
	memoryFileName         = "memory.db"
	memoryFileMode         = 0o640
	readTimeoutSecond      = 20
	readOutputMaximumBytes = 4 << 20
)

// Scope names one memory file. A subject's memory is a file of their own, so
// what a reader may read is which of those files their POSIX identity opens,
// rather than a label carried by every fact.
type Scope struct {
	Kind string
	ID   string
}

func PersonScope(personID string) Scope { return Scope{Kind: ScopePerson, ID: personID} }
func CircleScope(circleID string) Scope { return Scope{Kind: ScopeCircle, ID: circleID} }
func WorkspaceScope() Scope             { return Scope{Kind: ScopeWorkspace} }

// pathUnder puts a subject's memory in that subject's protected directory,
// where the permissions the workspace already declares are what decide who
// opens it.
func (scope Scope) pathUnder(workspaceRootPath string) (string, error) {
	if strings.TrimSpace(workspaceRootPath) == "" {
		return "", errors.New("memory has no workspace root to sit under")
	}
	directory := scope.protectedDirectoryUnder(workspaceRootPath)
	if directory == "" {
		return "", fmt.Errorf("memory scope %q with identifier %q names no directory", scope.Kind, scope.ID)
	}
	return filepath.Join(directory, memoryFileName), nil
}

func (scope Scope) protectedDirectoryUnder(workspaceRootPath string) string {
	switch scope.Kind {
	case ScopePerson:
		return security.PersonProtectedDirectoryPath(workspaceRootPath, scope.ID)
	case ScopeCircle:
		return security.ProtectedDirectoryPath(security.CircleDirectoryPath(workspaceRootPath, scope.ID))
	case ScopeWorkspace:
		return security.ProtectedDirectoryPath(security.SharedDirectoryPath(workspaceRootPath))
	}
	return ""
}

// Stores holds the memory files this company's agent reads and writes, opening
// each on first use and keeping it for the life of the process.
//
// A recall runs as the person whose memory it is, so the kernel refuses the
// files their identity cannot open. Everything else here runs as the service:
// a subject's memory is kept on their behalf, which is why they read it and
// do not write it.
type Stores struct {
	actor             security.WorkspaceActorFactory
	workspaceRootPath string
	configuration     bluememo.Configuration
	mutex             sync.Mutex
	open              map[string]*bluememo.Store
	recordIndex       *RecordIndex
}

func NewStores(workspaceRootPath string, configuration bluememo.Configuration, actor security.WorkspaceActorFactory) *Stores {
	return &Stores{actor: actor,
		workspaceRootPath: workspaceRootPath, configuration: configuration, open: map[string]*bluememo.Store{}}
}

// Store opens one subject's file as the service, which is how a memory is
// written on their behalf. A recall does not come through here: it runs as
// the person, so that the kernel and not this process decides what opens.
func (stores *Stores) Store(ctx context.Context, scope Scope) (*bluememo.Store, error) {
	path, errorValue := stores.Path(scope)
	if errorValue != nil {
		return nil, errorValue
	}
	stores.mutex.Lock()
	defer stores.mutex.Unlock()
	if held, isOpen := stores.open[path]; isOpen {
		return held, nil
	}
	if errorValue := makeHoldingDirectory(path); errorValue != nil {
		return nil, errorValue
	}
	opened, errorValue := bluememo.Open(ctx, path, stores.configuration)
	if errorValue != nil {
		return nil, errorValue
	}
	if errorValue := os.Chmod(path, memoryFileMode); errorValue != nil {
		_ = opened.Close()
		return nil, fmt.Errorf("let the subject read %s: %w", path, errorValue)
	}
	stores.open[path] = opened
	return opened, nil
}

func (stores *Stores) Close() error {
	stores.mutex.Lock()
	defer stores.mutex.Unlock()
	var firstFailure error
	if stores.recordIndex != nil {
		firstFailure = stores.recordIndex.Close()
	}
	for fileName, held := range stores.open {
		if errorValue := held.Close(); errorValue != nil && firstFailure == nil {
			firstFailure = errorValue
		}
		delete(stores.open, fileName)
	}
	return firstFailure
}

// ScopesToSearch says where to look for a reader's memory, nearest first. It grants nothing:
// a recall runs as that reader, so a file this list names and their identity
// cannot open is refused by the kernel. A list too generous costs a refusal,
// and a list too narrow costs a memory nobody finds.
func ScopesToSearch(personAccess policy.PersonAccess, containedCircles map[string][]string) []Scope {
	scopes := []Scope{}
	if personAccess.PersonID != "" {
		scopes = append(scopes, PersonScope(personAccess.PersonID))
	}
	seen := map[string]bool{}
	for _, circleID := range personAccess.Circles {
		for _, reachable := range append([]string{circleID}, containedCircles[circleID]...) {
			if !policy.CircleHoldsADirectory(reachable) || seen[reachable] {
				continue
			}
			seen[reachable] = true
			scopes = append(scopes, CircleScope(reachable))
		}
	}
	return append(scopes, WorkspaceScope())
}

// RecallAcross returns what the agent loop reads: the scopes are a stack, the
// person's own memory on top, and the read goes through all of it at once with
// what is nearer the top first. It reads as the person the recall is for, so a
// file their identity cannot open is refused by the kernel rather than by a
// list this code keeps. A scope with no file yet has nothing to say, which is
// not a failure, while a file that exists and will not open is.
func (stores *Stores) RecallAcross(ctx context.Context, personAccess policy.PersonAccess, scopes []Scope, query string, limit int) (Recalled, error) {
	recalled, recalledIDs, errorValue := stores.readThrough(ctx, personAccess, scopes, query, limit)
	if errorValue != nil {
		return recalled, errorValue
	}
	return recalled, stores.reinforce(ctx, recalledIDs)
}

// PreviewAcross reads the stack the way RecallAcross does and leaves every
// file as it was: a person looking up what would come to mind is not the agent
// using a memory, so nothing is reinforced.
func (stores *Stores) PreviewAcross(ctx context.Context, personAccess policy.PersonAccess, scopes []Scope, query string, limit int) (Recalled, error) {
	recalled, _, errorValue := stores.readThrough(ctx, personAccess, scopes, query, limit)
	return recalled, errorValue
}

func (stores *Stores) readThrough(ctx context.Context, personAccess policy.PersonAccess, scopes []Scope, query string, limit int) (Recalled, map[Scope][]string, error) {
	recalled := Recalled{Mode: "merged"}
	held, errorValue := stores.Held(ctx, scopes)
	if errorValue != nil || len(held) == 0 {
		return recalled, nil, errorValue
	}
	result, errorValue := stores.readAs(ctx, personAccess, held, query, limit)
	if errorValue != nil {
		return recalled, nil, errorValue
	}
	recalled.DegradedReason = result.Recall.DegradedReason
	recalledIDs := map[Scope][]string{}
	for index, entry := range result.Recall.Memories {
		scope := held[result.Layers[index]]
		recalled.Facts = append(recalled.Facts, memoryFactFrom(entry, scope))
		recalledIDs[scope] = append(recalledIDs[scope], entry.Memory.MemoryID)
	}
	return recalled, recalledIDs, nil
}

// Held opens, as their keeper, the scopes that already hold a file, because a
// reader that may not write a store reads it only while its keeper holds it
// open. A scope with no file is left out rather than created.
func (stores *Stores) Held(ctx context.Context, scopes []Scope) ([]Scope, error) {
	held := []Scope{}
	for _, scope := range scopes {
		path, errorValue := stores.Path(scope)
		if errorValue != nil {
			return nil, errorValue
		}
		if _, errorValue := os.Stat(path); errors.Is(errorValue, os.ErrNotExist) {
			continue
		}
		if _, errorValue := stores.Store(ctx, scope); errorValue != nil {
			return nil, errorValue
		}
		held = append(held, scope)
	}
	return held, nil
}

func (stores *Stores) reinforce(ctx context.Context, recalledIDs map[Scope][]string) error {
	for scope, memoryIDs := range recalledIDs {
		store, errorValue := stores.Store(ctx, scope)
		if errorValue != nil {
			return errorValue
		}
		if errorValue := store.Reinforce(ctx, memoryIDs); errorValue != nil {
			return fmt.Errorf("reinforce what was recalled from %s: %w", scope.Kind, errorValue)
		}
	}
	return nil
}

// Recalled is what one agent turn reads out of memory.
type Recalled struct {
	Facts          []MemoryFact
	Mode           string
	DegradedReason string
}

func memoryFactFrom(entry bluememo.RecalledMemory, scope Scope) MemoryFact {
	sourceKind := "fact"
	if entry.Memory.IsStatic {
		sourceKind = "identity"
	}
	validAt := entry.Memory.OccurredAt
	if validAt.IsZero() {
		validAt = entry.Memory.CreatedAt
	}
	return MemoryFact{
		FactID:          entry.Memory.MemoryID,
		ScopeType:       scope.Kind,
		ScopeID:         scope.ID,
		Content:         entry.Memory.Content,
		Score:           entry.Score,
		SourceEpisodeID: entry.Memory.OriginID,
		SourceKind:      sourceKind,
		ValidAt:         validAt,
	}
}

// StackToRemember is the scope a statement is written into followed by the
// layers it stands on: every searched scope broader than it. A person's memory
// stands on their circles' and the company's, and a circle's on the company's.
func StackToRemember(target Scope, searched []Scope) []Scope {
	stack := []Scope{target}
	for _, scope := range searched {
		if breadth(scope) > breadth(target) {
			stack = append(stack, scope)
		}
	}
	return stack
}

func breadth(scope Scope) int {
	return map[string]int{ScopePerson: 0, ScopeCircle: 1, ScopeWorkspace: 2}[scope.Kind]
}

// Remember takes what was said into the top of a stack and settles it there,
// so the caller's next recall can see it. What a layer beneath already knows
// is not written again, including any layer the caller adds beneath the stack.
func (stores *Stores) Remember(ctx context.Context, stack []Scope, note bluememo.Note, alsoBeneath ...bluememo.Known) (bluememo.SettleReport, error) {
	if len(stack) == 0 {
		return bluememo.SettleReport{}, errors.New("memory has no scope to remember into")
	}
	store, errorValue := stores.Store(ctx, stack[0])
	if errorValue != nil {
		return bluememo.SettleReport{}, errorValue
	}
	beneath, errorValue := stores.layers(ctx, stack[1:])
	if errorValue != nil {
		return bluememo.SettleReport{}, errorValue
	}
	layered := store.On(append(beneath, alsoBeneath...)...)
	if errorValue := layered.Memorize(ctx, note); errorValue != nil {
		return bluememo.SettleReport{}, errorValue
	}
	return layered.Settle(ctx)
}

func (stores *Stores) layers(ctx context.Context, scopes []Scope) ([]bluememo.Known, error) {
	held, errorValue := stores.Held(ctx, scopes)
	if errorValue != nil {
		return nil, errorValue
	}
	layers := make([]bluememo.Known, 0, len(held))
	for _, scope := range held {
		store, errorValue := stores.Store(ctx, scope)
		if errorValue != nil {
			return nil, errorValue
		}
		layers = append(layers, store)
	}
	return layers, nil
}

const DefaultRecallLimit = 12

// IdentityFactCount is how much of a recall is what the person is rather than
// what happened, which the loop reports so a turn's memory can be read.
func IdentityFactCount(facts []MemoryFact) int {
	count := 0
	for _, fact := range facts {
		if fact.SourceKind == "identity" {
			count++
		}
	}
	return count
}

// ForgetAcross forgets what the reader asked to forget, wherever among the
// files they searched it is held. It fails closed on an identifier no search
// of theirs surfaced, so a reader can only forget what they were shown.
func (stores *Stores) ForgetAcross(ctx context.Context, scopes []Scope, memoryIDs []string, requestPhrase string) ([]string, error) {
	forgotten := []string{}
	for _, scope := range scopes {
		store, errorValue := stores.Store(ctx, scope)
		if errorValue != nil {
			return nil, errorValue
		}
		memories, errorValue := store.ForgetMemories(ctx, memoryIDs, requestPhrase)
		if errorValue != nil {
			return nil, errorValue
		}
		for _, forgottenMemory := range memories {
			forgotten = append(forgotten, forgottenMemory.MemoryID)
		}
	}
	return forgotten, nil
}

// NewIdentifier names one note's origin bundle, so what was said in one breath
// settles together.
func NewIdentifier() string {
	identifierBytes := make([]byte, 16)
	if _, errorValue := rand.Read(identifierBytes); errorValue != nil {
		return "0000000000000000"
	}
	return hex.EncodeToString(identifierBytes)
}

const reembedBatchSize = 64

type MaintenanceReport struct {
	Scopes     int
	Reembedded bluememo.ReembedReport
}

// Maintain sweeps what has gone cold and re-embeds what a changed embedding
// model left stale, across every memory file on disk rather than only the ones
// this process has already opened. A scope that fails does not hold back the
// others; their failures come back together.
func (stores *Stores) Maintain(ctx context.Context) (MaintenanceReport, error) {
	scopes, errorValue := stores.storedScopes()
	if errorValue != nil {
		return MaintenanceReport{}, errorValue
	}
	report := MaintenanceReport{}
	failures := []error{}
	for _, scope := range scopes {
		reembedded, errorValue := stores.maintainScope(ctx, scope)
		if errorValue != nil {
			failures = append(failures, fmt.Errorf("%s %s: %w", scope.Kind, scope.ID, errorValue))
			continue
		}
		report.Scopes++
		report.Reembedded.Memories += reembedded.Memories
		report.Reembedded.Triggers += reembedded.Triggers
		report.Reembedded.Files += reembedded.Files
	}
	return report, errors.Join(failures...)
}

func (stores *Stores) maintainScope(ctx context.Context, scope Scope) (bluememo.ReembedReport, error) {
	store, errorValue := stores.Store(ctx, scope)
	if errorValue != nil {
		return bluememo.ReembedReport{}, errorValue
	}
	if _, errorValue := store.Sweep(ctx); errorValue != nil {
		return bluememo.ReembedReport{}, errorValue
	}
	return store.Reembed(ctx, reembedBatchSize)
}

// storedScopes lists the scopes that hold a file, by the directories their
// subjects live in. A directory whose name names no subject holds no memory.
func (stores *Stores) storedScopes() ([]Scope, error) {
	candidates := []Scope{WorkspaceScope()}
	for _, subjects := range []struct {
		directory string
		scopeOf   func(string) Scope
	}{
		{security.PeopleProtectedDirectoryPath(stores.workspaceRootPath), PersonScope},
		{security.CirclesDirectoryPath(stores.workspaceRootPath), CircleScope},
	} {
		entries, errorValue := os.ReadDir(subjects.directory)
		if errors.Is(errorValue, os.ErrNotExist) {
			continue
		}
		if errorValue != nil {
			return nil, errorValue
		}
		for _, entry := range entries {
			if entry.IsDir() {
				candidates = append(candidates, subjects.scopeOf(entry.Name()))
			}
		}
	}
	stored := []Scope{}
	for _, scope := range candidates {
		path, errorValue := stores.Path(scope)
		if errorValue != nil {
			continue
		}
		if _, errorValue := os.Stat(path); errorValue == nil {
			stored = append(stored, scope)
		}
	}
	return stored, nil
}

// MergePerson moves one person's memory into another's, for when two records
// turn out to be the same person. The losing file is renamed when the survivor
// has none, and its memories are adopted when both exist.
//
// Adopted memories arrive without their vectors, because a file does not hand
// them back, so the maintenance pass reembeds them and until then they answer
// on their wording.
func (stores *Stores) MergePerson(ctx context.Context, fromPersonID string, toPersonID string) error {
	if fromPersonID == "" || toPersonID == "" || fromPersonID == toPersonID {
		return nil
	}
	fromPath, errorValue := stores.Path(PersonScope(fromPersonID))
	if errorValue != nil {
		return errorValue
	}
	if _, errorValue := os.Stat(fromPath); errors.Is(errorValue, os.ErrNotExist) {
		return nil
	}
	toPath, errorValue := stores.Path(PersonScope(toPersonID))
	if errorValue != nil {
		return errorValue
	}
	stores.forget(PersonScope(fromPersonID))
	if _, errorValue := os.Stat(toPath); errors.Is(errorValue, os.ErrNotExist) {
		stores.forget(PersonScope(toPersonID))
		if errorValue := makeHoldingDirectory(toPath); errorValue != nil {
			return errorValue
		}
		return os.Rename(fromPath, toPath)
	}
	return stores.adoptFile(ctx, fromPath, PersonScope(toPersonID))
}

// adoptFile moves every memory one store file holds into a scope's store and
// then takes the file away.
func (stores *Stores) adoptFile(ctx context.Context, fromPath string, into Scope) error {
	memories, errorValue := memoriesIn(ctx, fromPath, stores.configuration)
	if errorValue != nil {
		return errorValue
	}
	surviving, errorValue := stores.Store(ctx, into)
	if errorValue != nil {
		return errorValue
	}
	adopted := make([]bluememo.AdoptedMemory, 0, len(memories))
	for _, memory := range memories {
		adopted = append(adopted, bluememo.AdoptedMemory{Memory: memory})
	}
	if _, errorValue := surviving.Adopt(ctx, adopted); errorValue != nil {
		return errorValue
	}
	return removeStoreFile(fromPath)
}

func memoriesIn(ctx context.Context, path string, configuration bluememo.Configuration) ([]bluememo.Memory, error) {
	store, errorValue := bluememo.Open(ctx, path, configuration)
	if errorValue != nil {
		return nil, errorValue
	}
	memories, errorValue := store.Memories(ctx)
	return memories, errors.Join(errorValue, store.Close())
}

// readAs embeds the query once, because the service holds the credentials for
// that, and reads the stack under the reader's own POSIX identity.
func (stores *Stores) readAs(ctx context.Context, personAccess policy.PersonAccess, scopes []Scope, query string, limit int) (ReadResult, error) {
	vector, errorValue := stores.configuration.Embedder.EmbedQuery(ctx, query)
	if errorValue != nil {
		return ReadResult{}, fmt.Errorf("embed the recall query: %w", errorValue)
	}
	paths := make([]string, len(scopes))
	for index, scope := range scopes {
		if paths[index], errorValue = stores.Path(scope); errorValue != nil {
			return ReadResult{}, errorValue
		}
	}
	request, errorValue := json.Marshal(ReadRequest{
		StorePaths:     paths,
		Query:          query,
		QueryVector:    vector,
		EmbeddingModel: stores.configuration.EmbeddingModel,
		Limit:          limit,
		LaneDepth:      stores.configuration.LaneDepth,
		RecallSources:  stores.configuration.RecallSources,
	})
	if errorValue != nil {
		return ReadResult{}, errorValue
	}
	actor, errorValue := stores.actor.Requester(ctx, security.WorkspaceActorRequest{
		PersonAccess:      personAccess,
		WorkspaceRootPath: stores.workspaceRootPath,
	})
	if errorValue != nil {
		return ReadResult{}, fmt.Errorf("read memory as %s: %w", personAccess.PersonID, errorValue)
	}
	executablePath, errorValue := os.Executable()
	if errorValue != nil {
		return ReadResult{}, fmt.Errorf("find the binary that serves a read: %w", errorValue)
	}
	return runRead(ctx, actor, executablePath, request, len(paths))
}

func runRead(ctx context.Context, actor security.WorkspaceActor, executablePath string, request []byte, layerCount int) (ReadResult, error) {
	result, errorValue := actor.Run(ctx, security.CommandRequest{
		ExecutableName:     executablePath,
		Arguments:          []string{ReadCommand},
		Stdin:              string(request),
		TimeoutSecond:      readTimeoutSecond,
		OutputMaximumBytes: readOutputMaximumBytes,
	})
	if errorValue != nil {
		return ReadResult{}, errorValue
	}
	if result.ExitCode != 0 {
		return ReadResult{}, fmt.Errorf("a read refused: %s", strings.TrimSpace(result.Stderr))
	}
	var read ReadResult
	if errorValue := json.Unmarshal([]byte(result.Stdout), &read); errorValue != nil {
		return ReadResult{}, fmt.Errorf("decode what a read answered: %w", errorValue)
	}
	return read, read.validate(layerCount)
}

// forget drops a cached handle so the file underneath it can be moved.
func (stores *Stores) forget(scope Scope) {
	path, errorValue := stores.Path(scope)
	if errorValue != nil {
		return
	}
	stores.mutex.Lock()
	defer stores.mutex.Unlock()
	if store, isOpen := stores.open[path]; isOpen {
		store.Close()
		delete(stores.open, path)
	}
}

// pathFor is where one scope's file lives, whether or not it exists yet.
// makeHoldingDirectory stands in for the POSIX helper where there is none,
// such as a test. On a host the helper has already made this directory
// setgid to the subject's group, and this call changes nothing.
func makeHoldingDirectory(memoryPath string) error {
	return os.MkdirAll(filepath.Dir(memoryPath), 0o750)
}

func (stores *Stores) Path(scope Scope) (string, error) {
	return scope.pathUnder(stores.workspaceRootPath)
}