diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b256b8..e0bcbfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,5 +5,6 @@ All notable changes to Noema are recorded here. Format follows Keep a Changelog; ## [Unreleased] ### 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`. - Repository foundation: plan, architecture, security model, threat model, ADRs, Makefile, container files. diff --git a/PLAN.md b/PLAN.md index 784b3f4..b375c29 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 - ☐ TOTP + recovery codes (moved to Phase 16 with the rest of hardening) -### Phase 2 – Mind model, identity, personality, self-model -- ☐ `migrations/0002_minds`: minds, mind_config, personalities, personality_versions (append-only), personality_templates, 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/selfmodel`: capability inventory derived from policy, limitations, activities -- ☐ API + UI: mind list, create, overview skeleton, personality editor (structured + text, preview, history, diff, rollback, templates), audit events -- ☐ `noemactl mind list|create|inspect` -- ☐ Tests: versioning, rollback, template overlay, audit emission +### Phase 2 – Mind model, identity, personality, self-model ☑ +- ☑ `migrations/0002_minds`: minds, personality_templates, personality_versions (append-only), self_models +- ☑ `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 +- ☑ 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: minds, personality (get/put/brief/versions/diff/restore/template), templates, self-model +- ☑ 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 +- ☑ `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 - ☐ `pkg/events`: types, categories, evidence refs, payload contracts diff --git a/cmd/noemactl/main.go b/cmd/noemactl/main.go index 95df015..fe8ab18 100644 --- a/cmd/noemactl/main.go +++ b/cmd/noemactl/main.go @@ -40,6 +40,9 @@ type command struct { var commands []command +// extraCommands are registered by other files in this package via init. +var extraCommands []command + func main() { commands = []command{ {"status", "show daemon status", cmdStatus}, @@ -47,6 +50,7 @@ func main() { {"user", "create [-role r] – manage users (offline)", cmdUser}, {"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" { usage() os.Exit(exitUsage) diff --git a/cmd/noemactl/mind.go b/cmd/noemactl/mind.go new file mode 100644 index 0000000..6d7bdd5 --- /dev/null +++ b/cmd/noemactl/mind.go @@ -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 |inspect |clone |personality ") + 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 [-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 ") + 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 ") + 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 ") + 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 +} diff --git a/cmd/noemad/main.go b/cmd/noemad/main.go index be24d15..48a9668 100644 --- a/cmd/noemad/main.go +++ b/cmd/noemad/main.go @@ -18,6 +18,7 @@ import ( "github.com/andydixon/noema/internal/audit" "github.com/andydixon/noema/internal/auth" "github.com/andydixon/noema/internal/config" + "github.com/andydixon/noema/internal/minds" "github.com/andydixon/noema/internal/observability" "github.com/andydixon/noema/internal/scheduler" "github.com/andydixon/noema/internal/storage" @@ -73,7 +74,7 @@ func run() error { metrics := observability.NewMetrics() authSvc := auth.NewService(db, cfg.SessionIdleTTL, cfg.SessionAbsoluteTTL) 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 { return err diff --git a/docs/personality.md b/docs/personality.md new file mode 100644 index 0000000..d357a44 --- /dev/null +++ b/docs/personality.md @@ -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. diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 251d532..61f8524 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -14,6 +14,7 @@ import ( "github.com/andydixon/noema/internal/audit" "github.com/andydixon/noema/internal/auth" "github.com/andydixon/noema/internal/config" + "github.com/andydixon/noema/internal/minds" "github.com/andydixon/noema/internal/observability" "github.com/andydixon/noema/internal/storage" "github.com/andydixon/noema/migrations" @@ -26,7 +27,7 @@ func testServer(t *testing.T) (*httptest.Server, string) { cfg.DatabaseURL = "postgres://test" log := slog.New(slog.DiscardHandler) 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() u, err := deps.Auth.CreateUser(ctx, "admin", "administrator-pass", auth.RoleAdministrator) if err != nil { diff --git a/internal/api/minds.go b/internal/api/minds.go new file mode 100644 index 0000000..d1b837e --- /dev/null +++ b/internal/api/minds.go @@ -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) +} diff --git a/internal/api/server.go b/internal/api/server.go index 7e13921..d16d736 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -51,6 +51,8 @@ func (s *Server) routes() { 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)) + s.mindRoutes() + m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint") }) diff --git a/internal/app/app.go b/internal/app/app.go index d08c530..880f276 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -13,6 +13,7 @@ import ( "github.com/andydixon/noema/internal/audit" "github.com/andydixon/noema/internal/auth" "github.com/andydixon/noema/internal/config" + "github.com/andydixon/noema/internal/minds" "github.com/andydixon/noema/internal/observability" ) @@ -24,6 +25,7 @@ type Deps struct { Auth *auth.Service Audit *audit.Logger Metrics *observability.Metrics + Minds *minds.Store Migrations fs.FS Started time.Time } diff --git a/internal/minds/config.go b/internal/minds/config.go new file mode 100644 index 0000000..9e8a81e --- /dev/null +++ b/internal/minds/config.go @@ -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) } diff --git a/internal/minds/minds_test.go b/internal/minds/minds_test.go new file mode 100644 index 0000000..7903281 --- /dev/null +++ b/internal/minds/minds_test.go @@ -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)) + } +} diff --git a/internal/minds/personality.go b/internal/minds/personality.go new file mode 100644 index 0000000..75c2317 --- /dev/null +++ b/internal/minds/personality.go @@ -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 + } +} diff --git a/internal/minds/selfmodel.go b/internal/minds/selfmodel.go new file mode 100644 index 0000000..491c44f --- /dev/null +++ b/internal/minds/selfmodel.go @@ -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() +} diff --git a/internal/minds/store.go b/internal/minds/store.go new file mode 100644 index 0000000..f8b8761 --- /dev/null +++ b/internal/minds/store.go @@ -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() +} diff --git a/internal/ui/minds.go b/internal/ui/minds.go new file mode 100644 index 0000000..78474ea --- /dev/null +++ b/internal/ui/minds.go @@ -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: ¬es}); 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") +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 74860c2..2234b3c 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -17,6 +17,7 @@ import ( "github.com/andydixon/noema/internal/app" "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/minds" "github.com/andydixon/noema/internal/version" "github.com/andydixon/noema/web" ) @@ -72,11 +73,27 @@ func (u *UI) parse(fsys fs.FS) error { "bytes": humanBytes, "pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) }, "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") if err != nil { 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") if err != nil { 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/{id}", u.require(auth.RoleAdministrator, u.usersUpdate)) + u.mindRoutes(m) + 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."}, {"/llm", "LLM", "Provider configuration arrives in phase 7."}, {"/prompts", "Prompts", "The prompt manager arrives in phase 7."}, diff --git a/migrations/0002_minds.down.sql b/migrations/0002_minds.down.sql new file mode 100644 index 0000000..0393000 --- /dev/null +++ b/migrations/0002_minds.down.sql @@ -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; diff --git a/migrations/0002_minds.up.sql b/migrations/0002_minds.up.sql new file mode 100644 index 0000000..77b2dd6 --- /dev/null +++ b/migrations/0002_minds.up.sql @@ -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() +); diff --git a/web/static/app.css b/web/static/app.css index c57fad1..97eafd9 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -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-bar { width: 64px; height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; } .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); } diff --git a/web/static/app.js b/web/static/app.js index 710a860..c546221 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -62,6 +62,12 @@ if (el && !window.confirm(el.getAttribute("data-confirm"))) { ev.preventDefault(); ev.stopImmediatePropagation(); } }, 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. document.querySelectorAll(".flash").forEach((f) => setTimeout(() => f.remove(), 8000)); diff --git a/web/templates/pages/mind.html b/web/templates/pages/mind.html new file mode 100644 index 0000000..9c2c61e --- /dev/null +++ b/web/templates/pages/mind.html @@ -0,0 +1,87 @@ +{{define "content"}} +{{$m := .Data.Mind}} +
+

