Phase 14: dreaming as an opt-in, budgeted, non-acting idle mode

- dreaming.Service: deterministic memory replay sample keyed by dream id,
  vocabulary-based association of unlinked memory pairs (similar_to edges +
  dream_association events), speculative hypotheses as hypothesis-kind beliefs
  with two weak memory citations (starts ~0.30, asserted <= 0.35)
- Run marks the context forbidden for capabilities before anything else;
  test proves Invoke refuses inside a dream
- per-mind settings: enabled (default off), hours window (wraps midnight),
  interval, model-call / memory / hypothesis caps; global pause switch
- optional model wording via new `dream` prompt (role hypothesise)
- promotion adds operator evidence, rejection retracts the belief; both
  audited and recorded as operator actions
- migration 0014, API, Dreams tab and page, noemactl dreams, docs/dreaming.md
This commit is contained in:
Andy Dixon
2026-07-01 21:12:13 +01:00
parent 89edbb7c06
commit 2ac8752bac
20 changed files with 1217 additions and 3 deletions
+519
View File
@@ -0,0 +1,519 @@
// Package dreaming implements the optional idle mode "dreaming" (spec §19):
// replay a deterministic sample of memories, find pairs that share vocabulary
// but were never linked, and propose speculative low-confidence hypotheses.
//
// Invariants: dreams run under a forbidden capability context so no action
// can ever be executed from a dream; every hypothesis is an ordinary belief
// with prior 0.25 and weak evidence, so it starts low and goes through the
// same evidence and review mechanisms as everything else; originals are never
// modified.
package dreaming
import (
"context"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"log/slog"
"sort"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/capabilities"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/internal/llm/prompts"
"github.com/andydixon/noema/internal/llm/router"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/internal/workspace"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Dream is one dreaming session.
type Dream struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Status string `json:"status"`
Trigger string `json:"trigger"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Sampled int `json:"sampled"`
Associations int `json:"associations"`
Hypotheses int `json:"hypotheses"`
LLMCalls int `json:"llm_calls"`
Inputs json.RawMessage `json:"inputs"`
Error string `json:"error"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
// Hypothesis is a speculative belief proposed by a dream, awaiting review.
type Hypothesis struct {
ID ids.ID `json:"id"`
DreamID ids.ID `json:"dream_id"`
MindID ids.ID `json:"mind_id"`
BeliefID ids.ID `json:"belief_id"`
MemoryIDs []ids.ID `json:"memory_ids"`
Proposition string `json:"proposition"`
Confidence float64 `json:"confidence"`
Basis string `json:"basis"`
Wording string `json:"wording"`
Status string `json:"status"`
ReviewedBy string `json:"reviewed_by"`
ReviewNote string `json:"review_note"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Pair is a candidate association between two sampled memories.
type Pair struct {
A, B memory.Memory
Shared []string
Score float64
}
// Service runs dreams.
type Service struct {
DB *pgxpool.Pool
Memory *memory.Store
Beliefs *beliefs.Store
Events *evstore.Store
LLM *router.Router
Prompts *prompts.Store
Log *slog.Logger
}
// ErrReviewed is returned when a hypothesis has already been promoted or rejected.
var ErrReviewed = errors.New("hypothesis already reviewed")
var paused = false
// SetPaused toggles the global kill switch. IsPaused reports it.
func SetPaused(p bool) { paused = p }
// IsPaused reports the global kill switch.
func IsPaused() bool { return paused }
// InWindow reports whether now falls inside an "HH-HH" local-hour window.
// Empty means always; a window may wrap midnight ("22-06").
func InWindow(hours string, now time.Time) bool {
hours = strings.TrimSpace(hours)
if hours == "" {
return true
}
a, b, ok := strings.Cut(hours, "-")
from, err1 := strconv.Atoi(strings.TrimSpace(a))
to, err2 := strconv.Atoi(strings.TrimSpace(b))
if !ok || err1 != nil || err2 != nil || from < 0 || from > 23 || to < 0 || to > 24 {
return false // unparseable window: never dream rather than always
}
h := now.Hour()
if from == to {
return true
}
if from < to {
return h >= from && h < to
}
return h >= from || h < to
}
// Sample picks up to n memories deterministically for a given seed: the
// score mixes importance with a hash so the same dream id always replays the
// same memories, while different dreams wander.
func Sample(seed ids.ID, mems []memory.Memory, n int) []memory.Memory {
type scored struct {
m memory.Memory
s float64
}
items := make([]scored, 0, len(mems))
for _, m := range mems {
h := fnv.New32a()
h.Write([]byte(seed))
h.Write([]byte(m.ID))
jitter := float64(h.Sum32()%1000) / 1000
items = append(items, scored{m, 0.5*m.Importance + 0.5*jitter})
}
sort.Slice(items, func(i, j int) bool {
if items[i].s != items[j].s {
return items[i].s > items[j].s
}
return items[i].m.ID < items[j].m.ID
})
if n > len(items) {
n = len(items)
}
out := make([]memory.Memory, 0, n)
for _, it := range items[:n] {
out = append(out, it.m)
}
return out
}
// Associate finds pairs that share vocabulary (Jaccard within [min, 0.8)) but
// are not near-duplicates, come from different moments, and are not already
// related. Deterministic and sorted by score.
func Associate(mems []memory.Memory, related func(a, b ids.ID) bool, min float64) []Pair {
terms := make([][]string, len(mems))
for i, m := range mems {
terms[i] = workspace.Tokenise(m.Content)
}
var out []Pair
for i := range mems {
for j := i + 1; j < len(mems); j++ {
a, b := mems[i], mems[j]
if a.OccurredAt.Sub(b.OccurredAt).Abs() < time.Hour && a.Source == b.Source {
continue // same episode, consolidation's job
}
jac := beliefs.Jaccard(terms[i], terms[j])
if jac < min || jac >= 0.8 {
continue
}
if related != nil && related(a.ID, b.ID) {
continue
}
shared := intersect(terms[i], terms[j])
if len(shared) == 0 {
continue
}
// Distance in time and difference of source make a link more "hidden", hence more interesting.
score := jac
if a.Source != b.Source {
score += 0.15
}
if a.OccurredAt.Sub(b.OccurredAt).Abs() > 24*time.Hour {
score += 0.1
}
out = append(out, Pair{A: a, B: b, Shared: shared, Score: score})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Score != out[j].Score {
return out[i].Score > out[j].Score
}
return out[i].A.ID+out[i].B.ID < out[j].A.ID+out[j].B.ID
})
return out
}
// stop lists function words that make a shared term meaningless.
var stop = map[string]bool{"the": true, "and": true, "for": true, "not": true, "with": true, "that": true, "this": true, "from": true, "said": true, "may": true, "are": true, "was": true, "has": true, "have": true, "but": true, "you": true, "its": true, "were": true, "been": true, "into": true, "than": true, "then": true, "also": true, "about": true, "definitely": true}
func intersect(a, b []string) []string {
set := map[string]bool{}
for _, t := range a {
if len(t) >= 3 && !stop[t] {
set[t] = true
}
}
seen := map[string]bool{}
var out []string
for _, t := range b {
if set[t] && !seen[t] {
seen[t] = true
out = append(out, t)
}
}
sort.Strings(out)
return out
}
// Proposition is the deterministic wording for a pair.
func Proposition(p Pair) string {
return fmt.Sprintf("%q and %q may be connected through %s", excerpt(p.A.Content, 70), excerpt(p.B.Content, 70), strings.Join(p.Shared, ", "))
}
// Run performs one dream. The context is marked forbidden for capabilities
// before anything else happens, so nothing reachable from here can act.
func (s *Service) Run(ctx context.Context, m minds.Mind, trigger, by string) (Dream, error) {
ctx = capabilities.WithForbidden(ctx, "dreaming")
d := Dream{ID: ids.New(), MindID: m.ID, Status: "running", Trigger: trigger, CreatedBy: by, CreatedAt: time.Now()}
now := time.Now()
d.StartedAt = &now
if _, err := s.DB.Exec(ctx, `INSERT INTO dreams (id, mind_id, status, trigger, started_at, created_by) VALUES ($1,$2,'running',$3,now(),$4)`, d.ID, m.ID, trigger, by); err != nil {
return d, err
}
finish := func(status, errText string, inputs []ids.ID) (Dream, error) {
in, _ := json.Marshal(inputs)
if inputs == nil {
in = []byte("[]")
}
_, err := s.DB.Exec(ctx, `UPDATE dreams SET status=$2, error=$3, sampled=$4, associations=$5, hypotheses=$6, llm_calls=$7, inputs=$8, finished_at=now() WHERE id=$1`, d.ID, status, errText, d.Sampled, d.Associations, d.Hypotheses, d.LLMCalls, in)
d.Status, d.Error, d.Inputs = status, errText, in
return d, err
}
if paused && trigger == "schedule" {
return finish("rejected", "dreaming is paused", nil)
}
if trigger == "schedule" && !InWindow(m.Config.DreamHours, now) {
return finish("rejected", "outside allowed hours "+m.Config.DreamHours, nil)
}
mems, err := s.Memory.List(ctx, m.ID, memory.Filter{Kinds: []concepts.MemoryKind{concepts.Episodic, concepts.Semantic}, Since: now.Add(-90 * 24 * time.Hour), Limit: 400})
if err != nil {
return finish("failed", err.Error(), nil)
}
sample := Sample(d.ID, mems, m.Config.DreamMaxMemories)
d.Sampled = len(sample)
var inputs []ids.ID
for _, sm := range sample {
inputs = append(inputs, sm.ID)
}
related := func(a, b ids.ID) bool {
var n int
s.DB.QueryRow(ctx, `SELECT count(*) FROM memory_relationships WHERE (from_memory=$1 AND to_memory=$2) OR (from_memory=$2 AND to_memory=$1)`, a, b).Scan(&n) //nolint:errcheck
return n > 0
}
pairs := Associate(sample, related, 0.12)
if len(pairs) > 10 {
pairs = pairs[:10]
}
for _, p := range pairs {
if err := s.associate(ctx, m, d.ID, p); err != nil {
s.Log.Warn("dream association failed", "mind", m.Slug, "err", err)
continue
}
d.Associations++
}
maxH := m.Config.DreamMaxHypotheses
if maxH > len(pairs) {
maxH = len(pairs)
}
props := s.wordings(ctx, m, &d, pairs[:maxH])
for i, p := range pairs[:maxH] {
w := props[i]
if w.Proposition == "" {
continue
}
if err := s.hypothesise(ctx, m, d.ID, p, w); err != nil {
s.Log.Warn("dream hypothesis failed", "mind", m.Slug, "err", err)
continue
}
d.Hypotheses++
}
return finish("done", "", inputs)
}
type wording struct {
Proposition, Rationale, Wording string
CallID ids.ID
}
// wordings returns one wording per pair: deterministic by default, replaced
// by the model's when a budgeted call succeeds and refers to a valid pair.
func (s *Service) wordings(ctx context.Context, m minds.Mind, d *Dream, pairs []Pair) []wording {
out := make([]wording, len(pairs))
for i, p := range pairs {
out[i] = wording{Proposition: Proposition(p), Rationale: "shared vocabulary: " + strings.Join(p.Shared, ", "), Wording: "deterministic"}
}
if len(pairs) == 0 || s.LLM == nil || s.Prompts == nil || d.LLMCalls >= m.Config.DreamLLMCalls || !s.LLM.Available(ctx, llm.RoleHypothesis) {
return out
}
pr, err := s.Prompts.Get(ctx, "dream")
if err != nil {
return out
}
type pv struct {
Index int
Shared, A, B, ADate, BDate string
ASource, BSource string
}
var view []pv
for i, p := range pairs {
view = append(view, pv{Index: i, Shared: strings.Join(p.Shared, ", "), A: excerpt(p.A.Content, 300), B: excerpt(p.B.Content, 300), ADate: p.A.OccurredAt.Format("2006-01-02"), BDate: p.B.OccurredAt.Format("2006-01-02"), ASource: p.A.Source, BSource: p.B.Source})
}
req, err := pr.Render(map[string]any{"Pairs": view, "Max": len(pairs)})
if err != nil {
return out
}
req.MindID, req.Purpose = m.ID, "dream "+d.ID.String()
d.LLMCalls++
resp, err := s.LLM.Complete(ctx, req)
if err != nil || len(resp.JSON) == 0 {
return out
}
var parsed struct {
Hypotheses []struct {
Pair int `json:"pair"`
Proposition string `json:"proposition"`
Rationale string `json:"rationale"`
} `json:"hypotheses"`
}
if json.Unmarshal(resp.JSON, &parsed) != nil {
return out
}
for _, h := range parsed.Hypotheses {
p := strings.TrimSpace(h.Proposition)
if h.Pair < 0 || h.Pair >= len(pairs) || len(p) < 8 || len(p) > 500 {
continue
}
out[h.Pair] = wording{Proposition: p, Rationale: excerpt(h.Rationale, 300), Wording: "model", CallID: resp.CallID}
}
return out
}
func (s *Service) associate(ctx context.Context, m minds.Mind, dream ids.ID, p Pair) error {
return storage.Tx(ctx, s.DB, func(tx pgx.Tx) error {
if _, err := s.Memory.Relate(ctx, tx, m.ID, p.A.ID, p.B.ID, concepts.SimilarTo, 0.3*p.Score, "dreaming"); err != nil {
return err
}
evs, err := s.Events.AppendIn(ctx, tx, events.New{MindID: m.ID, Type: events.DreamAssociation, Source: "dreaming", Summary: "dream linked: " + excerpt(p.A.Content, 60) + " ↔ " + excerpt(p.B.Content, 60),
Salience: 0.15, Confidence: 0.3 * p.Score, Evidence: []events.EvidenceRef{{Kind: "memory", ID: string(p.A.ID)}, {Kind: "memory", ID: string(p.B.ID)}},
Metadata: map[string]any{"dream_id": dream, "shared": strings.Join(p.Shared, " "), "score": p.Score}, Payload: map[string]any{"a": p.A.ID, "b": p.B.ID, "shared": p.Shared, "score": p.Score}})
if err != nil {
return err
}
s.Events.Publish(evs...)
return nil
})
}
func (s *Service) hypothesise(ctx context.Context, m minds.Mind, dream ids.ID, p Pair, w wording) error {
return storage.Tx(ctx, s.DB, func(tx pgx.Tx) error {
emit := func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) {
out, err := s.Events.AppendIn(ctx, q, n)
if err != nil {
return events.Event{}, err
}
return out[0], nil
}
ev := []beliefs.EvidenceInput{
{Direction: "for", Kind: "memory", RefID: string(p.A.ID), Source: "dreaming/" + p.A.Source, Weight: 0.1, Credibility: p.A.Credibility, Note: "dream replay: " + excerpt(p.A.Content, 80)},
{Direction: "for", Kind: "memory", RefID: string(p.B.ID), Source: "dreaming/" + p.B.Source, Weight: 0.1, Credibility: p.B.Credibility, Note: "dream replay: " + excerpt(p.B.Content, 80)},
}
detail := map[string]any{"dream_id": dream, "wording": w.Wording, "rationale": w.Rationale, "shared": p.Shared}
if !w.CallID.IsZero() {
detail["llm_call_id"] = w.CallID
}
res, err := s.Beliefs.Create(ctx, tx, emit, beliefs.New{MindID: m.ID, Proposition: w.Proposition, Subject: "dream:" + strings.Join(p.Shared, "+"), Epistemic: concepts.Hypothesis, Source: "dreaming",
Evidence: ev, Alternatives: []string{"the two memories are unrelated; the shared vocabulary is coincidence"}, Detail: detail})
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO dream_hypotheses (id, dream_id, mind_id, belief_id, memory_ids, proposition, confidence, basis, wording) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
ids.New(), dream, m.ID, res.Belief.ID, []ids.ID{p.A.ID, p.B.ID}, w.Proposition, res.Belief.Confidence, w.Rationale, w.Wording); err != nil {
return err
}
s.Events.Publish(res.Events...)
return nil
})
}
// Dreams lists recent dreams for a mind.
func (s *Service) Dreams(ctx context.Context, mind ids.ID, limit int) ([]Dream, error) {
if limit <= 0 || limit > 200 {
limit = 20
}
rows, err := s.DB.Query(ctx, `SELECT id, mind_id, status, trigger, started_at, finished_at, sampled, associations, hypotheses, llm_calls, inputs, error, created_by, created_at FROM dreams WHERE mind_id=$1 ORDER BY created_at DESC LIMIT $2`, mind, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Dream
for rows.Next() {
var d Dream
if err := rows.Scan(&d.ID, &d.MindID, &d.Status, &d.Trigger, &d.StartedAt, &d.FinishedAt, &d.Sampled, &d.Associations, &d.Hypotheses, &d.LLMCalls, &d.Inputs, &d.Error, &d.CreatedBy, &d.CreatedAt); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// Hypotheses lists dream hypotheses, optionally filtered by status.
func (s *Service) Hypotheses(ctx context.Context, mind ids.ID, status string, limit int) ([]Hypothesis, error) {
if limit <= 0 || limit > 500 {
limit = 50
}
rows, err := s.DB.Query(ctx, `SELECT id, dream_id, mind_id, belief_id, memory_ids, proposition, confidence, basis, wording, status, reviewed_by, review_note, reviewed_at, created_at FROM dream_hypotheses WHERE mind_id=$1 AND ($2='' OR status=$2) ORDER BY created_at DESC LIMIT $3`, mind, status, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Hypothesis
for rows.Next() {
var h Hypothesis
if err := rows.Scan(&h.ID, &h.DreamID, &h.MindID, &h.BeliefID, &h.MemoryIDs, &h.Proposition, &h.Confidence, &h.Basis, &h.Wording, &h.Status, &h.ReviewedBy, &h.ReviewNote, &h.ReviewedAt, &h.CreatedAt); err != nil {
return nil, err
}
out = append(out, h)
}
return out, rows.Err()
}
func (s *Service) hypothesis(ctx context.Context, mind, id ids.ID) (Hypothesis, error) {
var h Hypothesis
err := s.DB.QueryRow(ctx, `SELECT id, dream_id, mind_id, belief_id, memory_ids, proposition, confidence, basis, wording, status, reviewed_by, review_note, reviewed_at, created_at FROM dream_hypotheses WHERE mind_id=$1 AND id=$2`, mind, id).
Scan(&h.ID, &h.DreamID, &h.MindID, &h.BeliefID, &h.MemoryIDs, &h.Proposition, &h.Confidence, &h.Basis, &h.Wording, &h.Status, &h.ReviewedBy, &h.ReviewNote, &h.ReviewedAt, &h.CreatedAt)
return h, err
}
func (s *Service) emitter() beliefs.Emitter {
return func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) {
out, err := s.Events.AppendIn(ctx, q, n)
if err != nil {
return events.Event{}, err
}
s.Events.Publish(out...)
return out[0], nil
}
}
// Promote records operator endorsement as evidence on the hypothesis's
// belief. It does not set confidence directly: the endorsement is one more
// weighted piece of evidence and the arithmetic stays inspectable.
func (s *Service) Promote(ctx context.Context, m minds.Mind, id ids.ID, note, by string) (Hypothesis, error) {
h, err := s.hypothesis(ctx, m.ID, id)
if err != nil {
return h, err
}
if h.Status != "proposed" {
return h, fmt.Errorf("%w: %s", ErrReviewed, h.Status)
}
res, err := s.Beliefs.AddEvidence(ctx, s.DB, s.emitter(), m.ID, h.BeliefID, beliefs.EvidenceInput{Direction: "for", Kind: "operator", RefID: by, Source: "operator", Weight: 0.6, Credibility: 1, Note: "promoted from dream: " + note}, "operator promoted dream hypothesis", ids.Nil, ids.Nil)
if err != nil {
return h, err
}
if _, err := s.DB.Exec(ctx, `UPDATE dream_hypotheses SET status='promoted', reviewed_by=$2, review_note=$3, reviewed_at=now(), confidence=$4 WHERE id=$1`, id, by, note, res.Belief.Confidence); err != nil {
return h, err
}
s.Events.OperatorAction(ctx, m.ID, by, "dream.promote", "Operator "+by+" promoted a dream hypothesis: "+excerpt(h.Proposition, 100), map[string]any{"hypothesis_id": id, "belief_id": h.BeliefID, "note": note, "confidence": res.Belief.Confidence})
h.Status, h.ReviewedBy, h.ReviewNote, h.Confidence = "promoted", by, note, res.Belief.Confidence
return h, nil
}
// Reject retracts the hypothesis's belief. Memories and associations stay.
func (s *Service) Reject(ctx context.Context, m minds.Mind, id ids.ID, reason, by string) (Hypothesis, error) {
h, err := s.hypothesis(ctx, m.ID, id)
if err != nil {
return h, err
}
if h.Status != "proposed" {
return h, fmt.Errorf("%w: %s", ErrReviewed, h.Status)
}
if _, err := s.Beliefs.Retract(ctx, s.emitter(), m.ID, h.BeliefID, "dream hypothesis rejected: "+reason, by, false); err != nil {
return h, err
}
if _, err := s.DB.Exec(ctx, `UPDATE dream_hypotheses SET status='rejected', reviewed_by=$2, review_note=$3, reviewed_at=now() WHERE id=$1`, id, by, reason); err != nil {
return h, err
}
s.Events.OperatorAction(ctx, m.ID, by, "dream.reject", "Operator "+by+" rejected a dream hypothesis: "+reason, map[string]any{"hypothesis_id": id, "belief_id": h.BeliefID, "reason": reason})
h.Status, h.ReviewedBy, h.ReviewNote = "rejected", by, reason
return h, nil
}
func excerpt(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > n {
return s[:n-1] + "…"
}
return s
}