| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | "sync" |
| 9 | |
| 10 | "reasonix/internal/memory" |
| 11 | ) |
| 12 | |
| 13 | // memoryManager owns the session's loaded memory snapshot, the queue of pending |
| 14 | // turn-tail notes, and the serialization of memory writes — behind its own locks |
| 15 | // and off the controller's c.mu. Like goalMachine it is a strict leaf: its |
| 16 | // methods only touch its own state and never call back into the Controller, so a |
| 17 | // memory-panel save can't stall an approval or status poll on c.mu. |
| 18 | // |
| 19 | // set is an immutable snapshot: reads take mu briefly and return the pointer. |
| 20 | // Writes are serialized by writeMu and do their disk I/O (the doc/store write |
| 21 | // plus the memory.Load re-discovery) OFF mu, taking mu only to swap the freshly |
| 22 | // discovered snapshot in and queue the turn-tail note — so a write never holds a |
| 23 | // lock across a filesystem walk. A turn-tail note is queued for each write so the |
| 24 | // change applies this session without disturbing the cache-stable system prefix |
| 25 | // (it folds into the prefix on the next session). All write methods are no-ops |
| 26 | // returning "" when memory is disabled (set == nil). |
| 27 | type memoryManager struct { |
| 28 | // mu guards set (the snapshot pointer) and pending (the turn-tail queue); |
| 29 | // every critical section under it is short and non-blocking. |
| 30 | mu sync.Mutex |
| 31 | set *memory.Set |
| 32 | // pending holds memory notes added mid-session (via "#" quick-add or a memory |
| 33 | // edit) that haven't yet been folded into a turn. Compose drains it onto the |
| 34 | // next outgoing turn — never into the cache-stable system prefix — so a fresh |
| 35 | // memory takes effect this session without busting the prompt cache; it joins |
| 36 | // the prefix naturally on the next session. |
| 37 | pending []string |
| 38 | lastRecall memory.RecallResult |
| 39 | autoWrites map[[32]byte]int |
| 40 | |
| 41 | // writeMu serializes memory writes so each write+reload+swap is atomic with |
| 42 | // respect to the others. Taken OFF mu, so a read (current/drainPending) never |
| 43 | // blocks behind a write's disk I/O. |
| 44 | writeMu sync.Mutex |
| 45 | } |
| 46 | |
| 47 | func (m *memoryManager) authorizeAutoRemember(args json.RawMessage) { |
| 48 | key := sha256.Sum256(args) |
| 49 | m.mu.Lock() |
| 50 | if m.autoWrites == nil { |
| 51 | m.autoWrites = map[[32]byte]int{} |
| 52 | } |
| 53 | m.autoWrites[key]++ |
| 54 | m.mu.Unlock() |
| 55 | } |
| 56 | |
| 57 | func (m *memoryManager) revokeAutoRemember(args json.RawMessage) { |
| 58 | key := sha256.Sum256(args) |
| 59 | m.mu.Lock() |
| 60 | delete(m.autoWrites, key) |
| 61 | m.mu.Unlock() |
| 62 | } |
| 63 | |
| 64 | func (m *memoryManager) clearAutoRemember() { |
| 65 | m.mu.Lock() |
| 66 | m.autoWrites = nil |
| 67 | m.mu.Unlock() |
| 68 | } |
| 69 | |
| 70 | func (m *memoryManager) claimAutoRemember(args json.RawMessage) bool { |
| 71 | key := sha256.Sum256(args) |
| 72 | m.mu.Lock() |
| 73 | defer m.mu.Unlock() |
| 74 | if m.autoWrites[key] <= 0 { |
| 75 | return false |
| 76 | } |
| 77 | if m.autoWrites[key] == 1 { |
| 78 | delete(m.autoWrites, key) |
| 79 | } else { |
| 80 | m.autoWrites[key]-- |
| 81 | } |
| 82 | return true |
| 83 | } |
| 84 | |
| 85 | func (m *memoryManager) recall(query string) memory.RecallResult { |
| 86 | mem := m.current() |
| 87 | store := memory.Store{} |
| 88 | if mem != nil { |
| 89 | store = mem.Store |
| 90 | } |
| 91 | result := memory.AutoRecall(store, query, memory.RecallOptions{}) |
| 92 | m.recordRecall(result) |
| 93 | return result |
| 94 | } |
| 95 | |
| 96 | func (m *memoryManager) recordRecall(result memory.RecallResult) { |
| 97 | m.mu.Lock() |
| 98 | m.lastRecall = result |
| 99 | m.mu.Unlock() |
| 100 | } |
| 101 | |
| 102 | func (m *memoryManager) lastRecallResult() memory.RecallResult { |
| 103 | m.mu.Lock() |
| 104 | defer m.mu.Unlock() |
| 105 | return m.lastRecall |
| 106 | } |
| 107 | |
| 108 | func newMemoryManager(set *memory.Set) memoryManager { |
| 109 | return memoryManager{set: set} |
| 110 | } |
| 111 | |
| 112 | // current returns the loaded snapshot (nil when memory is disabled). The returned |
| 113 | // *Set is immutable — mutations go through quickAdd / saveDoc / saveMemory. |
| 114 | func (m *memoryManager) current() *memory.Set { |
| 115 | m.mu.Lock() |
| 116 | defer m.mu.Unlock() |
| 117 | return m.set |
| 118 | } |
| 119 | |
| 120 | // drainPending returns and clears the queued turn-tail notes, for Compose to fold |
| 121 | // onto the next outgoing turn. |
| 122 | func (m *memoryManager) drainPending() []string { |
| 123 | m.mu.Lock() |
| 124 | defer m.mu.Unlock() |
| 125 | notes := m.pending |
| 126 | m.pending = nil |
| 127 | return notes |
| 128 | } |
| 129 | |
| 130 | // applyWrite re-discovers memory from disk (off-lock, the expensive part) then, |
| 131 | // under a brief mu, swaps the fresh snapshot in and queues the turn-tail note so a |
| 132 | // later current() reflects the just-applied write. mem is the snapshot taken at |
| 133 | // the start of the writeMu-serialized write and supplies the discovery roots. |
| 134 | // Callers hold writeMu. |
| 135 | func (m *memoryManager) applyWrite(mem *memory.Set, note string) { |
| 136 | reloaded := memory.Load(memory.Options{CWD: mem.CWD, UserDir: mem.UserDir}) |
| 137 | m.mu.Lock() |
| 138 | if note != "" { |
| 139 | m.pending = append(m.pending, note) |
| 140 | } |
| 141 | m.set = reloaded |
| 142 | m.mu.Unlock() |
| 143 | } |
| 144 | |
| 145 | // quickAdd appends a one-line note to the doc-memory file for scope (project |
| 146 | // REASONIX.md by default) — the write side of "#<note>". Returns the file written. |
| 147 | func (m *memoryManager) quickAdd(scope memory.Scope, note string) (string, error) { |
| 148 | m.writeMu.Lock() |
| 149 | defer m.writeMu.Unlock() |
| 150 | mem := m.current() |
| 151 | if mem == nil { |
| 152 | return "", nil |
| 153 | } |
| 154 | path := mem.DocPath(scope) |
| 155 | if path == "" { |
| 156 | return "", fmt.Errorf("no target file for memory scope %q", scope) |
| 157 | } |
| 158 | if err := memory.AppendDoc(path, note); err != nil { |
| 159 | return "", err |
| 160 | } |
| 161 | m.applyWrite(mem, note) |
| 162 | return path, nil |
| 163 | } |
| 164 | |
| 165 | // saveDoc overwrites a recognized memory doc with body — the save side of the |
| 166 | // desktop panel's in-place editor. Returns the file written. |
| 167 | func (m *memoryManager) saveDoc(path, body string) (string, error) { |
| 168 | m.writeMu.Lock() |
| 169 | defer m.writeMu.Unlock() |
| 170 | mem := m.current() |
| 171 | if mem == nil { |
| 172 | return "", nil |
| 173 | } |
| 174 | written, err := mem.WriteDoc(path, body) |
| 175 | if err != nil { |
| 176 | return "", err |
| 177 | } |
| 178 | // Inject the new content once on the next turn: the cached prefix still holds |
| 179 | // the pre-edit version this session, so handing the model the current text |
| 180 | // avoids a stale-guidance gap until the next session re-folds it into the |
| 181 | // prefix. Trimmed to a single tail note (drained by Compose), not per-turn. |
| 182 | m.applyWrite(mem, |
| 183 | "Memory file "+written+" was just edited. Its current contents:\n"+strings.TrimSpace(body)) |
| 184 | return written, nil |
| 185 | } |
| 186 | |
| 187 | // saveMemory writes an active auto-memory fact and refreshes the in-session |
| 188 | // snapshot. It is the explicit user-confirmed counterpart to the model-owned |
| 189 | // remember tool, used by management surfaces that preview a candidate first. |
| 190 | func (m *memoryManager) saveMemory(fact memory.Memory) (string, error) { |
| 191 | m.writeMu.Lock() |
| 192 | defer m.writeMu.Unlock() |
| 193 | mem := m.current() |
| 194 | if mem == nil { |
| 195 | return "", nil |
| 196 | } |
| 197 | path, err := mem.Store.Save(fact) |
| 198 | if err != nil { |
| 199 | return "", err |
| 200 | } |
| 201 | m.applyWrite(mem, |
| 202 | "Saved memory \""+fact.Name+"\": "+strings.Join(strings.Fields(fact.Description), " ")+"\n"+strings.TrimSpace(fact.Body)) |
| 203 | return path, nil |
| 204 | } |
| 205 | |
| 206 | // forget removes a saved auto-memory by name — the panel/TUI forget action, the |
| 207 | // manual counterpart to the model's `forget` tool. It queues a turn-tail note so |
| 208 | // the removal applies this session (the cached prefix still lists the fact until |
| 209 | // the next session re-folds the index). The file is archived for traceability by |
| 210 | // Store.Delete. |
| 211 | func (m *memoryManager) forget(name string) error { |
| 212 | m.writeMu.Lock() |
| 213 | defer m.writeMu.Unlock() |
| 214 | mem := m.current() |
| 215 | if mem == nil { |
| 216 | return nil |
| 217 | } |
| 218 | if err := mem.Store.Delete(name); err != nil { |
| 219 | return err |
| 220 | } |
| 221 | m.applyWrite(mem, |
| 222 | "Forgot memory \""+name+"\" — disregard its loaded guidance and background-index entry for the rest of this session.") |
| 223 | return nil |
| 224 | } |
| 225 | |
| 226 | func (m *memoryManager) revisions(ref string) []memory.Memory { |
| 227 | mem := m.current() |
| 228 | if mem == nil { |
| 229 | return nil |
| 230 | } |
| 231 | return mem.Store.Revisions(ref) |
| 232 | } |
| 233 | |
| 234 | func (m *memoryManager) restore(ref string, revision int) (memory.Memory, error) { |
| 235 | m.writeMu.Lock() |
| 236 | defer m.writeMu.Unlock() |
| 237 | mem := m.current() |
| 238 | if mem == nil { |
| 239 | return memory.Memory{}, fmt.Errorf("memory unavailable") |
| 240 | } |
| 241 | result, err := mem.Store.Restore(ref, revision) |
| 242 | if err != nil { |
| 243 | return memory.Memory{}, err |
| 244 | } |
| 245 | m.applyWrite(mem, fmt.Sprintf("Restored memory %q as revision %d: %s\n%s", |
| 246 | result.Memory.Name, result.Memory.Revision, strings.Join(strings.Fields(result.Memory.Description), " "), strings.TrimSpace(result.Memory.Body))) |
| 247 | return result.Memory, nil |
| 248 | } |
| 249 | |
| 250 | func (m *memoryManager) restoreArchived(archivePath string) (memory.Memory, error) { |
| 251 | m.writeMu.Lock() |
| 252 | defer m.writeMu.Unlock() |
| 253 | mem := m.current() |
| 254 | if mem == nil { |
| 255 | return memory.Memory{}, fmt.Errorf("memory unavailable") |
| 256 | } |
| 257 | result, err := mem.Store.RestoreArchived(archivePath) |
| 258 | if err != nil { |
| 259 | return memory.Memory{}, err |
| 260 | } |
| 261 | m.applyWrite(mem, fmt.Sprintf("Recovered archived memory %q as revision %d: %s\n%s", |
| 262 | result.Memory.Name, result.Memory.Revision, strings.Join(strings.Fields(result.Memory.Description), " "), strings.TrimSpace(result.Memory.Body))) |
| 263 | return result.Memory, nil |
| 264 | } |
| 265 | |
| 266 | // queue rides a note on the next turn — the model's remember/forget tool path |
| 267 | // (memory.Queue). It refreshes the snapshot a memory panel reads when memory is |
| 268 | // enabled, and still queues the turn-tail note when it isn't (there's no snapshot |
| 269 | // to re-discover). |
| 270 | func (m *memoryManager) queue(note string) { |
| 271 | m.writeMu.Lock() |
| 272 | defer m.writeMu.Unlock() |
| 273 | if mem := m.current(); mem != nil { |
| 274 | m.applyWrite(mem, note) |
| 275 | return |
| 276 | } |
| 277 | m.mu.Lock() |
| 278 | m.pending = append(m.pending, note) |
| 279 | m.mu.Unlock() |
| 280 | } |
| 281 |