A personality belongs to a person

Owner's correction to 1.4.0: a model's character is per (model, person), and only
the description and the notes stay instance-wide. Two people talking to one model
are not talking to the same personality, and neither can see the other's.

The administrator's box becomes the DEFAULT, resolved by `personas.effective` as
a fallback and never as a layer -- two personalities at once contradict each
other with nothing to say which is losing, which is the reasoning behind "system
prompts replace, never stack". `persona_write` takes no argument naming a model
or a person; both come from the ToolContext, so it can only write the character
it has with whoever it is talking to, and it never touches the default.

Impressions move to their own table. Not a `kind` column: 1.4.0 shipped
`UNIQUE(model_key, owner_id)`, SQLite cannot alter a constraint and this schema
is additive-only, so a discriminator would leave an upgraded instance unable to
hold both rows for one pair. That leaves the first MANUAL_STEPS entry this
project has had -- the two shapes are indistinguishable, so nothing rewrites
them: a repair would be guessing at text that is read back in the first person.

TWO BUGS FROM A PHONE

`min-width` beats both `width` and `max-width` -- CSS clamps width to max-width
and then raises the result to min-width -- so `.canvas` and `.terminal` were
384px wide on every screen narrower than that, their `min(…, 100vw)` cap
overruled, and `.inspector` had no cap at all on a width that is a preference
draggable to 2400px. None of it scrolled sideways, because all three are
`position: fixed` and fixed overflow does not extend the scrollable area -- which
is exactly why the 1.1.0 narrow pass reported these pages clean. `min-width: 0`
in the overlay query, full width below the phone breakpoint, tablet column kept.

