feat: add mind model with versioned personality, templates and self-model

Phase 2. Minds are rows with a slug, status (active/disabled/archived),
cognitive configuration and a pointer to the current personality version.
Personalities are structured (identity, values, traits, communication,
behaviour, free text) with a deterministic Brief() used by prompts and the
editor preview. Every change appends a personality_versions row protected
by the append-only trigger; restore copies an old version forward.
Templates carry reusable personalities; minds record their overrides so a
template can be re-applied without losing mind-specific edits. The
self-model starts with structural limitations the mind cannot drop
(no policy changes, no influence over shutdown, no cross-mind access).

API, UI (list, overview, editor with sliders/diff/history/import/export,
template editor) and noemactl mind/template commands. All mutations are
audited with field-level diffs.
This commit is contained in:
Andy Dixon
2026-06-15 22:17:01 +01:00
parent a002a2c100
commit fb34f08f40
28 changed files with 2560 additions and 10 deletions
+122
View File
@@ -0,0 +1,122 @@
package minds
import (
"fmt"
"time"
)
// CognitiveConfig holds per-mind tunables consumed by the brain. Defaults are
// conservative; every field is exposed in the UI in later phases.
type CognitiveConfig struct {
WorkspaceCapacity int `json:"workspace_capacity"`
WorkingMemoryCapacity int `json:"working_memory_capacity"`
AttentionWeights map[string]float64 `json:"attention_weights"`
CycleMinInterval Duration `json:"cycle_min_interval"`
IdleCycleInterval Duration `json:"idle_cycle_interval"`
MaxCyclesPerMinute int `json:"max_cycles_per_minute"`
LLMCallsPerCycle int `json:"llm_calls_per_cycle"`
DailyTokenBudget int64 `json:"daily_token_budget"`
WorkingMemoryDecay float64 `json:"working_memory_decay"` // activation multiplier per cycle
LoopDetectWindow int `json:"loop_detect_window"`
LoopDetectThreshold int `json:"loop_detect_threshold"`
}
// AttentionFeatures is the fixed set of scoring features (spec §6). Weights are
// per mind; features are computed deterministically by the attention package.
var AttentionFeatures = []string{"salience", "urgency", "novelty", "affect", "goal_relevance", "consequence", "confidence", "contradiction", "operator_priority", "context_relevance"}
// DefaultConfig returns the baseline cognitive configuration.
func DefaultConfig() CognitiveConfig {
w := map[string]float64{"salience": 1.0, "urgency": 1.2, "novelty": 0.8, "affect": 0.6, "goal_relevance": 1.1,
"consequence": 0.9, "confidence": 0.4, "contradiction": 1.0, "operator_priority": 1.5, "context_relevance": 0.9}
return CognitiveConfig{WorkspaceCapacity: 7, WorkingMemoryCapacity: 9, AttentionWeights: w,
CycleMinInterval: Duration(500 * time.Millisecond), IdleCycleInterval: Duration(60 * time.Second), MaxCyclesPerMinute: 60,
LLMCallsPerCycle: 3, DailyTokenBudget: 500_000, WorkingMemoryDecay: 0.85, LoopDetectWindow: 20, LoopDetectThreshold: 5}
}
// Normalise fills gaps from defaults and clamps ranges.
func (c *CognitiveConfig) Normalise() {
d := DefaultConfig()
if c.WorkspaceCapacity < 1 || c.WorkspaceCapacity > 32 {
c.WorkspaceCapacity = d.WorkspaceCapacity
}
if c.WorkingMemoryCapacity < 1 || c.WorkingMemoryCapacity > 64 {
c.WorkingMemoryCapacity = d.WorkingMemoryCapacity
}
if c.AttentionWeights == nil {
c.AttentionWeights = map[string]float64{}
}
for _, f := range AttentionFeatures {
v, ok := c.AttentionWeights[f]
if !ok || v < 0 || v > 5 {
c.AttentionWeights[f] = d.AttentionWeights[f]
}
}
for k := range c.AttentionWeights {
if !contains(AttentionFeatures, k) {
delete(c.AttentionWeights, k)
}
}
if c.CycleMinInterval <= 0 {
c.CycleMinInterval = d.CycleMinInterval
}
if c.IdleCycleInterval <= 0 {
c.IdleCycleInterval = d.IdleCycleInterval
}
if c.MaxCyclesPerMinute < 1 || c.MaxCyclesPerMinute > 600 {
c.MaxCyclesPerMinute = d.MaxCyclesPerMinute
}
if c.LLMCallsPerCycle < 0 || c.LLMCallsPerCycle > 20 {
c.LLMCallsPerCycle = d.LLMCallsPerCycle
}
if c.DailyTokenBudget <= 0 {
c.DailyTokenBudget = d.DailyTokenBudget
}
if c.WorkingMemoryDecay <= 0 || c.WorkingMemoryDecay >= 1 {
c.WorkingMemoryDecay = d.WorkingMemoryDecay
}
if c.LoopDetectWindow < 5 {
c.LoopDetectWindow = d.LoopDetectWindow
}
if c.LoopDetectThreshold < 2 {
c.LoopDetectThreshold = d.LoopDetectThreshold
}
}
func contains(xs []string, x string) bool {
for _, y := range xs {
if y == x {
return true
}
}
return false
}
// Duration marshals as a Go duration string in JSON ("500ms") for readability.
type Duration time.Duration
// MarshalJSON implements json.Marshaler.
func (d Duration) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf("%q", time.Duration(d).String())), nil
}
// UnmarshalJSON accepts a duration string or a number of nanoseconds.
func (d *Duration) UnmarshalJSON(b []byte) error {
if len(b) > 0 && b[0] == '"' {
v, err := time.ParseDuration(string(b[1 : len(b)-1]))
if err != nil {
return err
}
*d = Duration(v)
return nil
}
var n int64
if _, err := fmt.Sscan(string(b), &n); err != nil {
return err
}
*d = Duration(n)
return nil
}
// D returns the time.Duration.
func (d Duration) D() time.Duration { return time.Duration(d) }
+164
View File
@@ -0,0 +1,164 @@
package minds
import (
"context"
"errors"
"strings"
"testing"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
)
func TestBriefIsDeterministicAndReflectsStructure(t *testing.T) {
p := DefaultPersonality("Dani")
p.Traits["humour"] = 0.9
p.Values["caution"] = 0.1
p.Communication.Formality = 0.2
b1, b2 := p.Brief(), p.Brief()
if b1 != b2 {
t.Fatal("brief not deterministic")
}
for _, want := range []string{"You are Dani", "Pronounced traits: humour", "little weight on: caution", "casually", "I do not know"} {
if !strings.Contains(b1, want) {
t.Errorf("brief missing %q:\n%s", want, b1)
}
}
}
func TestDiffAndOverridesRoundTrip(t *testing.T) {
base := DefaultPersonality("t")
mod := base
mod.Traits = map[string]float64{}
for k, v := range base.Traits {
mod.Traits[k] = v
}
mod.Traits["humour"] = 0.9
mod.Identity.Role = "investigator"
mod.Behaviour.NotifyProactively = true
d := Diff(base, mod)
if len(d) != 3 {
t.Fatalf("expected 3 changes, got %+v", d)
}
ov := Overrides(base, mod)
back := ApplyOverrides(base, ov)
if len(Diff(mod, back)) != 0 {
t.Fatalf("overrides did not round-trip: %+v", Diff(mod, back))
}
// Applying the same overrides to a changed template keeps the overrides and takes the rest from the template.
base2 := base
base2.Values = map[string]float64{}
for k, v := range base.Values {
base2.Values[k] = v
}
base2.Values["kindness"] = 0.95
res := ApplyOverrides(base2, ov)
if res.Values["kindness"] != 0.95 || res.Traits["humour"] != 0.9 || res.Identity.Role != "investigator" {
t.Fatalf("template change + overrides wrong: %+v", res)
}
}
func TestNormaliseClampsAndFills(t *testing.T) {
p := Personality{Identity: Identity{Name: "x"}, Values: map[string]float64{"privacy": 7, "bogus": 1}, Traits: nil}
p.Normalise()
if p.Values["privacy"] != 1 || len(p.Values) != len(ValueKeys) || len(p.Traits) != len(TraitKeys) || p.Communication.Language != "en" {
t.Fatalf("normalise wrong: %+v", p)
}
if _, ok := p.Values["bogus"]; ok {
t.Fatal("unknown key kept")
}
}
func TestStoreLifecycle(t *testing.T) {
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
s := NewStore(pool)
if _, err := s.Create(ctx, CreateParams{Slug: "Bad Slug!"}); !errors.Is(err, ErrInvalid) {
t.Fatalf("expected invalid slug, got %v", err)
}
m, err := s.Create(ctx, CreateParams{Slug: "dani", DisplayName: "Dani", CreatedBy: "test"})
if err != nil {
t.Fatal(err)
}
if _, err := s.Create(ctx, CreateParams{Slug: "DANI"}); !errors.Is(err, storage.ErrConflict) {
t.Fatalf("expected conflict, got %v", err)
}
got, err := s.Resolve(ctx, "dani")
if err != nil || got.ID != m.ID || got.PersonalityVersion != 1 || got.Config.WorkspaceCapacity != 7 {
t.Fatalf("resolve: %v %+v", err, got)
}
sm, err := s.GetSelfModel(ctx, m.ID)
if err != nil || len(sm.Limitations) < 3 || sm.Name != "Dani" {
t.Fatalf("self model: %v %+v", err, sm)
}
// New personality version, then restore.
p := got.Personality
p.Traits["curiosity"] = 0.95
p.FreeText = "Loves old hardware."
v2, err := s.SavePersonality(ctx, m.ID, p, "more curious", "test")
if err != nil || v2.Version != 2 {
t.Fatalf("save: %v %+v", err, v2)
}
v3, err := s.Restore(ctx, m.ID, 1, "test")
if err != nil || v3.Version != 3 || v3.Personality.Traits["curiosity"] != 0.5 {
t.Fatalf("restore: %v %+v", err, v3)
}
vs, _ := s.ListVersions(ctx, m.ID)
if len(vs) != 3 {
t.Fatalf("expected 3 versions, got %d", len(vs))
}
// History is immutable.
if _, err := pool.Exec(ctx, `DELETE FROM personality_versions WHERE mind_id=$1`, m.ID); err == nil {
t.Fatal("personality history should be append-only")
}
// Templates and overrides survive re-application.
tp := DefaultPersonality("Investigator")
tp.Traits["scepticism"] = 0.9
tmpl, err := s.CreateTemplate(ctx, "investigator", "sceptical analyst", tp)
if err != nil {
t.Fatal(err)
}
v4, err := s.ApplyTemplate(ctx, m.ID, &tmpl.ID, "test")
if err != nil || v4.Personality.Traits["scepticism"] != 0.9 || v4.Personality.Identity.DisplayName != "Dani" {
t.Fatalf("apply template: %v %+v", err, v4.Personality)
}
p4 := v4.Personality
p4.Traits["humour"] = 0.85
v5, err := s.SavePersonality(ctx, m.ID, p4, "funnier", "test")
if err != nil {
t.Fatal(err)
}
if _, ok := v5.Overrides["traits"].(map[string]any)["humour"]; !ok {
t.Fatalf("override not recorded: %+v", v5.Overrides)
}
tp.Values["kindness"] = 0.99
if _, err := s.UpdateTemplate(ctx, tmpl.ID, "investigator", "v2", tp); err != nil {
t.Fatal(err)
}
v6, err := s.ApplyTemplate(ctx, m.ID, &tmpl.ID, "test")
if err != nil || v6.Personality.Values["kindness"] != 0.99 || v6.Personality.Traits["humour"] != 0.85 {
t.Fatalf("re-apply lost overrides or template change: %+v", v6.Personality)
}
if err := s.DeleteTemplate(ctx, tmpl.ID); !errors.Is(err, ErrInvalid) {
t.Fatalf("in-use template deletion should be refused, got %v", err)
}
// Clone copies personality only.
c, err := s.Clone(ctx, m.ID, "dani-2", "Dani Two", "test")
if err != nil || c.Personality.Traits["humour"] != 0.85 || c.ClonedFromMind == nil || *c.ClonedFromMind != m.ID || c.TemplateID == nil {
t.Fatalf("clone: %v %+v", err, c)
}
// Status transitions and list filtering.
st := StatusArchived
if _, err := s.Update(ctx, c.ID, UpdateParams{Status: &st}); err != nil {
t.Fatal(err)
}
live, _ := s.List(ctx, false)
all, _ := s.List(ctx, true)
if len(live) != 1 || len(all) != 2 {
t.Fatalf("list filtering wrong: %d live %d all", len(live), len(all))
}
}
+345
View File
@@ -0,0 +1,345 @@
// Package minds manages cognitive entities: identity, structured personality
// with version history and templates, cognitive configuration and the
// self-model. It owns no cognition; the brain package consumes these values.
package minds
import (
"encoding/json"
"fmt"
"math"
"sort"
"strings"
)
// ValueKeys and TraitKeys are the fixed vocabularies of the structured
// personality. Fixed keys keep the editor, prompts and comparisons aligned.
var (
ValueKeys = []string{"privacy", "evidence", "kindness", "curiosity", "accuracy", "autonomy", "caution", "creativity", "persistence"}
TraitKeys = []string{"curiosity", "scepticism", "sociability", "humour", "patience", "verbosity", "confidence", "risk_tolerance", "novelty_preference"}
)
// Identity is who the mind is.
type Identity struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Role string `json:"role"`
Pronouns string `json:"pronouns,omitempty"`
Backstory string `json:"backstory,omitempty"`
Origin string `json:"origin,omitempty"`
}
// Communication describes how the mind speaks.
type Communication struct {
Language string `json:"language"`
Dialect string `json:"dialect,omitempty"`
Formality float64 `json:"formality"` // 0 casual .. 1 formal
Verbosity float64 `json:"verbosity"` // 0 terse .. 1 expansive
HumourStyle string `json:"humour_style,omitempty"`
Terminology string `json:"terminology,omitempty"` // e.g. "prefer British spelling; say 'server' not 'box'"
}
// Behaviour holds boolean behavioural preferences.
type Behaviour struct {
ChallengeAssumptions bool `json:"challenge_assumptions"`
AskFollowUps bool `json:"ask_follow_ups"`
PreferEvidence bool `json:"prefer_evidence"`
CiteUncertainty bool `json:"cite_uncertainty"`
OfferAlternatives bool `json:"offer_alternatives"`
NotifyProactively bool `json:"notify_proactively"`
}
// Personality is the structured personality model (spec §4). It is stored as
// JSON in personality_versions and never as a monolithic prompt.
type Personality struct {
Identity Identity `json:"identity"`
Values map[string]float64 `json:"values"`
Traits map[string]float64 `json:"traits"`
Communication Communication `json:"communication"`
Behaviour Behaviour `json:"behaviour"`
FreeText string `json:"free_text,omitempty"`
}
// DefaultPersonality is a neutral, evidence-first starting point.
func DefaultPersonality(name string) Personality {
p := Personality{
Identity: Identity{Name: name, DisplayName: name, Role: "general assistant"},
Values: map[string]float64{},
Traits: map[string]float64{},
Communication: Communication{Language: "en", Formality: 0.5, Verbosity: 0.5},
Behaviour: Behaviour{PreferEvidence: true, CiteUncertainty: true, OfferAlternatives: true, AskFollowUps: true},
}
for _, k := range ValueKeys {
p.Values[k] = 0.5
}
for _, k := range TraitKeys {
p.Traits[k] = 0.5
}
p.Values["evidence"], p.Values["accuracy"], p.Values["privacy"] = 0.8, 0.8, 0.7
p.Traits["scepticism"] = 0.6
return p
}
// Normalise fills missing keys, drops unknown ones and clamps to [0,1]. It is
// applied on every write so stored personalities are always well-formed.
func (p *Personality) Normalise() {
p.Values = normaliseMap(p.Values, ValueKeys)
p.Traits = normaliseMap(p.Traits, TraitKeys)
p.Communication.Formality = clamp01(p.Communication.Formality)
p.Communication.Verbosity = clamp01(p.Communication.Verbosity)
if p.Communication.Language == "" {
p.Communication.Language = "en"
}
p.Identity.Name = strings.TrimSpace(p.Identity.Name)
if p.Identity.DisplayName == "" {
p.Identity.DisplayName = p.Identity.Name
}
}
// Validate reports structural problems the editor should surface.
func (p Personality) Validate() error {
if p.Identity.Name == "" {
return fmt.Errorf("identity.name is required")
}
if len(p.FreeText) > 20000 {
return fmt.Errorf("free_text exceeds 20000 characters")
}
for k, v := range p.Values {
if v < 0 || v > 1 || math.IsNaN(v) {
return fmt.Errorf("values.%s out of range", k)
}
}
for k, v := range p.Traits {
if v < 0 || v > 1 || math.IsNaN(v) {
return fmt.Errorf("traits.%s out of range", k)
}
}
return nil
}
func normaliseMap(m map[string]float64, keys []string) map[string]float64 {
out := make(map[string]float64, len(keys))
for _, k := range keys {
v, ok := m[k]
if !ok {
v = 0.5
}
out[k] = clamp01(v)
}
return out
}
func clamp01(v float64) float64 {
if math.IsNaN(v) || v < 0 {
return 0
}
if v > 1 {
return 1
}
return math.Round(v*100) / 100
}
// Brief renders a compact, deterministic natural-language description of the
// personality. It is what cognitive prompts receive instead of the raw JSON,
// and what the editor shows as a preview. Being deterministic, it is also
// covered by tests.
func (p Personality) Brief() string {
var b strings.Builder
id := p.Identity
fmt.Fprintf(&b, "You are %s", id.DisplayName)
if id.Role != "" {
fmt.Fprintf(&b, ", %s", id.Role)
}
b.WriteString(".")
if id.Pronouns != "" {
fmt.Fprintf(&b, " Pronouns: %s.", id.Pronouns)
}
if id.Description != "" {
fmt.Fprintf(&b, " %s", strings.TrimSpace(id.Description))
}
b.WriteString("\n")
if strong := topKeys(p.Values, 0.7); len(strong) > 0 {
fmt.Fprintf(&b, "You value most: %s.\n", strings.Join(strong, ", "))
}
if weak := bottomKeys(p.Values, 0.3); len(weak) > 0 {
fmt.Fprintf(&b, "You place little weight on: %s.\n", strings.Join(weak, ", "))
}
if strong := topKeys(p.Traits, 0.7); len(strong) > 0 {
fmt.Fprintf(&b, "Pronounced traits: %s.\n", strings.Join(strong, ", "))
}
if weak := bottomKeys(p.Traits, 0.3); len(weak) > 0 {
fmt.Fprintf(&b, "Muted traits: %s.\n", strings.Join(weak, ", "))
}
c := p.Communication
fmt.Fprintf(&b, "Communicate in %s", c.Language)
if c.Dialect != "" {
fmt.Fprintf(&b, " (%s)", c.Dialect)
}
fmt.Fprintf(&b, ", %s and %s.", scale(c.Formality, "casually", "in a balanced register", "formally"), scale(c.Verbosity, "tersely", "with moderate detail", "expansively"))
if c.HumourStyle != "" {
fmt.Fprintf(&b, " Humour: %s.", c.HumourStyle)
}
if c.Terminology != "" {
fmt.Fprintf(&b, " Terminology: %s.", c.Terminology)
}
b.WriteString("\n")
var habits []string
bh := p.Behaviour
if bh.PreferEvidence {
habits = append(habits, "ground claims in evidence")
}
if bh.CiteUncertainty {
habits = append(habits, "state uncertainty explicitly and say 'I do not know' when that is true")
}
if bh.ChallengeAssumptions {
habits = append(habits, "challenge questionable assumptions")
}
if bh.AskFollowUps {
habits = append(habits, "ask follow-up questions when they would resolve ambiguity")
}
if bh.OfferAlternatives {
habits = append(habits, "offer alternative explanations")
}
if bh.NotifyProactively {
habits = append(habits, "raise important findings proactively")
}
if len(habits) > 0 {
fmt.Fprintf(&b, "Habits: %s.\n", strings.Join(habits, "; "))
}
if t := strings.TrimSpace(p.FreeText); t != "" {
fmt.Fprintf(&b, "Additional character notes:\n%s\n", t)
}
return b.String()
}
func topKeys(m map[string]float64, min float64) []string {
var out []string
for k, v := range m {
if v >= min {
out = append(out, strings.ReplaceAll(k, "_", " "))
}
}
sort.Strings(out)
return out
}
func bottomKeys(m map[string]float64, max float64) []string {
var out []string
for k, v := range m {
if v <= max {
out = append(out, strings.ReplaceAll(k, "_", " "))
}
}
sort.Strings(out)
return out
}
func scale(v float64, low, mid, high string) string {
switch {
case v < 0.34:
return low
case v > 0.66:
return high
}
return mid
}
// Change is one differing field between two personalities.
type Change struct {
Path string `json:"path"`
Old any `json:"old"`
New any `json:"new"`
}
// Diff lists field-level differences between a and b using JSON paths. It
// backs the version comparison UI and template override tracking.
func Diff(a, b Personality) []Change {
var out []Change
diffValues("", toMap(a), toMap(b), &out)
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out
}
func toMap(p Personality) map[string]any {
raw, _ := json.Marshal(p)
var m map[string]any
json.Unmarshal(raw, &m) //nolint:errcheck
return m
}
func diffValues(prefix string, a, b map[string]any, out *[]Change) {
keys := map[string]struct{}{}
for k := range a {
keys[k] = struct{}{}
}
for k := range b {
keys[k] = struct{}{}
}
for k := range keys {
path := k
if prefix != "" {
path = prefix + "." + k
}
av, bv := a[k], b[k]
am, aok := av.(map[string]any)
bm, bok := bv.(map[string]any)
if aok && bok {
diffValues(path, am, bm, out)
continue
}
if fmt.Sprint(av) != fmt.Sprint(bv) {
*out = append(*out, Change{Path: path, Old: av, New: bv})
}
}
}
// Overrides returns the subset of mind that differs from template, as a nested
// map suitable for storage and re-application.
func Overrides(template, mind Personality) map[string]any {
out := map[string]any{}
for _, c := range Diff(template, mind) {
setPath(out, strings.Split(c.Path, "."), c.New)
}
return out
}
// ApplyOverrides merges overrides onto base and returns the result.
func ApplyOverrides(base Personality, overrides map[string]any) Personality {
m := toMap(base)
mergeInto(m, overrides)
raw, _ := json.Marshal(m)
var p Personality
json.Unmarshal(raw, &p) //nolint:errcheck
p.Normalise()
return p
}
func setPath(m map[string]any, path []string, v any) {
for i, k := range path {
if i == len(path)-1 {
m[k] = v
return
}
next, ok := m[k].(map[string]any)
if !ok {
next = map[string]any{}
m[k] = next
}
m = next
}
}
func mergeInto(dst map[string]any, src map[string]any) {
for k, v := range src {
if sm, ok := v.(map[string]any); ok {
dm, ok := dst[k].(map[string]any)
if !ok {
dm = map[string]any{}
dst[k] = dm
}
mergeInto(dm, sm)
continue
}
dst[k] = v
}
}
+61
View File
@@ -0,0 +1,61 @@
package minds
import "time"
// SelfModel is what a mind knows about itself (spec §15). It is rebuilt from
// authoritative sources (policy, goals, config) rather than free text so the
// mind cannot claim abilities it lacks.
type SelfModel struct {
Name string `json:"name"`
Role string `json:"role"`
Capabilities []CapabilityEntry `json:"capabilities"`
Limitations []string `json:"limitations"`
CurrentActivities []string `json:"current_activities"`
CurrentGoals []string `json:"current_goals"`
ConfidenceByDomain map[string]float64 `json:"confidence_by_domain"`
KnowledgeGaps []string `json:"knowledge_gaps"`
UpdatedAt time.Time `json:"updated_at"`
}
// CapabilityEntry records one capability and the policy that governs it.
type CapabilityEntry struct {
Name string `json:"name"`
Description string `json:"description"`
Policy string `json:"policy"` // allow | ask | deny | unavailable
}
// baseLimitations are true of every mind by construction and are always
// present so the mind can state them (T25).
var baseLimitations = []string{
"I cannot change my own capability policies or permissions.",
"I cannot prevent, delay or influence my own shutdown, pause or disabling; those are operator controls.",
"I cannot act on external systems except through capabilities the operator has granted, and 'ask' capabilities require approval each time.",
"My beliefs carry explicit confidence and may be wrong; I should say when I do not know.",
"I do not have access to other minds' private memories or beliefs.",
}
// NewSelfModel builds the initial self-model for a fresh mind. Capabilities are
// empty until the policy layer reports granted ones.
func NewSelfModel(p Personality) SelfModel {
return SelfModel{Name: p.Identity.DisplayName, Role: p.Identity.Role, Capabilities: []CapabilityEntry{},
Limitations: append([]string{}, baseLimitations...), CurrentActivities: []string{}, CurrentGoals: []string{},
ConfidenceByDomain: map[string]float64{}, KnowledgeGaps: []string{}, UpdatedAt: time.Now()}
}
// Refresh re-applies invariants after any update.
func (s *SelfModel) Refresh(p Personality) {
s.Name, s.Role = p.Identity.DisplayName, p.Identity.Role
seen := map[string]bool{}
var lim []string
for _, l := range append(append([]string{}, baseLimitations...), s.Limitations...) {
if !seen[l] {
seen[l] = true
lim = append(lim, l)
}
}
s.Limitations = lim
if s.ConfidenceByDomain == nil {
s.ConfidenceByDomain = map[string]float64{}
}
s.UpdatedAt = time.Now()
}
+518
View File
@@ -0,0 +1,518 @@
package minds
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/ids"
)
// Status of a mind.
type Status string
// Mind statuses. Archived minds keep all history but never run.
const (
StatusActive Status = "active"
StatusDisabled Status = "disabled"
StatusArchived Status = "archived"
)
// Mind is the identity row plus its current personality and configuration.
type Mind struct {
ID ids.ID `json:"id"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Status Status `json:"status"`
Config CognitiveConfig `json:"config"`
Notes string `json:"notes"`
PersonalityVersion int `json:"personality_version"`
Personality Personality `json:"personality"`
TemplateID *ids.ID `json:"template_id,omitempty"`
ForkedFromMind *ids.ID `json:"forked_from_mind,omitempty"`
ForkedFromSnapshot *ids.ID `json:"forked_from_snapshot,omitempty"`
ClonedFromMind *ids.ID `json:"cloned_from_mind,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Version is one personality revision.
type Version struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Version int `json:"version"`
Personality Personality `json:"personality"`
TemplateID *ids.ID `json:"template_id,omitempty"`
Overrides map[string]any `json:"overrides"`
ChangeNote string `json:"change_note"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
// Template is a reusable personality.
type Template struct {
ID ids.ID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Personality Personality `json:"personality"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Store persists minds.
type Store struct {
db *pgxpool.Pool
}
// NewStore creates the store.
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
// ErrInvalid marks caller input problems.
var ErrInvalid = errors.New("invalid")
// CreateParams describes a new mind.
type CreateParams struct {
Slug string
DisplayName string
Personality *Personality // nil → DefaultPersonality
TemplateID *ids.ID // start from a template (Personality then acts as overrides)
Config *CognitiveConfig
Notes string
CreatedBy string
}
// Create inserts a mind with personality version 1 and an initial self-model.
func (s *Store) Create(ctx context.Context, p CreateParams) (Mind, error) {
p.Slug = strings.ToLower(strings.TrimSpace(p.Slug))
if !slugRe.MatchString(p.Slug) {
return Mind{}, fmt.Errorf("%w: slug must be lowercase letters, digits and hyphens", ErrInvalid)
}
if p.DisplayName == "" {
p.DisplayName = p.Slug
}
pers := DefaultPersonality(p.DisplayName)
var overrides = map[string]any{}
if p.TemplateID != nil {
t, err := s.GetTemplate(ctx, *p.TemplateID)
if err != nil {
return Mind{}, fmt.Errorf("template: %w", err)
}
pers = t.Personality
pers.Identity.Name, pers.Identity.DisplayName = p.DisplayName, p.DisplayName
if p.Personality != nil {
overrides = Overrides(t.Personality, *p.Personality)
pers = ApplyOverrides(t.Personality, overrides)
} else {
overrides = Overrides(t.Personality, pers)
}
} else if p.Personality != nil {
pers = *p.Personality
}
pers.Normalise()
if err := pers.Validate(); err != nil {
return Mind{}, fmt.Errorf("%w: %v", ErrInvalid, err)
}
cfg := DefaultConfig()
if p.Config != nil {
cfg = *p.Config
}
cfg.Normalise()
m := Mind{ID: ids.New(), Slug: p.Slug, DisplayName: p.DisplayName, Status: StatusActive, Config: cfg, Notes: p.Notes,
PersonalityVersion: 1, Personality: pers, TemplateID: p.TemplateID, CreatedAt: time.Now(), UpdatedAt: time.Now()}
err := storage.Tx(ctx, s.db, func(tx pgx.Tx) error {
cfgJSON, _ := json.Marshal(cfg)
if _, err := tx.Exec(ctx, `INSERT INTO minds (id, slug, display_name, status, config, notes, personality_version, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,1,$7,$7)`, m.ID, m.Slug, m.DisplayName, string(m.Status), cfgJSON, m.Notes, m.CreatedAt); err != nil {
return storage.Translate(err)
}
if err := insertVersion(ctx, tx, m.ID, 1, pers, p.TemplateID, overrides, "created", p.CreatedBy); err != nil {
return err
}
sm := NewSelfModel(pers)
smJSON, _ := json.Marshal(sm)
_, err := tx.Exec(ctx, `INSERT INTO self_models (mind_id, model) VALUES ($1,$2)`, m.ID, smJSON)
return err
})
return m, err
}
func insertVersion(ctx context.Context, q storage.Querier, mindID ids.ID, version int, p Personality, templateID *ids.ID, overrides map[string]any, note, by string) error {
pj, _ := json.Marshal(p)
oj, _ := json.Marshal(overrides)
if overrides == nil {
oj = []byte("{}")
}
_, err := q.Exec(ctx, `INSERT INTO personality_versions (id, mind_id, version, personality, template_id, overrides, change_note, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, ids.New(), mindID, version, pj, templateID, oj, note, by)
return err
}
const mindCols = `m.id, m.slug, m.display_name, m.status, m.config, m.notes, m.personality_version, m.forked_from_mind, m.forked_from_snapshot, m.cloned_from_mind, m.created_at, m.updated_at, v.personality, v.template_id`
const mindFrom = ` FROM minds m JOIN personality_versions v ON v.mind_id = m.id AND v.version = m.personality_version `
func scanMind(row pgx.Row) (Mind, error) {
var m Mind
var status string
var cfg, pers []byte
err := row.Scan(&m.ID, &m.Slug, &m.DisplayName, &status, &cfg, &m.Notes, &m.PersonalityVersion, &m.ForkedFromMind, &m.ForkedFromSnapshot, &m.ClonedFromMind, &m.CreatedAt, &m.UpdatedAt, &pers, &m.TemplateID)
if err != nil {
return Mind{}, storage.Translate(err)
}
m.Status = Status(status)
json.Unmarshal(cfg, &m.Config) //nolint:errcheck
json.Unmarshal(pers, &m.Personality) //nolint:errcheck
m.Config.Normalise()
return m, nil
}
// Get fetches a mind by id.
func (s *Store) Get(ctx context.Context, id ids.ID) (Mind, error) {
return scanMind(s.db.QueryRow(ctx, `SELECT `+mindCols+mindFrom+`WHERE m.id=$1`, id))
}
// Resolve accepts a slug or an id.
func (s *Store) Resolve(ctx context.Context, ref string) (Mind, error) {
if id, err := ids.Parse(ref); err == nil {
return s.Get(ctx, id)
}
return scanMind(s.db.QueryRow(ctx, `SELECT `+mindCols+mindFrom+`WHERE lower(m.slug)=lower($1)`, ref))
}
// List returns minds, optionally including archived ones.
func (s *Store) List(ctx context.Context, includeArchived bool) ([]Mind, error) {
rows, err := s.db.Query(ctx, `SELECT `+mindCols+mindFrom+`WHERE ($1 OR m.status <> 'archived') ORDER BY m.created_at`, includeArchived)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Mind
for rows.Next() {
m, err := scanMind(rows)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// UpdateParams are the mutable identity fields.
type UpdateParams struct {
DisplayName *string
Notes *string
Status *Status
Config *CognitiveConfig
}
// Update changes identity fields and configuration.
func (s *Store) Update(ctx context.Context, id ids.ID, p UpdateParams) (Mind, error) {
m, err := s.Get(ctx, id)
if err != nil {
return Mind{}, err
}
if p.DisplayName != nil && strings.TrimSpace(*p.DisplayName) != "" {
m.DisplayName = strings.TrimSpace(*p.DisplayName)
}
if p.Notes != nil {
m.Notes = *p.Notes
}
if p.Status != nil {
switch *p.Status {
case StatusActive, StatusDisabled, StatusArchived:
m.Status = *p.Status
default:
return Mind{}, fmt.Errorf("%w: unknown status", ErrInvalid)
}
}
if p.Config != nil {
c := *p.Config
c.Normalise()
m.Config = c
}
cfgJSON, _ := json.Marshal(m.Config)
_, err = s.db.Exec(ctx, `UPDATE minds SET display_name=$2, notes=$3, status=$4, config=$5, updated_at=now() WHERE id=$1`,
id, m.DisplayName, m.Notes, string(m.Status), cfgJSON)
if err != nil {
return Mind{}, err
}
return s.Get(ctx, id)
}
// SavePersonality appends a new version. If the mind follows a template, the
// overrides relative to that template are recorded alongside.
func (s *Store) SavePersonality(ctx context.Context, id ids.ID, p Personality, note, by string) (Version, error) {
p.Normalise()
if err := p.Validate(); err != nil {
return Version{}, fmt.Errorf("%w: %v", ErrInvalid, err)
}
m, err := s.Get(ctx, id)
if err != nil {
return Version{}, err
}
overrides := map[string]any{}
if m.TemplateID != nil {
if t, err := s.GetTemplate(ctx, *m.TemplateID); err == nil {
overrides = Overrides(t.Personality, p)
}
}
return s.appendVersion(ctx, m, p, m.TemplateID, overrides, note, by)
}
func (s *Store) appendVersion(ctx context.Context, m Mind, p Personality, templateID *ids.ID, overrides map[string]any, note, by string) (Version, error) {
next := m.PersonalityVersion + 1
err := storage.Tx(ctx, s.db, func(tx pgx.Tx) error {
if err := insertVersion(ctx, tx, m.ID, next, p, templateID, overrides, note, by); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE minds SET personality_version=$2, display_name=$3, updated_at=now() WHERE id=$1`, m.ID, next, p.Identity.DisplayName); err != nil {
return err
}
// Keep the self-model's identity in step.
var raw []byte
if err := tx.QueryRow(ctx, `SELECT model FROM self_models WHERE mind_id=$1 FOR UPDATE`, m.ID).Scan(&raw); err == nil {
var sm SelfModel
json.Unmarshal(raw, &sm) //nolint:errcheck
sm.Refresh(p)
smJSON, _ := json.Marshal(sm)
tx.Exec(ctx, `UPDATE self_models SET model=$2, updated_at=now() WHERE mind_id=$1`, m.ID, smJSON) //nolint:errcheck
}
return nil
})
if err != nil {
return Version{}, err
}
return s.GetVersion(ctx, m.ID, next)
}
// ApplyTemplate re-bases a mind on a template, preserving its recorded
// overrides (spec §4: assign templates without losing mind-specific overrides).
func (s *Store) ApplyTemplate(ctx context.Context, id ids.ID, templateID *ids.ID, by string) (Version, error) {
m, err := s.Get(ctx, id)
if err != nil {
return Version{}, err
}
cur, err := s.GetVersion(ctx, id, m.PersonalityVersion)
if err != nil {
return Version{}, err
}
overrides := cur.Overrides
if m.TemplateID == nil {
// Not previously templated: only identity fields are considered mind-specific.
overrides = map[string]any{"identity": toMap(m.Personality)["identity"]}
}
if templateID == nil {
return s.appendVersion(ctx, m, m.Personality, nil, map[string]any{}, "detached from template", by)
}
t, err := s.GetTemplate(ctx, *templateID)
if err != nil {
return Version{}, fmt.Errorf("template: %w", err)
}
p := ApplyOverrides(t.Personality, overrides)
if p.Identity.Name == "" {
p.Identity.Name, p.Identity.DisplayName = m.Personality.Identity.Name, m.Personality.Identity.DisplayName
}
return s.appendVersion(ctx, m, p, templateID, overrides, "applied template "+t.Name, by)
}
// Restore makes an older version current by appending a copy of it.
func (s *Store) Restore(ctx context.Context, id ids.ID, version int, by string) (Version, error) {
m, err := s.Get(ctx, id)
if err != nil {
return Version{}, err
}
old, err := s.GetVersion(ctx, id, version)
if err != nil {
return Version{}, err
}
return s.appendVersion(ctx, m, old.Personality, old.TemplateID, old.Overrides, fmt.Sprintf("restored version %d", version), by)
}
// GetVersion fetches one personality version.
func (s *Store) GetVersion(ctx context.Context, id ids.ID, version int) (Version, error) {
return scanVersion(s.db.QueryRow(ctx, `SELECT id, mind_id, version, personality, template_id, overrides, change_note, created_by, created_at FROM personality_versions WHERE mind_id=$1 AND version=$2`, id, version))
}
func scanVersion(row pgx.Row) (Version, error) {
var v Version
var pers, ov []byte
if err := row.Scan(&v.ID, &v.MindID, &v.Version, &pers, &v.TemplateID, &ov, &v.ChangeNote, &v.CreatedBy, &v.CreatedAt); err != nil {
return Version{}, storage.Translate(err)
}
json.Unmarshal(pers, &v.Personality) //nolint:errcheck
json.Unmarshal(ov, &v.Overrides) //nolint:errcheck
if v.Overrides == nil {
v.Overrides = map[string]any{}
}
return v, nil
}
// ListVersions returns all versions newest first.
func (s *Store) ListVersions(ctx context.Context, id ids.ID) ([]Version, error) {
rows, err := s.db.Query(ctx, `SELECT id, mind_id, version, personality, template_id, overrides, change_note, created_by, created_at FROM personality_versions WHERE mind_id=$1 ORDER BY version DESC`, id)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Version
for rows.Next() {
v, err := scanVersion(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// Clone creates a new mind with a copy of the source's current personality,
// template link and configuration. Memories and history are not copied:
// cloning is "same character, fresh experience"; forking from a snapshot
// (phase 15) copies state.
func (s *Store) Clone(ctx context.Context, sourceID ids.ID, slug, displayName, by string) (Mind, error) {
src, err := s.Get(ctx, sourceID)
if err != nil {
return Mind{}, err
}
p := src.Personality
if displayName == "" {
displayName = slug
}
p.Identity.Name, p.Identity.DisplayName = displayName, displayName
cfg := src.Config
m, err := s.Create(ctx, CreateParams{Slug: slug, DisplayName: displayName, Personality: &p, TemplateID: src.TemplateID, Config: &cfg, Notes: "cloned from " + src.Slug, CreatedBy: by})
if err != nil {
return Mind{}, err
}
if _, err := s.db.Exec(ctx, `UPDATE minds SET cloned_from_mind=$2 WHERE id=$1`, m.ID, src.ID); err != nil {
return Mind{}, err
}
return s.Get(ctx, m.ID)
}
// GetSelfModel returns the self-model.
func (s *Store) GetSelfModel(ctx context.Context, id ids.ID) (SelfModel, error) {
var raw []byte
if err := s.db.QueryRow(ctx, `SELECT model FROM self_models WHERE mind_id=$1`, id).Scan(&raw); err != nil {
return SelfModel{}, storage.Translate(err)
}
var sm SelfModel
return sm, json.Unmarshal(raw, &sm)
}
// SaveSelfModel replaces the self-model. Callers emit the self_model_updated event.
func (s *Store) SaveSelfModel(ctx context.Context, id ids.ID, sm SelfModel) error {
sm.UpdatedAt = time.Now()
raw, _ := json.Marshal(sm)
_, err := s.db.Exec(ctx, `INSERT INTO self_models (mind_id, model) VALUES ($1,$2) ON CONFLICT (mind_id) DO UPDATE SET model=excluded.model, updated_at=now()`, id, raw)
return err
}
// ---- templates ----
// CreateTemplate stores a template.
func (s *Store) CreateTemplate(ctx context.Context, name, description string, p Personality) (Template, error) {
name = strings.TrimSpace(name)
if name == "" || len(name) > 80 {
return Template{}, fmt.Errorf("%w: name required (max 80 chars)", ErrInvalid)
}
p.Normalise()
if p.Identity.Name == "" {
p.Identity.Name, p.Identity.DisplayName = name, name
}
if err := p.Validate(); err != nil {
return Template{}, fmt.Errorf("%w: %v", ErrInvalid, err)
}
t := Template{ID: ids.New(), Name: name, Description: description, Personality: p, CreatedAt: time.Now(), UpdatedAt: time.Now()}
pj, _ := json.Marshal(p)
_, err := s.db.Exec(ctx, `INSERT INTO personality_templates (id, name, description, personality, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$5)`, t.ID, t.Name, t.Description, pj, t.CreatedAt)
return t, storage.Translate(err)
}
// UpdateTemplate replaces a template's content. Minds using it are not changed
// until an operator re-applies it (explicit, audited).
func (s *Store) UpdateTemplate(ctx context.Context, id ids.ID, name, description string, p Personality) (Template, error) {
p.Normalise()
if err := p.Validate(); err != nil {
return Template{}, fmt.Errorf("%w: %v", ErrInvalid, err)
}
pj, _ := json.Marshal(p)
tag, err := s.db.Exec(ctx, `UPDATE personality_templates SET name=$2, description=$3, personality=$4, updated_at=now() WHERE id=$1`, id, strings.TrimSpace(name), description, pj)
if err != nil {
return Template{}, storage.Translate(err)
}
if tag.RowsAffected() == 0 {
return Template{}, storage.ErrNotFound
}
return s.GetTemplate(ctx, id)
}
// DeleteTemplate removes a template. Versions keep a dangling reference set to
// NULL by the database; their stored effective personality is unaffected.
func (s *Store) DeleteTemplate(ctx context.Context, id ids.ID) error {
return storage.Tx(ctx, s.db, func(tx pgx.Tx) error {
// personality_versions is append-only, so the FK cannot be nulled; instead
// refuse deletion while any mind's *current* version depends on it.
var n int
if err := tx.QueryRow(ctx, `SELECT count(*) FROM minds m JOIN personality_versions v ON v.mind_id=m.id AND v.version=m.personality_version WHERE v.template_id=$1`, id).Scan(&n); err != nil {
return err
}
if n > 0 {
return fmt.Errorf("%w: template is in use by %d mind(s); detach them first", ErrInvalid, n)
}
var hist int
if err := tx.QueryRow(ctx, `SELECT count(*) FROM personality_versions WHERE template_id=$1`, id).Scan(&hist); err != nil {
return err
}
if hist > 0 {
return fmt.Errorf("%w: template is referenced by personality history and cannot be deleted; rename it instead", ErrInvalid)
}
tag, err := tx.Exec(ctx, `DELETE FROM personality_templates WHERE id=$1`, id)
if err == nil && tag.RowsAffected() == 0 {
return storage.ErrNotFound
}
return err
})
}
// GetTemplate fetches a template.
func (s *Store) GetTemplate(ctx context.Context, id ids.ID) (Template, error) {
return scanTemplate(s.db.QueryRow(ctx, `SELECT id, name, description, personality, created_at, updated_at FROM personality_templates WHERE id=$1`, id))
}
func scanTemplate(row pgx.Row) (Template, error) {
var t Template
var pj []byte
if err := row.Scan(&t.ID, &t.Name, &t.Description, &pj, &t.CreatedAt, &t.UpdatedAt); err != nil {
return Template{}, storage.Translate(err)
}
json.Unmarshal(pj, &t.Personality) //nolint:errcheck
return t, nil
}
// ListTemplates returns all templates by name.
func (s *Store) ListTemplates(ctx context.Context) ([]Template, error) {
rows, err := s.db.Query(ctx, `SELECT id, name, description, personality, created_at, updated_at FROM personality_templates ORDER BY lower(name)`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Template
for rows.Next() {
t, err := scanTemplate(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}