{{$m.DisplayName}} {{$m.Slug}}

+

{{$m.Personality.Identity.Role}}{{with $m.Personality.Identity.Description}} · {{.}}{{end}}

+
{{template "status_badge" $m.Status}}
+
+ +
+
+

Identity

+
+
Name
{{$m.Personality.Identity.Name}}
+
Role
{{$m.Personality.Identity.Role}}
+ {{with $m.Personality.Identity.Pronouns}}
Pronouns
{{.}}
{{end}} +
Created
{{date $m.CreatedAt}}
+
Personality
v{{$m.PersonalityVersion}} {{if $m.TemplateID}}templated{{end}} · edit
+ {{if $m.ClonedFromMind}}
Cloned from
{{$m.ClonedFromMind}}
{{end}} + {{with $m.Notes}}
Notes
{{.}}
{{end}} +
+
+
+

Current focus

+

Working memory, workspace and narrative appear here once the cognitive runtime is online (phase 4).

+
+
+

Cognitive configuration

+
+
Workspace capacity
{{$m.Config.WorkspaceCapacity}}
+
Working memory
{{$m.Config.WorkingMemoryCapacity}} items · decay {{$m.Config.WorkingMemoryDecay}}
+
Cycle interval
{{$m.Config.CycleMinInterval}} min · {{$m.Config.IdleCycleInterval}} idle · max {{$m.Config.MaxCyclesPerMinute}}/min
+
LLM per cycle
{{$m.Config.LLMCallsPerCycle}} calls · {{$m.Config.DailyTokenBudget}} tokens/day
+
+
+
+
+
+

