mesh/merge-gate head 305601d25ef4 merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 305601d25ef4 merged into trunk a84d0094a0a3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
A value in the state could name a project such as ../../.. and be written outside the memory folder; a file or folder that could not be read was carried as a removal; text kept over a removal or merged in a conflict reached the state unscreened; and a conflict whose file could not be written was merged into the state again on every look. Names are judged before any file is touched, what cannot be read is kept and listed, every state write is screened, a conflict is written to the file before the state and warns once when it cannot be, a file changed between reading and writing is not written over, non-UTF-8 text is not carried, and a node that was away takes a removal it holds no change against (ADR 0343 §9, as clarified).
1093 lines
41 KiB
Go
1093 lines
41 KiB
Go
package main
|
|
|
|
// A session's memory follows the operator (novox/hq ADR 0343, issue 367).
|
|
//
|
|
// Claude Code keeps what a session learned about a project in the agent's home, under
|
|
// `~/.claude/projects/<project>/memory/`, on the node where it was learned. This module carries those folders —
|
|
// and nothing else of the home — between the nodes it runs on:
|
|
//
|
|
// - **Picked up by this process.** The long-running half looks at the memory folders every LookEvery; a file whose
|
|
// text differs from the version last carried is put in the module's kept `memory` state, one key per home kind
|
|
// and file. The text travels in that state and nowhere else: no event, no tool answer.
|
|
// - **Written by this process.** Every node watches the state and writes an incoming version only where its own
|
|
// file is still the version the change replaced, or there is none. The version replaced is kept first, in
|
|
// `memory-copies/` in the module's state directory. No tool writes a memory file or the state (issue 340).
|
|
// - **Both kept on a conflict.** A file changed on two nodes since it was last carried becomes one file holding
|
|
// both, the newer first, then a line naming each, then the older; that file is carried, and the operator is
|
|
// warned through the operator channel's `notify`.
|
|
// - **A removal is a marker.** A removed file is a value marked removed, never a deleted key; a node still holding
|
|
// the removed version moves it into the copies, a node that changed it keeps it and carries it again. A missing
|
|
// folder or an absent key removes nothing.
|
|
// - **Homes of one kind.** The operator's own homes carry among themselves, and the homes of an agent account of its
|
|
// own among themselves: a confined agent's notes never reach a session run as the operator.
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const (
|
|
// MemoryState is the module's kept state the memory travels in.
|
|
MemoryState = "memory"
|
|
// MemoryMostBytes is the largest file carried: the state's value holds the text, and the largest memory file
|
|
// measured on the laptop on 2026-10-13 was 70 KiB.
|
|
MemoryMostBytes = 128 * 1024
|
|
// MemoryValueBytes is the most a file's encoded text may take of one value of the state (256 KiB, ADR 0201),
|
|
// leaving room for the rest of the value.
|
|
MemoryValueBytes = 250 * 1024
|
|
// MemoryKeep is how long a replaced or removed version is kept in the copies, and a removal marker in the state.
|
|
MemoryKeep = 30 * 24 * time.Hour
|
|
// LookEvery is how often the memory folders are looked at.
|
|
LookEvery = 10 * time.Second
|
|
// The two home kinds (ADR 0343 §4).
|
|
HomeOperator = "operator"
|
|
HomeAgent = "agent"
|
|
// stagePrefix names a file staged beside its target; a look never carries one.
|
|
stagePrefix = ".mesh-memory-"
|
|
)
|
|
|
|
// MemoryStore is the module's `memory` state as the carrier uses it. Every write is at the revision read — a create
|
|
// where there was nothing — so of two nodes writing one file at once only one wins, and the other takes the winner's
|
|
// version as it comes: two conflicts are never merged into each other back and forth.
|
|
type MemoryStore interface {
|
|
Get(key string) (json.RawMessage, uint64, bool, error)
|
|
Create(key string, value any) error
|
|
Update(key string, value any, revision uint64) error
|
|
Keys() ([]string, error)
|
|
Delete(key string) error
|
|
}
|
|
|
|
// MemoryVersion is one file's version as the state carries it.
|
|
type MemoryVersion struct {
|
|
Kind string `json:"kind"`
|
|
Project string `json:"project"`
|
|
Path string `json:"path"`
|
|
Text string `json:"text,omitempty"`
|
|
Digest string `json:"digest,omitempty"`
|
|
Base string `json:"base,omitempty"`
|
|
Node string `json:"node"`
|
|
Changed time.Time `json:"changed"`
|
|
Removed bool `json:"removed,omitempty"`
|
|
}
|
|
|
|
// carried is what this node last carried of one file: the digest both its folder and the state held.
|
|
type carried struct {
|
|
Project string `json:"project"`
|
|
Path string `json:"path"`
|
|
Digest string `json:"digest"`
|
|
Node string `json:"node"`
|
|
Changed time.Time `json:"changed"`
|
|
}
|
|
|
|
// conflict is one conflict still open on this node.
|
|
type conflict struct {
|
|
Project string `json:"project"`
|
|
Path string `json:"path"`
|
|
Nodes []string `json:"nodes"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
// memoryRecord is the carrier's own record, in the module's state directory.
|
|
type memoryRecord struct {
|
|
Files map[string]carried `json:"files"`
|
|
Conflicts map[string]conflict `json:"conflicts"`
|
|
}
|
|
|
|
// MemoryCarrier carries one node's memory folders.
|
|
type MemoryCarrier struct {
|
|
Home, Node, State string
|
|
// Kind is the home kind carried for; empty carries nothing, and Why says why.
|
|
Kind, Why string
|
|
Store MemoryStore
|
|
// Notify tells the operator through the operator channel's `notify` verb.
|
|
Notify func(args map[string]any) error
|
|
Now func() time.Time
|
|
Say func(format string, args ...any)
|
|
|
|
mu sync.Mutex
|
|
rec memoryRecord
|
|
loaded bool
|
|
notCarried map[string]string
|
|
failed map[string]bool
|
|
pruned time.Time
|
|
seen map[string]seenFile
|
|
}
|
|
|
|
// HomeKind is the kind of the home this node's agent runs in, or "" with why none is carried.
|
|
func HomeKind(p Paths) (string, string) {
|
|
if !p.Confined() {
|
|
return HomeOperator, ""
|
|
}
|
|
if p.HomeIsTheAgentsOwn() {
|
|
return HomeAgent, ""
|
|
}
|
|
return "", "the agent runs as an account of its own here, and the home it runs in is not that account's own: " +
|
|
"whose memory it is cannot be judged, so none is carried (novox/hq ADR 0343 §4)"
|
|
}
|
|
|
|
// NewMemoryCarrier is the carrier for these paths.
|
|
func NewMemoryCarrier(p Paths, store MemoryStore, notify func(map[string]any) error) *MemoryCarrier {
|
|
kind, why := HomeKind(p)
|
|
return &MemoryCarrier{Home: p.Home, Node: p.Node, State: p.State, Kind: kind, Why: why, Store: store,
|
|
Notify: notify, Now: time.Now, Say: say}
|
|
}
|
|
|
|
func (m *MemoryCarrier) projects() string { return filepath.Join(m.Home, ".claude", "projects") }
|
|
func (m *MemoryCarrier) recordPath() string {
|
|
return filepath.Join(m.State, "memory.json")
|
|
}
|
|
func (m *MemoryCarrier) copies() string { return filepath.Join(m.State, "memory-copies") }
|
|
|
|
// MemoryKey is a file's key in the state: its home kind, then the project and path, encoded to the characters a
|
|
// key may hold.
|
|
func MemoryKey(kind, project, path string) string {
|
|
return kind + "." + base64.RawURLEncoding.EncodeToString([]byte(project+"/"+path))
|
|
}
|
|
|
|
func digestOf(text []byte) string {
|
|
sum := sha256.Sum256(text)
|
|
return "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// memorySecret are the shapes that are always a secret (ADR 0343 §2): a key block, a signed token, a known token
|
|
// prefix, a password in an address. Narrower than the mesh's general check on purpose: memory names commit hashes.
|
|
var memorySecret = []*regexp.Regexp{
|
|
regexp.MustCompile(`-----BEGIN [A-Z ]+-----`),
|
|
regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`),
|
|
regexp.MustCompile(`\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})`),
|
|
regexp.MustCompile(`\b[a-z][a-z0-9+.-]*://[^/\s:@]+:[^/\s@]+@`),
|
|
}
|
|
|
|
// memoryProblem says why a file is not carried, or "".
|
|
func memoryProblem(text []byte) string {
|
|
if len(text) > MemoryMostBytes {
|
|
return fmt.Sprintf("it is %d KiB, over the %d KiB a memory file may be to be carried", len(text)/1024, MemoryMostBytes/1024)
|
|
}
|
|
if !utf8.Valid(text) {
|
|
return "it is not text (not valid UTF-8), and the state carries text only"
|
|
}
|
|
// One value of the state holds at most 256 KiB (ADR 0201): the text as it is encoded, with room for the rest.
|
|
if encoded, _ := json.Marshal(string(text)); len(encoded) > MemoryValueBytes {
|
|
return "its text, as the state encodes it, would not fit in one value of the state"
|
|
}
|
|
for _, re := range memorySecret {
|
|
if re.Match(text) {
|
|
return "it holds something shaped like a secret, which kept state never carries"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ---- the record -----------------------------------------------------------------------------------------
|
|
|
|
func (m *MemoryCarrier) load() {
|
|
if m.loaded {
|
|
return
|
|
}
|
|
m.loaded = true
|
|
m.rec = memoryRecord{}
|
|
if raw, err := os.ReadFile(m.recordPath()); err == nil {
|
|
_ = json.Unmarshal(raw, &m.rec)
|
|
}
|
|
if m.rec.Files == nil {
|
|
m.rec.Files = map[string]carried{}
|
|
}
|
|
if m.rec.Conflicts == nil {
|
|
m.rec.Conflicts = map[string]conflict{}
|
|
}
|
|
if m.notCarried == nil {
|
|
m.notCarried = map[string]string{}
|
|
}
|
|
if m.failed == nil {
|
|
m.failed = map[string]bool{}
|
|
}
|
|
}
|
|
|
|
func (m *MemoryCarrier) save() error {
|
|
raw, err := json.MarshalIndent(m.rec, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := m.recordPath() + ".next"
|
|
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, m.recordPath())
|
|
}
|
|
|
|
// ---- the folders ----------------------------------------------------------------------------------------
|
|
|
|
// seenFile is a file as the last look read it.
|
|
type seenFile struct {
|
|
size int64
|
|
mod time.Time
|
|
text []byte
|
|
}
|
|
|
|
// localFile is one memory file found on this node.
|
|
type localFile struct {
|
|
Project, Path string
|
|
Text []byte
|
|
Changed time.Time
|
|
}
|
|
|
|
// memoryFolder is a project's memory folder, when it is a directory and not a symbolic link.
|
|
func (m *MemoryCarrier) memoryFolder(project string) (string, bool) {
|
|
if !entryName(project) {
|
|
return "", false
|
|
}
|
|
dir := filepath.Join(m.projects(), project, "memory")
|
|
info, err := fsys.Lstat(dir)
|
|
return dir, err == nil && info.IsDir() && info.Mode()&fs.ModeSymlink == 0
|
|
}
|
|
|
|
// sight is what one look saw of the memory folders.
|
|
type sight struct {
|
|
files map[string]localFile
|
|
// folders are the memory folders seen whole: only in them is a carried file that is gone a removal.
|
|
folders map[string]bool
|
|
// unseen are the files that are there but could not be read on this look: kept as they are everywhere.
|
|
unseen map[string]bool
|
|
not map[string]string
|
|
}
|
|
|
|
// scan reads every memory folder: the files it may carry, the folders seen whole, and why any file is not carried.
|
|
// What cannot be read is never taken for gone: a file that cannot be read is unseen, and a folder that cannot be
|
|
// listed whole is not seen at all on this look (ADR 0343 §9).
|
|
func (m *MemoryCarrier) scan() (sight, error) {
|
|
in := sight{files: map[string]localFile{}, folders: map[string]bool{}, unseen: map[string]bool{}, not: map[string]string{}}
|
|
seen := map[string]seenFile{}
|
|
defer func() { m.seen = seen }()
|
|
projects, err := fsys.ReadDir(m.projects())
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return in, nil
|
|
}
|
|
if err != nil {
|
|
return in, fmt.Errorf("the projects directory could not be listed (%v); nothing was carried on this look", err)
|
|
}
|
|
for _, pe := range projects {
|
|
if !pe.IsDir() || !entryName(pe.Name()) {
|
|
continue
|
|
}
|
|
project := pe.Name()
|
|
dir, ok := m.memoryFolder(project)
|
|
if !ok {
|
|
if _, err := fsys.Lstat(dir); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
in.not[project+"/"] = fmt.Sprintf("its memory folder could not be looked at (%v); nothing of it was carried or removed on this look", err)
|
|
}
|
|
continue
|
|
}
|
|
files, unseen, not := map[string]localFile{}, map[string]bool{}, map[string]string{}
|
|
whole := true
|
|
_ = fsys.WalkDir(dir, func(full string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
// A folder that cannot be listed: this project is not seen on this look.
|
|
whole = false
|
|
in.not[project+"/"] = fmt.Sprintf("its memory folder could not be listed whole (%v); nothing of it was carried or removed on this look", err)
|
|
return filepath.SkipAll
|
|
}
|
|
if full == dir {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(dir, full)
|
|
rel = filepath.ToSlash(rel)
|
|
if strings.HasPrefix(d.Name(), stagePrefix) {
|
|
return nil
|
|
}
|
|
if NameProblem(project, rel) != "" {
|
|
return nil
|
|
}
|
|
key := MemoryKey(m.Kind, project, rel)
|
|
if d.Type()&fs.ModeSymlink != 0 {
|
|
not[project+"/"+rel] = "it is a symbolic link, which is never carried"
|
|
unseen[key] = true
|
|
return nil
|
|
}
|
|
if d.IsDir() || !d.Type().IsRegular() {
|
|
return nil
|
|
}
|
|
info, err := fsys.Lstat(full)
|
|
if err != nil {
|
|
not[project+"/"+rel] = fmt.Sprintf("it could not be looked at here (%v); it is kept as it is everywhere", err)
|
|
unseen[key] = true
|
|
return nil
|
|
}
|
|
if info.Size() > MemoryMostBytes {
|
|
not[project+"/"+rel] = fmt.Sprintf("it is %d KiB, over the %d KiB a memory file may be to be carried",
|
|
info.Size()/1024, MemoryMostBytes/1024)
|
|
unseen[key] = true
|
|
return nil
|
|
}
|
|
// A file whose size and time did not change since the last look is not read again.
|
|
var text []byte
|
|
if was, ok := m.seen[full]; ok && was.size == info.Size() && was.mod.Equal(info.ModTime()) {
|
|
text = was.text
|
|
} else if text, err = fsys.ReadFile(full); err != nil {
|
|
not[project+"/"+rel] = fmt.Sprintf("it could not be read here (%v); it is kept as it is everywhere", err)
|
|
unseen[key] = true
|
|
return nil
|
|
}
|
|
seen[full] = seenFile{size: info.Size(), mod: info.ModTime(), text: text}
|
|
if why := memoryProblem(text); why != "" {
|
|
not[project+"/"+rel] = why
|
|
unseen[key] = true
|
|
return nil
|
|
}
|
|
files[key] = localFile{Project: project, Path: rel, Text: text, Changed: info.ModTime()}
|
|
return nil
|
|
})
|
|
if !whole {
|
|
continue
|
|
}
|
|
in.folders[project] = true
|
|
for k, v := range files {
|
|
in.files[k] = v
|
|
}
|
|
for k := range unseen {
|
|
in.unseen[k] = true
|
|
}
|
|
for k, v := range not {
|
|
in.not[k] = v
|
|
}
|
|
}
|
|
return in, nil
|
|
}
|
|
|
|
// readLocal reads one file of a memory folder: absent, or its text; a symbolic link or anything but a file is an
|
|
// error, never followed.
|
|
func (m *MemoryCarrier) readLocal(project, path string) ([]byte, time.Time, bool, error) {
|
|
if err := badName(project, path); err != nil {
|
|
return nil, time.Time{}, false, err
|
|
}
|
|
dir, ok := m.memoryFolder(project)
|
|
if !ok {
|
|
if _, err := fsys.Lstat(dir); err == nil {
|
|
return nil, time.Time{}, false, fmt.Errorf("the memory folder of %s is not a directory of its own", project)
|
|
}
|
|
return nil, time.Time{}, false, nil
|
|
}
|
|
full := filepath.Join(dir, filepath.FromSlash(path))
|
|
if !inside(dir, full) {
|
|
return nil, time.Time{}, false, fmt.Errorf("%s leaves the memory folder", path)
|
|
}
|
|
info, err := fsys.Lstat(full)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil, time.Time{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return nil, time.Time{}, false, err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil, time.Time{}, false, fmt.Errorf("%s/%s is not a plain file and is left as it is", project, path)
|
|
}
|
|
text, err := fsys.ReadFile(full)
|
|
return text, info.ModTime(), err == nil, err
|
|
}
|
|
|
|
// NameProblem says why a project and a path may not name a memory file, or "": the project is one entry of the
|
|
// projects directory, and the path is relative, each of its parts a name — never empty, "." or "..". It is judged
|
|
// before any file is touched and on every value taken from the state, so no value leads outside a memory folder.
|
|
func NameProblem(project, path string) string {
|
|
if !entryName(project) {
|
|
return fmt.Sprintf("the project %q is not one entry of the projects directory", project)
|
|
}
|
|
if path == "" || strings.HasPrefix(path, "/") || strings.ContainsRune(path, 0) {
|
|
return fmt.Sprintf("the path %q is not a relative path in a memory folder", path)
|
|
}
|
|
for _, part := range strings.Split(path, "/") {
|
|
if part == "" || part == "." || part == ".." {
|
|
return fmt.Sprintf("the path %q has an empty, \".\" or \"..\" part", path)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func badName(project, path string) error {
|
|
if why := NameProblem(project, path); why != "" {
|
|
return fmt.Errorf("%s; nothing is touched for it (novox/hq ADR 0343)", why)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func inside(dir, full string) bool {
|
|
rel, err := filepath.Rel(dir, full)
|
|
return err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
|
}
|
|
|
|
// keep copies a version into the copies before it is replaced or removed: nothing of a memory folder is lost.
|
|
func (m *MemoryCarrier) keep(project, path string, text []byte, why string) error {
|
|
if err := badName(project, path); err != nil {
|
|
return err
|
|
}
|
|
stamp := m.Now().UTC().Format("20060102T150405.000000000Z")
|
|
full := filepath.Join(m.copies(), project, filepath.FromSlash(path)+"."+stamp+"."+why)
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(full, text, 0o600)
|
|
}
|
|
|
|
// errChangedHere says a file changed on this node between its reading and its writing: it is not written, and the
|
|
// next look judges the change, a conflict where there is one.
|
|
var errChangedHere = errors.New("it changed here meanwhile, and is judged again on the next look")
|
|
|
|
// writeLocal writes one file of a memory folder over the version the caller read (expect: its digest, "" for none):
|
|
// the version there kept first, then the new text staged in the same folder and renamed into place, never through
|
|
// a symbolic link. A file that is no longer the version read is not written (errChangedHere).
|
|
func (m *MemoryCarrier) writeLocal(project, path string, text []byte, why, expect string) error {
|
|
if err := badName(project, path); err != nil {
|
|
return err
|
|
}
|
|
if was, _, ok, err := m.readLocal(project, path); err != nil {
|
|
return err
|
|
} else if !ok && expect != "" || ok && digestOf(was) != expect {
|
|
return fmt.Errorf("%s/%s: %w", project, path, errChangedHere)
|
|
} else if ok {
|
|
if bytes.Equal(was, text) {
|
|
return nil
|
|
}
|
|
if err := m.keep(project, path, was, why); err != nil {
|
|
return fmt.Errorf("the version of %s/%s here could not be kept, so it is not replaced: %w", project, path, err)
|
|
}
|
|
}
|
|
dir := filepath.Join(m.projects(), project, "memory")
|
|
full := filepath.Join(dir, filepath.FromSlash(path))
|
|
if !inside(dir, full) {
|
|
return fmt.Errorf("%s leaves the memory folder", path)
|
|
}
|
|
for d := filepath.Dir(full); ; d = filepath.Dir(d) {
|
|
if info, err := fsys.Lstat(d); err == nil && info.Mode()&fs.ModeSymlink != 0 {
|
|
return fmt.Errorf("%s is a symbolic link, and nothing is written through one", d)
|
|
}
|
|
if d == m.Home || d == filepath.Dir(d) {
|
|
break
|
|
}
|
|
}
|
|
if err := fsys.MkdirAll(filepath.Dir(full), 0o700); err != nil {
|
|
return err
|
|
}
|
|
stage := filepath.Join(filepath.Dir(full), stagePrefix+filepath.Base(full))
|
|
if err := fsys.WriteFile(stage, text, 0o600); err != nil {
|
|
return err
|
|
}
|
|
if err := fsys.Rename(stage, full); err != nil {
|
|
_ = fsys.Remove(stage)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// removeLocal moves one file of a memory folder into the copies.
|
|
func (m *MemoryCarrier) removeLocal(project, path string, text []byte) error {
|
|
if err := badName(project, path); err != nil {
|
|
return err
|
|
}
|
|
if now, _, ok, err := m.readLocal(project, path); err != nil {
|
|
return err
|
|
} else if !ok || !bytes.Equal(now, text) {
|
|
return fmt.Errorf("%s/%s: %w", project, path, errChangedHere)
|
|
}
|
|
if err := m.keep(project, path, text, "removed"); err != nil {
|
|
return fmt.Errorf("%s/%s could not be kept, so it is not removed: %w", project, path, err)
|
|
}
|
|
return fsys.Remove(filepath.Join(m.projects(), project, "memory", filepath.FromSlash(path)))
|
|
}
|
|
|
|
// ---- conflicts -----------------------------------------------------------------------------------------
|
|
|
|
// MergeVersions is one file holding both versions: the newer first, then a line naming each, then the older. Equal
|
|
// times are ordered by node name.
|
|
func MergeVersions(aText []byte, aNode string, aAt time.Time, bText []byte, bNode string, bAt time.Time) []byte {
|
|
newer, older := struct {
|
|
text []byte
|
|
node string
|
|
at time.Time
|
|
}{aText, aNode, aAt}, struct {
|
|
text []byte
|
|
node string
|
|
at time.Time
|
|
}{bText, bNode, bAt}
|
|
if bAt.After(aAt) || (bAt.Equal(aAt) && bNode < aNode) {
|
|
newer, older = older, newer
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString(strings.TrimRight(string(newer.text), "\n"))
|
|
fmt.Fprintf(&b, "\n\n<!-- novox mesh: this file was changed on two nodes, and both versions were kept (novox/hq ADR 0343). "+
|
|
"Above: the newer, changed on %s at %s. Below: the older, changed on %s at %s. Keep what is right and remove this line. -->\n\n",
|
|
newer.node, newer.at.UTC().Format("2006-01-02 15:04 MST"), older.node, older.at.UTC().Format("2006-01-02 15:04 MST"))
|
|
b.WriteString(strings.TrimRight(string(older.text), "\n"))
|
|
b.WriteString("\n")
|
|
return []byte(b.String())
|
|
}
|
|
|
|
// projectLabel names a project in plain words: the folder's name after its last "-projects-", where it has one.
|
|
func projectLabel(project string) string {
|
|
if i := strings.LastIndex(project, "-projects-"); i >= 0 && i+len("-projects-") < len(project) {
|
|
return project[i+len("-projects-"):]
|
|
}
|
|
return strings.Trim(project, "-")
|
|
}
|
|
|
|
func noticeKey(key string) string {
|
|
sum := sha256.Sum256([]byte(key))
|
|
return "claude-code.memory." + hex.EncodeToString(sum[:])[:16] + ".conflict"
|
|
}
|
|
|
|
// warn tells the operator of a conflict, once per file while it is open.
|
|
func (m *MemoryCarrier) warn(key, project, path string, nodes []string, beside bool) {
|
|
nodes = append([]string(nil), nodes...)
|
|
sort.Strings(nodes)
|
|
m.rec.Conflicts[key] = conflict{Project: project, Path: path, Nodes: nodes, At: m.Now().UTC()}
|
|
if m.Notify == nil {
|
|
return
|
|
}
|
|
label := projectLabel(project)
|
|
headline := "Claude memory of " + label + " kept twice"
|
|
if len([]rune(headline)) > 60 {
|
|
headline = "Claude memory kept twice"
|
|
}
|
|
explanation := fmt.Sprintf("Needs you: keep what is right in the memory file of project %s. It was changed on %s "+
|
|
"before either change reached the other, so both versions are now in it on every machine, the newer first.",
|
|
label, strings.Join(nodes, " and "))
|
|
if beside {
|
|
explanation = fmt.Sprintf("Needs you: keep what is right in the memory file of project %s. It was changed on %s "+
|
|
"before either change reached the other, and both were too large for one file, so the newer is in it and the "+
|
|
"older in a second file beside it, on every machine.", label, strings.Join(nodes, " and "))
|
|
}
|
|
// The plain rule (ADR 0253 §3): no path, file name, time, "agent" or "session"; the verdict opens the explanation.
|
|
err := m.Notify(map[string]any{
|
|
"key": noticeKey(key),
|
|
"severity": "warning",
|
|
"subject": "claude-code",
|
|
"summary": fmt.Sprintf("memory of project %s changed on %s and kept twice", label, strings.Join(nodes, " and ")),
|
|
"headline": headline,
|
|
"explanation": explanation,
|
|
"resolved": fmt.Sprintf("the memory file of project %s holds one version again", label),
|
|
})
|
|
if err != nil && m.Say != nil {
|
|
m.Say("memory: the operator could not be warned of the conflict in %s/%s: %v", project, path, err)
|
|
}
|
|
}
|
|
|
|
// settle clears a conflict once its file changed without one.
|
|
func (m *MemoryCarrier) settle(key string) {
|
|
if _, open := m.rec.Conflicts[key]; !open {
|
|
return
|
|
}
|
|
delete(m.rec.Conflicts, key)
|
|
if m.Notify != nil {
|
|
_ = m.Notify(map[string]any{"key": noticeKey(key), "severity": "warning", "clear": true, "subject": "claude-code",
|
|
"summary": "a memory file kept twice was tidied"})
|
|
}
|
|
}
|
|
|
|
// ---- carrying ------------------------------------------------------------------------------------------
|
|
|
|
func (m *MemoryCarrier) current(key string) (*MemoryVersion, uint64, error) {
|
|
raw, revision, ok, err := m.Store.Get(key)
|
|
if err != nil || !ok {
|
|
return nil, 0, err
|
|
}
|
|
var v MemoryVersion
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return nil, 0, fmt.Errorf("the state's value for %s does not read: %w", key, err)
|
|
}
|
|
return &v, revision, nil
|
|
}
|
|
|
|
// put writes a version at the revision read (0: there was none), and records it as carried once it is in the state.
|
|
func (m *MemoryCarrier) put(key string, v MemoryVersion, revision uint64) error {
|
|
if err := m.write(key, v, revision); err != nil {
|
|
return err
|
|
}
|
|
m.record(key, v)
|
|
return nil
|
|
}
|
|
|
|
// errNotCarried says a version was screened out before it reached the state.
|
|
var errNotCarried = errors.New("not carried")
|
|
|
|
// write puts a version into the state at the revision read — the one way anything reaches the state — after
|
|
// judging it as a file found here is judged (ADR 0343 §2): its project and path, and its text, on every path.
|
|
func (m *MemoryCarrier) write(key string, v MemoryVersion, revision uint64) error {
|
|
v.Kind = m.Kind
|
|
if why := NameProblem(v.Project, v.Path); why != "" {
|
|
return fmt.Errorf("%s: %w", why, errNotCarried)
|
|
}
|
|
if MemoryKey(m.Kind, v.Project, v.Path) != key {
|
|
return fmt.Errorf("%s does not name %s/%s: %w", key, v.Project, v.Path, errNotCarried)
|
|
}
|
|
if !v.Removed {
|
|
if why := memoryProblem([]byte(v.Text)); why != "" {
|
|
m.notCarried[v.Project+"/"+v.Path] = why
|
|
return fmt.Errorf("%s/%s is not carried, because %s: %w", v.Project, v.Path, why, errNotCarried)
|
|
}
|
|
if v.Digest != digestOf([]byte(v.Text)) {
|
|
return fmt.Errorf("%s/%s: its digest does not name its text: %w", v.Project, v.Path, errNotCarried)
|
|
}
|
|
}
|
|
var err error
|
|
if revision == 0 {
|
|
err = m.Store.Create(key, v)
|
|
} else {
|
|
err = m.Store.Update(key, v, revision)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("%s/%s was not carried, because the state changed meanwhile or did not answer; it is "+
|
|
"carried on the next look: %w", v.Project, v.Path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MemoryCarrier) record(key string, v MemoryVersion) {
|
|
if v.Removed {
|
|
delete(m.rec.Files, key)
|
|
return
|
|
}
|
|
m.rec.Files[key] = carried{Project: v.Project, Path: v.Path, Digest: v.Digest, Node: v.Node, Changed: v.Changed}
|
|
}
|
|
|
|
// Look looks at the memory folders once and carries what changed (ADR 0343 §5, §7, §9, §10).
|
|
func (m *MemoryCarrier) Look() error {
|
|
if m.Kind == "" {
|
|
return nil
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.load()
|
|
in, err := m.scan()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
files, folders, not := in.files, in.folders, in.not
|
|
m.notCarried = not
|
|
var problems []error
|
|
// A key the state lost is carried again, never read as a removal (ADR 0343 §9): one listing per look.
|
|
// When the state does not answer, nothing is carried on this look: the next one tries again.
|
|
listed, err := m.Store.Keys()
|
|
if err != nil {
|
|
return fmt.Errorf("the memory state did not answer; nothing was carried on this look: %w", err)
|
|
}
|
|
inState := map[string]bool{}
|
|
for _, k := range listed {
|
|
inState[k] = true
|
|
}
|
|
keys := make([]string, 0, len(files))
|
|
for k := range files {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
if !inState[key] {
|
|
delete(m.rec.Files, key)
|
|
}
|
|
if err := m.lookAt(key, files[key]); err != nil {
|
|
problems = append(problems, err)
|
|
}
|
|
}
|
|
// A carried file gone from a folder that still exists is a removal; a folder gone removes nothing.
|
|
gone := []string{}
|
|
for key, c := range m.rec.Files {
|
|
if _, here := files[key]; !here && folders[c.Project] && !in.unseen[key] && strings.HasPrefix(key, m.Kind+".") {
|
|
gone = append(gone, key)
|
|
}
|
|
}
|
|
sort.Strings(gone)
|
|
for _, key := range gone {
|
|
if err := m.removedHere(key); err != nil {
|
|
problems = append(problems, err)
|
|
}
|
|
}
|
|
m.prune()
|
|
if err := m.save(); err != nil {
|
|
problems = append(problems, err)
|
|
}
|
|
return errors.Join(problems...)
|
|
}
|
|
|
|
// lookAt carries one file found here.
|
|
func (m *MemoryCarrier) lookAt(key string, f localFile) error {
|
|
digest := digestOf(f.Text)
|
|
rec, known := m.rec.Files[key]
|
|
if known && rec.Digest == digest {
|
|
return nil
|
|
}
|
|
cur, revision, err := m.current(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mine := MemoryVersion{Project: f.Project, Path: f.Path, Text: string(f.Text), Digest: digest, Node: m.Node, Changed: f.Changed}
|
|
switch {
|
|
case cur != nil && !cur.Removed && cur.Digest == digest:
|
|
// The state already holds this text: agreed.
|
|
m.rec.Files[key] = carried{Project: f.Project, Path: f.Path, Digest: digest, Node: cur.Node, Changed: cur.Changed}
|
|
return nil
|
|
case cur == nil, cur.Removed, known && cur.Digest == rec.Digest, !known && cur.Node == m.Node:
|
|
// Nothing else changed it since this node's last version (or the state lost it): a plain change.
|
|
if known {
|
|
mine.Base = rec.Digest
|
|
}
|
|
if err := m.put(key, mine, revision); err != nil {
|
|
return err
|
|
}
|
|
m.settle(key)
|
|
return nil
|
|
case cur.Base == digest:
|
|
// The state's version replaced this very text: it is newer here too, and is written.
|
|
return m.apply(key, *cur, revision)
|
|
default:
|
|
// Changed here and elsewhere since it was last carried, or two nodes' first versions differ: both kept.
|
|
return m.conflict(key, f.Project, f.Path, f.Text, f.Changed, *cur, revision)
|
|
}
|
|
}
|
|
|
|
// conflict writes one file holding both versions, carries it, and warns. The merged version goes into the state
|
|
// first, at the revision the other version was read at: when another node got there first, nothing is written
|
|
// here, and its version is taken as it comes.
|
|
func (m *MemoryCarrier) conflict(key, project, path string, local []byte, localAt time.Time, cur MemoryVersion, revision uint64) error {
|
|
merged := MergeVersions(local, m.Node, localAt, []byte(cur.Text), cur.Node, cur.Changed)
|
|
if memoryProblem(merged) != "" {
|
|
return m.conflictBeside(key, project, path, local, localAt, cur, revision)
|
|
}
|
|
v := MemoryVersion{Kind: m.Kind, Project: project, Path: path, Text: string(merged), Digest: digestOf(merged),
|
|
Base: cur.Digest, Node: m.Node, Changed: m.Now().UTC()}
|
|
// The file first, over exactly the version merged: a write that fails leaves the state as it was, so the next
|
|
// look merges the same two versions again and nothing grows. Only then the state, at the revision read; when
|
|
// another node got there first, its version is judged against the merged file as it comes.
|
|
if err := m.writeLocal(project, path, merged, "before-merge", digestOf(local)); err != nil {
|
|
m.unwritten(key, project, path, err)
|
|
return err
|
|
}
|
|
if err := m.write(key, v, revision); err != nil {
|
|
return fmt.Errorf("%s/%s changed on two nodes; both are kept in it here, and the state changed again "+
|
|
"meanwhile, so it is judged again on the next change: %w", project, path, err)
|
|
}
|
|
m.record(key, v)
|
|
if m.Say != nil {
|
|
m.Say("memory: %s/%s was changed on %s and on %s; both versions are kept in it, the newer first", project, path, m.Node, cur.Node)
|
|
}
|
|
m.warn(key, project, path, []string{m.Node, cur.Node}, false)
|
|
return nil
|
|
}
|
|
|
|
// unwritten says, once per file, that a memory file could not be written here: the operator is told, since a
|
|
// file that cannot be written cannot follow them (ADR 0343 §8).
|
|
func (m *MemoryCarrier) unwritten(key, project, path string, err error) {
|
|
if errors.Is(err, errChangedHere) {
|
|
return
|
|
}
|
|
if m.Say != nil {
|
|
m.Say("memory: %s/%s could not be written here: %v", project, path, err)
|
|
}
|
|
if _, told := m.failed[key]; told || m.Notify == nil {
|
|
return
|
|
}
|
|
m.failed[key] = true
|
|
label := projectLabel(project)
|
|
_ = m.Notify(map[string]any{
|
|
"key": noticeKey(key) + ".unwritten", "severity": "warning", "subject": "claude-code",
|
|
"summary": fmt.Sprintf("a memory file of project %s could not be written on %s", label, m.Node),
|
|
"headline": "Claude memory not written on " + m.Node,
|
|
"explanation": fmt.Sprintf("Needs you: make the memory folder of project %s writable on %s. Two versions of one "+
|
|
"of its files are waiting to be kept together there, and nothing is lost meanwhile.", label, m.Node),
|
|
"resolved": fmt.Sprintf("the memory of project %s is written on %s again", label, m.Node),
|
|
})
|
|
}
|
|
|
|
// OlderBeside is where the older of two versions too large to share one file is kept: beside the file, named for
|
|
// the node it was changed on ("MEMORY.md" from shanks: "MEMORY.older-from-shanks.md").
|
|
func OlderBeside(path, node string) string {
|
|
ext := filepath.Ext(path)
|
|
return strings.TrimSuffix(path, ext) + ".older-from-" + node + ext
|
|
}
|
|
|
|
// conflictBeside keeps two versions too large to share one file in two (ADR 0343 §7): the newer in the file, the
|
|
// older beside it, each carried.
|
|
func (m *MemoryCarrier) conflictBeside(key, project, path string, local []byte, localAt time.Time, cur MemoryVersion, revision uint64) error {
|
|
newer, newerNode, newerAt := local, m.Node, localAt
|
|
older, olderNode := []byte(cur.Text), cur.Node
|
|
if cur.Changed.After(localAt) || (cur.Changed.Equal(localAt) && cur.Node < m.Node) {
|
|
newer, newerNode, newerAt = []byte(cur.Text), cur.Node, cur.Changed
|
|
older, olderNode = local, m.Node
|
|
}
|
|
beside := OlderBeside(path, olderNode)
|
|
v := MemoryVersion{Kind: m.Kind, Project: project, Path: path, Text: string(newer), Digest: digestOf(newer),
|
|
Base: cur.Digest, Node: newerNode, Changed: newerAt}
|
|
if newerNode == m.Node {
|
|
v.Node, v.Changed = m.Node, m.Now().UTC()
|
|
}
|
|
// The files first, then the state, as conflict does.
|
|
besideNow := ""
|
|
if was, _, ok, err := m.readLocal(project, beside); err != nil {
|
|
return err
|
|
} else if ok {
|
|
besideNow = digestOf(was)
|
|
}
|
|
if err := m.writeLocal(project, beside, older, "before-merge", besideNow); err != nil {
|
|
m.unwritten(key, project, path, err)
|
|
return err
|
|
}
|
|
if err := m.writeLocal(project, path, newer, "before-merge", digestOf(local)); err != nil {
|
|
m.unwritten(key, project, path, err)
|
|
return err
|
|
}
|
|
if err := m.write(key, v, revision); err != nil {
|
|
return fmt.Errorf("%s/%s changed on two nodes; both are kept here, and the state changed again meanwhile, "+
|
|
"so it is judged again on the next change: %w", project, path, err)
|
|
}
|
|
m.record(key, v)
|
|
if m.Say != nil {
|
|
m.Say("memory: %s/%s was changed on %s and on %s, too large to hold both: the newer is in it, the older beside it as %s",
|
|
project, path, m.Node, cur.Node, beside)
|
|
}
|
|
m.warn(key, project, path, []string{m.Node, cur.Node}, true)
|
|
return nil
|
|
}
|
|
|
|
// removedHere carries the removal of a file this node carried before.
|
|
func (m *MemoryCarrier) removedHere(key string) error {
|
|
rec := m.rec.Files[key]
|
|
cur, revision, err := m.current(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch {
|
|
case cur != nil && cur.Removed:
|
|
delete(m.rec.Files, key)
|
|
return nil
|
|
case cur == nil || cur.Digest == rec.Digest:
|
|
return m.put(key, MemoryVersion{Project: rec.Project, Path: rec.Path, Base: rec.Digest, Node: m.Node,
|
|
Changed: m.Now().UTC(), Removed: true}, revision)
|
|
default:
|
|
// Changed elsewhere since: a change outlives a removal, and the file is written back.
|
|
return m.apply(key, *cur, revision)
|
|
}
|
|
}
|
|
|
|
// Take is one change of the `memory` state, as the watch hands it over.
|
|
func (m *MemoryCarrier) Take(key, op string, value json.RawMessage, revision uint64) error {
|
|
if m.Kind == "" || !strings.HasPrefix(key, m.Kind+".") || op != "put" {
|
|
// A deleted key removes nothing (ADR 0343 §9); another kind's key is never taken (§4).
|
|
return nil
|
|
}
|
|
var v MemoryVersion
|
|
if err := json.Unmarshal(value, &v); err != nil {
|
|
return fmt.Errorf("the state's value for %s does not read: %w", key, err)
|
|
}
|
|
if v.Kind != m.Kind || MemoryKey(m.Kind, v.Project, v.Path) != key {
|
|
return fmt.Errorf("the state's value for %s names another file or kind, and is not taken", key)
|
|
}
|
|
if err := badName(v.Project, v.Path); err != nil {
|
|
return fmt.Errorf("the state's value for %s is not taken: %w", key, err)
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.load()
|
|
// A change handed over late is never written over a newer one: what is applied is the state's current version.
|
|
cur, rev, err := m.current(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cur == nil || rev < revision {
|
|
return nil
|
|
}
|
|
if cur.Kind != m.Kind || MemoryKey(m.Kind, cur.Project, cur.Path) != key {
|
|
return fmt.Errorf("the state's value for %s names another file or kind, and is not taken", key)
|
|
}
|
|
err = m.apply(key, *cur, rev)
|
|
return errors.Join(err, m.save())
|
|
}
|
|
|
|
// apply writes one incoming version, read at revision, into this node's folder (ADR 0343 §6, §7, §9).
|
|
func (m *MemoryCarrier) apply(key string, v MemoryVersion, revision uint64) error {
|
|
if err := badName(v.Project, v.Path); err != nil {
|
|
return fmt.Errorf("the state's value for %s is not taken: %w", key, err)
|
|
}
|
|
if v.Node == m.Node && !v.Removed {
|
|
// This node's own version: what is here is either it, or newer, which the next look carries.
|
|
if text, _, ok, _ := m.readLocal(v.Project, v.Path); ok && digestOf(text) == v.Digest {
|
|
m.rec.Files[key] = carried{Project: v.Project, Path: v.Path, Digest: v.Digest, Node: v.Node, Changed: v.Changed}
|
|
}
|
|
return nil
|
|
}
|
|
if !v.Removed && (v.Digest != digestOf([]byte(v.Text)) || memoryProblem([]byte(v.Text)) != "") {
|
|
return fmt.Errorf("the state's version of %s/%s does not hold the text its digest names, or holds what is never carried; it is not written", v.Project, v.Path)
|
|
}
|
|
local, localAt, here, err := m.readLocal(v.Project, v.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rec, known := m.rec.Files[key]
|
|
localDigest := ""
|
|
if here {
|
|
localDigest = digestOf(local)
|
|
}
|
|
if v.Removed {
|
|
switch {
|
|
case !here:
|
|
delete(m.rec.Files, key)
|
|
// The file here holds no change this node did not carry — it is the version removed, or the version this
|
|
// node last carried, which the removal replaced whether or not this node saw the versions between — so the
|
|
// removal takes it, into the copies. Anything else is a change made here, which outlives the removal (§9).
|
|
case localDigest == v.Base || (known && localDigest == rec.Digest):
|
|
if err := m.removeLocal(v.Project, v.Path, local); err != nil {
|
|
return err
|
|
}
|
|
delete(m.rec.Files, key)
|
|
if m.Say != nil {
|
|
m.Say("memory: %s/%s was removed on %s; the version here is kept in the copies", v.Project, v.Path, v.Node)
|
|
}
|
|
default:
|
|
// Changed here since the removed version: kept, and carried again.
|
|
if err := m.put(key, MemoryVersion{Project: v.Project, Path: v.Path, Text: string(local), Digest: localDigest,
|
|
Base: v.Base, Node: m.Node, Changed: localAt}, revision); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
switch {
|
|
case here && localDigest == v.Digest:
|
|
m.rec.Files[key] = carried{Project: v.Project, Path: v.Path, Digest: v.Digest, Node: v.Node, Changed: v.Changed}
|
|
m.settle(key)
|
|
return nil
|
|
case !here && (!known || rec.Digest != v.Digest),
|
|
here && known && localDigest == rec.Digest,
|
|
here && v.Base != "" && localDigest == v.Base:
|
|
// Nothing here, or here still the version it replaced: written, the version here kept first.
|
|
if err := m.writeLocal(v.Project, v.Path, []byte(v.Text), "replaced", localDigest); err != nil {
|
|
m.unwritten(key, v.Project, v.Path, err)
|
|
return err
|
|
}
|
|
m.rec.Files[key] = carried{Project: v.Project, Path: v.Path, Digest: v.Digest, Node: v.Node, Changed: v.Changed}
|
|
if !strings.Contains(v.Text, "<!-- novox mesh: this file was changed on two nodes") {
|
|
m.settle(key)
|
|
}
|
|
return nil
|
|
case !here:
|
|
// Removed here, and the state still holds the version removed: the next look carries the removal.
|
|
return nil
|
|
default:
|
|
if why := memoryProblem(local); why != "" {
|
|
// What is here is never carried, so it is not merged into what is: both stay where they are.
|
|
m.notCarried[v.Project+"/"+v.Path] = why
|
|
return fmt.Errorf("%s/%s changed here and elsewhere, and what is here is not carried, because %s; both "+
|
|
"stay as they are: %w", v.Project, v.Path, why, errNotCarried)
|
|
}
|
|
return m.conflict(key, v.Project, v.Path, local, localAt, v, revision)
|
|
}
|
|
}
|
|
|
|
// prune removes copies and removal markers older than MemoryKeep, once an hour.
|
|
func (m *MemoryCarrier) prune() {
|
|
now := m.Now()
|
|
if now.Sub(m.pruned) < time.Hour {
|
|
return
|
|
}
|
|
m.pruned = now
|
|
_ = filepath.WalkDir(m.copies(), func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil
|
|
}
|
|
if info, err := d.Info(); err == nil && now.Sub(info.ModTime()) > MemoryKeep {
|
|
_ = os.Remove(path)
|
|
}
|
|
return nil
|
|
})
|
|
keys, err := m.Store.Keys()
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, key := range keys {
|
|
if !strings.HasPrefix(key, m.Kind+".") {
|
|
continue
|
|
}
|
|
if v, _, err := m.current(key); err == nil && v != nil && v.Removed && now.Sub(v.Changed) > MemoryKeep {
|
|
_ = m.Store.Delete(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- the tool ------------------------------------------------------------------------------------------
|
|
|
|
// Status is what `claude_code_memory` answers: fingerprints, never the text.
|
|
func (m *MemoryCarrier) Status() map[string]any {
|
|
if m == nil {
|
|
return map[string]any{"carrying": false, "why": "the module's long-running half has not started here"}
|
|
}
|
|
if m.Kind == "" {
|
|
return map[string]any{"carrying": false, "node": m.Node, "why": m.Why}
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.load()
|
|
projects := map[string][]map[string]any{}
|
|
for _, c := range m.rec.Files {
|
|
projects[c.Project] = append(projects[c.Project], map[string]any{"file": c.Path,
|
|
"fingerprint": Fingerprint(c.Digest), "from": c.Node, "changed": c.Changed.UTC().Format(time.RFC3339)})
|
|
}
|
|
for _, list := range projects {
|
|
sort.Slice(list, func(i, j int) bool { return list[i]["file"].(string) < list[j]["file"].(string) })
|
|
}
|
|
conflicts := []conflict{}
|
|
for _, c := range m.rec.Conflicts {
|
|
conflicts = append(conflicts, c)
|
|
}
|
|
sort.Slice(conflicts, func(i, j int) bool {
|
|
return conflicts[i].Project+conflicts[i].Path < conflicts[j].Project+conflicts[j].Path
|
|
})
|
|
kept := 0
|
|
_ = filepath.WalkDir(m.copies(), func(_ string, d fs.DirEntry, err error) error {
|
|
if err == nil && !d.IsDir() {
|
|
kept++
|
|
}
|
|
return nil
|
|
})
|
|
not := m.notCarried
|
|
if not == nil {
|
|
not = map[string]string{}
|
|
}
|
|
return map[string]any{"carrying": true, "node": m.Node, "home kind": m.Kind, "projects": projects,
|
|
"not carried": not, "conflicts": conflicts, "copies kept": kept,
|
|
"copies": m.copies() + " (each replaced or removed version, kept 30 days; getting one back is the operator's own act)"}
|
|
}
|
|
|
|
// Run follows the `memory` state with watch (the whole current set, then each change) and looks at the folders
|
|
// every LookEvery, for as long as the process runs.
|
|
func (m *MemoryCarrier) Run(watch func(handle func(key, op string, value json.RawMessage, revision uint64) error) error) {
|
|
if m.Kind == "" {
|
|
if m.Say != nil {
|
|
m.Say("memory: not carried here: %s", m.Why)
|
|
}
|
|
return
|
|
}
|
|
go persist("watching the agent's memory", func() error {
|
|
return watch(func(key, op string, value json.RawMessage, revision uint64) error {
|
|
if err := m.Take(key, op, value, revision); err != nil && m.Say != nil {
|
|
m.Say("memory: taking %s: %v", key, err)
|
|
}
|
|
return nil
|
|
})
|
|
}, func(n int) { say("watching the agent's memory%s", refusals(n)) })
|
|
for {
|
|
if err := m.Look(); err != nil && m.Say != nil {
|
|
m.Say("memory: %v", err)
|
|
}
|
|
time.Sleep(LookEvery)
|
|
}
|
|
}
|