Files
mesh-catalog/modules/claude-code/cmd/claude-code/memory.go
T
jschoubben e46eeb8298
mesh/merge-gate head e46eeb829853 merged into trunk 4d15a155e634 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head e46eeb829853 merged into trunk 4d15a155e634 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
claude-code: the memory watch only notes a change, so it starts on a node with hundreds of files (issue 367)
The runtime hands a watch its current values one at a time and gives them ten
seconds. The handler read the state, waited for a running look and wrote files
for each value, so with about 450 memory values the watch never started on g14
or shanks, and a file written on g14 never reached shanks. The handler now
notes each key's newest revision and returns; a worker applies what was noted
at the state's current version.
2026-10-13 16:40:41 +02:00

1146 lines
43 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
// queue is what the watch handed over and Drain has not applied yet: each key's newest revision.
queueMu sync.Mutex
queue map[string]uint64
wake chan struct{}
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, wake: make(chan struct{}, 1)}
}
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)
}
// Only noted here, never applied: the runtime hands the watch's values over one at a time and gives the whole
// current set ten seconds, so a watch whose handler reads the state, waits for a look or writes a file never
// starts on a node with a few hundred memory files (issue 367, seen on shanks on 2026-10-13). Drain applies
// what was noted, the newest revision of each key.
m.queueMu.Lock()
if m.queue == nil {
m.queue = map[string]uint64{}
}
if m.queue[key] < revision {
m.queue[key] = revision
}
m.queueMu.Unlock()
select {
case m.wake <- struct{}{}:
default:
}
return nil
}
// Drain applies every change Take noted, each key once, at the state's current version (ADR 0343 §6).
func (m *MemoryCarrier) Drain() error {
m.queueMu.Lock()
queued := m.queue
m.queue = nil
m.queueMu.Unlock()
if len(queued) == 0 {
return nil
}
keys := make([]string, 0, len(queued))
for k := range queued {
keys = append(keys, k)
}
sort.Strings(keys)
m.mu.Lock()
defer m.mu.Unlock()
m.load()
var problems []error
for _, key := range keys {
if err := m.takeNow(key, queued[key]); err != nil {
problems = append(problems, err)
}
}
return errors.Join(append(problems, m.save())...)
}
// takeNow applies one key at the state's current version: a change noted late is never written over a newer one.
func (m *MemoryCarrier) takeNow(key string, revision uint64) error {
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)
}
return m.apply(key, *cur, rev)
}
// 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 func() {
for range m.wake {
if err := m.Drain(); err != nil && m.Say != nil {
m.Say("memory: %v", err)
}
}
}()
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)
}
}