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
+1
View File
@@ -5,5 +5,6 @@ All notable changes to Noema are recorded here. Format follows Keep a Changelog;
## [Unreleased] ## [Unreleased]
### Added ### Added
- Phase 2 minds: structured personalities with append-only version history, diff, restore, templates with preserved overrides, self-model, mind lifecycle (create, clone, disable, archive), API, UI editor and CLI.
- Phase 1 foundation: configuration, structured logging, metrics, migrations, local authentication (Argon2id, sessions, CSRF, lockout, roles, API tokens), audit log, JSON API skeleton, server-rendered admin UI with light/dark theme, `noemad` and `noemactl`. - Phase 1 foundation: configuration, structured logging, metrics, migrations, local authentication (Argon2id, sessions, CSRF, lockout, roles, API tokens), audit log, JSON API skeleton, server-rendered admin UI with light/dark theme, `noemad` and `noemactl`.
- Repository foundation: plan, architecture, security model, threat model, ADRs, Makefile, container files. - Repository foundation: plan, architecture, security model, threat model, ADRs, Makefile, container files.
+9 -7
View File
@@ -50,13 +50,15 @@ An administrator can: start Noema → log in → create a mind → configure per
- ☑ Tests: config precedence, migration up/down + append-only trigger, hashing, session lifecycle, lockout, CSRF, roles, API envelope - ☑ Tests: config precedence, migration up/down + append-only trigger, hashing, session lifecycle, lockout, CSRF, roles, API envelope
- ☐ TOTP + recovery codes (moved to Phase 16 with the rest of hardening) - ☐ TOTP + recovery codes (moved to Phase 16 with the rest of hardening)
### Phase 2 – Mind model, identity, personality, self-model ### Phase 2 – Mind model, identity, personality, self-model ☑
- ☐ `migrations/0002_minds`: minds, mind_config, personalities, personality_versions (append-only), personality_templates, self_models - ☑ `migrations/0002_minds`: minds, personality_templates, personality_versions (append-only), self_models
- ☐ `internal/minds`: create/clone/disable/archive/list/inspect; structured identity, values, traits, communication style, behavioural preferences; free-form text; versions with diff/rollback; templates with overrides; export/import of personality - ☑ `internal/minds`: structured personality (identity, values, traits, communication, behaviour, free text) with normalisation, validation, deterministic brief, field diff, template overrides; cognitive config with defaults; self-model with structural limitations
- ☐ `internal/selfmodel`: capability inventory derived from policy, limitations, activities - ☑ Store: create/clone/update status/list/resolve by slug or id, versions with restore, templates CRUD with in-use protection, apply/detach template preserving overrides
- ☐ API + UI: mind list, create, overview skeleton, personality editor (structured + text, preview, history, diff, rollback, templates), audit events - ☑ API: minds, personality (get/put/brief/versions/diff/restore/template), templates, self-model
- ☐ `noemactl mind list|create|inspect` - ☑ UI: minds list + create, mind overview (identity, brief, self-model, config, lifecycle, clone), personality editor (sliders, preview, history, diff, restore, template, import/export JSON), template editor
- ☐ Tests: versioning, rollback, template overlay, audit emission - ☑ `noemactl mind list|create|inspect|clone|personality`, `noemactl template list`
- ☑ Audit events for every change; docs/personality.md
- ☑ Tests: brief determinism, diff/override round trip, normalisation, store lifecycle including append-only history
### Phase 3 – Immutable cognitive event store ### Phase 3 – Immutable cognitive event store
- ☐ `pkg/events`: types, categories, evidence refs, payload contracts - ☐ `pkg/events`: types, categories, evidence refs, payload contracts
+4
View File
@@ -40,6 +40,9 @@ type command struct {
var commands []command var commands []command
// extraCommands are registered by other files in this package via init.
var extraCommands []command
func main() { func main() {
commands = []command{ commands = []command{
{"status", "show daemon status", cmdStatus}, {"status", "show daemon status", cmdStatus},
@@ -47,6 +50,7 @@ func main() {
{"user", "create <username> [-role r] – manage users (offline)", cmdUser}, {"user", "create <username> [-role r] – manage users (offline)", cmdUser},
{"version", "print version", func([]string) int { fmt.Println(version.Version); return exitOK }}, {"version", "print version", func([]string) int { fmt.Println(version.Version); return exitOK }},
} }
commands = append(commands, extraCommands...)
if len(os.Args) < 2 || os.Args[1] == "-h" || os.Args[1] == "--help" || os.Args[1] == "help" { if len(os.Args) < 2 || os.Args[1] == "-h" || os.Args[1] == "--help" || os.Args[1] == "help" {
usage() usage()
os.Exit(exitUsage) os.Exit(exitUsage)
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"text/tabwriter"
)
func init() {
extraCommands = append(extraCommands,
command{"mind", "list|create|inspect|clone|personality – manage minds", cmdMind},
command{"template", "list – personality templates", cmdTemplate},
)
}
func cmdMind(args []string) int {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: noemactl mind list|create <slug>|inspect <mind>|clone <mind> <new-slug>|personality <mind>")
return exitUsage
}
switch args[0] {
case "list":
fs := flag.NewFlagSet("mind list", flag.ContinueOnError)
all := fs.Bool("all", false, "include archived")
asJSON := fs.Bool("json", false, "raw JSON")
if fs.Parse(args[1:]) != nil {
return exitUsage
}
var out struct{ Items []map[string]any }
q := ""
if *all {
q = "?include_archived=true"
}
if code, err := apiRequest("GET", "/api/v1/minds"+q, nil, &out); err != nil {
return exitFor(code, err)
}
if *asJSON {
json.NewEncoder(os.Stdout).Encode(out.Items) //nolint:errcheck
return exitOK
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "SLUG\tNAME\tSTATUS\tPERSONALITY\tID")
for _, m := range out.Items {
fmt.Fprintf(tw, "%v\t%v\t%v\tv%v\t%v\n", m["slug"], m["display_name"], m["status"], m["personality_version"], m["id"])
}
tw.Flush()
return exitOK
case "create":
fs := flag.NewFlagSet("mind create", flag.ContinueOnError)
name := fs.String("name", "", "display name")
if len(args) < 2 || fs.Parse(args[2:]) != nil {
fmt.Fprintln(os.Stderr, "usage: noemactl mind create <slug> [-name display]")
return exitUsage
}
var m map[string]any
if code, err := apiRequest("POST", "/api/v1/minds", map[string]any{"slug": args[1], "display_name": *name}, &m); err != nil {
return exitFor(code, err)
}
fmt.Printf("created mind %v (%v)\n", m["slug"], m["id"])
return exitOK
case "inspect":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: noemactl mind inspect <mind>")
return exitUsage
}
var m map[string]any
if code, err := apiRequest("GET", "/api/v1/minds/"+args[1], nil, &m); err != nil {
return exitFor(code, err)
}
var sm map[string]any
apiRequest("GET", "/api/v1/minds/"+args[1]+"/selfmodel", nil, &sm) //nolint:errcheck
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.Encode(map[string]any{"mind": m, "self_model": sm}) //nolint:errcheck
return exitOK
case "clone":
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "usage: noemactl mind clone <mind> <new-slug>")
return exitUsage
}
var m map[string]any
if code, err := apiRequest("POST", "/api/v1/minds/"+args[1]+"/clone", map[string]any{"slug": args[2]}, &m); err != nil {
return exitFor(code, err)
}
fmt.Printf("cloned into %v (%v)\n", m["slug"], m["id"])
return exitOK
case "personality":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: noemactl mind personality <mind>")
return exitUsage
}
var b map[string]string
if code, err := apiRequest("GET", "/api/v1/minds/"+args[1]+"/personality/brief", nil, &b); err != nil {
return exitFor(code, err)
}
fmt.Print(b["brief"])
return exitOK
}
fmt.Fprintln(os.Stderr, "unknown mind subcommand", args[0])
return exitUsage
}
func cmdTemplate(args []string) int {
var out struct{ Items []map[string]any }
if code, err := apiRequest("GET", "/api/v1/personality-templates", nil, &out); err != nil {
return exitFor(code, err)
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "NAME\tDESCRIPTION\tID")
for _, t := range out.Items {
fmt.Fprintf(tw, "%v\t%v\t%v\n", t["name"], t["description"], t["id"])
}
tw.Flush()
return exitOK
}
+2 -1
View File
@@ -18,6 +18,7 @@ import (
"github.com/andydixon/noema/internal/audit" "github.com/andydixon/noema/internal/audit"
"github.com/andydixon/noema/internal/auth" "github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/config" "github.com/andydixon/noema/internal/config"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/observability" "github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/scheduler" "github.com/andydixon/noema/internal/scheduler"
"github.com/andydixon/noema/internal/storage" "github.com/andydixon/noema/internal/storage"
@@ -73,7 +74,7 @@ func run() error {
metrics := observability.NewMetrics() metrics := observability.NewMetrics()
authSvc := auth.NewService(db, cfg.SessionIdleTTL, cfg.SessionAbsoluteTTL) authSvc := auth.NewService(db, cfg.SessionIdleTTL, cfg.SessionAbsoluteTTL)
auditLog := audit.New(db, log) auditLog := audit.New(db, log)
deps := app.Deps{Config: cfg, Log: log, DB: db, Auth: authSvc, Audit: auditLog, Metrics: metrics, Migrations: migrations.FS, Started: time.Now()} deps := app.Deps{Config: cfg, Log: log, DB: db, Auth: authSvc, Audit: auditLog, Metrics: metrics, Minds: minds.NewStore(db), Migrations: migrations.FS, Started: time.Now()}
if err := bootstrapAdmin(ctx, deps); err != nil { if err := bootstrapAdmin(ctx, deps); err != nil {
return err return err
+41
View File
@@ -0,0 +1,41 @@
# Personality
A mind's personality is a **structured document**, not a system prompt. It lives in `personality_versions` (append-only) and the mind row points at the current version.
## Structure
| Section | Fields | Notes |
|---|---|---|
| Identity | name, display name, description, role, pronouns, backstory, origin | The only free-text part prompts always include |
| Values | privacy, evidence, kindness, curiosity, accuracy, autonomy, caution, creativity, persistence | 0–1, fixed vocabulary |
| Traits | curiosity, scepticism, sociability, humour, patience, verbosity, confidence, risk tolerance, novelty preference | 0–1, fixed vocabulary |
| Communication | language, dialect, formality, verbosity, humour style, terminology | |
| Behaviour | challenge assumptions, ask follow-ups, prefer evidence, cite uncertainty, offer alternatives, notify proactively | booleans |
| Free text | additional character notes | supplements, never replaces, the structured fields |
Unknown keys are dropped and numbers are clamped on every write (`Personality.Normalise`).
## Brief
`Personality.Brief()` renders the structured data into a short deterministic paragraph. Cognitive prompts receive the brief, not the JSON, and the editor's *Preview* shows exactly that text. Because it is deterministic it is unit-tested.
## Versions
Every save, import, restore or template application appends a version with a change note and author. Restoring an old version writes a *new* version copying it. The diff view compares any two versions field by field. Deleting history is impossible: the table has an append-only trigger.
## Templates and overrides
A template is a reusable personality. When a mind is based on a template, each version also records the mind's **overrides**: the fields that differ from the template. Re-applying the template (for example after editing it) recomputes `template + overrides`, so mind-specific changes survive. Editing a template never changes minds implicitly; an operator re-applies it and that action is audited.
## Traits and the runtime
Later phases consume the personality as follows:
- attention weights are biased by traits such as curiosity and novelty preference;
- affect baselines derive from traits (patience, confidence);
- the response generator receives the brief and the behavioural preferences;
- notification thresholds honour *notify proactively*.
## Audit
`personality.update`, `personality.import`, `personality.restore`, `personality.template`, `template.create/update/delete` entries are written to the audit log with the field-level diff.
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"github.com/andydixon/noema/internal/audit" "github.com/andydixon/noema/internal/audit"
"github.com/andydixon/noema/internal/auth" "github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/config" "github.com/andydixon/noema/internal/config"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/observability" "github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/storage" "github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations" "github.com/andydixon/noema/migrations"
@@ -26,7 +27,7 @@ func testServer(t *testing.T) (*httptest.Server, string) {
cfg.DatabaseURL = "postgres://test" cfg.DatabaseURL = "postgres://test"
log := slog.New(slog.DiscardHandler) log := slog.New(slog.DiscardHandler)
deps := app.Deps{Config: cfg, Log: log, DB: pool, Auth: auth.NewService(pool, time.Hour, 2*time.Hour), deps := app.Deps{Config: cfg, Log: log, DB: pool, Auth: auth.NewService(pool, time.Hour, 2*time.Hour),
Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Migrations: migrations.FS, Started: time.Now()} Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Minds: minds.NewStore(pool), Migrations: migrations.FS, Started: time.Now()}
ctx := context.Background() ctx := context.Background()
u, err := deps.Auth.CreateUser(ctx, "admin", "administrator-pass", auth.RoleAdministrator) u, err := deps.Auth.CreateUser(ctx, "admin", "administrator-pass", auth.RoleAdministrator)
if err != nil { if err != nil {
+375
View File
@@ -0,0 +1,375 @@
package api
import (
"errors"
"net/http"
"strconv"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/pkg/ids"
)
func (s *Server) mindRoutes() {
m := s.mux
m.Handle("GET /api/v1/minds", require(auth.RoleObserver, s.mindsList))
m.Handle("POST /api/v1/minds", require(auth.RoleAdministrator, s.mindsCreate))
m.Handle("GET /api/v1/minds/{mind}", require(auth.RoleObserver, s.mindGet))
m.Handle("PATCH /api/v1/minds/{mind}", require(auth.RoleAdministrator, s.mindPatch))
m.Handle("POST /api/v1/minds/{mind}/clone", require(auth.RoleAdministrator, s.mindClone))
m.Handle("GET /api/v1/minds/{mind}/selfmodel", require(auth.RoleObserver, s.mindSelfModel))
m.Handle("GET /api/v1/minds/{mind}/personality", require(auth.RoleObserver, s.personalityGet))
m.Handle("PUT /api/v1/minds/{mind}/personality", require(auth.RoleAdministrator, s.personalityPut))
m.Handle("GET /api/v1/minds/{mind}/personality/brief", require(auth.RoleObserver, s.personalityBrief))
m.Handle("GET /api/v1/minds/{mind}/personality/versions", require(auth.RoleObserver, s.personalityVersions))
m.Handle("GET /api/v1/minds/{mind}/personality/versions/{n}", require(auth.RoleObserver, s.personalityVersion))
m.Handle("POST /api/v1/minds/{mind}/personality/versions/{n}/restore", require(auth.RoleAdministrator, s.personalityRestore))
m.Handle("GET /api/v1/minds/{mind}/personality/diff", require(auth.RoleObserver, s.personalityDiff))
m.Handle("POST /api/v1/minds/{mind}/personality/template", require(auth.RoleAdministrator, s.personalityTemplate))
m.Handle("GET /api/v1/personality-templates", require(auth.RoleObserver, s.templatesList))
m.Handle("POST /api/v1/personality-templates", require(auth.RoleAdministrator, s.templatesCreate))
m.Handle("GET /api/v1/personality-templates/{id}", require(auth.RoleObserver, s.templateGet))
m.Handle("PUT /api/v1/personality-templates/{id}", require(auth.RoleAdministrator, s.templatePut))
m.Handle("DELETE /api/v1/personality-templates/{id}", require(auth.RoleAdministrator, s.templateDelete))
}
// mind resolves the {mind} path value (slug or id) or writes an error.
func (s *Server) mind(w http.ResponseWriter, r *http.Request) (minds.Mind, bool) {
m, err := s.deps.Minds.Resolve(r.Context(), r.PathValue("mind"))
if err != nil {
fail(w, r, err)
return minds.Mind{}, false
}
return m, true
}
func actor(r *http.Request) string {
if p, ok := auth.PrincipalFrom(r.Context()); ok {
return p.User.Username
}
return "system"
}
func mindErr(err error) error {
if errors.Is(err, minds.ErrInvalid) {
return ValidationError(err.Error())
}
return err
}
func (s *Server) mindsList(w http.ResponseWriter, r *http.Request) {
list, err := s.deps.Minds.List(r.Context(), r.URL.Query().Get("include_archived") == "true")
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []minds.Mind{}
}
writeJSON(w, http.StatusOK, page[minds.Mind]{Items: list})
}
func (s *Server) mindsCreate(w http.ResponseWriter, r *http.Request) {
var in struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Personality *minds.Personality `json:"personality"`
TemplateID *ids.ID `json:"template_id"`
Config *minds.CognitiveConfig `json:"config"`
Notes string `json:"notes"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
m, err := s.deps.Minds.Create(r.Context(), minds.CreateParams{Slug: in.Slug, DisplayName: in.DisplayName, Personality: in.Personality,
TemplateID: in.TemplateID, Config: in.Config, Notes: in.Notes, CreatedBy: actor(r)})
if err != nil {
fail(w, r, mindErr(err))
return
}
s.deps.Audit.Log(r.Context(), "mind.create", "mind", m.ID.String(), map[string]any{"slug": m.Slug})
writeJSON(w, http.StatusCreated, m)
}
func (s *Server) mindGet(w http.ResponseWriter, r *http.Request) {
if m, ok := s.mind(w, r); ok {
writeJSON(w, http.StatusOK, m)
}
}
func (s *Server) mindPatch(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
var in struct {
DisplayName *string `json:"display_name"`
Notes *string `json:"notes"`
Status *minds.Status `json:"status"`
Config *minds.CognitiveConfig `json:"config"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
out, err := s.deps.Minds.Update(r.Context(), m.ID, minds.UpdateParams{DisplayName: in.DisplayName, Notes: in.Notes, Status: in.Status, Config: in.Config})
if err != nil {
fail(w, r, mindErr(err))
return
}
s.deps.Audit.Log(r.Context(), "mind.update", "mind", m.ID.String(), in)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) mindClone(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
var in struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
c, err := s.deps.Minds.Clone(r.Context(), m.ID, in.Slug, in.DisplayName, actor(r))
if err != nil {
fail(w, r, mindErr(err))
return
}
s.deps.Audit.Log(r.Context(), "mind.clone", "mind", c.ID.String(), map[string]any{"source": m.Slug, "slug": c.Slug})
writeJSON(w, http.StatusCreated, c)
}
func (s *Server) mindSelfModel(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
sm, err := s.deps.Minds.GetSelfModel(r.Context(), m.ID)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, sm)
}
func (s *Server) personalityGet(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
v, err := s.deps.Minds.GetVersion(r.Context(), m.ID, m.PersonalityVersion)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, v)
}
func (s *Server) personalityBrief(w http.ResponseWriter, r *http.Request) {
if m, ok := s.mind(w, r); ok {
writeJSON(w, http.StatusOK, map[string]string{"brief": m.Personality.Brief()})
}
}
func (s *Server) personalityPut(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
var in struct {
Personality minds.Personality `json:"personality"`
ChangeNote string `json:"change_note"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
v, err := s.deps.Minds.SavePersonality(r.Context(), m.ID, in.Personality, in.ChangeNote, actor(r))
if err != nil {
fail(w, r, mindErr(err))
return
}
s.deps.Audit.Log(r.Context(), "personality.update", "mind", m.ID.String(), map[string]any{"version": v.Version, "note": in.ChangeNote, "changes": minds.Diff(m.Personality, v.Personality)})
writeJSON(w, http.StatusOK, v)
}
func (s *Server) personalityVersions(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
vs, err := s.deps.Minds.ListVersions(r.Context(), m.ID)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, page[minds.Version]{Items: vs})
}
func (s *Server) personalityVersion(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil {
fail(w, r, ValidationError("bad version"))
return
}
v, err := s.deps.Minds.GetVersion(r.Context(), m.ID, n)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, v)
}
func (s *Server) personalityRestore(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil {
fail(w, r, ValidationError("bad version"))
return
}
v, err := s.deps.Minds.Restore(r.Context(), m.ID, n, actor(r))
if err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "personality.restore", "mind", m.ID.String(), map[string]any{"restored": n, "version": v.Version})
writeJSON(w, http.StatusOK, v)
}
func (s *Server) personalityDiff(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
a, errA := strconv.Atoi(r.URL.Query().Get("a"))
b, errB := strconv.Atoi(r.URL.Query().Get("b"))
if errA != nil || errB != nil {
fail(w, r, ValidationError("a and b version numbers required"))
return
}
va, err := s.deps.Minds.GetVersion(r.Context(), m.ID, a)
if err != nil {
fail(w, r, err)
return
}
vb, err := s.deps.Minds.GetVersion(r.Context(), m.ID, b)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"a": a, "b": b, "changes": minds.Diff(va.Personality, vb.Personality)})
}
func (s *Server) personalityTemplate(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
var in struct {
TemplateID *ids.ID `json:"template_id"` // null detaches
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
v, err := s.deps.Minds.ApplyTemplate(r.Context(), m.ID, in.TemplateID, actor(r))
if err != nil {
fail(w, r, mindErr(err))
return
}
s.deps.Audit.Log(r.Context(), "personality.template", "mind", m.ID.String(), map[string]any{"template_id": in.TemplateID, "version": v.Version})
writeJSON(w, http.StatusOK, v)
}
func (s *Server) templatesList(w http.ResponseWriter, r *http.Request) {
list, err := s.deps.Minds.ListTemplates(r.Context())
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []minds.Template{}
}
writeJSON(w, http.StatusOK, page[minds.Template]{Items: list})
}
type templateIn struct {
Name string `json:"name"`
Description string `json:"description"`
Personality minds.Personality `json:"personality"`
}
func (s *Server) templatesCreate(w http.ResponseWriter, r *http.Request) {
var in templateIn
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
t, err := s.deps.Minds.CreateTemplate(r.Context(), in.Name, in.Description, in.Personality)
if err != nil {
fail(w, r, mindErr(err))
return
}
s.deps.Audit.Log(r.Context(), "template.create", "personality_template", t.ID.String(), map[string]any{"name": t.Name})
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) templateGet(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
t, err := s.deps.Minds.GetTemplate(r.Context(), id)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) templatePut(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
var in templateIn
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
t, err := s.deps.Minds.UpdateTemplate(r.Context(), id, in.Name, in.Description, in.Personality)
if err != nil {
fail(w, r, mindErr(err))
return
}
s.deps.Audit.Log(r.Context(), "template.update", "personality_template", t.ID.String(), map[string]any{"name": t.Name})
writeJSON(w, http.StatusOK, t)
}
func (s *Server) templateDelete(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
if err := s.deps.Minds.DeleteTemplate(r.Context(), id); err != nil {
fail(w, r, mindErr(err))
return
}
s.deps.Audit.Log(r.Context(), "template.delete", "personality_template", id.String(), nil)
w.WriteHeader(http.StatusNoContent)
}
+2
View File
@@ -51,6 +51,8 @@ func (s *Server) routes() {
m.Handle("GET /api/v1/me/tokens", require(auth.RoleObserver, s.myTokens)) m.Handle("GET /api/v1/me/tokens", require(auth.RoleObserver, s.myTokens))
m.Handle("DELETE /api/v1/me/tokens/{id}", require(auth.RoleObserver, s.myTokenRevoke)) m.Handle("DELETE /api/v1/me/tokens/{id}", require(auth.RoleObserver, s.myTokenRevoke))
s.mindRoutes()
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint") writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")
}) })
+2
View File
@@ -13,6 +13,7 @@ import (
"github.com/andydixon/noema/internal/audit" "github.com/andydixon/noema/internal/audit"
"github.com/andydixon/noema/internal/auth" "github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/config" "github.com/andydixon/noema/internal/config"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/observability" "github.com/andydixon/noema/internal/observability"
) )
@@ -24,6 +25,7 @@ type Deps struct {
Auth *auth.Service Auth *auth.Service
Audit *audit.Logger Audit *audit.Logger
Metrics *observability.Metrics Metrics *observability.Metrics
Minds *minds.Store
Migrations fs.FS Migrations fs.FS
Started time.Time Started time.Time
} }
+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()
}
+414
View File
@@ -0,0 +1,414 @@
package ui
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/ids"
)
func (u *UI) mindRoutes(m *http.ServeMux) {
m.Handle("GET /minds", u.require(auth.RoleObserver, u.mindsList))
m.Handle("POST /minds", u.require(auth.RoleAdministrator, u.mindsCreate))
m.Handle("GET /minds/{mind}", u.require(auth.RoleObserver, u.mindOverview))
m.Handle("POST /minds/{mind}/status", u.require(auth.RoleAdministrator, u.mindStatus))
m.Handle("POST /minds/{mind}/notes", u.require(auth.RoleAdministrator, u.mindNotes))
m.Handle("POST /minds/{mind}/clone", u.require(auth.RoleAdministrator, u.mindClone))
m.Handle("GET /minds/{mind}/personality", u.require(auth.RoleObserver, u.personalityEditor))
m.Handle("GET /minds/{mind}/personality.json", u.require(auth.RoleObserver, u.personalityExport))
m.Handle("POST /minds/{mind}/personality", u.require(auth.RoleAdministrator, u.personalitySave))
m.Handle("POST /minds/{mind}/personality/preview", u.require(auth.RoleObserver, u.personalityPreview))
m.Handle("POST /minds/{mind}/personality/import", u.require(auth.RoleAdministrator, u.personalityImport))
m.Handle("POST /minds/{mind}/personality/template", u.require(auth.RoleAdministrator, u.personalityTemplate))
m.Handle("POST /minds/{mind}/personality/versions/{n}/restore", u.require(auth.RoleAdministrator, u.personalityRestore))
m.Handle("GET /minds/{mind}/personality/diff", u.require(auth.RoleObserver, u.personalityDiff))
m.Handle("GET /personality-templates", u.require(auth.RoleObserver, u.templates))
m.Handle("POST /personality-templates", u.require(auth.RoleAdministrator, u.templateCreate))
m.Handle("GET /personality-templates/{id}", u.require(auth.RoleObserver, u.templateEdit))
m.Handle("POST /personality-templates/{id}", u.require(auth.RoleAdministrator, u.templateSave))
m.Handle("POST /personality-templates/{id}/delete", u.require(auth.RoleAdministrator, u.templateDelete))
}
func (u *UI) mind(w http.ResponseWriter, r *http.Request) (minds.Mind, bool) {
m, err := u.deps.Minds.Resolve(r.Context(), r.PathValue("mind"))
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
u.errorPage(w, r, http.StatusNotFound, "No such mind.")
} else {
u.errorPage(w, r, http.StatusInternalServerError, "Could not load mind.")
}
return minds.Mind{}, false
}
return m, true
}
func username(r *http.Request) string {
if p, ok := auth.PrincipalFrom(r.Context()); ok {
return p.User.Username
}
return "system"
}
func (u *UI) mindsList(w http.ResponseWriter, r *http.Request) {
list, err := u.deps.Minds.List(r.Context(), r.URL.Query().Get("include_archived") == "1")
if err != nil {
u.errorPage(w, r, 500, "Could not list minds.")
return
}
tmpls, _ := u.deps.Minds.ListTemplates(r.Context())
u.render(w, r, "minds", "Minds", map[string]any{"Minds": list, "Templates": tmpls})
}
func (u *UI) mindsCreate(w http.ResponseWriter, r *http.Request) {
r.ParseForm() //nolint:errcheck
p := minds.CreateParams{Slug: r.PostFormValue("slug"), DisplayName: strings.TrimSpace(r.PostFormValue("display_name")), CreatedBy: username(r)}
if t := r.PostFormValue("template_id"); t != "" {
if id, err := ids.Parse(t); err == nil {
p.TemplateID = &id
}
}
m, err := u.deps.Minds.Create(r.Context(), p)
if err != nil {
msg := err.Error()
if errors.Is(err, storage.ErrConflict) {
msg = "A mind with that slug already exists."
}
u.flash(w, r, "error", msg, "/minds")
return
}
u.deps.Audit.Log(r.Context(), "mind.create", "mind", m.ID.String(), map[string]any{"slug": m.Slug})
u.flash(w, r, "ok", "Mind created. Shape its personality next.", "/minds/"+m.Slug+"/personality")
}
func (u *UI) mindOverview(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
sm, _ := u.deps.Minds.GetSelfModel(r.Context(), m.ID)
u.render(w, r, "mind", m.DisplayName, map[string]any{"Mind": m, "Brief": m.Personality.Brief(), "SelfModel": sm})
}
func (u *UI) mindStatus(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
st := minds.Status(r.PostFormValue("status"))
if _, err := u.deps.Minds.Update(r.Context(), m.ID, minds.UpdateParams{Status: &st}); err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug)
return
}
u.deps.Audit.Log(r.Context(), "mind.status", "mind", m.ID.String(), map[string]any{"status": st})
u.flash(w, r, "ok", "Mind is now "+string(st)+".", "/minds/"+m.Slug)
}
func (u *UI) mindNotes(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
notes := r.PostFormValue("notes")
if _, err := u.deps.Minds.Update(r.Context(), m.ID, minds.UpdateParams{Notes: &notes}); err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug)
return
}
u.deps.Audit.Log(r.Context(), "mind.notes", "mind", m.ID.String(), nil)
u.flash(w, r, "ok", "Notes saved.", "/minds/"+m.Slug)
}
func (u *UI) mindClone(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
c, err := u.deps.Minds.Clone(r.Context(), m.ID, r.PostFormValue("slug"), strings.TrimSpace(r.PostFormValue("display_name")), username(r))
if err != nil {
msg := err.Error()
if errors.Is(err, storage.ErrConflict) {
msg = "A mind with that slug already exists."
}
u.flash(w, r, "error", msg, "/minds/"+m.Slug)
return
}
u.deps.Audit.Log(r.Context(), "mind.clone", "mind", c.ID.String(), map[string]any{"source": m.Slug, "slug": c.Slug})
u.flash(w, r, "ok", "Cloned into "+c.DisplayName+".", "/minds/"+c.Slug)
}
func (u *UI) personalityData(r *http.Request, m minds.Mind) map[string]any {
versions, _ := u.deps.Minds.ListVersions(r.Context(), m.ID)
tmpls, _ := u.deps.Minds.ListTemplates(r.Context())
data := map[string]any{"Mind": m, "Versions": versions, "Templates": tmpls}
if m.TemplateID != nil {
if t, err := u.deps.Minds.GetTemplate(r.Context(), *m.TemplateID); err == nil {
data["Template"] = t
}
if v, err := u.deps.Minds.GetVersion(r.Context(), m.ID, m.PersonalityVersion); err == nil {
data["Overrides"] = flattenKeys("", v.Overrides)
}
}
return data
}
func flattenKeys(prefix string, m map[string]any) map[string]any {
out := map[string]any{}
for k, v := range m {
path := k
if prefix != "" {
path = prefix + "." + k
}
if sub, ok := v.(map[string]any); ok {
for kk, vv := range flattenKeys(path, sub) {
out[kk] = vv
}
continue
}
out[path] = v
}
return out
}
func (u *UI) personalityEditor(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
u.render(w, r, "personality", m.DisplayName+" · Personality", u.personalityData(r, m))
}
func (u *UI) personalityExport(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="`+m.Slug+`-personality-v`+strconv.Itoa(m.PersonalityVersion)+`.json"`)
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
enc.Encode(map[string]any{"schema": "noema.personality/1", "mind": m.Slug, "version": m.PersonalityVersion, "personality": m.Personality}) //nolint:errcheck
}
// personalityFromForm reads the structured editor fields.
func personalityFromForm(r *http.Request) minds.Personality {
f := r.PostForm
num := func(k string) float64 { v, _ := strconv.ParseFloat(f.Get(k), 64); return v }
p := minds.Personality{
Identity: minds.Identity{Name: f.Get("identity.name"), DisplayName: f.Get("identity.display_name"), Description: f.Get("identity.description"),
Role: f.Get("identity.role"), Pronouns: f.Get("identity.pronouns"), Backstory: f.Get("identity.backstory"), Origin: f.Get("identity.origin")},
Values: map[string]float64{}, Traits: map[string]float64{},
Communication: minds.Communication{Language: f.Get("communication.language"), Dialect: f.Get("communication.dialect"), Formality: num("communication.formality"),
Verbosity: num("communication.verbosity"), HumourStyle: f.Get("communication.humour_style"), Terminology: f.Get("communication.terminology")},
Behaviour: minds.Behaviour{ChallengeAssumptions: f.Get("behaviour.challenge_assumptions") != "", AskFollowUps: f.Get("behaviour.ask_follow_ups") != "",
PreferEvidence: f.Get("behaviour.prefer_evidence") != "", CiteUncertainty: f.Get("behaviour.cite_uncertainty") != "",
OfferAlternatives: f.Get("behaviour.offer_alternatives") != "", NotifyProactively: f.Get("behaviour.notify_proactively") != ""},
FreeText: f.Get("free_text"),
}
for _, k := range minds.ValueKeys {
p.Values[k] = num("values." + k)
}
for _, k := range minds.TraitKeys {
p.Traits[k] = num("traits." + k)
}
p.Normalise()
return p
}
func (u *UI) personalitySave(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
r.ParseForm() //nolint:errcheck
p := personalityFromForm(r)
v, err := u.deps.Minds.SavePersonality(r.Context(), m.ID, p, r.PostFormValue("change_note"), username(r))
if err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/personality")
return
}
u.deps.Audit.Log(r.Context(), "personality.update", "mind", m.ID.String(), map[string]any{"version": v.Version, "note": v.ChangeNote, "changes": minds.Diff(m.Personality, v.Personality)})
u.flash(w, r, "ok", "Saved as version "+strconv.Itoa(v.Version)+".", "/minds/"+m.Slug+"/personality")
}
func (u *UI) personalityPreview(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
r.ParseForm() //nolint:errcheck
p := personalityFromForm(r)
m.Personality = p // show the edited values back in the form
data := u.personalityData(r, m)
data["Preview"] = p.Brief()
u.render(w, r, "personality", m.DisplayName+" · Personality", data)
}
func (u *UI) personalityImport(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
raw := strings.TrimSpace(r.PostFormValue("json"))
if len(raw) > 64<<10 {
u.flash(w, r, "error", "Import too large.", "/minds/"+m.Slug+"/personality")
return
}
// Accept either a bare personality or the export envelope.
var env struct {
Personality *minds.Personality `json:"personality"`
}
var p minds.Personality
dec := json.NewDecoder(strings.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil || p.Identity.Name == "" {
if err := json.Unmarshal([]byte(raw), &env); err != nil || env.Personality == nil {
u.flash(w, r, "error", "Not a valid personality document.", "/minds/"+m.Slug+"/personality")
return
}
p = *env.Personality
}
v, err := u.deps.Minds.SavePersonality(r.Context(), m.ID, p, "imported", username(r))
if err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/personality")
return
}
u.deps.Audit.Log(r.Context(), "personality.import", "mind", m.ID.String(), map[string]any{"version": v.Version})
u.flash(w, r, "ok", "Imported as version "+strconv.Itoa(v.Version)+".", "/minds/"+m.Slug+"/personality")
}
func (u *UI) personalityTemplate(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
var tid *ids.ID
if t := r.PostFormValue("template_id"); t != "" {
id, err := ids.Parse(t)
if err != nil {
u.flash(w, r, "error", "Bad template.", "/minds/"+m.Slug+"/personality")
return
}
tid = &id
}
v, err := u.deps.Minds.ApplyTemplate(r.Context(), m.ID, tid, username(r))
if err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/personality")
return
}
u.deps.Audit.Log(r.Context(), "personality.template", "mind", m.ID.String(), map[string]any{"template_id": tid, "version": v.Version})
u.flash(w, r, "ok", v.ChangeNote+" (version "+strconv.Itoa(v.Version)+").", "/minds/"+m.Slug+"/personality")
}
func (u *UI) personalityRestore(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil {
u.flash(w, r, "error", "Bad version.", "/minds/"+m.Slug+"/personality")
return
}
v, err := u.deps.Minds.Restore(r.Context(), m.ID, n, username(r))
if err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/personality")
return
}
u.deps.Audit.Log(r.Context(), "personality.restore", "mind", m.ID.String(), map[string]any{"restored": n, "version": v.Version})
u.flash(w, r, "ok", "Restored version "+strconv.Itoa(n)+" as version "+strconv.Itoa(v.Version)+".", "/minds/"+m.Slug+"/personality")
}
func (u *UI) personalityDiff(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
a, _ := strconv.Atoi(r.URL.Query().Get("a"))
b, _ := strconv.Atoi(r.URL.Query().Get("b"))
va, errA := u.deps.Minds.GetVersion(r.Context(), m.ID, a)
vb, errB := u.deps.Minds.GetVersion(r.Context(), m.ID, b)
if errA != nil || errB != nil {
u.errorPage(w, r, 404, "Version not found.")
return
}
u.render(w, r, "personality_diff", "Personality diff", map[string]any{"Mind": m, "A": a, "B": b, "Changes": minds.Diff(va.Personality, vb.Personality)})
}
func (u *UI) templates(w http.ResponseWriter, r *http.Request) {
list, err := u.deps.Minds.ListTemplates(r.Context())
if err != nil {
u.errorPage(w, r, 500, "Could not list templates.")
return
}
u.render(w, r, "templates", "Personality templates", map[string]any{"Templates": list})
}
func (u *UI) templateCreate(w http.ResponseWriter, r *http.Request) {
name := r.PostFormValue("name")
t, err := u.deps.Minds.CreateTemplate(r.Context(), name, r.PostFormValue("description"), minds.DefaultPersonality(name))
if err != nil {
msg := err.Error()
if errors.Is(err, storage.ErrConflict) {
msg = "A template with that name exists."
}
u.flash(w, r, "error", msg, "/personality-templates")
return
}
u.deps.Audit.Log(r.Context(), "template.create", "personality_template", t.ID.String(), map[string]any{"name": t.Name})
http.Redirect(w, r, "/personality-templates/"+t.ID.String(), http.StatusSeeOther)
}
func (u *UI) template(w http.ResponseWriter, r *http.Request) (minds.Template, bool) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
u.errorPage(w, r, 404, "No such template.")
return minds.Template{}, false
}
t, err := u.deps.Minds.GetTemplate(r.Context(), id)
if err != nil {
u.errorPage(w, r, 404, "No such template.")
return minds.Template{}, false
}
return t, true
}
func (u *UI) templateEdit(w http.ResponseWriter, r *http.Request) {
t, ok := u.template(w, r)
if !ok {
return
}
u.render(w, r, "template_edit", "Template "+t.Name, map[string]any{"Template": t, "Brief": t.Personality.Brief()})
}
func (u *UI) templateSave(w http.ResponseWriter, r *http.Request) {
t, ok := u.template(w, r)
if !ok {
return
}
r.ParseForm() //nolint:errcheck
p := personalityFromForm(r)
if _, err := u.deps.Minds.UpdateTemplate(r.Context(), t.ID, r.PostFormValue("name"), r.PostFormValue("description"), p); err != nil {
u.flash(w, r, "error", err.Error(), "/personality-templates/"+t.ID.String())
return
}
u.deps.Audit.Log(r.Context(), "template.update", "personality_template", t.ID.String(), map[string]any{"changes": minds.Diff(t.Personality, p)})
u.flash(w, r, "ok", "Template saved. Re-apply it to minds that should pick up the change.", "/personality-templates/"+t.ID.String())
}
func (u *UI) templateDelete(w http.ResponseWriter, r *http.Request) {
t, ok := u.template(w, r)
if !ok {
return
}
if err := u.deps.Minds.DeleteTemplate(r.Context(), t.ID); err != nil {
u.flash(w, r, "error", err.Error(), "/personality-templates")
return
}
u.deps.Audit.Log(r.Context(), "template.delete", "personality_template", t.ID.String(), map[string]any{"name": t.Name})
u.flash(w, r, "ok", "Template deleted.", "/personality-templates")
}
+19 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/andydixon/noema/internal/app" "github.com/andydixon/noema/internal/app"
"github.com/andydixon/noema/internal/auth" "github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/version" "github.com/andydixon/noema/internal/version"
"github.com/andydixon/noema/web" "github.com/andydixon/noema/web"
) )
@@ -72,11 +73,27 @@ func (u *UI) parse(fsys fs.FS) error {
"bytes": humanBytes, "bytes": humanBytes,
"pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) }, "pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) },
"conf": func(f float64) string { return fmt.Sprintf("%.2f", f) }, "conf": func(f float64) string { return fmt.Sprintf("%.2f", f) },
"inc": func(i int) int { return i + 1 },
"title": func(s string) string { s = strings.ReplaceAll(s, "_", " "); return strings.ToUpper(s[:1]) + s[1:] },
"valueKeys": func() []string { return minds.ValueKeys },
"traitKeys": func() []string { return minds.TraitKeys },
"dict": func(kv ...any) map[string]any {
m := map[string]any{}
for i := 0; i+1 < len(kv); i += 2 {
m[fmt.Sprint(kv[i])] = kv[i+1]
}
return m
},
} }
base, err := template.New("base").Funcs(funcs).ParseFS(fsys, "layout.html") base, err := template.New("base").Funcs(funcs).ParseFS(fsys, "layout.html")
if err != nil { if err != nil {
return fmt.Errorf("ui: parse layout: %w", err) return fmt.Errorf("ui: parse layout: %w", err)
} }
if partials, _ := fs.Glob(fsys, "partials/*.html"); len(partials) > 0 {
if _, err := base.ParseFS(fsys, partials...); err != nil {
return fmt.Errorf("ui: parse partials: %w", err)
}
}
pages, err := fs.Glob(fsys, "pages/*.html") pages, err := fs.Glob(fsys, "pages/*.html")
if err != nil { if err != nil {
return err return err
@@ -180,8 +197,9 @@ func (u *UI) Register(m *http.ServeMux) {
m.Handle("POST /admin/users", u.require(auth.RoleAdministrator, u.usersCreate)) m.Handle("POST /admin/users", u.require(auth.RoleAdministrator, u.usersCreate))
m.Handle("POST /admin/users/{id}", u.require(auth.RoleAdministrator, u.usersUpdate)) m.Handle("POST /admin/users/{id}", u.require(auth.RoleAdministrator, u.usersUpdate))
u.mindRoutes(m)
for _, p := range []struct{ path, title, note string }{ for _, p := range []struct{ path, title, note string }{
{"/minds", "Minds", "Mind management arrives in phase 2."},
{"/approvals", "Approvals", "The approval centre arrives with the capability system."}, {"/approvals", "Approvals", "The approval centre arrives with the capability system."},
{"/llm", "LLM", "Provider configuration arrives in phase 7."}, {"/llm", "LLM", "Provider configuration arrives in phase 7."},
{"/prompts", "Prompts", "The prompt manager arrives in phase 7."}, {"/prompts", "Prompts", "The prompt manager arrives in phase 7."},
+4
View File
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS self_models;
DROP TABLE IF EXISTS personality_versions;
DROP TABLE IF EXISTS personality_templates;
DROP TABLE IF EXISTS minds;
+50
View File
@@ -0,0 +1,50 @@
-- Minds, personality versions (append-only), templates, self-models.
CREATE TABLE minds (
id uuid PRIMARY KEY,
slug text NOT NULL,
display_name text NOT NULL,
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled','archived')),
config jsonb NOT NULL DEFAULT '{}'::jsonb,
notes text NOT NULL DEFAULT '',
personality_version integer NOT NULL DEFAULT 0,
forked_from_mind uuid REFERENCES minds(id),
forked_from_snapshot uuid,
cloned_from_mind uuid REFERENCES minds(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX minds_slug_idx ON minds (lower(slug));
CREATE TABLE personality_templates (
id uuid PRIMARY KEY,
name text NOT NULL,
description text NOT NULL DEFAULT '',
personality jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX personality_templates_name_idx ON personality_templates (lower(name));
-- Every personality change is a new row; minds.personality_version points at
-- the current one. Rollback writes a new version copying an older one.
CREATE TABLE personality_versions (
id uuid PRIMARY KEY,
mind_id uuid NOT NULL REFERENCES minds(id),
version integer NOT NULL,
personality jsonb NOT NULL, -- effective personality
template_id uuid REFERENCES personality_templates(id),
overrides jsonb NOT NULL DEFAULT '{}'::jsonb, -- mind-specific deviations from the template
change_note text NOT NULL DEFAULT '',
created_by text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (mind_id, version)
);
CREATE TRIGGER personality_versions_append_only BEFORE UPDATE OR DELETE ON personality_versions
FOR EACH ROW EXECUTE FUNCTION noema_forbid_change();
CREATE TABLE self_models (
mind_id uuid PRIMARY KEY REFERENCES minds(id),
model jsonb NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
+19
View File
@@ -111,3 +111,22 @@ pre.token { background: var(--surface-2); padding: .75rem; border-radius: 8px; o
.conf { display: inline-flex; align-items: center; gap: .4rem; font-variant-numeric: tabular-nums; } .conf { display: inline-flex; align-items: center; gap: .4rem; font-variant-numeric: tabular-nums; }
.conf-bar { width: 64px; height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; } .conf-bar { width: 64px; height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; }
.conf-bar i { display: block; height: 100%; background: var(--accent); } .conf-bar i { display: block; height: 100%; background: var(--accent); }
/* Mind pages */
.mindhead { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap; }
.tabs { display: flex; gap: .25rem; border-bottom: 1px solid var(--border); margin-bottom: 1.25rem; overflow-x: auto; }
.tabs a { padding: .5rem .8rem; color: var(--muted); border-bottom: 2px solid transparent; white-space: nowrap; }
.tabs a[aria-current="page"] { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.tabs a.disabled { opacity: .45; cursor: not-allowed; }
.tabs a:hover { text-decoration: none; color: var(--text); }
pre.brief { white-space: pre-wrap; font-family: var(--font); font-size: .92rem; background: var(--surface-2); padding: .9rem; border-radius: 8px; margin: 0; line-height: 1.55; }
.fs { border: 1px solid var(--border); border-radius: 8px; padding: .75rem 1rem 1rem; margin: 0; }
.fs legend { font-weight: 600; padding: 0 .3rem; }
.grid.tight { gap: .6rem; margin-bottom: .6rem; }
label.slider { display: grid; grid-template-columns: 9.5rem 1fr 2.6rem; align-items: center; gap: .6rem; font-weight: 500; margin: .25rem 0; }
label.slider output { font-variant-numeric: tabular-nums; text-align: right; color: var(--muted); font-size: .85rem; }
input[type=range] { accent-color: var(--accent); width: 100%; }
label.check { flex-direction: row; align-items: center; gap: .5rem; font-weight: 400; margin: .3rem 0; }
.grow { flex: 1; }
ul.plain { list-style: none; padding: 0; margin: 0; }
.diff-old { color: var(--danger); text-decoration: line-through; } .diff-new { color: var(--ok); }
+6
View File
@@ -62,6 +62,12 @@
if (el && !window.confirm(el.getAttribute("data-confirm"))) { ev.preventDefault(); ev.stopImmediatePropagation(); } if (el && !window.confirm(el.getAttribute("data-confirm"))) { ev.preventDefault(); ev.stopImmediatePropagation(); }
}, true); }, true);
// Live value readouts for range inputs.
document.querySelectorAll("input[type=range][data-out]").forEach((el) => {
const out = el.parentElement.querySelector("output");
if (out) el.addEventListener("input", () => (out.value = el.value));
});
// Auto-dismiss flashes. // Auto-dismiss flashes.
document.querySelectorAll(".flash").forEach((f) => setTimeout(() => f.remove(), 8000)); document.querySelectorAll(".flash").forEach((f) => setTimeout(() => f.remove(), 8000));
+87
View File
@@ -0,0 +1,87 @@
{{define "content"}}
{{$m := .Data.Mind}}
<div class="pagehead mindhead">
<div><h1>{{$m.DisplayName}} <span class="muted mono small">{{$m.Slug}}</span></h1>
<p class="muted">{{$m.Personality.Identity.Role}}{{with $m.Personality.Identity.Description}} · {{.}}{{end}}</p></div>
<div class="row">{{template "status_badge" $m.Status}}</div>
</div>
<nav class="tabs" aria-label="Mind sections">
<a href="/minds/{{$m.Slug}}" aria-current="page">Overview</a>
<a href="/minds/{{$m.Slug}}/personality">Personality</a>
<a class="disabled" title="Phase 3">Thoughts</a><a class="disabled" title="Phase 4">Workspace</a><a class="disabled" title="Phase 5">Memory</a><a class="disabled" title="Phase 6">Beliefs</a><a class="disabled" title="Phase 8">Conversation</a>
</nav>
<section class="grid cols-3">
<div class="card">
<h2>Identity</h2>
<dl class="kv">
<dt>Name</dt><dd>{{$m.Personality.Identity.Name}}</dd>
<dt>Role</dt><dd>{{$m.Personality.Identity.Role}}</dd>
{{with $m.Personality.Identity.Pronouns}}<dt>Pronouns</dt><dd>{{.}}</dd>{{end}}
<dt>Created</dt><dd>{{date $m.CreatedAt}}</dd>
<dt>Personality</dt><dd>v{{$m.PersonalityVersion}} {{if $m.TemplateID}}<span class="badge accent">templated</span>{{end}} · <a href="/minds/{{$m.Slug}}/personality">edit</a></dd>
{{if $m.ClonedFromMind}}<dt>Cloned from</dt><dd class="mono small">{{$m.ClonedFromMind}}</dd>{{end}}
{{with $m.Notes}}<dt>Notes</dt><dd>{{.}}</dd>{{end}}
</dl>
</div>
<div class="card">
<h2>Current focus</h2>
<p class="muted">Working memory, workspace and narrative appear here once the cognitive runtime is online (phase 4).</p>
</div>
<div class="card">
<h2>Cognitive configuration</h2>
<dl class="kv">
<dt>Workspace capacity</dt><dd>{{$m.Config.WorkspaceCapacity}}</dd>
<dt>Working memory</dt><dd>{{$m.Config.WorkingMemoryCapacity}} items · decay {{$m.Config.WorkingMemoryDecay}}</dd>
<dt>Cycle interval</dt><dd>{{$m.Config.CycleMinInterval}} min · {{$m.Config.IdleCycleInterval}} idle · max {{$m.Config.MaxCyclesPerMinute}}/min</dd>
<dt>LLM per cycle</dt><dd>{{$m.Config.LLMCallsPerCycle}} calls · {{$m.Config.DailyTokenBudget}} tokens/day</dd>
</dl>
</div>
</section>
<section class="grid cols-2">
<div class="card">
<h2>Personality brief <span class="muted small">what cognitive prompts receive</span></h2>
<pre class="brief">{{.Data.Brief}}</pre>
</div>
<div class="card">
<h2>Self-model</h2>
{{$sm := .Data.SelfModel}}
<h3>Capabilities</h3>
{{if $sm.Capabilities}}<ul class="plain">{{range $sm.Capabilities}}<li><code>{{.Name}}</code> <span class="badge {{if eq .Policy "allow"}}ok{{else if eq .Policy "ask"}}warn{{else}}danger{{end}}">{{.Policy}}</span> <span class="muted small">{{.Description}}</span></li>{{end}}</ul>
{{else}}<p class="muted small">No capabilities granted. This mind cannot act on external systems.</p>{{end}}
<h3>Known limitations</h3>
<ul class="small">{{range $sm.Limitations}}<li>{{.}}</li>{{end}}</ul>
{{if $sm.KnowledgeGaps}}<h3>Knowledge gaps</h3><ul class="small">{{range $sm.KnowledgeGaps}}<li>{{.}}</li>{{end}}</ul>{{end}}
<p class="muted small">Updated {{ago $sm.UpdatedAt}}</p>
</div>
</section>
{{if .Principal.User.Role.AtLeast "administrator"}}
<section class="grid cols-2">
<div class="card">
<h2>Lifecycle</h2>
<form method="post" action="/minds/{{$m.Slug}}/status" class="row">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
{{if ne $m.Status "active"}}<button class="btn" name="status" value="active">Enable</button>{{end}}
{{if eq $m.Status "active"}}<button class="btn" name="status" value="disabled" data-confirm="Disable {{$m.DisplayName}}? Cognition stops; history is kept.">Disable</button>{{end}}
{{if ne $m.Status "archived"}}<button class="btn btn-danger" name="status" value="archived" data-confirm="Archive {{$m.DisplayName}}? It is hidden from lists but nothing is deleted.">Archive</button>{{end}}
</form>
<p class="muted small">Cognitive history is never hard-deleted by these actions.</p>
<form method="post" action="/minds/{{$m.Slug}}/notes" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Operator notes <textarea name="notes" rows="2">{{$m.Notes}}</textarea></label>
<div><button class="btn small" type="submit">Save notes</button></div>
</form>
</div>
<div class="card">
<h2>Clone</h2>
<p class="muted small">Same personality and configuration, fresh experience. Forking with memories arrives with snapshots.</p>
<form method="post" action="/minds/{{$m.Slug}}/clone" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>New slug <input name="slug" required pattern="[a-z0-9][a-z0-9-]{0,62}" placeholder="{{$m.Slug}}-experiment"></label>
<label>Display name <input name="display_name"></label>
<div><button class="btn" type="submit">Clone</button></div>
</form>
</div>
</section>
{{end}}
{{end}}
{{define "status_badge"}}{{if eq . "active"}}<span class="badge ok">active</span>{{else if eq . "disabled"}}<span class="badge warn">disabled</span>{{else}}<span class="badge">archived</span>{{end}}{{end}}
+33
View File
@@ -0,0 +1,33 @@
{{define "content"}}
<div class="pagehead"><h1>Minds</h1><p class="muted">Independent cognitive entities. Each has its own personality, memory, beliefs and configuration.</p></div>
<section class="grid cols-3-1">
<div class="card">
{{if .Data.Minds}}
<table class="table">
<thead><tr><th>Mind</th><th>Role</th><th>Status</th><th>Personality</th><th>Created</th></tr></thead>
<tbody>{{range .Data.Minds}}<tr>
<td><a href="/minds/{{.Slug}}"><strong>{{.DisplayName}}</strong></a> <span class="muted small mono">{{.Slug}}</span></td>
<td class="small">{{.Personality.Identity.Role}}</td>
<td>{{template "status_badge" .Status}}</td>
<td class="small muted">v{{.PersonalityVersion}}{{if .TemplateID}} · templated{{end}}</td>
<td class="small muted nowrap">{{date .CreatedAt}}</td>
</tr>{{end}}</tbody>
</table>
{{else}}<p class="muted">No minds yet. Create the first one.</p>{{end}}
<p class="small muted"><a href="/minds?include_archived=1">Show archived</a> · <a href="/personality-templates">Personality templates</a></p>
</div>
{{if .Principal.User.Role.AtLeast "administrator"}}
<div class="card">
<h2>Create mind</h2>
<form method="post" action="/minds" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Slug <input name="slug" required pattern="[a-z0-9][a-z0-9-]{0,62}" placeholder="dani" title="lowercase letters, digits, hyphens"></label>
<label>Display name <input name="display_name" placeholder="Dani"></label>
<label>Start from template <select name="template_id"><option value="">Neutral default</option>{{range .Data.Templates}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select></label>
<button class="btn btn-primary" type="submit">Create</button>
</form>
</div>
{{end}}
</section>
{{end}}
{{define "status_badge"}}{{if eq . "active"}}<span class="badge ok">active</span>{{else if eq . "disabled"}}<span class="badge warn">disabled</span>{{else}}<span class="badge">archived</span>{{end}}{{end}}
+60
View File
@@ -0,0 +1,60 @@
{{define "content"}}
{{$m := .Data.Mind}}
<div class="pagehead mindhead">
<div><h1>{{$m.DisplayName}} <span class="muted small">personality</span></h1><p class="muted">Version {{$m.PersonalityVersion}}{{if .Data.Template}} · based on template <strong>{{.Data.Template.Name}}</strong>{{end}}. Every save creates a new version; nothing is overwritten.</p></div>
<div class="row"><a class="btn small" href="/minds/{{$m.Slug}}/personality.json">Export JSON</a></div>
</div>
<nav class="tabs" aria-label="Mind sections"><a href="/minds/{{$m.Slug}}">Overview</a><a href="/minds/{{$m.Slug}}/personality" aria-current="page">Personality</a></nav>
{{$admin := .Principal.User.Role.AtLeast "administrator"}}
<section class="grid cols-3-1">
<div>
<form method="post" action="/minds/{{$m.Slug}}/personality" class="card stack" id="pform">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
{{template "personality_form" (dict "P" $m.Personality)}}
<div class="row">
<input name="change_note" placeholder="Change note (why)" maxlength="200" class="grow">
{{if $admin}}<button class="btn btn-primary" type="submit">Save as version {{inc $m.PersonalityVersion}}</button>{{end}}
<button class="btn" type="submit" formaction="/minds/{{$m.Slug}}/personality/preview" formmethod="post">Preview brief</button>
</div>
</form>
{{with .Data.Preview}}<div class="card highlight"><h2>Preview (unsaved)</h2><pre class="brief">{{.}}</pre></div>{{end}}
</div>
<div class="stack">
<div class="card">
<h2>Template</h2>
{{if $admin}}
<form method="post" action="/minds/{{$m.Slug}}/personality/template" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<select name="template_id"><option value="">None (detach)</option>{{range .Data.Templates}}<option value="{{.ID}}" {{if and $.Data.Template (eq .ID $.Data.Template.ID)}}selected{{end}}>{{.Name}}</option>{{end}}</select>
<button class="btn small" type="submit">Apply template</button>
</form>
<p class="muted small">Mind-specific overrides are kept when a template is applied or re-applied.</p>
{{else}}<p class="muted small">{{if .Data.Template}}{{.Data.Template.Name}}{{else}}None{{end}}</p>{{end}}
{{if .Data.Overrides}}<h3>Overrides</h3><ul class="small mono">{{range $k, $v := .Data.Overrides}}<li>{{$k}}</li>{{end}}</ul>{{end}}
</div>
<div class="card">
<h2>History</h2>
<table class="table compact">
<thead><tr><th>v</th><th>Note</th><th>By</th><th></th></tr></thead>
<tbody>{{range .Data.Versions}}<tr>
<td>{{.Version}}{{if eq .Version $m.PersonalityVersion}} <span class="badge accent">current</span>{{end}}</td>
<td class="small">{{.ChangeNote}}<div class="muted">{{ago .CreatedAt}}</div></td>
<td class="small muted">{{.CreatedBy}}</td>
<td class="nowrap">{{if ne .Version $m.PersonalityVersion}}<a class="small" href="/minds/{{$m.Slug}}/personality/diff?a={{.Version}}&b={{$m.PersonalityVersion}}">diff</a>
{{if $admin}}<form method="post" action="/minds/{{$m.Slug}}/personality/versions/{{.Version}}/restore" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small" data-confirm="Restore version {{.Version}} as a new version?">restore</button></form>{{end}}{{end}}</td>
</tr>{{end}}</tbody>
</table>
</div>
{{if $admin}}
<div class="card">
<h2>Import JSON</h2>
<form method="post" action="/minds/{{$m.Slug}}/personality/import" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<textarea name="json" rows="5" placeholder='{"identity":{...},"values":{...}}' required></textarea>
<button class="btn small" type="submit">Import as new version</button>
</form>
</div>
{{end}}
</div>
</section>
{{end}}
+12
View File
@@ -0,0 +1,12 @@
{{define "content"}}
{{$m := .Data.Mind}}
<div class="pagehead"><h1>{{$m.DisplayName}} <span class="muted small">personality v{{.Data.A}} → v{{.Data.B}}</span></h1><p class="muted"><a href="/minds/{{$m.Slug}}/personality">Back to editor</a></p></div>
<div class="card">
{{if .Data.Changes}}
<table class="table">
<thead><tr><th>Field</th><th>v{{.Data.A}}</th><th>v{{.Data.B}}</th></tr></thead>
<tbody>{{range .Data.Changes}}<tr><td class="mono small">{{.Path}}</td><td class="diff-old">{{.Old}}</td><td class="diff-new">{{.New}}</td></tr>{{end}}</tbody>
</table>
{{else}}<p class="muted">No differences.</p>{{end}}
</div>
{{end}}
+14
View File
@@ -0,0 +1,14 @@
{{define "content"}}
{{$t := .Data.Template}}
<div class="pagehead"><h1>Template: {{$t.Name}}</h1><p class="muted"><a href="/personality-templates">All templates</a> · Editing a template does not change minds until an operator re-applies it.</p></div>
<form method="post" action="/personality-templates/{{$t.ID}}" class="card stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="grid cols-2 tight">
<label>Name <input name="name" value="{{$t.Name}}" required maxlength="80"></label>
<label>Description <input name="description" value="{{$t.Description}}" maxlength="200"></label>
</div>
{{template "personality_form" (dict "P" $t.Personality)}}
<div class="row"><button class="btn btn-primary" type="submit" {{if not (.Principal.User.Role.AtLeast "administrator")}}disabled{{end}}>Save template</button></div>
</form>
<div class="card"><h2>Brief</h2><pre class="brief">{{.Data.Brief}}</pre></div>
{{end}}
+24
View File
@@ -0,0 +1,24 @@
{{define "content"}}
<div class="pagehead"><h1>Personality templates</h1><p class="muted">Reusable personalities. Minds keep their own overrides when a template is applied.</p></div>
<section class="grid cols-3-1">
<div class="card">
<table class="table">
<thead><tr><th>Name</th><th>Description</th><th>Role</th><th>Updated</th><th></th></tr></thead>
<tbody>{{range .Data.Templates}}<tr><td><a href="/personality-templates/{{.ID}}"><strong>{{.Name}}</strong></a></td><td class="small">{{.Description}}</td><td class="small muted">{{.Personality.Identity.Role}}</td><td class="small muted">{{ago .UpdatedAt}}</td>
<td>{{if $.Principal.User.Role.AtLeast "administrator"}}<form method="post" action="/personality-templates/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small btn-danger" data-confirm="Delete template {{.Name}}?">Delete</button></form>{{end}}</td></tr>
{{else}}<tr><td colspan="5" class="muted">No templates yet.</td></tr>{{end}}</tbody>
</table>
</div>
{{if .Principal.User.Role.AtLeast "administrator"}}
<div class="card">
<h2>New template</h2>
<form method="post" action="/personality-templates" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Name <input name="name" required maxlength="80"></label>
<label>Description <input name="description" maxlength="200"></label>
<button class="btn btn-primary" type="submit">Create (then edit)</button>
</form>
</div>
{{end}}
</section>
{{end}}
@@ -0,0 +1,53 @@
{{define "personality_form"}}{{/* expects .P (Personality) and .Prefix "" */}}
{{$p := .P}}
<fieldset class="fs">
<legend>Identity</legend>
<div class="grid cols-2 tight">
<label>Name <input name="identity.name" value="{{$p.Identity.Name}}" required maxlength="80"></label>
<label>Display name <input name="identity.display_name" value="{{$p.Identity.DisplayName}}" maxlength="80"></label>
<label>Role <input name="identity.role" value="{{$p.Identity.Role}}" maxlength="120" placeholder="e.g. infrastructure investigator"></label>
<label>Pronouns <input name="identity.pronouns" value="{{$p.Identity.Pronouns}}" maxlength="40"></label>
</div>
<label>Description <textarea name="identity.description" rows="2" maxlength="2000">{{$p.Identity.Description}}</textarea></label>
<div class="grid cols-2 tight">
<label>Backstory <textarea name="identity.backstory" rows="3" maxlength="4000">{{$p.Identity.Backstory}}</textarea></label>
<label>Origin <textarea name="identity.origin" rows="3" maxlength="2000">{{$p.Identity.Origin}}</textarea></label>
</div>
</fieldset>
<div class="grid cols-2">
<fieldset class="fs">
<legend>Values <span class="muted small">how much the mind weighs each</span></legend>
{{range $k := valueKeys}}<label class="slider"><span>{{title $k}}</span><input type="range" name="values.{{$k}}" min="0" max="1" step="0.05" value="{{index $p.Values $k}}" data-out><output>{{index $p.Values $k}}</output></label>{{end}}
</fieldset>
<fieldset class="fs">
<legend>Traits <span class="muted small">dispositions</span></legend>
{{range $k := traitKeys}}<label class="slider"><span>{{title $k}}</span><input type="range" name="traits.{{$k}}" min="0" max="1" step="0.05" value="{{index $p.Traits $k}}" data-out><output>{{index $p.Traits $k}}</output></label>{{end}}
</fieldset>
</div>
<div class="grid cols-2">
<fieldset class="fs">
<legend>Communication</legend>
<div class="grid cols-2 tight">
<label>Language <input name="communication.language" value="{{$p.Communication.Language}}" maxlength="16"></label>
<label>Dialect <input name="communication.dialect" value="{{$p.Communication.Dialect}}" maxlength="40" placeholder="e.g. British English"></label>
</div>
<label class="slider"><span>Formality</span><input type="range" name="communication.formality" min="0" max="1" step="0.05" value="{{$p.Communication.Formality}}" data-out><output>{{$p.Communication.Formality}}</output></label>
<label class="slider"><span>Verbosity</span><input type="range" name="communication.verbosity" min="0" max="1" step="0.05" value="{{$p.Communication.Verbosity}}" data-out><output>{{$p.Communication.Verbosity}}</output></label>
<label>Humour style <input name="communication.humour_style" value="{{$p.Communication.HumourStyle}}" maxlength="120" placeholder="dry, sparing"></label>
<label>Terminology <input name="communication.terminology" value="{{$p.Communication.Terminology}}" maxlength="400" placeholder="prefer British spelling; say 'server' not 'box'"></label>
</fieldset>
<fieldset class="fs">
<legend>Behavioural preferences</legend>
<label class="check"><input type="checkbox" name="behaviour.challenge_assumptions" {{if $p.Behaviour.ChallengeAssumptions}}checked{{end}}> Challenge assumptions</label>
<label class="check"><input type="checkbox" name="behaviour.ask_follow_ups" {{if $p.Behaviour.AskFollowUps}}checked{{end}}> Ask follow-up questions</label>
<label class="check"><input type="checkbox" name="behaviour.prefer_evidence" {{if $p.Behaviour.PreferEvidence}}checked{{end}}> Prefer evidence</label>
<label class="check"><input type="checkbox" name="behaviour.cite_uncertainty" {{if $p.Behaviour.CiteUncertainty}}checked{{end}}> Cite uncertainty</label>
<label class="check"><input type="checkbox" name="behaviour.offer_alternatives" {{if $p.Behaviour.OfferAlternatives}}checked{{end}}> Offer alternatives</label>
<label class="check"><input type="checkbox" name="behaviour.notify_proactively" {{if $p.Behaviour.NotifyProactively}}checked{{end}}> Notify proactively</label>
</fieldset>
</div>
<fieldset class="fs">
<legend>Free-form personality text <span class="muted small">supplements the structured fields; never replaces them</span></legend>
<textarea name="free_text" rows="6" maxlength="20000">{{$p.FreeText}}</textarea>
</fieldset>
{{end}}