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

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

API, UI (list, overview, editor with sliders/diff/history/import/export,
template editor) and noemactl mind/template commands. All mutations are
audited with field-level diffs.
This commit is contained in:
Andy Dixon
2026-06-15 22:17:01 +01:00
parent a002a2c100
commit fb34f08f40
28 changed files with 2560 additions and 10 deletions
+87
View File
@@ -0,0 +1,87 @@
{{define "content"}}
{{$m := .Data.Mind}}
<div class="pagehead mindhead">
<div><h1>{{$m.DisplayName}} <span class="muted mono small">{{$m.Slug}}</span></h1>
<p class="muted">{{$m.Personality.Identity.Role}}{{with $m.Personality.Identity.Description}} · {{.}}{{end}}</p></div>
<div class="row">{{template "status_badge" $m.Status}}</div>
</div>
<nav class="tabs" aria-label="Mind sections">
<a href="/minds/{{$m.Slug}}" aria-current="page">Overview</a>
<a href="/minds/{{$m.Slug}}/personality">Personality</a>
<a class="disabled" title="Phase 3">Thoughts</a><a class="disabled" title="Phase 4">Workspace</a><a class="disabled" title="Phase 5">Memory</a><a class="disabled" title="Phase 6">Beliefs</a><a class="disabled" title="Phase 8">Conversation</a>
</nav>
<section class="grid cols-3">
<div class="card">
<h2>Identity</h2>
<dl class="kv">
<dt>Name</dt><dd>{{$m.Personality.Identity.Name}}</dd>
<dt>Role</dt><dd>{{$m.Personality.Identity.Role}}</dd>
{{with $m.Personality.Identity.Pronouns}}<dt>Pronouns</dt><dd>{{.}}</dd>{{end}}
<dt>Created</dt><dd>{{date $m.CreatedAt}}</dd>
<dt>Personality</dt><dd>v{{$m.PersonalityVersion}} {{if $m.TemplateID}}<span class="badge accent">templated</span>{{end}} · <a href="/minds/{{$m.Slug}}/personality">edit</a></dd>
{{if $m.ClonedFromMind}}<dt>Cloned from</dt><dd class="mono small">{{$m.ClonedFromMind}}</dd>{{end}}
{{with $m.Notes}}<dt>Notes</dt><dd>{{.}}</dd>{{end}}
</dl>
</div>
<div class="card">
<h2>Current focus</h2>
<p class="muted">Working memory, workspace and narrative appear here once the cognitive runtime is online (phase 4).</p>
</div>
<div class="card">
<h2>Cognitive configuration</h2>
<dl class="kv">
<dt>Workspace capacity</dt><dd>{{$m.Config.WorkspaceCapacity}}</dd>
<dt>Working memory</dt><dd>{{$m.Config.WorkingMemoryCapacity}} items · decay {{$m.Config.WorkingMemoryDecay}}</dd>
<dt>Cycle interval</dt><dd>{{$m.Config.CycleMinInterval}} min · {{$m.Config.IdleCycleInterval}} idle · max {{$m.Config.MaxCyclesPerMinute}}/min</dd>
<dt>LLM per cycle</dt><dd>{{$m.Config.LLMCallsPerCycle}} calls · {{$m.Config.DailyTokenBudget}} tokens/day</dd>
</dl>
</div>
</section>
<section class="grid cols-2">
<div class="card">
<h2>Personality brief <span class="muted small">what cognitive prompts receive</span></h2>
<pre class="brief">{{.Data.Brief}}</pre>
</div>
<div class="card">
<h2>Self-model</h2>
{{$sm := .Data.SelfModel}}
<h3>Capabilities</h3>
{{if $sm.Capabilities}}<ul class="plain">{{range $sm.Capabilities}}<li><code>{{.Name}}</code> <span class="badge {{if eq .Policy "allow"}}ok{{else if eq .Policy "ask"}}warn{{else}}danger{{end}}">{{.Policy}}</span> <span class="muted small">{{.Description}}</span></li>{{end}}</ul>
{{else}}<p class="muted small">No capabilities granted. This mind cannot act on external systems.</p>{{end}}
<h3>Known limitations</h3>
<ul class="small">{{range $sm.Limitations}}<li>{{.}}</li>{{end}}</ul>
{{if $sm.KnowledgeGaps}}<h3>Knowledge gaps</h3><ul class="small">{{range $sm.KnowledgeGaps}}<li>{{.}}</li>{{end}}</ul>{{end}}
<p class="muted small">Updated {{ago $sm.UpdatedAt}}</p>
</div>
</section>
{{if .Principal.User.Role.AtLeast "administrator"}}
<section class="grid cols-2">
<div class="card">
<h2>Lifecycle</h2>
<form method="post" action="/minds/{{$m.Slug}}/status" class="row">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
{{if ne $m.Status "active"}}<button class="btn" name="status" value="active">Enable</button>{{end}}
{{if eq $m.Status "active"}}<button class="btn" name="status" value="disabled" data-confirm="Disable {{$m.DisplayName}}? Cognition stops; history is kept.">Disable</button>{{end}}
{{if ne $m.Status "archived"}}<button class="btn btn-danger" name="status" value="archived" data-confirm="Archive {{$m.DisplayName}}? It is hidden from lists but nothing is deleted.">Archive</button>{{end}}
</form>
<p class="muted small">Cognitive history is never hard-deleted by these actions.</p>
<form method="post" action="/minds/{{$m.Slug}}/notes" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Operator notes <textarea name="notes" rows="2">{{$m.Notes}}</textarea></label>
<div><button class="btn small" type="submit">Save notes</button></div>
</form>
</div>
<div class="card">
<h2>Clone</h2>
<p class="muted small">Same personality and configuration, fresh experience. Forking with memories arrives with snapshots.</p>
<form method="post" action="/minds/{{$m.Slug}}/clone" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>New slug <input name="slug" required pattern="[a-z0-9][a-z0-9-]{0,62}" placeholder="{{$m.Slug}}-experiment"></label>
<label>Display name <input name="display_name"></label>
<div><button class="btn" type="submit">Clone</button></div>
</form>
</div>
</section>
{{end}}
{{end}}
{{define "status_badge"}}{{if eq . "active"}}<span class="badge ok">active</span>{{else if eq . "disabled"}}<span class="badge warn">disabled</span>{{else}}<span class="badge">archived</span>{{end}}{{end}}
+33
View File
@@ -0,0 +1,33 @@
{{define "content"}}
<div class="pagehead"><h1>Minds</h1><p class="muted">Independent cognitive entities. Each has its own personality, memory, beliefs and configuration.</p></div>
<section class="grid cols-3-1">
<div class="card">
{{if .Data.Minds}}
<table class="table">
<thead><tr><th>Mind</th><th>Role</th><th>Status</th><th>Personality</th><th>Created</th></tr></thead>
<tbody>{{range .Data.Minds}}<tr>
<td><a href="/minds/{{.Slug}}"><strong>{{.DisplayName}}</strong></a> <span class="muted small mono">{{.Slug}}</span></td>
<td class="small">{{.Personality.Identity.Role}}</td>
<td>{{template "status_badge" .Status}}</td>
<td class="small muted">v{{.PersonalityVersion}}{{if .TemplateID}} · templated{{end}}</td>
<td class="small muted nowrap">{{date .CreatedAt}}</td>
</tr>{{end}}</tbody>
</table>
{{else}}<p class="muted">No minds yet. Create the first one.</p>{{end}}
<p class="small muted"><a href="/minds?include_archived=1">Show archived</a> · <a href="/personality-templates">Personality templates</a></p>
</div>
{{if .Principal.User.Role.AtLeast "administrator"}}
<div class="card">
<h2>Create mind</h2>
<form method="post" action="/minds" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Slug <input name="slug" required pattern="[a-z0-9][a-z0-9-]{0,62}" placeholder="dani" title="lowercase letters, digits, hyphens"></label>
<label>Display name <input name="display_name" placeholder="Dani"></label>
<label>Start from template <select name="template_id"><option value="">Neutral default</option>{{range .Data.Templates}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select></label>
<button class="btn btn-primary" type="submit">Create</button>
</form>
</div>
{{end}}
</section>
{{end}}
{{define "status_badge"}}{{if eq . "active"}}<span class="badge ok">active</span>{{else if eq . "disabled"}}<span class="badge warn">disabled</span>{{else}}<span class="badge">archived</span>{{end}}{{end}}
+60
View File
@@ -0,0 +1,60 @@
{{define "content"}}
{{$m := .Data.Mind}}
<div class="pagehead mindhead">
<div><h1>{{$m.DisplayName}} <span class="muted small">personality</span></h1><p class="muted">Version {{$m.PersonalityVersion}}{{if .Data.Template}} · based on template <strong>{{.Data.Template.Name}}</strong>{{end}}. Every save creates a new version; nothing is overwritten.</p></div>
<div class="row"><a class="btn small" href="/minds/{{$m.Slug}}/personality.json">Export JSON</a></div>
</div>
<nav class="tabs" aria-label="Mind sections"><a href="/minds/{{$m.Slug}}">Overview</a><a href="/minds/{{$m.Slug}}/personality" aria-current="page">Personality</a></nav>
{{$admin := .Principal.User.Role.AtLeast "administrator"}}
<section class="grid cols-3-1">
<div>
<form method="post" action="/minds/{{$m.Slug}}/personality" class="card stack" id="pform">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
{{template "personality_form" (dict "P" $m.Personality)}}
<div class="row">
<input name="change_note" placeholder="Change note (why)" maxlength="200" class="grow">
{{if $admin}}<button class="btn btn-primary" type="submit">Save as version {{inc $m.PersonalityVersion}}</button>{{end}}
<button class="btn" type="submit" formaction="/minds/{{$m.Slug}}/personality/preview" formmethod="post">Preview brief</button>
</div>
</form>
{{with .Data.Preview}}<div class="card highlight"><h2>Preview (unsaved)</h2><pre class="brief">{{.}}</pre></div>{{end}}
</div>
<div class="stack">
<div class="card">
<h2>Template</h2>
{{if $admin}}
<form method="post" action="/minds/{{$m.Slug}}/personality/template" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<select name="template_id"><option value="">None (detach)</option>{{range .Data.Templates}}<option value="{{.ID}}" {{if and $.Data.Template (eq .ID $.Data.Template.ID)}}selected{{end}}>{{.Name}}</option>{{end}}</select>
<button class="btn small" type="submit">Apply template</button>
</form>
<p class="muted small">Mind-specific overrides are kept when a template is applied or re-applied.</p>
{{else}}<p class="muted small">{{if .Data.Template}}{{.Data.Template.Name}}{{else}}None{{end}}</p>{{end}}
{{if .Data.Overrides}}<h3>Overrides</h3><ul class="small mono">{{range $k, $v := .Data.Overrides}}<li>{{$k}}</li>{{end}}</ul>{{end}}
</div>
<div class="card">
<h2>History</h2>
<table class="table compact">
<thead><tr><th>v</th><th>Note</th><th>By</th><th></th></tr></thead>
<tbody>{{range .Data.Versions}}<tr>
<td>{{.Version}}{{if eq .Version $m.PersonalityVersion}} <span class="badge accent">current</span>{{end}}</td>
<td class="small">{{.ChangeNote}}<div class="muted">{{ago .CreatedAt}}</div></td>
<td class="small muted">{{.CreatedBy}}</td>
<td class="nowrap">{{if ne .Version $m.PersonalityVersion}}<a class="small" href="/minds/{{$m.Slug}}/personality/diff?a={{.Version}}&b={{$m.PersonalityVersion}}">diff</a>
{{if $admin}}<form method="post" action="/minds/{{$m.Slug}}/personality/versions/{{.Version}}/restore" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small" data-confirm="Restore version {{.Version}} as a new version?">restore</button></form>{{end}}{{end}}</td>
</tr>{{end}}</tbody>
</table>
</div>
{{if $admin}}
<div class="card">
<h2>Import JSON</h2>
<form method="post" action="/minds/{{$m.Slug}}/personality/import" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<textarea name="json" rows="5" placeholder='{"identity":{...},"values":{...}}' required></textarea>
<button class="btn small" type="submit">Import as new version</button>
</form>
</div>
{{end}}
</div>
</section>
{{end}}
+12
View File
@@ -0,0 +1,12 @@
{{define "content"}}
{{$m := .Data.Mind}}
<div class="pagehead"><h1>{{$m.DisplayName}} <span class="muted small">personality v{{.Data.A}} → v{{.Data.B}}</span></h1><p class="muted"><a href="/minds/{{$m.Slug}}/personality">Back to editor</a></p></div>
<div class="card">
{{if .Data.Changes}}
<table class="table">
<thead><tr><th>Field</th><th>v{{.Data.A}}</th><th>v{{.Data.B}}</th></tr></thead>
<tbody>{{range .Data.Changes}}<tr><td class="mono small">{{.Path}}</td><td class="diff-old">{{.Old}}</td><td class="diff-new">{{.New}}</td></tr>{{end}}</tbody>
</table>
{{else}}<p class="muted">No differences.</p>{{end}}
</div>
{{end}}
+14
View File
@@ -0,0 +1,14 @@
{{define "content"}}
{{$t := .Data.Template}}
<div class="pagehead"><h1>Template: {{$t.Name}}</h1><p class="muted"><a href="/personality-templates">All templates</a> · Editing a template does not change minds until an operator re-applies it.</p></div>
<form method="post" action="/personality-templates/{{$t.ID}}" class="card stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="grid cols-2 tight">
<label>Name <input name="name" value="{{$t.Name}}" required maxlength="80"></label>
<label>Description <input name="description" value="{{$t.Description}}" maxlength="200"></label>
</div>
{{template "personality_form" (dict "P" $t.Personality)}}
<div class="row"><button class="btn btn-primary" type="submit" {{if not (.Principal.User.Role.AtLeast "administrator")}}disabled{{end}}>Save template</button></div>
</form>
<div class="card"><h2>Brief</h2><pre class="brief">{{.Data.Brief}}</pre></div>
{{end}}
+24
View File
@@ -0,0 +1,24 @@
{{define "content"}}
<div class="pagehead"><h1>Personality templates</h1><p class="muted">Reusable personalities. Minds keep their own overrides when a template is applied.</p></div>
<section class="grid cols-3-1">
<div class="card">
<table class="table">
<thead><tr><th>Name</th><th>Description</th><th>Role</th><th>Updated</th><th></th></tr></thead>
<tbody>{{range .Data.Templates}}<tr><td><a href="/personality-templates/{{.ID}}"><strong>{{.Name}}</strong></a></td><td class="small">{{.Description}}</td><td class="small muted">{{.Personality.Identity.Role}}</td><td class="small muted">{{ago .UpdatedAt}}</td>
<td>{{if $.Principal.User.Role.AtLeast "administrator"}}<form method="post" action="/personality-templates/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small btn-danger" data-confirm="Delete template {{.Name}}?">Delete</button></form>{{end}}</td></tr>
{{else}}<tr><td colspan="5" class="muted">No templates yet.</td></tr>{{end}}</tbody>
</table>
</div>
{{if .Principal.User.Role.AtLeast "administrator"}}
<div class="card">
<h2>New template</h2>
<form method="post" action="/personality-templates" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Name <input name="name" required maxlength="80"></label>
<label>Description <input name="description" maxlength="200"></label>
<button class="btn btn-primary" type="submit">Create (then edit)</button>
</form>
</div>
{{end}}
</section>
{{end}}