Summary
The last-resort naming backstop in LifeOS/install/hooks/PromptProcessing.hook.ts writes an unvalidated slice of the user's prompt as the session's permanent name. On any non-English prompt this is reliably a function-word fragment, and it is what the statusline shows for the whole life of the session.
This is the sibling of #1718: same inversion (the guard rejects the good source and rubber-stamps the bad one), different branch.
Why this is worse than it looks: not everyone prompts in English
LifeOS accepts any language — the harness never asks the user to switch, and people talk to their assistant in the language they think in. The session-naming pipeline, however, is English by construction at three separate points: the NOISE_WORDS / ACTION_VERBS lists, the [^a-zA-Z0-9\s] cleaner, and the naming prompt itself, which asks the model for a five-word Title Case English phrase.
That combination decides how bad this bug is per user, not per install:
- Prompting in English —
NOISE_WORDS catches the function words, so the last-resort name comes out as content words. Mediocre, but readable. The bypass is invisible.
- Prompting in anything else — no function word is recognised as noise, so the fragment that gets stored starts with articles and prepositions. The name is not merely imprecise, it is a random slice of a sentence.
This is also why the defect has survived: it is silent for the people most likely to be reading the code, and permanent for everyone else. A validator that is only ever exercised on English input is not a validator, it is a coincidence.
The fix below is language-agnostic on purpose — it does not try to teach the pipeline more languages, it just stops the pipeline from writing a name it cannot vouch for, in any language. Widening the word lists is a separate and endless job; refusing to store an unvalidated name is one line and works for languages nobody has thought of yet.
Where
LifeOS/install/hooks/PromptProcessing.hook.ts:1096-1114
if (isFirstPrompt && !readSessionNames()[sessionId]) {
const contentWords = sanitizedPrompt
.replace(/[^a-zA-Z0-9\s]/g, ' ') // ← strips accented letters too
.split(/\s+/)
.filter(w => w.length > 0 && !NOISE_WORDS.has(w.toLowerCase()))
.slice(0, 5);
const lastResort = contentWords.length >= 2
? contentWords.map(w => titleCase(w)).join(' ')
: `Session ${new Date().toISOString().slice(0, 10)}`;
storeName(sessionId, lastResort, 'last-resort'); // ← no validation at all
Both isValidSessionName and the inference path's validation are skipped here. The comment justifies the bypass with "a session that leaves this block unnamed shows a blank statusline slot for its whole life" — but that premise is false in the same file: isFirstPrompt is defined at line 832 as
const isFirstPrompt = !existingNames[sessionId];
so not writing does not make the session permanently anonymous. It makes the next prompt retry the full naming path, inference included.
Reproduction
Italian prompt (any non-English one behaves the same):
il lavoro di controllo dei segnalibri deve essere quotidiano
contentWords → the leading function words survive, because NOISE_WORDS cannot cover every language
- stored name →
Il Lavoro Di Controllo Segnalibri
isValidSessionName("Il Lavoro Di Controllo Segnalibri") → false — the project's own validator would have refused it, if it had been asked
Observed on a real install: a session whose statusline label was a five-word fragment of the user's question, while the telemetry line for the same second recorded a correct model-produced name that had been rejected upstream of this block.
Two independent defects meet here, and the second makes the first permanent:
- a rejected inference name silently falls through to this block;
- this block then writes whatever it built, with no verdict.
Also on line 1098
[^a-zA-Z0-9\s] deletes accented letters along with punctuation: perché reaches the comparison as perch, velocità as velocit. #1718 fixed two sibling cleaners in this file (NON_WORD_CHARS = /[^A-Za-zÀ-ÖØ-öø-ÿ\s]/g); this is the third, and it was missed.
Suggested fix
Ask the validator, and stay silent when it says no:
if (isFirstPrompt && !readSessionNames()[sessionId]) {
const contentWords = sanitizedPrompt
.replace(NON_WORD_CHARS, ' ') // accents survive
.split(/\s+/)
.filter(w => w.length > 0 && !NOISE_WORDS.has(w.toLowerCase()))
.slice(0, 5);
const lastResort = contentWords.length >= 2
? contentWords.map(w => titleCase(w)).join(' ')
: `Session ${new Date().toISOString().slice(0, 10)}`;
const accepted = isValidSessionName(lastResort);
if (accepted) storeName(sessionId, lastResort, 'last-resort');
appendPromptProcessingTelemetry({
// …
session_name: accepted ? lastResort : null,
source: accepted ? 'last-resort-name' : 'last-resort-rejected',
});
}
How often would a session actually go unlabelled?
The obvious objection to "store nothing" is that the user now stares at an empty statusline slot and has to keep prompting before the session gets a name. Measured on one install's prompt-processing.jsonl, over 218 named sessions:
| source |
count |
inference |
217 |
inference-failed |
4 |
last-resort-name |
1 |
The backstop fires roughly once in 218 sessions, and when it fires it is precisely the case where it has nothing good to write. The blank slot lasts one prompt, not a conversation: isFirstPrompt is recomputed from readSessionNames() on every turn, so the very next message re-enters the full naming path, inference included. There is no state to clear and no retry counter to tune.
So the trade is: a label that is missing for one turn in ~0.5% of sessions, against a label that is wrong forever in that same 0.5%. And the row is not lost either — last-resort-rejected records the skip, so the frequency stays measurable instead of becoming folklore.
Cost of the change: a session can stay unlabelled for a turn or two while inference retries. Benefit: it never gets a permanent name that means nothing. Between one unlabelled turn and a meaningless label forever, the unlabelled turn is the honest one — and the telemetry row makes the skip visible instead of silent.
Worth extracting the name construction into a named function too, so the pair (what it builds, what the validator says) can be exercised in a test without running the hook.
Summary
The last-resort naming backstop in
LifeOS/install/hooks/PromptProcessing.hook.tswrites an unvalidated slice of the user's prompt as the session's permanent name. On any non-English prompt this is reliably a function-word fragment, and it is what the statusline shows for the whole life of the session.This is the sibling of #1718: same inversion (the guard rejects the good source and rubber-stamps the bad one), different branch.
Why this is worse than it looks: not everyone prompts in English
LifeOS accepts any language — the harness never asks the user to switch, and people talk to their assistant in the language they think in. The session-naming pipeline, however, is English by construction at three separate points: the
NOISE_WORDS/ACTION_VERBSlists, the[^a-zA-Z0-9\s]cleaner, and the naming prompt itself, which asks the model for a five-word Title Case English phrase.That combination decides how bad this bug is per user, not per install:
NOISE_WORDScatches the function words, so the last-resort name comes out as content words. Mediocre, but readable. The bypass is invisible.This is also why the defect has survived: it is silent for the people most likely to be reading the code, and permanent for everyone else. A validator that is only ever exercised on English input is not a validator, it is a coincidence.
The fix below is language-agnostic on purpose — it does not try to teach the pipeline more languages, it just stops the pipeline from writing a name it cannot vouch for, in any language. Widening the word lists is a separate and endless job; refusing to store an unvalidated name is one line and works for languages nobody has thought of yet.
Where
LifeOS/install/hooks/PromptProcessing.hook.ts:1096-1114Both
isValidSessionNameand the inference path's validation are skipped here. The comment justifies the bypass with "a session that leaves this block unnamed shows a blank statusline slot for its whole life" — but that premise is false in the same file:isFirstPromptis defined at line 832 asso not writing does not make the session permanently anonymous. It makes the next prompt retry the full naming path, inference included.
Reproduction
Italian prompt (any non-English one behaves the same):
contentWords→ the leading function words survive, becauseNOISE_WORDScannot cover every languageIl Lavoro Di Controllo SegnalibriisValidSessionName("Il Lavoro Di Controllo Segnalibri")→ false — the project's own validator would have refused it, if it had been askedObserved on a real install: a session whose statusline label was a five-word fragment of the user's question, while the telemetry line for the same second recorded a correct model-produced name that had been rejected upstream of this block.
Two independent defects meet here, and the second makes the first permanent:
Also on line 1098
[^a-zA-Z0-9\s]deletes accented letters along with punctuation:perchéreaches the comparison asperch,velocitàasvelocit. #1718 fixed two sibling cleaners in this file (NON_WORD_CHARS = /[^A-Za-zÀ-ÖØ-öø-ÿ\s]/g); this is the third, and it was missed.Suggested fix
Ask the validator, and stay silent when it says no:
How often would a session actually go unlabelled?
The obvious objection to "store nothing" is that the user now stares at an empty statusline slot and has to keep prompting before the session gets a name. Measured on one install's
prompt-processing.jsonl, over 218 named sessions:inferenceinference-failedlast-resort-nameThe backstop fires roughly once in 218 sessions, and when it fires it is precisely the case where it has nothing good to write. The blank slot lasts one prompt, not a conversation:
isFirstPromptis recomputed fromreadSessionNames()on every turn, so the very next message re-enters the full naming path, inference included. There is no state to clear and no retry counter to tune.So the trade is: a label that is missing for one turn in ~0.5% of sessions, against a label that is wrong forever in that same 0.5%. And the row is not lost either —
last-resort-rejectedrecords the skip, so the frequency stays measurable instead of becoming folklore.Cost of the change: a session can stay unlabelled for a turn or two while inference retries. Benefit: it never gets a permanent name that means nothing. Between one unlabelled turn and a meaningless label forever, the unlabelled turn is the honest one — and the telemetry row makes the skip visible instead of silent.
Worth extracting the name construction into a named function too, so the pair (what it builds, what the validator says) can be exercised in a test without running the hook.