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