mirror of
https://github.com/severian-dev/sucker.severian.dev.git
synced 2026-10-18 01:20:29 +00:00
7569fb8e82
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
162 lines
6.5 KiB
TypeScript
162 lines
6.5 KiB
TypeScript
/**
|
|
* Telling people what actually went wrong.
|
|
*
|
|
* Every failure used to produce one message: "You dingus, read the directions
|
|
* on sucker before trying again", with a note about context size. For a
|
|
* truncated card that was fair. For anything else it blamed the user for
|
|
* something they could not have done anything about.
|
|
*
|
|
* The split that matters is not explained-vs-unexplained, it is
|
|
* **fixable-vs-not**:
|
|
*
|
|
* - 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
|
|
* be the old message wearing a new coat. Those say plainly that it is not
|
|
* their fault and ask them to report it, carrying a short tag so the
|
|
* report identifies which assumption broke.
|
|
*/
|
|
|
|
/** Where to send someone when sucker is at fault. */
|
|
export const CONTACT = "DM @lyseverian on Discord";
|
|
|
|
/** A failure the person who hit it can actually do something about. */
|
|
export class SuckerError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "SuckerError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A request shaped in a way Janitor does not produce. Not the user's fault
|
|
* and not theirs to fix; `detail` exists so their report says which
|
|
* assumption broke, not so they can act on it.
|
|
*/
|
|
export class ImpossibleRequest extends Error {
|
|
readonly detail: string;
|
|
constructor(detail: string) {
|
|
super(`impossible request: ${detail}`);
|
|
this.name = "ImpossibleRequest";
|
|
this.detail = detail;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Both are checked by name as well as by identity: Next can bundle a module
|
|
* into more than one chunk, and a mismatched class would quietly send every
|
|
* classified error down the wrong path.
|
|
*/
|
|
const named = (error: unknown, name: string): boolean =>
|
|
typeof error === "object" &&
|
|
error !== null &&
|
|
(error as { name?: unknown }).name === name;
|
|
|
|
export function isSuckerError(error: unknown): error is SuckerError {
|
|
return error instanceof SuckerError || named(error, "SuckerError");
|
|
}
|
|
|
|
export function isImpossibleRequest(error: unknown): error is ImpossibleRequest {
|
|
return error instanceof ImpossibleRequest || named(error, "ImpossibleRequest");
|
|
}
|
|
|
|
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. 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}`
|
|
);
|
|
}
|
|
|
|
const PERSONA_OPEN = "<UserPersona>";
|
|
const PERSONA_CLOSE = "</UserPersona>";
|
|
|
|
/** 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: 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}`
|
|
);
|
|
}
|
|
|
|
// None of these can happen through Janitor. They exist so that if one ever
|
|
// does, the reply is honest about whose problem it is and the report says
|
|
// which assumption broke.
|
|
export const NO_CARD = new ImpossibleRequest("no system message in the request");
|
|
export const NO_GREETING = new ImpossibleRequest("no greeting in the request");
|
|
export const TOO_FEW_MESSAGES = new ImpossibleRequest("fewer than two messages in the request");
|