]> git.giacomolli.uk - onechat/blob - memory.go
eb28341f69821e6158ad49e29fd2b644314ecff3
[onechat] / memory.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "sync"
11 "time"
12 )
13
14 // errNoView tells that a chat has messages but no saved view.
15 var errNoView = errors.New("the chat has messages but no saved view")
16
17 // Memory is the memory of one chat: the message log, the summary tree, the chat
18 // view, and the compaction view. The compaction view is the chat view merged
19 // further, at the limits of 16 to 32 KB. It takes the same new lines, and it
20 // merges again in one batch when it is too large or when the chat view merges.
21 // So the compactions share its prefix in the prompt cache. It saves the views
22 // to view.json after each change and loads them at start. It never builds the
23 // views again from the log, because a new view differs from the live one and so
24 // loses the prompt cache.
25 type Memory struct {
26 mutex sync.Mutex
27 root string
28 log *Log
29 tree *Tree
30 chat *View
31 cview *View
32 }
33
34 // viewFile is the content of view.json.
35 type viewFile struct {
36 Chat viewState `json:"chat"`
37 Compaction viewState `json:"compaction"`
38 }
39
40 // openMemory opens the chat in root. The clock gives the dates of new messages
41 // and the days of new nodes.
42 func openMemory(ctx context.Context, root string, clock func() time.Time) (*Memory, error) {
43 log, err := openLog(ctx, root, clock)
44 if err != nil {
45 return nil, err
46 }
47
48 m := &Memory{
49 root: root,
50 log: log,
51 }
52
53 m.tree, err = openTree(ctx, root, log.Len(), clock)
54 if err != nil {
55 return nil, errors.Join(err, m.Close())
56 }
57
58 err = m.load()
59 if err != nil {
60 return nil, errors.Join(fmt.Errorf("load view: %w", err), m.Close())
61 }
62
63 return m, nil
64 }
65
66 // load reads the saved view. A crash can stop an append after the log write and
67 // before the view save, so load continues the view with the messages that it
68 // does not cover yet.
69 func (m *Memory) load() error {
70 data, err := os.ReadFile(m.viewPath())
71 if errors.Is(err, os.ErrNotExist) && m.log.Len() == 0 {
72 m.chat = newView(chatHigh, chatLow)
73 m.cview = newView(compactionHigh, compactionLow)
74
75 return nil
76 }
77
78 if errors.Is(err, os.ErrNotExist) {
79 return errNoView
80 }
81
82 if err != nil {
83 return fmt.Errorf("read view: %w", err)
84 }
85
86 var file viewFile
87 err = json.Unmarshal(data, &file)
88 if err != nil {
89 return fmt.Errorf("decode view: %w", err)
90 }
91
92 m.chat, err = restoreView(file.Chat, chatHigh, chatLow, m.tree)
93 if err != nil {
94 return fmt.Errorf("chat view: %w", err)
95 }
96
97 m.cview, err = restoreView(file.Compaction, compactionHigh, compactionLow, m.tree)
98 if err != nil {
99 return fmt.Errorf("compaction view: %w", err)
100 }
101
102 if m.cview.end != m.chat.end {
103 return fmt.Errorf("compaction view covers %d messages, but the chat view %d", m.cview.end, m.chat.end)
104 }
105
106 if m.chat.end > m.log.Len() {
107 return fmt.Errorf("view covers %d messages, but the log has %d", m.chat.end, m.log.Len())
108 }
109
110 if m.chat.end == m.log.Len() {
111 return nil
112 }
113
114 for m.chat.end < m.log.Len() {
115 err = m.addMessage(m.log.Message(m.chat.end))
116 if err != nil {
117 return err
118 }
119 }
120
121 return m.save()
122 }
123
124 // Close closes the files of the chat and releases its lock.
125 func (m *Memory) Close() error {
126 m.mutex.Lock()
127 defer m.mutex.Unlock()
128
129 var err error
130 if m.tree != nil {
131 err = m.tree.Close()
132 }
133
134 return errors.Join(err, m.log.Close())
135 }
136
137 // Append logs a new message and adds it to the tree and the view.
138 func (m *Memory) Append(kind, text string) (Message, error) {
139 m.mutex.Lock()
140 defer m.mutex.Unlock()
141
142 message, err := m.log.Append(kind, text)
143 if err != nil {
144 return Message{}, err
145 }
146
147 err = m.addMessage(message)
148 if err != nil {
149 return Message{}, err
150 }
151
152 err = m.save()
153 if err != nil {
154 return Message{}, err
155 }
156
157 return message, nil
158 }
159
160 // addMessage builds the leaf of message when it needs no model call and appends
161 // it to the view.
162 func (m *Memory) addMessage(message Message) error {
163 _, built := m.tree.Node(0, message.ID)
164 leaf, free := freeLeaf(message)
165 if free && !built {
166 err := m.build(leaf)
167 if err != nil {
168 return err
169 }
170 }
171
172 merged := m.chat.Append(m.tree)
173 m.cview.Append(m.tree)
174 if merged {
175 m.cview.Coarsen(m.chat, m.tree)
176 }
177
178 return nil
179 }
180
181 // build adds node to the tree, and then each parent above it that needs no
182 // model call.
183 func (m *Memory) build(node Node) error {
184 for {
185 err := m.tree.Add(node)
186 if err != nil {
187 return err
188 }
189
190 sibling, built := m.tree.Node(node.Level, node.Index^1)
191 if !built {
192 return nil
193 }
194
195 left, right := node, sibling
196 if node.Index%2 != 0 {
197 left, right = sibling, node
198 }
199
200 parent, free := freeMerge(left, right)
201 if !free {
202 return nil
203 }
204
205 node = parent
206 }
207 }
208
209 // save writes the views to view.json.
210 func (m *Memory) save() error {
211 data, err := json.Marshal(viewFile{
212 Chat: m.chat.State(),
213 Compaction: m.cview.State(),
214 })
215 if err != nil {
216 return fmt.Errorf("encode view: %w", err)
217 }
218
219 err = writeFileAtomic(m.viewPath(), append(data, '\n'))
220 if err != nil {
221 return fmt.Errorf("save view: %w", err)
222 }
223
224 return nil
225 }
226
227 func (m *Memory) viewPath() string {
228 return filepath.Join(m.root, "view.json")
229 }
230
231 // View returns the chat view as the model reads it.
232 func (m *Memory) View() string {
233 m.mutex.Lock()
234 defer m.mutex.Unlock()
235
236 return m.chat.Render(m.tree)
237 }
238
239 // Context returns the compaction view for the build of node (l, i). It ends
240 // before the message of a leaf, or after the last message of a merge, and it
241 // stops at the first line that is not built.
242 func (m *Memory) Context(level, index int) string {
243 m.mutex.Lock()
244 defer m.mutex.Unlock()
245
246 return m.context(level, index)
247 }
248
249 func (m *Memory) context(level, index int) string {
250 end := index
251 if level > 0 {
252 end = nodeEnd(level, index)
253 }
254
255 return m.cview.Context(m.tree, end)
256 }
257
258 // writeFileAtomic replaces the file at path with data. A reader or a crash sees
259 // the old file or the new file, never a part of one.
260 func writeFileAtomic(path string, data []byte) error {
261 dir := filepath.Dir(path)
262 file, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*")
263 if err != nil {
264 return fmt.Errorf("create temporary file: %w", err)
265 }
266
267 _, err = file.Write(data)
268 if err == nil {
269 err = file.Sync()
270 }
271
272 err = errors.Join(err, file.Close())
273 if err == nil {
274 err = os.Rename(file.Name(), path)
275 }
276
277 if err != nil {
278 return errors.Join(fmt.Errorf("replace file: %w", err), os.Remove(file.Name()))
279 }
280
281 return syncDir(dir)
282 }
283
284 // syncDir flushes the entries of dir to the disk.
285 func syncDir(dir string) (err error) {
286 handle, err := os.Open(dir)
287 if err != nil {
288 return fmt.Errorf("open directory: %w", err)
289 }
290
291 defer func() {
292 err = errors.Join(err, handle.Close())
293 }()
294
295 err = handle.Sync()
296 if err != nil {
297 return fmt.Errorf("flush directory: %w", err)
298 }
299
300 return nil
301 }