And the install button now says why it is absent. Measured against the live
instance: the manifest meets every Chrome criterion and the blocker is a
certificate from a private CA, so the origin is not trustworthy, the service
worker is refused and no install is offered. `base.html` had been swallowing that
with an empty catch -- which kept the page working, the reason it was there, and
threw away the only evidence. It now records the outcome and `app.js` turns it
into a sentence naming the certificate, which is the cause the old hint did not
mention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 12:20:40 +00:00
co-authored by Claude Opus 5
parent df52ec9d96
commit ac51dd46cc
20 changed files with 1019 additions and 201 deletions
@@ -362,17 +362,26 @@
{# Outside the form above, and it has to be: two forms cannot nest, and this one
posts somewhere else. See the note beside the Detect button. #}
<section class="card">
<h2 class="card__title">Personality</h2>
<h2 class="card__title">Default personality</h2>
<p class="card__lede">
Who this model is, carried into every conversation rather than given to it for
one. Different from the system prompt above: that is an instruction you write,
this is a character it can be — and, with
<strong>Edit its own personality</strong> ticked, one it can rewrite itself.
Every version is kept below.
Who this model is before it has worked out who it is with somebody. Different
from the system prompt above: that is an instruction you write, this is a
character it can be — and, with <strong>Edit its own personality</strong>
ticked, one it rewrites for itself.
</p>
<p class="card__lede">
<strong>A personality belongs to a person.</strong> Each account gets its own
version of this model's character, starting from what you write here and
diverging from it the first time the model writes its own. Changing this
afterwards does not reach anybody who already has one, and it is not stacked
underneath theirs — two personalities at once would contradict each other and
nobody could tell which was losing. What the model *is*, as opposed to who it
has become with somebody, belongs in <strong>Description</strong> and
<strong>Facts for other models</strong> above, which are the same for everyone.
</p>
<form method="post" action="/admin/models/{{ model.id }}/persona">
<div class="field">
<label class="field__label visually-hidden" for="persona">Personality</label>
<label class="field__label visually-hidden" for="persona">Default personality</label>
<textarea class="textarea" id="persona" name="content" rows="6"
placeholder="Nothing yet. Write one, or let the model write its own."
>{{ persona.content if persona else "" }}</textarea>
@@ -396,11 +405,12 @@
{% if persona and persona.revisions %}
<section class="card">
<h2 class="card__title">
Earlier personalities <span class="badge">{{ persona.revisions|length }}</span>
Earlier defaults <span class="badge">{{ persona.revisions|length }}</span>
</h2>
<p class="card__lede">
What it said before each change. This is the whole safety story for a model
that may rewrite itself: not a gate, but a record and a way back.
What this default said before each change. Each person's own personality keeps
its own history, which they can see and restore in their own settings — this is
the starting point's history, not theirs.
</p>
<ul class="model-list">
{% for revision in persona.revisions %}
+31 -6
View File
@@ -142,15 +142,40 @@
{#
The version in the query string is what versions the worker's cache, so a
release invalidates it without anyone remembering to bump a constant.
serviceWorker is absent over plain http, which is why a LAN install without
TLS silently offers no install prompt -- that is the browser's rule, not ours.
🚨 The outcome is *recorded*, not swallowed. serviceWorker is absent over plain
http and registration is refused on a page with a certificate error, and in
both cases the only symptom was that the Install button never appeared -- with
a hint beside it saying installing needs HTTPS, which is true and is not an
answer. A self-signed or private-CA certificate the phone does not trust looks
exactly like a browser that cannot install at all. `window.lembasWorker` is
what `app.js` turns into a sentence on the settings page.
#}
<script>
if ("serviceWorker" in navigator) {
window.lembasWorker = { state: "unsupported" };
if (!window.isSecureContext) {
/* Reported separately from an outright failure: the fix is different. */
window.lembasWorker = { state: "insecure" };
} else if ("serviceWorker" in navigator) {
window.lembasWorker = { state: "registering" };
window.addEventListener("load", function () {
navigator.serviceWorker.register("/sw.js?v={{ version }}").catch(function () {
/* An install failure must never break the page it was loaded from. */
});
/* Two callbacks rather than .then().catch(): a throw inside the success
path must not be reported as a registration failure. */
navigator.serviceWorker.register("/sw.js?v={{ version }}").then(
function () {
window.lembasWorker = { state: "ready" };
document.dispatchEvent(new CustomEvent("lembas:worker"));
},
function (error) {
window.lembasWorker = {
state: "failed",
reason: (error && error.name) || "Error",
detail: (error && error.message) || ""
};
document.dispatchEvent(new CustomEvent("lembas:worker"));
/* An install failure must never break the page it was loaded from. */
}
);
});
}
</script>
+68 -12
View File
@@ -270,9 +270,15 @@
{{ icon("plus", "icon--sm") }} Install
</button>
</div>
{# Filled by app.js from what actually happened, because every
reason the button is absent looks the same from here. The static
line below it used to be the only explanation, and it named the
one cause that is least likely on a home network. #}
<p class="field__hint" data-install-status hidden></p>
<p class="field__hint">
Only offered over HTTPS or on localhost, and not at all in some
browsers. On iOS, use Share → Add to Home Screen.
Installing needs a secure connection — HTTPS with a certificate
this device trusts, or localhost — and some browsers never offer
it. On iOS, use Share → Add to Home Screen.
</p>
</div>
</section>
@@ -421,15 +427,65 @@
</p>
</div>
{# What each model has made of you, in its own words. Shown whether or
not any model is still allowed to write one: a model whose
permission was taken away has not forgotten, and this is the only
place the text can be read or removed. #}
{% if reflections %}
{# Both halves are shown whether or not any model may still write
one: a model whose permission was taken away has not forgotten, and
this is the only place either text can be read or removed. #}
{% if personalities %}
<div class="card">
<h2 class="card__title">
Who each model is with you
<span class="badge">{{ personalities|length }}</span>
</h2>
<p class="card__lede">
A model's character is something it works out with a particular
person, so this is yours — somebody else talking to the same model
is talking to a different one, and neither of you can see the
other's. Delete one and that model starts again from the default
its administrator wrote.
</p>
<ul class="model-list">
{% for personality in personalities %}
<li class="model-list__item">
<div style="min-width: 0">
<strong>{{ personality.model_key }}</strong>
{% if personality.author == "model" %}
<span class="badge badge--leaf">its own words</span>
{% endif %}
<div class="text-sm">{{ personality.content }}</div>
{% if personality.revisions %}
<details class="text-xs faint">
<summary>{{ personality.revisions|length }} earlier version(s)</summary>
<ul>
{% for revision in personality.revisions %}
<li>
{{ revision.created_at.strftime("%Y-%m-%d %H:%M") }} —
{{ revision.content }}
</li>
{% endfor %}
</ul>
</details>
{% endif %}
</div>
<form method="post"
action="/api/library/personalities/{{ personality.id }}/delete">
<button class="btn btn--sm btn--danger" type="submit"
data-confirm-button="Reset this model's personality with you?"
data-confirm-title="Reset"
aria-label="Reset this" title="Reset this">
{{ icon("trash", "icon--sm") }}
</button>
</form>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if impressions %}
<div class="card">
<h2 class="card__title">
What models make of you
<span class="badge">{{ reflections|length }}</span>
<span class="badge">{{ impressions|length }}</span>
</h2>
<p class="card__lede">
Each model's own impression of how you work, kept by that model and
@@ -439,14 +495,14 @@
if it has reason to.
</p>
<ul class="model-list">
{% for reflection in reflections %}
{% for impression in impressions %}
<li class="model-list__item">
<div style="min-width: 0">
<strong>{{ reflection.model_key }}</strong>
<div class="text-sm">{{ reflection.content }}</div>
<strong>{{ impression.model_key }}</strong>
<div class="text-sm">{{ impression.content }}</div>
</div>
<form method="post"
action="/api/library/reflections/{{ reflection.id }}/delete">
action="/api/library/impressions/{{ impression.id }}/delete">
<button class="btn btn--sm btn--danger" type="submit"
data-confirm-button="Delete what this model makes of you?"
data-confirm-title="Delete"