claude-code: carry each project's memory folder between nodes (issue 367, ADR 0343)
mesh/merge-gate head 6aa30eae66be merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 6aa30eae66be merged into trunk a84d0094a0a3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head 6aa30eae66be merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 6aa30eae66be merged into trunk a84d0094a0a3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
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.
This commit is contained in:
@@ -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/<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"
|
||||
)
|
||||
|
||||
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<!-- 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) {
|
||||
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, "<!-- 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:
|
||||
return m.conflict(key, v.Project, v.Path, local, localAt, v, revision)
|
||||
}
|
||||
}
|
||||
|
||||
// prune removes copies and removal markers older than MemoryKeep, once an hour.
|
||||
func (m *MemoryCarrier) prune() {
|
||||
now := m.Now()
|
||||
if now.Sub(m.pruned) < time.Hour {
|
||||
return
|
||||
}
|
||||
m.pruned = now
|
||||
_ = filepath.WalkDir(m.copies(), func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if info, err := d.Info(); err == nil && now.Sub(info.ModTime()) > MemoryKeep {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
keys, err := m.Store.Keys()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, key := range keys {
|
||||
if !strings.HasPrefix(key, m.Kind+".") {
|
||||
continue
|
||||
}
|
||||
if v, _, err := m.current(key); err == nil && v != nil && v.Removed && now.Sub(v.Changed) > MemoryKeep {
|
||||
_ = m.Store.Delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the tool ------------------------------------------------------------------------------------------
|
||||
|
||||
// Status is what `claude_code_memory` answers: fingerprints, never the text.
|
||||
func (m *MemoryCarrier) Status() map[string]any {
|
||||
if m == nil {
|
||||
return map[string]any{"carrying": false, "why": "the module's long-running half has not started here"}
|
||||
}
|
||||
if m.Kind == "" {
|
||||
return map[string]any{"carrying": false, "node": m.Node, "why": m.Why}
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.load()
|
||||
projects := map[string][]map[string]any{}
|
||||
for _, c := range m.rec.Files {
|
||||
projects[c.Project] = append(projects[c.Project], map[string]any{"file": c.Path,
|
||||
"fingerprint": Fingerprint(c.Digest), "from": c.Node, "changed": c.Changed.UTC().Format(time.RFC3339)})
|
||||
}
|
||||
for _, list := range projects {
|
||||
sort.Slice(list, func(i, j int) bool { return list[i]["file"].(string) < list[j]["file"].(string) })
|
||||
}
|
||||
conflicts := []conflict{}
|
||||
for _, c := range m.rec.Conflicts {
|
||||
conflicts = append(conflicts, c)
|
||||
}
|
||||
sort.Slice(conflicts, func(i, j int) bool {
|
||||
return conflicts[i].Project+conflicts[i].Path < conflicts[j].Project+conflicts[j].Path
|
||||
})
|
||||
kept := 0
|
||||
_ = filepath.WalkDir(m.copies(), func(_ string, d fs.DirEntry, err error) error {
|
||||
if err == nil && !d.IsDir() {
|
||||
kept++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
not := m.notCarried
|
||||
if not == nil {
|
||||
not = map[string]string{}
|
||||
}
|
||||
return map[string]any{"carrying": true, "node": m.Node, "home kind": m.Kind, "projects": projects,
|
||||
"not carried": not, "conflicts": conflicts, "copies kept": kept,
|
||||
"copies": m.copies() + " (each replaced or removed version, kept 30 days; getting one back is the operator's own act)"}
|
||||
}
|
||||
|
||||
// Run follows the `memory` state with watch (the whole current set, then each change) and looks at the folders
|
||||
// every LookEvery, for as long as the process runs.
|
||||
func (m *MemoryCarrier) Run(watch func(handle func(key, op string, value json.RawMessage, revision uint64) error) error) {
|
||||
if m.Kind == "" {
|
||||
if m.Say != nil {
|
||||
m.Say("memory: not carried here: %s", m.Why)
|
||||
}
|
||||
return
|
||||
}
|
||||
go persist("watching the agent's memory", func() error {
|
||||
return watch(func(key, op string, value json.RawMessage, revision uint64) error {
|
||||
if err := m.Take(key, op, value, revision); err != nil && m.Say != nil {
|
||||
m.Say("memory: taking %s: %v", key, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}, func(n int) { say("watching the agent's memory%s", refusals(n)) })
|
||||
for {
|
||||
if err := m.Look(); err != nil && m.Say != nil {
|
||||
m.Say("memory: %v", err)
|
||||
}
|
||||
time.Sleep(LookEvery)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user