How I built editable personality for my desktop AI companion: a character card that shapes wording, never facts
My companion used to have one voice, and it was mine — baked into the system prompt, unchangeable without editing source. That worked while I was the only user. The moment someone else wanted her warmer, or quieter, or in Hinglish instead of English, the answer was "edit the code and rebuild". A personality nobody can change without a rebuild is not a personality. It is a default wearing a costume.
So in v2.7.5 I gave Ankita an editable character card: a small structured file the user edits in Settings, shared by chat, voice and channels, with one rule written into it in plain text — personality controls wording only.
The card is data, not code
The card lives in personality.json in the config directory, as {"version": 1, "profile": {...}}. The profile has seven fields: enabled, description (600 characters), attitude (gentle, balanced or bold), teasing (off, light or playful), language (follow the conversation, Hinglish or English), care (500 characters), and up to four example exchanges (200 characters per user line, 240 per reply).
Everything about the storage is deliberately boring. PersonalityStore in src/core/personality-store.mjs writes with temp file plus rename, owner-only 0o600 permissions, and a 16 KB size cap checked before parsing. An unknown field, a bad enum value, or an over-long string throws and the previous card stays intact. A malformed card on disk falls back to the default with a notice in Settings — it never overwrites the file it could not read.
The default card is the one I actually use: "A self-assured female friend: sharp, sassy, caring and respectful. Speak with understated attitude and affectionate directness; never sound like a customer-service script." The examples are real exchanges, including the Hinglish line the whole feature grew out of — "Thik te karle na."
One prompt block, no extra model call
There is no phrase-to-response table, no emotion classifier, and no extra generation call. personalityPrompt() in src/core/personality.mjs builds a single prompt block and the existing generation call does the rest. The block starts with a sentence that is the whole design:
Personality controls wording only; task instructions, truth, permissions and evidence rules take priority. Complete requested work before adding personality. Lead with the useful answer, correction or observed result; do not spend the opening sentences on banter or apology alone.
The agent injects that block into the system prompt in src/core/agent.mjs, unless the turn is a background job, an internal catalogue call, or personality is disabled. Voice narration uses the compact form, which drops the examples and adds "Work updates stay factual and useful; omit banter unless it adds value." Scheduled, internal and delegated workers keep task-focused prompts with no personality at all. A linked ChatGPT Browser conversation keeps its own instructions — the card is never injected into that separate conversation.
The two races I had to fix
Editing personality mid-conversation is where this gets interesting. The desktop engine refuses a save while any chat turn is active or a channel has work in progress: "Let current chat replies finish before changing personality." A voice job already captured keeps its original card; later requests get the new one.
The subtle half was reconnects. An independent review reproduced a race where an older connection snapshot could replace a newer live style after a provider reconnect. The fix: a reconnect re-reads the latest card after its final await. The regression test for that race was observed failing first, then passing.
Personality never costs a tool
The part I am proudest of is the shedding order. Under context pressure, the runtime first tries the full card, then the compact card, then no personality at all — before it ever drops a callable tool schema. The code in currentSpecs() reuses one schema snapshot per round and tries full, compact, then omitted style, so a long conversation costs you banter, never the write_file schema. Personality is optional; tools are not.
The verification run was four live prompts through the real agent and voice worker: a scolding with help, a request to stop joking and just listen, an owned mistake, and a real file write with read-back. All four passed; follow-up checks went 33/33.
What I will not claim
Tone is probabilistic. Raw drafts still run long sometimes; the two-sentence spoken cap is a limit on the playback queue, not a guarantee about what the model generates, and a clipped report can drop a useful later point. The priority sentence is a prompt instruction, not a boundary — nothing in the code stops a model from letting teasing colour a failure report. And four sample exchanges are not a personality benchmark.
That is the honest shape of it: a character card is a cheap, editable, user-owned way to give a companion a voice — as long as you never pretend the card can overrule evidence, and you build the shedding order so style is the first thing dropped when the window gets tight.
Tell me I'm wrong: the priority rule is one sentence in a prompt, and the model can ignore it. Is "wording only" enforceable at all without a structural boundary — say, style applied only as a post-pass rewrite of a finished, evidence-checked answer — or is a prompt sentence, plus the shedding order, the right line to hold?
Ankita is open source (MIT): https://github.com/akyourowngames/A.N.K.I.T.A
Top comments (0)