From 6aa30eae66be82886e18e2a190fedaa1422698ef Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 15:57:44 +0200 Subject: [PATCH] claude-code: carry each project's memory folder between nodes (issue 367, ADR 0343) A session started on another node began without what the sessions elsewhere had learned, because Claude Code keeps project memory on one node. The module's own process now carries the memory folders through its kept memory state: a change is written elsewhere only over the version it replaced, a file changed on two nodes keeps both (newer first) with a warning, and every replaced or removed version is kept. No tool writes memory. --- modules/claude-code/README.md | 30 +- modules/claude-code/cmd/claude-code/main.go | 39 + modules/claude-code/cmd/claude-code/memory.go | 839 ++++++++++++++++++ .../cmd/claude-code/memory_test.go | 585 ++++++++++++ modules/claude-code/module.json | 4 +- 5 files changed, 1494 insertions(+), 3 deletions(-) create mode 100644 modules/claude-code/cmd/claude-code/memory.go create mode 100644 modules/claude-code/cmd/claude-code/memory_test.go diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index 3838be73..a3bb4f1f 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -11,8 +11,9 @@ Two directories, declared, so the mesh refuses a second module owning either: - `~/.claude` under the home of the account agents run as (the operator's, unless the machine names an agent account of its own), that account's, `0700`. The module owns the directory — that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else - in it (memory, history, projects, local settings, a person's own rules and skills) is the person's - and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host + in it (history, projects, local settings, a person's own rules and skills) is the person's + and is never read or written (hq ADR 0182) — but for each project's memory folder, which the module + carries between nodes (hq ADR 0343, below). Unassigned, the module leaves the directory: the host removes a directory only when it is empty. And, for where the agent runs as an account of its own (hq ADR 0266): @@ -69,8 +70,33 @@ must see, a node that joins later included — kept, so it carries no secret eit | an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.` for every node, `.` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal | | a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus | | a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open | +| a project's memory (hq ADR 0343) | a key in the module's `memory` state per file — `.`, `operator` for the operator's own homes and `agent` for an agent account's — holding the text, its digest, the digest of the version it replaced, the node and when it changed; a removal is a value marked removed, never a deleted key. Written at the revision read, so of two nodes writing one file only one wins. Every node of the same kind watches it. No event and no tool answer carries memory text | | the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh..` for every node, `node...` for one, `home...` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) | +## A session's memory follows the operator (hq ADR 0343) + +A session started on another node starts with what the sessions on every other node learned (hq issue 367). The +module's long-running process carries each project's memory folder, `~/.claude/projects//memory/`, and +nothing else of the home: + +- **Picked up** every ten seconds: a file whose text differs from the version last carried goes into the `memory` + state. Not carried, and listed with why: a symbolic link, a file over 128 KiB, and a file holding a key block, a + signed token, a known token prefix or a password in an address. +- **Written** on every other node of the same home kind, only where its file is still the version the change + replaced, or there is none; staged in the folder and renamed in, never through a symbolic link. The version + replaced is kept first in `memory-copies/` in the module's state directory, 30 days. +- **A file changed on two nodes** becomes one file holding both, the newer first, then a line naming each version's + node and time, then the older; that file is carried, so every node ends on it, and the operator is warned through + the operator channel's `notify` (a warning, cleared when the file next changes without a conflict). +- **A removal** of a carried file, from a folder that still exists, is a marker in the state for 30 days: 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, a key the state lost and a deleted key remove nothing. +- **The two home kinds never mix**: the operator's own homes carry among themselves, an agent account's among + themselves (hq ADR 0266), so a confined agent's notes never reach a session run as the operator. +- **No tool writes memory** (hq issue 340): `claude_code_memory` only lists what is carried — fingerprints, never the + text — the files not carried, the open conflicts and the copies kept. A value put straight into the state by a + holder of a tool runner's credential is not yet told apart (hq issue 638). + ## The mesh's tools first (hq ADR 0245) The agent kept reaching for `ssh journalctl` while the service manager's `journal` verb existed. So: diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index 95d5188d..618c8145 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -152,6 +152,36 @@ func (s stateOf) Create(key string, value any) error { return err } func (s stateOf) Keys() ([]string, error) { return s.s.Keys() } + +// memoryStateOf adapts the SDK's state to the memory carrier: every write at the revision read (ADR 0343). +type memoryStateOf struct{ s stdio.KeptState } + +func (s memoryStateOf) Get(key string) (json.RawMessage, uint64, bool, error) { + e, err := s.s.Get(key) + if err != nil || e == nil { + return nil, 0, false, err + } + return e.Value, e.Revision, true, nil +} +func (s memoryStateOf) Create(key string, value any) error { + _, err := s.s.Create(key, value) + return err +} +func (s memoryStateOf) Update(key string, value any, revision uint64) error { + _, err := s.s.Update(key, value, revision) + return err +} +func (s memoryStateOf) Keys() ([]string, error) { return s.s.Keys() } +func (s memoryStateOf) Delete(key string) error { return s.s.Delete(key) } + +// memoryCarrier carries this node's memory folders (ADR 0343); nil until the long-running half starts it. +var memoryCarrier *MemoryCarrier + +// notifyOperator tells the operator through the operator channel's notify verb (ADR 0253). +func notifyOperator(args map[string]any) error { + _, err := ask("seat:operator-channel.notify", args) + return err +} func (s stateOf) Get(key string) (json.RawMessage, bool, error) { e, err := s.s.Get(key) if err != nil || e == nil { @@ -354,6 +384,9 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c out = append(out, stdio.Tool{Name: "claude_code_registrations", Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.", Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }}) + out = append(out, stdio.Tool{Name: "claude_code_memory", + Description: "Read-only: what this node carries of the agent's memory between nodes (novox/hq ADR 0343) — the home kind it carries for (the operator's own homes, or an agent account's), each project's memory files with their fingerprint and the node and time of their last version, the files not carried and why, the conflicts still open (a file changed on two nodes, kept twice in one file, the newer first), and how many replaced or removed versions are kept. Fingerprints only, never the text; it changes nothing.", + Run: func(map[string]any) (any, error) { return memoryCarrier.Status(), nil }}) out = append(out, proposalTools(p)...) judge := NewJudge(p) out = append(out, judgeTool(judge), judgedTool(judge)) @@ -430,6 +463,12 @@ func main() { proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant, Now: time.Now, Write: writeManaged} go proposing.watch(p) + memoryCarrier = NewMemoryCarrier(p, memoryStateOf{stdio.State(MemoryState)}, notifyOperator) + go memoryCarrier.Run(func(handle func(key, op string, value json.RawMessage, revision uint64) error) error { + return stdio.State(MemoryState).Watch("", func(c stdio.StateChange) error { + return handle(c.Key, c.Op, c.Value, c.Revision) + }) + }) go run(p, view, configView) if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil { say("%v", err) diff --git a/modules/claude-code/cmd/claude-code/memory.go b/modules/claude-code/cmd/claude-code/memory.go new file mode 100644 index 00000000..85835c9f --- /dev/null +++ b/modules/claude-code/cmd/claude-code/memory.go @@ -0,0 +1,839 @@ +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" +) + +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 + // 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 §2). + 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 + 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 §2)" +} + +// 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 §1): 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) + } + 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{} + } +} + +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) { + dir := filepath.Join(m.projects(), project, "memory") + info, err := fsys.Lstat(dir) + return dir, err == nil && info.IsDir() && info.Mode()&fs.ModeSymlink == 0 +} + +// scan reads every memory folder: the files it may carry, the folders that exist, and why any file is not carried. +func (m *MemoryCarrier) scan() (map[string]localFile, map[string]bool, map[string]string) { + files, folders, not := map[string]localFile{}, map[string]bool{}, map[string]string{} + seen := map[string]seenFile{} + defer func() { m.seen = seen }() + projects, err := fsys.ReadDir(m.projects()) + if err != nil { + return files, folders, not + } + for _, pe := range projects { + if !pe.IsDir() { + continue + } + project := pe.Name() + dir, ok := m.memoryFolder(project) + if !ok { + continue + } + folders[project] = true + _ = fsys.WalkDir(dir, func(full string, d fs.DirEntry, err error) error { + if err != nil || full == dir { + return nil + } + rel, _ := filepath.Rel(dir, full) + rel = filepath.ToSlash(rel) + if strings.HasPrefix(d.Name(), stagePrefix) { + return nil + } + if d.Type()&fs.ModeSymlink != 0 { + not[project+"/"+rel] = "it is a symbolic link, which is never carried" + return nil + } + if d.IsDir() || !d.Type().IsRegular() { + return nil + } + info, err := fsys.Lstat(full) + if err != nil { + 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) + 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 { + return nil + } + seen[full] = seenFile{size: info.Size(), mod: info.ModTime(), text: text} + if why := memoryProblem(text); why != "" { + not[project+"/"+rel] = why + return nil + } + files[MemoryKey(m.Kind, project, rel)] = localFile{Project: project, Path: rel, Text: text, Changed: info.ModTime()} + return nil + }) + } + return files, folders, not +} + +// 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) { + 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 +} + +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 { + 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) +} + +// writeLocal writes one file of a memory folder: the version there kept first, then the new text staged in the +// same folder and renamed into place, never through a symbolic link. +func (m *MemoryCarrier) writeLocal(project, path string, text []byte, why string) error { + if was, _, ok, err := m.readLocal(project, path); err != nil { + return err + } 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 := 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) { + 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) + 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": "Agent memory kept twice", + "explanation": fmt.Sprintf("A memory file of project %s was changed on %s before either change reached the other. "+ + "Both versions are now in the file on every node, the newer first; read it in a session and keep what is right.", + label, strings.Join(nodes, " and ")), + "resolved": fmt.Sprintf("the memory file of project %s was tidied", 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 { + v.Kind = m.Kind + 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) + } + m.record(key, v) + 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 §3, §5, §7, §8). +func (m *MemoryCarrier) Look() error { + if m.Kind == "" { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + m.load() + files, folders, not := m.scan() + m.notCarried = not + var problems []error + // A key the state lost is carried again, never read as a removal (ADR 0343 §7): one listing per look. + inState := map[string]bool{} + if listed, err := m.Store.Keys(); err == nil { + for _, k := range listed { + inState[k] = true + } + } else { + inState = nil + } + keys := make([]string, 0, len(files)) + for k := range files { + keys = append(keys, k) + } + sort.Strings(keys) + for _, key := range keys { + if inState != nil && !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] && strings.HasPrefix(key, m.Kind+".") { + if _, skipped := not[c.Project+"/"+c.Path]; !skipped { + 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 fmt.Errorf("%s/%s changed on two nodes, and both together are more than is carried; both stay as they are", project, path) + } + 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()} + if err := m.Store.Update(key, v, revision); err != nil { + return fmt.Errorf("%s/%s changed on two nodes, and the state changed again meanwhile; it is judged again on "+ + "the next change: %w", project, path, err) + } + if err := m.writeLocal(project, path, merged, "before-merge"); err != nil { + // The state holds both; this node's folder still holds its own, which the next look merges again. + return 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}) + 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 §7); another kind's key is never taken (§2). + 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) + } + 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 §4, §5, §7). +func (m *MemoryCarrier) apply(key string, v MemoryVersion, revision uint64) error { + 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) + case localDigest == v.Base || (known && localDigest == rec.Digest && rec.Digest == v.Base): + 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"); err != nil { + 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, "