feat: add mind model with versioned personality, templates and self-model
Phase 2. Minds are rows with a slug, status (active/disabled/archived), cognitive configuration and a pointer to the current personality version. Personalities are structured (identity, values, traits, communication, behaviour, free text) with a deterministic Brief() used by prompts and the editor preview. Every change appends a personality_versions row protected by the append-only trigger; restore copies an old version forward. Templates carry reusable personalities; minds record their overrides so a template can be re-applied without losing mind-specific edits. The self-model starts with structural limitations the mind cannot drop (no policy changes, no influence over shutdown, no cross-mind access). API, UI (list, overview, editor with sliders/diff/history/import/export, template editor) and noemactl mind/template commands. All mutations are audited with field-level diffs.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <username> [-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)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
func init() {
|
||||
extraCommands = append(extraCommands,
|
||||
command{"mind", "list|create|inspect|clone|personality – manage minds", cmdMind},
|
||||
command{"template", "list – personality templates", cmdTemplate},
|
||||
)
|
||||
}
|
||||
|
||||
func cmdMind(args []string) int {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: noemactl mind list|create <slug>|inspect <mind>|clone <mind> <new-slug>|personality <mind>")
|
||||
return exitUsage
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
fs := flag.NewFlagSet("mind list", flag.ContinueOnError)
|
||||
all := fs.Bool("all", false, "include archived")
|
||||
asJSON := fs.Bool("json", false, "raw JSON")
|
||||
if fs.Parse(args[1:]) != nil {
|
||||
return exitUsage
|
||||
}
|
||||
var out struct{ Items []map[string]any }
|
||||
q := ""
|
||||
if *all {
|
||||
q = "?include_archived=true"
|
||||
}
|
||||
if code, err := apiRequest("GET", "/api/v1/minds"+q, nil, &out); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
if *asJSON {
|
||||
json.NewEncoder(os.Stdout).Encode(out.Items) //nolint:errcheck
|
||||
return exitOK
|
||||
}
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "SLUG\tNAME\tSTATUS\tPERSONALITY\tID")
|
||||
for _, m := range out.Items {
|
||||
fmt.Fprintf(tw, "%v\t%v\t%v\tv%v\t%v\n", m["slug"], m["display_name"], m["status"], m["personality_version"], m["id"])
|
||||
}
|
||||
tw.Flush()
|
||||
return exitOK
|
||||
case "create":
|
||||
fs := flag.NewFlagSet("mind create", flag.ContinueOnError)
|
||||
name := fs.String("name", "", "display name")
|
||||
if len(args) < 2 || fs.Parse(args[2:]) != nil {
|
||||
fmt.Fprintln(os.Stderr, "usage: noemactl mind create <slug> [-name display]")
|
||||
return exitUsage
|
||||
}
|
||||
var m map[string]any
|
||||
if code, err := apiRequest("POST", "/api/v1/minds", map[string]any{"slug": args[1], "display_name": *name}, &m); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
fmt.Printf("created mind %v (%v)\n", m["slug"], m["id"])
|
||||
return exitOK
|
||||
case "inspect":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: noemactl mind inspect <mind>")
|
||||
return exitUsage
|
||||
}
|
||||
var m map[string]any
|
||||
if code, err := apiRequest("GET", "/api/v1/minds/"+args[1], nil, &m); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
var sm map[string]any
|
||||
apiRequest("GET", "/api/v1/minds/"+args[1]+"/selfmodel", nil, &sm) //nolint:errcheck
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
enc.Encode(map[string]any{"mind": m, "self_model": sm}) //nolint:errcheck
|
||||
return exitOK
|
||||
case "clone":
|
||||
if len(args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "usage: noemactl mind clone <mind> <new-slug>")
|
||||
return exitUsage
|
||||
}
|
||||
var m map[string]any
|
||||
if code, err := apiRequest("POST", "/api/v1/minds/"+args[1]+"/clone", map[string]any{"slug": args[2]}, &m); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
fmt.Printf("cloned into %v (%v)\n", m["slug"], m["id"])
|
||||
return exitOK
|
||||
case "personality":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: noemactl mind personality <mind>")
|
||||
return exitUsage
|
||||
}
|
||||
var b map[string]string
|
||||
if code, err := apiRequest("GET", "/api/v1/minds/"+args[1]+"/personality/brief", nil, &b); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
fmt.Print(b["brief"])
|
||||
return exitOK
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "unknown mind subcommand", args[0])
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
func cmdTemplate(args []string) int {
|
||||
var out struct{ Items []map[string]any }
|
||||
if code, err := apiRequest("GET", "/api/v1/personality-templates", nil, &out); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "NAME\tDESCRIPTION\tID")
|
||||
for _, t := range out.Items {
|
||||
fmt.Fprintf(tw, "%v\t%v\t%v\n", t["name"], t["description"], t["id"])
|
||||
}
|
||||
tw.Flush()
|
||||
return exitOK
|
||||
}
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
+19
-1
@@ -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."},
|
||||
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
);
|
||||
@@ -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); }
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
{{define "content"}}
|
||||
{{$m := .Data.Mind}}
|
||||
<div class="pagehead mindhead">
|
||||
<div><h1>{{$m.DisplayName}} <span class="muted mono small">{{$m.Slug}}</span></h1>
|
||||
<p class="muted">{{$m.Personality.Identity.Role}}{{with $m.Personality.Identity.Description}} · {{.}}{{end}}</p></div>
|
||||
<div class="row">{{template "status_badge" $m.Status}}</div>
|
||||
</div>
|
||||
<nav class="tabs" aria-label="Mind sections">
|
||||
<a href="/minds/{{$m.Slug}}" aria-current="page">Overview</a>
|
||||
<a href="/minds/{{$m.Slug}}/personality">Personality</a>
|
||||
<a class="disabled" title="Phase 3">Thoughts</a><a class="disabled" title="Phase 4">Workspace</a><a class="disabled" title="Phase 5">Memory</a><a class="disabled" title="Phase 6">Beliefs</a><a class="disabled" title="Phase 8">Conversation</a>
|
||||
</nav>
|
||||
<section class="grid cols-3">
|
||||
<div class="card">
|
||||
<h2>Identity</h2>
|
||||
<dl class="kv">
|
||||
<dt>Name</dt><dd>{{$m.Personality.Identity.Name}}</dd>
|
||||
<dt>Role</dt><dd>{{$m.Personality.Identity.Role}}</dd>
|
||||
{{with $m.Personality.Identity.Pronouns}}<dt>Pronouns</dt><dd>{{.}}</dd>{{end}}
|
||||
<dt>Created</dt><dd>{{date $m.CreatedAt}}</dd>
|
||||
<dt>Personality</dt><dd>v{{$m.PersonalityVersion}} {{if $m.TemplateID}}<span class="badge accent">templated</span>{{end}} · <a href="/minds/{{$m.Slug}}/personality">edit</a></dd>
|
||||
{{if $m.ClonedFromMind}}<dt>Cloned from</dt><dd class="mono small">{{$m.ClonedFromMind}}</dd>{{end}}
|
||||
{{with $m.Notes}}<dt>Notes</dt><dd>{{.}}</dd>{{end}}
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Current focus</h2>
|
||||
<p class="muted">Working memory, workspace and narrative appear here once the cognitive runtime is online (phase 4).</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Cognitive configuration</h2>
|
||||
<dl class="kv">
|
||||
<dt>Workspace capacity</dt><dd>{{$m.Config.WorkspaceCapacity}}</dd>
|
||||
<dt>Working memory</dt><dd>{{$m.Config.WorkingMemoryCapacity}} items · decay {{$m.Config.WorkingMemoryDecay}}</dd>
|
||||
<dt>Cycle interval</dt><dd>{{$m.Config.CycleMinInterval}} min · {{$m.Config.IdleCycleInterval}} idle · max {{$m.Config.MaxCyclesPerMinute}}/min</dd>
|
||||
<dt>LLM per cycle</dt><dd>{{$m.Config.LLMCallsPerCycle}} calls · {{$m.Config.DailyTokenBudget}} tokens/day</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
<section class="grid cols-2">
|
||||
<div class="card">
|
||||
<h2>Personality brief <span class="muted small">what cognitive prompts receive</span></h2>
|
||||
<pre class="brief">{{.Data.Brief}}</pre>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Self-model</h2>
|
||||
{{$sm := .Data.SelfModel}}
|
||||
<h3>Capabilities</h3>
|
||||
{{if $sm.Capabilities}}<ul class="plain">{{range $sm.Capabilities}}<li><code>{{.Name}}</code> <span class="badge {{if eq .Policy "allow"}}ok{{else if eq .Policy "ask"}}warn{{else}}danger{{end}}">{{.Policy}}</span> <span class="muted small">{{.Description}}</span></li>{{end}}</ul>
|
||||
{{else}}<p class="muted small">No capabilities granted. This mind cannot act on external systems.</p>{{end}}
|
||||
<h3>Known limitations</h3>
|
||||
<ul class="small">{{range $sm.Limitations}}<li>{{.}}</li>{{end}}</ul>
|
||||
{{if $sm.KnowledgeGaps}}<h3>Knowledge gaps</h3><ul class="small">{{range $sm.KnowledgeGaps}}<li>{{.}}</li>{{end}}</ul>{{end}}
|
||||
<p class="muted small">Updated {{ago $sm.UpdatedAt}}</p>
|
||||
</div>
|
||||
</section>
|
||||
{{if .Principal.User.Role.AtLeast "administrator"}}
|
||||
<section class="grid cols-2">
|
||||
<div class="card">
|
||||
<h2>Lifecycle</h2>
|
||||
<form method="post" action="/minds/{{$m.Slug}}/status" class="row">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
{{if ne $m.Status "active"}}<button class="btn" name="status" value="active">Enable</button>{{end}}
|
||||
{{if eq $m.Status "active"}}<button class="btn" name="status" value="disabled" data-confirm="Disable {{$m.DisplayName}}? Cognition stops; history is kept.">Disable</button>{{end}}
|
||||
{{if ne $m.Status "archived"}}<button class="btn btn-danger" name="status" value="archived" data-confirm="Archive {{$m.DisplayName}}? It is hidden from lists but nothing is deleted.">Archive</button>{{end}}
|
||||
</form>
|
||||
<p class="muted small">Cognitive history is never hard-deleted by these actions.</p>
|
||||
<form method="post" action="/minds/{{$m.Slug}}/notes" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<label>Operator notes <textarea name="notes" rows="2">{{$m.Notes}}</textarea></label>
|
||||
<div><button class="btn small" type="submit">Save notes</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Clone</h2>
|
||||
<p class="muted small">Same personality and configuration, fresh experience. Forking with memories arrives with snapshots.</p>
|
||||
<form method="post" action="/minds/{{$m.Slug}}/clone" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<label>New slug <input name="slug" required pattern="[a-z0-9][a-z0-9-]{0,62}" placeholder="{{$m.Slug}}-experiment"></label>
|
||||
<label>Display name <input name="display_name"></label>
|
||||
<div><button class="btn" type="submit">Clone</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{define "status_badge"}}{{if eq . "active"}}<span class="badge ok">active</span>{{else if eq . "disabled"}}<span class="badge warn">disabled</span>{{else}}<span class="badge">archived</span>{{end}}{{end}}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "content"}}
|
||||
<div class="pagehead"><h1>Minds</h1><p class="muted">Independent cognitive entities. Each has its own personality, memory, beliefs and configuration.</p></div>
|
||||
<section class="grid cols-3-1">
|
||||
<div class="card">
|
||||
{{if .Data.Minds}}
|
||||
<table class="table">
|
||||
<thead><tr><th>Mind</th><th>Role</th><th>Status</th><th>Personality</th><th>Created</th></tr></thead>
|
||||
<tbody>{{range .Data.Minds}}<tr>
|
||||
<td><a href="/minds/{{.Slug}}"><strong>{{.DisplayName}}</strong></a> <span class="muted small mono">{{.Slug}}</span></td>
|
||||
<td class="small">{{.Personality.Identity.Role}}</td>
|
||||
<td>{{template "status_badge" .Status}}</td>
|
||||
<td class="small muted">v{{.PersonalityVersion}}{{if .TemplateID}} · templated{{end}}</td>
|
||||
<td class="small muted nowrap">{{date .CreatedAt}}</td>
|
||||
</tr>{{end}}</tbody>
|
||||
</table>
|
||||
{{else}}<p class="muted">No minds yet. Create the first one.</p>{{end}}
|
||||
<p class="small muted"><a href="/minds?include_archived=1">Show archived</a> · <a href="/personality-templates">Personality templates</a></p>
|
||||
</div>
|
||||
{{if .Principal.User.Role.AtLeast "administrator"}}
|
||||
<div class="card">
|
||||
<h2>Create mind</h2>
|
||||
<form method="post" action="/minds" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<label>Slug <input name="slug" required pattern="[a-z0-9][a-z0-9-]{0,62}" placeholder="dani" title="lowercase letters, digits, hyphens"></label>
|
||||
<label>Display name <input name="display_name" placeholder="Dani"></label>
|
||||
<label>Start from template <select name="template_id"><option value="">Neutral default</option>{{range .Data.Templates}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select></label>
|
||||
<button class="btn btn-primary" type="submit">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{define "status_badge"}}{{if eq . "active"}}<span class="badge ok">active</span>{{else if eq . "disabled"}}<span class="badge warn">disabled</span>{{else}}<span class="badge">archived</span>{{end}}{{end}}
|
||||
@@ -0,0 +1,60 @@
|
||||
{{define "content"}}
|
||||
{{$m := .Data.Mind}}
|
||||
<div class="pagehead mindhead">
|
||||
<div><h1>{{$m.DisplayName}} <span class="muted small">personality</span></h1><p class="muted">Version {{$m.PersonalityVersion}}{{if .Data.Template}} · based on template <strong>{{.Data.Template.Name}}</strong>{{end}}. Every save creates a new version; nothing is overwritten.</p></div>
|
||||
<div class="row"><a class="btn small" href="/minds/{{$m.Slug}}/personality.json">Export JSON</a></div>
|
||||
</div>
|
||||
<nav class="tabs" aria-label="Mind sections"><a href="/minds/{{$m.Slug}}">Overview</a><a href="/minds/{{$m.Slug}}/personality" aria-current="page">Personality</a></nav>
|
||||
{{$admin := .Principal.User.Role.AtLeast "administrator"}}
|
||||
<section class="grid cols-3-1">
|
||||
<div>
|
||||
<form method="post" action="/minds/{{$m.Slug}}/personality" class="card stack" id="pform">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
{{template "personality_form" (dict "P" $m.Personality)}}
|
||||
<div class="row">
|
||||
<input name="change_note" placeholder="Change note (why)" maxlength="200" class="grow">
|
||||
{{if $admin}}<button class="btn btn-primary" type="submit">Save as version {{inc $m.PersonalityVersion}}</button>{{end}}
|
||||
<button class="btn" type="submit" formaction="/minds/{{$m.Slug}}/personality/preview" formmethod="post">Preview brief</button>
|
||||
</div>
|
||||
</form>
|
||||
{{with .Data.Preview}}<div class="card highlight"><h2>Preview (unsaved)</h2><pre class="brief">{{.}}</pre></div>{{end}}
|
||||
</div>
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<h2>Template</h2>
|
||||
{{if $admin}}
|
||||
<form method="post" action="/minds/{{$m.Slug}}/personality/template" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<select name="template_id"><option value="">None (detach)</option>{{range .Data.Templates}}<option value="{{.ID}}" {{if and $.Data.Template (eq .ID $.Data.Template.ID)}}selected{{end}}>{{.Name}}</option>{{end}}</select>
|
||||
<button class="btn small" type="submit">Apply template</button>
|
||||
</form>
|
||||
<p class="muted small">Mind-specific overrides are kept when a template is applied or re-applied.</p>
|
||||
{{else}}<p class="muted small">{{if .Data.Template}}{{.Data.Template.Name}}{{else}}None{{end}}</p>{{end}}
|
||||
{{if .Data.Overrides}}<h3>Overrides</h3><ul class="small mono">{{range $k, $v := .Data.Overrides}}<li>{{$k}}</li>{{end}}</ul>{{end}}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>History</h2>
|
||||
<table class="table compact">
|
||||
<thead><tr><th>v</th><th>Note</th><th>By</th><th></th></tr></thead>
|
||||
<tbody>{{range .Data.Versions}}<tr>
|
||||
<td>{{.Version}}{{if eq .Version $m.PersonalityVersion}} <span class="badge accent">current</span>{{end}}</td>
|
||||
<td class="small">{{.ChangeNote}}<div class="muted">{{ago .CreatedAt}}</div></td>
|
||||
<td class="small muted">{{.CreatedBy}}</td>
|
||||
<td class="nowrap">{{if ne .Version $m.PersonalityVersion}}<a class="small" href="/minds/{{$m.Slug}}/personality/diff?a={{.Version}}&b={{$m.PersonalityVersion}}">diff</a>
|
||||
{{if $admin}}<form method="post" action="/minds/{{$m.Slug}}/personality/versions/{{.Version}}/restore" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small" data-confirm="Restore version {{.Version}} as a new version?">restore</button></form>{{end}}{{end}}</td>
|
||||
</tr>{{end}}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{if $admin}}
|
||||
<div class="card">
|
||||
<h2>Import JSON</h2>
|
||||
<form method="post" action="/minds/{{$m.Slug}}/personality/import" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<textarea name="json" rows="5" placeholder='{"identity":{...},"values":{...}}' required></textarea>
|
||||
<button class="btn small" type="submit">Import as new version</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,12 @@
|
||||
{{define "content"}}
|
||||
{{$m := .Data.Mind}}
|
||||
<div class="pagehead"><h1>{{$m.DisplayName}} <span class="muted small">personality v{{.Data.A}} → v{{.Data.B}}</span></h1><p class="muted"><a href="/minds/{{$m.Slug}}/personality">Back to editor</a></p></div>
|
||||
<div class="card">
|
||||
{{if .Data.Changes}}
|
||||
<table class="table">
|
||||
<thead><tr><th>Field</th><th>v{{.Data.A}}</th><th>v{{.Data.B}}</th></tr></thead>
|
||||
<tbody>{{range .Data.Changes}}<tr><td class="mono small">{{.Path}}</td><td class="diff-old">{{.Old}}</td><td class="diff-new">{{.New}}</td></tr>{{end}}</tbody>
|
||||
</table>
|
||||
{{else}}<p class="muted">No differences.</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,14 @@
|
||||
{{define "content"}}
|
||||
{{$t := .Data.Template}}
|
||||
<div class="pagehead"><h1>Template: {{$t.Name}}</h1><p class="muted"><a href="/personality-templates">All templates</a> · Editing a template does not change minds until an operator re-applies it.</p></div>
|
||||
<form method="post" action="/personality-templates/{{$t.ID}}" class="card stack">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<div class="grid cols-2 tight">
|
||||
<label>Name <input name="name" value="{{$t.Name}}" required maxlength="80"></label>
|
||||
<label>Description <input name="description" value="{{$t.Description}}" maxlength="200"></label>
|
||||
</div>
|
||||
{{template "personality_form" (dict "P" $t.Personality)}}
|
||||
<div class="row"><button class="btn btn-primary" type="submit" {{if not (.Principal.User.Role.AtLeast "administrator")}}disabled{{end}}>Save template</button></div>
|
||||
</form>
|
||||
<div class="card"><h2>Brief</h2><pre class="brief">{{.Data.Brief}}</pre></div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "content"}}
|
||||
<div class="pagehead"><h1>Personality templates</h1><p class="muted">Reusable personalities. Minds keep their own overrides when a template is applied.</p></div>
|
||||
<section class="grid cols-3-1">
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead><tr><th>Name</th><th>Description</th><th>Role</th><th>Updated</th><th></th></tr></thead>
|
||||
<tbody>{{range .Data.Templates}}<tr><td><a href="/personality-templates/{{.ID}}"><strong>{{.Name}}</strong></a></td><td class="small">{{.Description}}</td><td class="small muted">{{.Personality.Identity.Role}}</td><td class="small muted">{{ago .UpdatedAt}}</td>
|
||||
<td>{{if $.Principal.User.Role.AtLeast "administrator"}}<form method="post" action="/personality-templates/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small btn-danger" data-confirm="Delete template {{.Name}}?">Delete</button></form>{{end}}</td></tr>
|
||||
{{else}}<tr><td colspan="5" class="muted">No templates yet.</td></tr>{{end}}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{if .Principal.User.Role.AtLeast "administrator"}}
|
||||
<div class="card">
|
||||
<h2>New template</h2>
|
||||
<form method="post" action="/personality-templates" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<label>Name <input name="name" required maxlength="80"></label>
|
||||
<label>Description <input name="description" maxlength="200"></label>
|
||||
<button class="btn btn-primary" type="submit">Create (then edit)</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,53 @@
|
||||
{{define "personality_form"}}{{/* expects .P (Personality) and .Prefix "" */}}
|
||||
{{$p := .P}}
|
||||
<fieldset class="fs">
|
||||
<legend>Identity</legend>
|
||||
<div class="grid cols-2 tight">
|
||||
<label>Name <input name="identity.name" value="{{$p.Identity.Name}}" required maxlength="80"></label>
|
||||
<label>Display name <input name="identity.display_name" value="{{$p.Identity.DisplayName}}" maxlength="80"></label>
|
||||
<label>Role <input name="identity.role" value="{{$p.Identity.Role}}" maxlength="120" placeholder="e.g. infrastructure investigator"></label>
|
||||
<label>Pronouns <input name="identity.pronouns" value="{{$p.Identity.Pronouns}}" maxlength="40"></label>
|
||||
</div>
|
||||
<label>Description <textarea name="identity.description" rows="2" maxlength="2000">{{$p.Identity.Description}}</textarea></label>
|
||||
<div class="grid cols-2 tight">
|
||||
<label>Backstory <textarea name="identity.backstory" rows="3" maxlength="4000">{{$p.Identity.Backstory}}</textarea></label>
|
||||
<label>Origin <textarea name="identity.origin" rows="3" maxlength="2000">{{$p.Identity.Origin}}</textarea></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="grid cols-2">
|
||||
<fieldset class="fs">
|
||||
<legend>Values <span class="muted small">how much the mind weighs each</span></legend>
|
||||
{{range $k := valueKeys}}<label class="slider"><span>{{title $k}}</span><input type="range" name="values.{{$k}}" min="0" max="1" step="0.05" value="{{index $p.Values $k}}" data-out><output>{{index $p.Values $k}}</output></label>{{end}}
|
||||
</fieldset>
|
||||
<fieldset class="fs">
|
||||
<legend>Traits <span class="muted small">dispositions</span></legend>
|
||||
{{range $k := traitKeys}}<label class="slider"><span>{{title $k}}</span><input type="range" name="traits.{{$k}}" min="0" max="1" step="0.05" value="{{index $p.Traits $k}}" data-out><output>{{index $p.Traits $k}}</output></label>{{end}}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="grid cols-2">
|
||||
<fieldset class="fs">
|
||||
<legend>Communication</legend>
|
||||
<div class="grid cols-2 tight">
|
||||
<label>Language <input name="communication.language" value="{{$p.Communication.Language}}" maxlength="16"></label>
|
||||
<label>Dialect <input name="communication.dialect" value="{{$p.Communication.Dialect}}" maxlength="40" placeholder="e.g. British English"></label>
|
||||
</div>
|
||||
<label class="slider"><span>Formality</span><input type="range" name="communication.formality" min="0" max="1" step="0.05" value="{{$p.Communication.Formality}}" data-out><output>{{$p.Communication.Formality}}</output></label>
|
||||
<label class="slider"><span>Verbosity</span><input type="range" name="communication.verbosity" min="0" max="1" step="0.05" value="{{$p.Communication.Verbosity}}" data-out><output>{{$p.Communication.Verbosity}}</output></label>
|
||||
<label>Humour style <input name="communication.humour_style" value="{{$p.Communication.HumourStyle}}" maxlength="120" placeholder="dry, sparing"></label>
|
||||
<label>Terminology <input name="communication.terminology" value="{{$p.Communication.Terminology}}" maxlength="400" placeholder="prefer British spelling; say 'server' not 'box'"></label>
|
||||
</fieldset>
|
||||
<fieldset class="fs">
|
||||
<legend>Behavioural preferences</legend>
|
||||
<label class="check"><input type="checkbox" name="behaviour.challenge_assumptions" {{if $p.Behaviour.ChallengeAssumptions}}checked{{end}}> Challenge assumptions</label>
|
||||
<label class="check"><input type="checkbox" name="behaviour.ask_follow_ups" {{if $p.Behaviour.AskFollowUps}}checked{{end}}> Ask follow-up questions</label>
|
||||
<label class="check"><input type="checkbox" name="behaviour.prefer_evidence" {{if $p.Behaviour.PreferEvidence}}checked{{end}}> Prefer evidence</label>
|
||||
<label class="check"><input type="checkbox" name="behaviour.cite_uncertainty" {{if $p.Behaviour.CiteUncertainty}}checked{{end}}> Cite uncertainty</label>
|
||||
<label class="check"><input type="checkbox" name="behaviour.offer_alternatives" {{if $p.Behaviour.OfferAlternatives}}checked{{end}}> Offer alternatives</label>
|
||||
<label class="check"><input type="checkbox" name="behaviour.notify_proactively" {{if $p.Behaviour.NotifyProactively}}checked{{end}}> Notify proactively</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<fieldset class="fs">
|
||||
<legend>Free-form personality text <span class="muted small">supplements the structured fields; never replaces them</span></legend>
|
||||
<textarea name="free_text" rows="6" maxlength="20000">{{$p.FreeText}}</textarea>
|
||||
</fieldset>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user