repos
/ orchard main

orchard

mirror

Every 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

18.4 KB · 568 lines · Go Raw History
  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) }