orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1// What the chat remembers about Isaac between conversations.
2//
3// One sentence facts and nothing longer. A memory that holds paragraphs is a
4// second transcript, and the point of this is that a fact costs almost nothing
5// to carry into every turn, so only the ones worth carrying are kept.
6//
7// Every write goes through the model under the rules below. There is no text
8// box that edits a fact directly, which is deliberate: a fact typed by hand
9// drifts out of the shape the retrieval expects, and a contradiction typed by
10// hand leaves both versions in. Deleting is the one manual operation, because
11// deciding something should be forgotten needs no judgement a model can add.
12package main
13
14import (
15 "context"
16 "encoding/json"
17 "fmt"
18 "log/slog"
19 "sort"
20 "strings"
21
22 "chat.bythewood.me/tools"
23 "time"
24 "unicode"
25)
26
27const (
28 // A fact longer than this is a paragraph wearing a full stop.
29 maxFactChars = 200
30 // How many facts a turn carries. Enough to be useful, few enough that a
31 // wrong one is visible rather than buried.
32 factsPerTurn = 6
33)
34
35const factSchema = `
36CREATE TABLE IF NOT EXISTS facts (
37 id INTEGER PRIMARY KEY,
38 fact TEXT NOT NULL UNIQUE,
39 created_at INTEGER NOT NULL,
40 updated_at INTEGER NOT NULL,
41 used INTEGER NOT NULL DEFAULT 0,
42 last_used INTEGER NOT NULL DEFAULT 0
43);
44`
45
46type Fact struct {
47 ID int64 `json:"id"`
48 Text string `json:"fact"`
49 Created time.Time `json:"created"`
50 Updated time.Time `json:"updated"`
51 Used int `json:"used"`
52}
53
54func (s *Store) initFacts() error {
55 _, err := s.db.Exec(factSchema)
56 return err
57}
58
59func (s *Store) Facts() ([]Fact, error) {
60 rows, err := s.db.Query(`SELECT id, fact, created_at, updated_at, used FROM facts ORDER BY updated_at DESC`)
61 if err != nil {
62 return nil, err
63 }
64 defer rows.Close()
65 out := []Fact{}
66 for rows.Next() {
67 var f Fact
68 var created, updated int64
69 if err := rows.Scan(&f.ID, &f.Text, &created, &updated, &f.Used); err != nil {
70 return nil, err
71 }
72 f.Created, f.Updated = time.Unix(created, 0), time.Unix(updated, 0)
73 out = append(out, f)
74 }
75 return out, rows.Err()
76}
77
78// AddFact is idempotent on the subject rather than on the exact text.
79//
80// The unique index only ever caught a fact proposed back word for word, and
81// nothing else did, so on 2026-09-08 one turn wrote two nearly identical facts
82// about X post search and left a third standing that contradicted both. The
83// model is asked to replace rather than add and cannot be relied on to, and
84// both the automatic pass and the remember tool land here, so this is the one
85// place that can hold the line for both.
86func (s *Store) AddFact(text string) (int64, error) {
87 text = tidyFact(text)
88 if text == "" {
89 return 0, fmt.Errorf("an empty fact")
90 }
91 if id, keep := s.sameSubject(text); id != 0 {
92 if !keep {
93 // The new wording says nothing the stored one does not, so the
94 // stored one stands and only its timestamp moves.
95 s.touchFact(id)
96 return id, nil
97 }
98 return id, s.ReplaceFact(id, text)
99 }
100 now := time.Now().Unix()
101 r, err := s.db.Exec(`
102 INSERT INTO facts(fact, created_at, updated_at) VALUES(?,?,?)
103 ON CONFLICT(fact) DO UPDATE SET updated_at = excluded.updated_at`, text, now, now)
104 if err != nil {
105 return 0, err
106 }
107 return r.LastInsertId()
108}
109
110// sameSubject finds a stored fact this one is really a rewording of, and says
111// whether the new text should take its place. A zero id means it is new.
112func (s *Store) sameSubject(text string) (id int64, replace bool) {
113 all, err := s.Facts()
114 if err != nil {
115 return 0, false
116 }
117 fresh := terms(text)
118 var best float64
119 for _, f := range all {
120 held := terms(f.Text)
121 shared := overlap(fresh, held)
122 // Three shared words is the floor. Below it "Isaac has a young son" and
123 // "Isaac likes pizza" start to look related because they share a name.
124 if shared < minSharedTerms {
125 continue
126 }
127 inHeld := float64(shared) / float64(len(fresh))
128 inFresh := float64(shared) / float64(len(held))
129 score := max(inHeld, inFresh)
130 if score < sameSubjectAt || score < best {
131 continue
132 }
133 best, id = score, f.ID
134 // When the new wording is the one wholly contained in the old, it adds
135 // nothing: "Isaac has a young son" against a fact that already says so
136 // and names where he lives.
137 replace = inHeld < inFresh
138 }
139 return id, replace
140}
141
142// How alike two facts have to be before they are treated as one. Measured
143// against the duplicates the database actually accumulated: the pair about X
144// post search scores 0.75, "Isaac has a young son" inside the longer fact that
145// already says it scores 1.0, and no unrelated pair in the table reaches 0.5.
146const (
147 sameSubjectAt = 0.7
148 minSharedTerms = 3
149)
150
151func (s *Store) touchFact(id int64) {
152 _, _ = s.db.Exec(`UPDATE facts SET updated_at=? WHERE id=?`, time.Now().Unix(), id)
153}
154
155func (s *Store) ReplaceFact(id int64, text string) error {
156 text = tidyFact(text)
157 if text == "" {
158 return fmt.Errorf("an empty fact")
159 }
160 _, err := s.db.Exec(`UPDATE facts SET fact=?, updated_at=? WHERE id=?`, text, time.Now().Unix(), id)
161 return err
162}
163
164func (s *Store) DeleteFact(id int64) error {
165 _, err := s.db.Exec(`DELETE FROM facts WHERE id=?`, id)
166 return err
167}
168
169func (s *Store) ForgetEverything() error {
170 _, err := s.db.Exec(`DELETE FROM facts`)
171 return err
172}
173
174func (s *Store) markUsed(ids []int64) {
175 if len(ids) == 0 {
176 return
177 }
178 now := time.Now().Unix()
179 for _, id := range ids {
180 if _, err := s.db.Exec(`UPDATE facts SET used = used + 1, last_used = ? WHERE id = ?`, now, id); err != nil {
181 slog.Debug("marking a fact used", "err", err)
182 return
183 }
184 }
185}
186
187// Relevant scores every fact against the message in Go rather than in SQL.
188//
189// This is a few hundred rows at most, so a full scan costs less than the
190// round trip to ask for a clever one, and it means the scoring is a function
191// with a test rather than a query whose behaviour is the database's opinion.
192func (s *Store) Relevant(message string, limit int) []Fact {
193 all, err := s.Facts()
194 if err != nil || len(all) == 0 {
195 return nil
196 }
197 want := terms(message)
198 if len(want) == 0 {
199 return nil
200 }
201
202 type scored struct {
203 f Fact
204 n int
205 }
206 hits := make([]scored, 0, len(all))
207 for _, f := range all {
208 n := overlap(want, terms(f.Text))
209 if n > 0 {
210 hits = append(hits, scored{f, n})
211 }
212 }
213 sort.SliceStable(hits, func(i, j int) bool {
214 if hits[i].n != hits[j].n {
215 return hits[i].n > hits[j].n
216 }
217 // A tie goes to the fact that has earned its place, then to the newer
218 // one, so a stale duplicate loses to the one actually being used.
219 if hits[i].f.Used != hits[j].f.Used {
220 return hits[i].f.Used > hits[j].f.Used
221 }
222 return hits[i].f.Updated.After(hits[j].f.Updated)
223 })
224 if len(hits) > limit {
225 hits = hits[:limit]
226 }
227 out := make([]Fact, 0, len(hits))
228 ids := make([]int64, 0, len(hits))
229 for _, h := range hits {
230 out = append(out, h.f)
231 ids = append(ids, h.f.ID)
232 }
233 s.markUsed(ids)
234 return out
235}
236
237// stopwords are the words that match everything and therefore mean nothing
238// here. Without them "what is my plan for the weekend" matches every fact
239// containing "my".
240var stopwords = map[string]bool{
241 "a": true, "about": true, "all": true, "am": true, "an": true, "and": true,
242 "any": true, "are": true, "as": true, "at": true, "be": true, "been": true,
243 "but": true, "by": true, "can": true, "did": true, "do": true, "does": true,
244 "for": true, "from": true, "get": true, "had": true, "has": true, "have": true,
245 "he": true, "her": true, "him": true, "his": true, "how": true, "i": true,
246 "if": true, "in": true, "is": true, "it": true, "its": true, "just": true,
247 "like": true, "me": true, "my": true, "no": true, "not": true, "of": true,
248 "on": true, "or": true, "our": true, "out": true, "she": true, "so": true,
249 "some": true, "than": true, "that": true, "the": true, "their": true,
250 "them": true, "then": true, "there": true, "these": true, "they": true,
251 "this": true, "to": true, "up": true, "was": true, "we": true, "were": true,
252 "what": true, "when": true, "where": true, "which": true, "who": true,
253 "why": true, "will": true, "with": true, "would": true, "you": true,
254 "your": true,
255}
256
257func terms(s string) map[string]bool {
258 out := map[string]bool{}
259 for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
260 return !unicode.IsLetter(r) && !unicode.IsDigit(r)
261 }) {
262 if len(w) < 3 || stopwords[w] {
263 continue
264 }
265 out[stem(w)] = true
266 }
267 return out
268}
269
270// stem is the smallest thing that makes retrieval work on real questions.
271// Without it "camping" in a question never reaches "camps" in a fact, which was
272// the first case tried and the first one that failed. It is not a real stemmer
273// and does not need to be: over a few hundred short facts an occasional wrong
274// pairing costs one irrelevant line in the prompt.
275func stem(w string) string {
276 switch {
277 case len(w) > 5 && strings.HasSuffix(w, "ing"):
278 w = strings.TrimSuffix(w, "ing")
279 case len(w) > 4 && strings.HasSuffix(w, "ed"):
280 w = strings.TrimSuffix(w, "ed")
281 case len(w) > 3 && strings.HasSuffix(w, "s") && !strings.HasSuffix(w, "ss"):
282 w = strings.TrimSuffix(w, "s")
283 }
284 // "running" leaves "runn", so a doubled final consonant loses one.
285 if n := len(w); n > 2 && w[n-1] == w[n-2] && !strings.ContainsRune("aeiou", rune(w[n-1])) {
286 w = w[:n-1]
287 }
288 return w
289}
290
291func overlap(a, b map[string]bool) int {
292 n := 0
293 for w := range a {
294 if b[w] {
295 n++
296 }
297 }
298 return n
299}
300
301func tidyFact(s string) string {
302 s = strings.Join(strings.Fields(s), " ")
303 s = strings.Trim(s, "-*• ")
304 if len(s) > maxFactChars {
305 s = s[:maxFactChars]
306 }
307 return s
308}
309
310// ---------------------------------------------------------------- the rules
311
312// factRules is the whole contract for writing to this. It is one string used by
313// both the pass that runs after a turn and the box in the memory panel, so the
314// two cannot disagree about what belongs here.
315//
316// The framing is positive on purpose. An earlier version listed what not to keep
317// first and ended on "an empty list is the right answer most of the time", and a
318// small model took that as permission to propose nothing every single time. The
319// same conversation against this wording produces five usable facts.
320const factRules = `You pull durable facts about Isaac out of a conversation and keep them for later.
321
322Your job is to notice what was revealed about him. Read the exchange and write down each thing that will still be true in six months.
323
324Worth keeping:
325- who he is, his people, his home, his work
326- his machines, his tools, what he runs and how
327- his money: what he banks with, what he spends on, what he pays for regularly
328- his preferences and habits, stated or clearly implied
329
330Not worth keeping:
331- what the weather is, what a page said, a price, a score, anything a tool looks up
332- what he is doing this minute, today or this week
333- passwords, keys, tokens, account numbers, anything secret
334- facts about the world rather than about him
335
336Each fact is one short sentence in the third person, starting with Isaac or with the thing it is about, specific enough to act on. "Isaac likes coffee" is too vague. "Isaac drinks his coffee black" is right. Carry the number when there is one: "Isaac spends about $40 a month on coffee" beats "Isaac spends money on coffee".
337
338If a new fact contradicts one you already have, replace that one rather than adding a second.
339
340Reply with JSON and nothing else:
341{"changes":[{"op":"add","fact":"..."},{"op":"replace","id":3,"fact":"..."},{"op":"delete","id":7}]}`
342
343// memChange is one proposed edit. Delete and replace carry the id of the fact
344// they act on, which is why the existing facts are numbered in the prompt.
345type memChange struct {
346 Op string `json:"op"`
347 ID int64 `json:"id,omitempty"`
348 Fact string `json:"fact,omitempty"`
349 Why string `json:"why,omitempty"`
350}
351
352// Remember runs the extraction pass over one exchange and applies what comes
353// back. It is called after the answer has been sent, so its cost is never in
354// front of the reader, and every failure is logged and dropped rather than
355// surfaced, because a memory that did not save is not worth an error message.
356func (s *site) Remember(ctx context.Context, user, assistant string) {
357 if strings.TrimSpace(user) == "" {
358 return
359 }
360 existing, err := s.store.Facts()
361 if err != nil {
362 return
363 }
364 changes, err := s.proposeChanges(ctx, existing,
365 fmt.Sprintf("The exchange:\n\nIsaac said:\n%s\n\nYou answered:\n%s", trim(user, 4000), trim(assistant, 2000)),
366 "What did this reveal about Isaac that is worth keeping?")
367 if err != nil {
368 slog.Error("the memory pass failed", "err", err)
369 return
370 }
371 // Logged at info even when nothing changed. A pass that silently proposes
372 // an empty list every time looks exactly like one that is not running, and
373 // telling those apart took a trip through the gateway's call log.
374 applied := s.applyChanges(changes, existing)
375 slog.Info("memory pass", "known", len(existing), "proposed", len(changes), "applied", len(applied))
376}
377
378// proposeChanges is the one place the model is asked to write memory. Both the
379// automatic pass and the memory page go through it, so the rules apply to both.
380func (s *site) proposeChanges(ctx context.Context, existing []Fact, situation, ask string) ([]memChange, error) {
381 var known strings.Builder
382 if len(existing) == 0 {
383 known.WriteString("(nothing yet)")
384 }
385 for _, f := range existing {
386 fmt.Fprintf(&known, "%d. %s\n", f.ID, f.Text)
387 }
388
389 user := fmt.Sprintf("Facts you already have:\n%s\n\n%s\n\n%s", known.String(), situation, ask)
390
391 out, err := s.llm.Complete(ctx, []Message{
392 {Role: RoleSystem, Content: factRules},
393 {Role: RoleUser, Content: user},
394 }, nil, 600)
395 if err != nil {
396 return nil, err
397 }
398 return parseChanges(out.Content), nil
399}
400
401// parseChanges is forgiving about the wrapping and strict about the contents. A
402// small model puts JSON in a fence or writes a sentence before it, and neither
403// is a reason to lose the edit.
404func parseChanges(raw string) []memChange {
405 raw = strings.TrimSpace(raw)
406 if i := strings.Index(raw, "{"); i > 0 {
407 raw = raw[i:]
408 }
409 if j := strings.LastIndex(raw, "}"); j >= 0 {
410 raw = raw[:j+1]
411 }
412 var body struct {
413 Changes []memChange `json:"changes"`
414 }
415 if json.Unmarshal([]byte(raw), &body) != nil {
416 return nil
417 }
418 out := make([]memChange, 0, len(body.Changes))
419 for _, c := range body.Changes {
420 c.Op = strings.ToLower(strings.TrimSpace(c.Op))
421 c.Fact = tidyFact(c.Fact)
422 switch c.Op {
423 case "add":
424 if c.Fact != "" {
425 out = append(out, c)
426 }
427 case "replace":
428 if c.ID > 0 && c.Fact != "" {
429 out = append(out, c)
430 }
431 case "delete":
432 if c.ID > 0 {
433 out = append(out, c)
434 }
435 }
436 }
437 return out
438}
439
440// applyChanges is the fence the model does not get to talk its way past. It
441// refuses anything that looks like a credential whatever the rules said, and it
442// will not act on an id that is not really there.
443func (s *site) applyChanges(changes []memChange, existing []Fact) []memChange {
444 known := map[int64]bool{}
445 for _, f := range existing {
446 known[f.ID] = true
447 }
448 applied := make([]memChange, 0, len(changes))
449 for _, c := range changes {
450 if c.Fact != "" && looksSecret(c.Fact) {
451 slog.Warn("a proposed fact looked like a credential and was dropped")
452 continue
453 }
454 var err error
455 switch c.Op {
456 case "add":
457 _, err = s.store.AddFact(c.Fact)
458 case "replace":
459 if !known[c.ID] {
460 continue
461 }
462 err = s.store.ReplaceFact(c.ID, c.Fact)
463 case "delete":
464 if !known[c.ID] {
465 continue
466 }
467 err = s.store.DeleteFact(c.ID)
468 }
469 if err != nil {
470 slog.Debug("applying a memory change", "op", c.Op, "err", err)
471 continue
472 }
473 applied = append(applied, c)
474 }
475 return applied
476}
477
478// looksSecret is a last fence rather than the only one. The rules already say
479// not to keep a credential, and a model that ignores them once must not be able
480// to write one into a file that is read into every future turn.
481func looksSecret(s string) bool {
482 low := strings.ToLower(s)
483 for _, w := range []string{"password", "passphrase", "api key", "apikey", "secret key",
484 "private key", "token is", "bearer ", "orch-", "ssh-rsa", "begin private"} {
485 if strings.Contains(low, w) {
486 return true
487 }
488 }
489 // A long unbroken run of key shaped characters is a credential whatever it
490 // is called, and no real sentence about a person contains one.
491 for _, field := range strings.Fields(s) {
492 if len(field) >= 24 && !strings.ContainsAny(field, " .,;:!?") && looksRandom(field) {
493 return true
494 }
495 }
496 return false
497}
498
499func looksRandom(s string) bool {
500 var digits, upper, lower int
501 for _, r := range s {
502 switch {
503 case unicode.IsDigit(r):
504 digits++
505 case unicode.IsUpper(r):
506 upper++
507 case unicode.IsLower(r):
508 lower++
509 }
510 }
511 return digits > 0 && upper > 0 && lower > 0
512}
513
514// memoryBlock is what gets folded into the system prompt. It is left out
515// entirely when nothing matched, rather than saying it knows nothing, which a
516// model reads as an instruction to talk about not knowing things.
517func memoryBlock(facts []Fact) string {
518 if len(facts) == 0 {
519 return ""
520 }
521 var sb strings.Builder
522 sb.WriteString("\n\nWhat you remember about Isaac, which may or may not bear on this question:\n")
523 for _, f := range facts {
524 fmt.Fprintf(&sb, "- %s\n", f.Text)
525 }
526 sb.WriteString("Use one only if it actually helps. Do not list them back at him or mention remembering.")
527 return sb.String()
528}
529
530// memoryStore hands the fact store to the remember tool. The tools package
531// cannot see Store, and the names differ either side because Store has other
532// kinds of row to keep straight and the tool has only the one.
533type memoryStore struct{ s *Store }
534
535func (m memoryStore) Facts() ([]tools.MemoryFact, error) {
536 all, err := m.s.Facts()
537 if err != nil {
538 return nil, err
539 }
540 out := make([]tools.MemoryFact, 0, len(all))
541 for _, f := range all {
542 out = append(out, tools.MemoryFact{ID: f.ID, Text: f.Text})
543 }
544 return out, nil
545}
546
547// historyStore hands the conversation store to the chat_history tool. Separate
548// from memoryStore because the two answer different questions and a tool that
549// reads facts has no business reaching messages.
550type historyStore struct{ s *Store }
551
552func (h historyStore) SearchPast(query string, limit int) ([]tools.PastExchange, error) {
553 hits, err := h.s.SearchHistory(query, limit)
554 if err != nil {
555 return nil, err
556 }
557 out := make([]tools.PastExchange, 0, len(hits))
558 for _, x := range hits {
559 out = append(out, tools.PastExchange{ConvID: x.ConvID, Title: x.Title,
560 When: x.When, Question: x.Question, Answer: x.Answer})
561 }
562 return out, nil
563}
564
565func (m memoryStore) Add(text string) (int64, error) { return m.s.AddFact(text) }
566func (m memoryStore) Replace(id int64, text string) error { return m.s.ReplaceFact(id, text) }
567func (m memoryStore) Delete(id int64) error { return m.s.DeleteFact(id) }