Personality brief what cognitive prompts receive

+
{{.Data.Brief}}
+
+
+

Self-model

+ {{$sm := .Data.SelfModel}} +

Capabilities

+ {{if $sm.Capabilities}}
    {{range $sm.Capabilities}}
  • {{.Name}} {{.Policy}} {{.Description}}
  • {{end}}
+ {{else}}

No capabilities granted. This mind cannot act on external systems.

{{end}} +

Known limitations

+
    {{range $sm.Limitations}}
  • {{.}}
  • {{end}}
+ {{if $sm.KnowledgeGaps}}

Knowledge gaps

    {{range $sm.KnowledgeGaps}}
  • {{.}}
  • {{end}}
{{end}} +

Updated {{ago $sm.UpdatedAt}}

+
+
+{{if .Principal.User.Role.AtLeast "administrator"}} +
+
+

Lifecycle

+
+ + {{if ne $m.Status "active"}}{{end}} + {{if eq $m.Status "active"}}{{end}} + {{if ne $m.Status "archived"}}{{end}} +
+

Cognitive history is never hard-deleted by these actions.

+
+ + +
+
+
+
+

Clone

+

Same personality and configuration, fresh experience. Forking with memories arrives with snapshots.

+
+ + + +
+
+
+
+{{end}} +{{end}} +{{define "status_badge"}}{{if eq . "active"}}active{{else if eq . "disabled"}}disabled{{else}}archived{{end}}{{end}} diff --git a/web/templates/pages/minds.html b/web/templates/pages/minds.html new file mode 100644 index 0000000..f3d94f4 --- /dev/null +++ b/web/templates/pages/minds.html @@ -0,0 +1,33 @@ +{{define "content"}} +

Minds

Independent cognitive entities. Each has its own personality, memory, beliefs and configuration.

+
+
+ {{if .Data.Minds}} + + + {{range .Data.Minds}} + + + + + + {{end}} +
MindRoleStatusPersonalityCreated
{{.DisplayName}} {{.Slug}}{{.Personality.Identity.Role}}{{template "status_badge" .Status}}v{{.PersonalityVersion}}{{if .TemplateID}} · templated{{end}}{{date .CreatedAt}}
+ {{else}}

No minds yet. Create the first one.

{{end}} +

Show archived · Personality templates

+
+ {{if .Principal.User.Role.AtLeast "administrator"}} +
+

Create mind

+
+ + + + + +
+
+ {{end}} +
+{{end}} +{{define "status_badge"}}{{if eq . "active"}}active{{else if eq . "disabled"}}disabled{{else}}archived{{end}}{{end}} diff --git a/web/templates/pages/personality.html b/web/templates/pages/personality.html new file mode 100644 index 0000000..0c94ba2 --- /dev/null +++ b/web/templates/pages/personality.html @@ -0,0 +1,60 @@ +{{define "content"}} +{{$m := .Data.Mind}} +
+

{{$m.DisplayName}} personality

Version {{$m.PersonalityVersion}}{{if .Data.Template}} · based on template {{.Data.Template.Name}}{{end}}. Every save creates a new version; nothing is overwritten.

