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//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\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 node-d: "MEMORY.older-from-node-d.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 node-d 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, "