Diagnose setup by what is certain, and deliver errors the way the client asked

Review of the branch found the persona diagnosis was mostly truncation
wearing a name tag. The <.> marker sits at the start of the system
message and the persona marker after the card, so a card Janitor cut
short arrives with the prompt present and the persona gone — and was
told its persona was wrong, with truncation as an afterthought. The
original message was only ever about context size, which is the best
evidence there is about what actually happens.

diagnoseSetup() now says only what the markers make certain. A missing
<.> is a missing prompt, since truncation cannot remove the first three
characters. A persona marker that is present but not "." is a wrong
description, quoted back so there is nothing to guess. A persona marker
that is gone, or cut off mid-tag, leads with truncation and mentions the
persona second.

The error path decided "streaming?" from the Accept header while the
success path used body.stream, so a client that sent stream:true without
that header got JSON back for every error and never displayed it. The
flag is hoisted above the try; the header is only a fallback for when
the body could not be read.

Token search is restricted to after the greeting, in order. A card built
from a sucker-proxied chat could carry an expired token in its text, and
a lookup on it would fail forever. Conversation order also means the
token a chat was started with always wins over one pasted later. The
system fallback now takes a role-less first message only; a request with
roles but no system turn is reported as such rather than misdiagnosed as
a missing prompt. The echo feature reads the parser's lastUser instead of
its own case-sensitive lookup. "Nothing was saved" is dropped from the
unknown-error message, since it is not guaranteed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
This commit is contained in:
Severian
2026-09-11 06:46:52 +08:00
parent a8b3669c17
commit 7569fb8e82
5 changed files with 201 additions and 84 deletions
+77 -29
View File
@@ -9,8 +9,8 @@
* The split that matters is not explained-vs-unexplained, it is
* **fixable-vs-not**:
*
* - A truncated card is the one failure a user can act on: raise the
* context size. That message is shown verbatim.
* - A setup step or a truncated card is something a user can act on;
* those messages are shown verbatim, and name the step.
* - Everything else — a request with no card in it, no greeting, nothing
* at all — cannot happen through Janitor, and if it does the user has no
* way to cause or fix it. Telling them to "open the chat properly" would
@@ -66,42 +66,90 @@ export function userFacingMessage(error: unknown): string {
if (isSuckerError(error)) return error.message;
const detail = isImpossibleRequest(error) ? ` (${error.detail})` : "";
return (
"Something went wrong that sucker has no explanation for, and nothing was " +
`saved. This one is not your fault and there is nothing for you to fix — ` +
"Something went wrong that sucker has no explanation for. This one is " +
"not your fault and there is nothing for you to fix — " +
`please ${CONTACT} and say what you were pulling, because unreported ones ` +
`do not get fixed.${detail}`
);
}
/**
* The two markers sucker reads map one-to-one onto the two steps the
* instructions mark REQUIRED, so a missing one names the setting that is not
* coming through rather than sending people back to re-read everything.
*
* Truncation produces the same symptom — Janitor cuts a card bigger than the
* context size and takes the markers with it — so it is named too, second,
* because it only applies once the settings really are right.
*/
export function missingSetup(hasPrompt: boolean, hasPersona: boolean): SuckerError {
const PROMPT = "your custom prompt is not coming through as <.> — set it in your API settings";
const PERSONA =
"your persona is not coming through — it needs the name {{user}} and a description of just a dot";
const PERSONA_OPEN = "<UserPersona>";
const PERSONA_CLOSE = "</UserPersona>";
let what: string;
if (!hasPrompt && !hasPersona) {
what =
"neither of the two required settings came through: the custom prompt " +
"has to be <.>, and your persona needs the name {{user}} with a " +
"description of just a dot";
} else {
what = hasPrompt ? PERSONA : PROMPT;
/** What the persona marker says, or null if it is absent or cut off mid-tag. */
function personaDescription(content: string): string | null {
const open = content.indexOf(PERSONA_OPEN);
if (open === -1) return null;
const close = content.indexOf(PERSONA_CLOSE, open + PERSONA_OPEN.length);
if (close === -1) return null;
return content.slice(open + PERSONA_OPEN.length, close);
}
/** A short, single-line quote of what the user actually set. */
function quoted(text: string): string {
if (text.trim() === "") return "empty";
const flat = text.replace(/\s+/g, " ").trim();
return `"${flat.length > 40 ? flat.slice(0, 40) + "…" : flat}"`;
}
/**
* Reads the two markers the two REQUIRED setup steps produce and says which
* step is wrong — or, when a marker is simply gone, that the card was
* probably cut short. Returns null when both are as they should be.
*
* `<.>` is the custom prompt and sits at the very start of the system
* message, so if it is missing the prompt is not set: truncation cannot
* remove the first three characters. `<UserPersona>…</UserPersona>` is the
* persona description and sits after the card, so:
*
* - present with something other than "." inside → the description is
* wrong. Certain, and the message quotes what it currently says.
* - absent, or cut off mid-tag → either the persona is not set or Janitor
* truncated the card because the bot is bigger than the context size.
* Truncation is the common one, so it leads.
*/
export function diagnoseSetup(content: string): SuckerError | null {
const hasPrompt = content.includes("<.>");
const persona = personaDescription(content);
const personaOk = persona === ".";
if (hasPrompt && personaOk) return null;
const stillStuck = `Still stuck: ${CONTACT}.`;
if (!hasPrompt) {
let also = "";
if (persona === null) {
also =
" Your persona did not come through either: it needs the name " +
"{{user}} and a description of just a dot.";
} else if (!personaOk) {
also = ` Your persona description is ${quoted(persona)} as well — it has to be just a dot.`;
}
return new SuckerError(
"Sucker could not read that card: your custom prompt is not coming " +
"through as <.> — set it in your API settings." +
also +
` Save, then refresh Janitor, not sucker. ${stillStuck}`
);
}
if (persona !== null) {
// Present but not "." — the one case that is certain.
return new SuckerError(
`Sucker could not read that card: your persona description is ${quoted(persona)} — ` +
"it has to be just a dot, with the name {{user}}. Save, then refresh " +
`Janitor, not sucker. ${stillStuck}`
);
}
// Prompt is fine and the persona marker is gone entirely: the end of the
// card is missing, and the usual reason is that Janitor cut it short.
return new SuckerError(
`Sucker could not read that card: ${what}. Save it, then refresh Janitor — ` +
"the settings page, not sucker. If it is already set that way, the bot is " +
"probably bigger than your context size and Janitor cut the card short, " +
`so raise Context Size in Generation Settings. Still stuck: ${CONTACT}.`
"Sucker could not read that card: the end of it is missing. That usually " +
"means the bot is bigger than your context size and Janitor cut it " +
"short — raise Context Size in Generation Settings and try again. If it " +
"is already generous, check your persona: name {{user}}, description " +
`just a dot, then refresh Janitor. ${stillStuck}`
);
}