+ +
+ +{{$admin := .Principal.User.Role.AtLeast "administrator"}} +
+
+
+ + {{template "personality_form" (dict "P" $m.Personality)}} +
+ + {{if $admin}}{{end}} + +
+
+ {{with .Data.Preview}}

Preview (unsaved)

{{.}}
{{end}} +
+
+
+

Template

+ {{if $admin}} +
+ + + +
+

Mind-specific overrides are kept when a template is applied or re-applied.

+ {{else}}

{{if .Data.Template}}{{.Data.Template.Name}}{{else}}None{{end}}

{{end}} + {{if .Data.Overrides}}

Overrides

    {{range $k, $v := .Data.Overrides}}
  • {{$k}}
  • {{end}}
{{end}} +
+
+

History

+ + + {{range .Data.Versions}} + + + + + {{end}} +
vNoteBy
{{.Version}}{{if eq .Version $m.PersonalityVersion}} current{{end}}{{.ChangeNote}}
{{ago .CreatedAt}}
{{.CreatedBy}}{{if ne .Version $m.PersonalityVersion}}diff + {{if $admin}}
{{end}}{{end}}
+
+ {{if $admin}} +
+

Import JSON

+
+ + + +
+
+ {{end}} +
+
+{{end}} diff --git a/web/templates/pages/personality_diff.html b/web/templates/pages/personality_diff.html new file mode 100644 index 0000000..01e1612 --- /dev/null +++ b/web/templates/pages/personality_diff.html @@ -0,0 +1,12 @@ +{{define "content"}} +{{$m := .Data.Mind}} +

{{$m.DisplayName}} personality v{{.Data.A}} → v{{.Data.B}}

Back to editor

+
+ {{if .Data.Changes}} + + + {{range .Data.Changes}}{{end}} +
Fieldv{{.Data.A}}v{{.Data.B}}
{{.Path}}{{.Old}}{{.New}}
+ {{else}}

No differences.

{{end}} +
+{{end}} diff --git a/web/templates/pages/template_edit.html b/web/templates/pages/template_edit.html new file mode 100644 index 0000000..f67300c --- /dev/null +++ b/web/templates/pages/template_edit.html @@ -0,0 +1,14 @@ +{{define "content"}} +{{$t := .Data.Template}} +

Template: {{$t.Name}}

All templates · Editing a template does not change minds until an operator re-applies it.

+
+ +
+ + +
+ {{template "personality_form" (dict "P" $t.Personality)}} +
+
+

Brief

{{.Data.Brief}}
+{{end}} diff --git a/web/templates/pages/templates.html b/web/templates/pages/templates.html new file mode 100644 index 0000000..095a480 --- /dev/null +++ b/web/templates/pages/templates.html @@ -0,0 +1,24 @@ +{{define "content"}} +

Personality templates

Reusable personalities. Minds keep their own overrides when a template is applied.

+
+
+ + + {{range .Data.Templates}} + + {{else}}{{end}} +
NameDescriptionRoleUpdated
{{.Name}}{{.Description}}{{.Personality.Identity.Role}}{{ago .UpdatedAt}}{{if $.Principal.User.Role.AtLeast "administrator"}}
{{end}}
No templates yet.
+
+ {{if .Principal.User.Role.AtLeast "administrator"}} +
+

New template

+
+ + + + +
+
+ {{end}} +
+{{end}} diff --git a/web/templates/partials/personality_form.html b/web/templates/partials/personality_form.html new file mode 100644 index 0000000..99659d0 --- /dev/null +++ b/web/templates/partials/personality_form.html @@ -0,0 +1,53 @@ +{{define "personality_form"}}{{/* expects .P (Personality) and .Prefix "" */}} +{{$p := .P}} +
+ Identity +
+ + + + +
+ +
+ + +
+
+
+
+ Values how much the mind weighs each + {{range $k := valueKeys}}{{end}} +
+
+ Traits dispositions + {{range $k := traitKeys}}{{end}} +
+
+
+
+ Communication +
+ + +
+ + + + +
+
+ Behavioural preferences + + + + + + +
+
+
+ Free-form personality text supplements the structured fields; never replaces them + +
+{{end}}