Read the request by role, not by position

Some users' JanitorAI sends a "." user turn between the card and the
greeting; others' does not. Sucker read fixed indices, so on the shorter
shape every slot was off by one: first_mes was set to the name line, and
the name slot did not exist at all. That TypeError was caught by the
handler and reported as the "you dingus, read the directions" message,
which blamed the user for a shape they never chose.

src/lib/messages.ts now resolves the slots by role instead — the card is
the system turn, the greeting is the first assistant turn, the user's
latest input is the last user turn, and sucker's own previous reply is the
last assistant turn. All four hold in both shapes and in a continued
conversation, so the conversation token is found in either as well.

Extraction now throws a description of what is missing rather than a
TypeError, and tests/messages.test.ts pins both shapes. The tests need no
framework: `npm test` runs node's built-in runner.

Existing cards are unaffected: the update path already discards the
extracted name and keeps the original.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
This commit is contained in:
Severian
2026-09-11 04:14:04 +08:00
parent e7da1aab4d
commit 11c244fdd2
4 changed files with 142 additions and 11 deletions
+2 -1
View File
@@ -6,7 +6,8 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"test": "node --experimental-strip-types --test \"tests/**/*.test.ts\""
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-accordion": "^1.2.2",
+20 -10
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { selectMessageSlots, type Message } from "@/lib/messages";
interface CardVersion { interface CardVersion {
version: number; version: number;
@@ -64,10 +65,6 @@ function cleanupExpiredCards() {
); );
} }
interface Message {
content: string;
}
// Extracted shape used during POST handling // Extracted shape used during POST handling
interface ExtractedCard { interface ExtractedCard {
trackingName: string; trackingName: string;
@@ -118,9 +115,18 @@ function removePersonaTags(content: string): string {
} }
function extractCardData(messages: Message[]): ExtractedCard { function extractCardData(messages: Message[]): ExtractedCard {
const first_mes = messages[2].content.replace(/{user}/g, "{{user}}"); const { system, greeting, lastUser } = selectMessageSlots(messages);
const nameContent = messages[3].content; if (!system?.content) {
throw new Error("No system message: this request carries no character card.");
}
if (!greeting?.content) {
throw new Error("No assistant message: this chat has no greeting to read.");
}
const first_mes = greeting.content.replace(/{user}/g, "{{user}}");
const nameContent = lastUser?.content ?? "";
// If the name slot is actually a token, ignore it for naming purposes // If the name slot is actually a token, ignore it for naming purposes
const tokenInNameSlot = parseConversationToken(nameContent); const tokenInNameSlot = parseConversationToken(nameContent);
const lastColonIndex = nameContent.lastIndexOf(": "); const lastColonIndex = nameContent.lastIndexOf(": ");
@@ -130,7 +136,7 @@ function extractCardData(messages: Message[]): ExtractedCard {
? nameContent.substring(lastColonIndex + 2).trim() ? nameContent.substring(lastColonIndex + 2).trim()
: ""; : "";
let content = messages[0].content.replace(/{user}/g, "{{user}}"); let content = system.content.replace(/{user}/g, "{{user}}");
const inferredName = extractPersonaName(content); const inferredName = extractPersonaName(content);
content = removePersonaTags(content); content = removePersonaTags(content);
@@ -340,9 +346,13 @@ export async function POST(request: NextRequest) {
); );
} }
// Parse potential token from messages[3] (user) or messages[4] (assistant prior reply) // The token is either in what the user just typed, or in sucker's own
const tokenCandidateUser: string | undefined = body.messages?.[3]?.content; // previous reply. Both are found by role, so a missing "." turn or a
const tokenCandidateAssistant: string | undefined = body.messages?.[4]?.content; // longer conversation cannot shift them out from under us.
const tokenSlots = selectMessageSlots(body.messages);
const tokenCandidateUser: string | undefined = tokenSlots.lastUser?.content;
const tokenCandidateAssistant: string | undefined =
tokenSlots.lastAssistant?.content;
const providedConversationId = const providedConversationId =
parseConversationToken(tokenCandidateUser || undefined) || parseConversationToken(tokenCandidateUser || undefined) ||
parseConversationToken(tokenCandidateAssistant || undefined); parseConversationToken(tokenCandidateAssistant || undefined);
+52
View File
@@ -0,0 +1,52 @@
/**
* Locating the parts of a JanitorAI request.
*
* JanitorAI does not send a fixed number of messages. Most clients put a
* throwaway "." user turn between the card and the greeting; some do not.
* Sucker used to read fixed indices, so on the shorter shape it read the name
* slot as the greeting and the slot after that did not exist at all — a
* TypeError that reached the user as the "you dingus" message, blaming them
* for something they had no control over.
*
* So nothing here depends on a position. What is actually stable:
*
* - the card is the system turn;
* - the greeting is the FIRST assistant turn;
* - whatever the user just typed is the LAST user turn;
* - sucker's own previous reply, which carries the conversation token, is
* the LAST assistant turn.
*
* If JanitorAI changes the shape again, this is the only file that should
* need to know.
*/
export interface Message {
role?: string;
content?: string;
}
export interface MessageSlots {
/** The character card. */
system: Message | undefined;
/** The character's opening message. */
greeting: Message | undefined;
/** What the user typed most recently — the name, or a conversation token. */
lastUser: Message | undefined;
/** Sucker's own most recent reply, when there is one. */
lastAssistant: Message | undefined;
}
export function selectMessageSlots(messages: Message[] | undefined): MessageSlots {
const list = Array.isArray(messages) ? messages : [];
const assistants = list.filter((m) => m?.role === "assistant");
const users = list.filter((m) => m?.role === "user");
return {
// Falling back to the first message keeps working if a client ever omits
// the role on the card, which is where it has always been.
system: list.find((m) => m?.role === "system") ?? list[0],
greeting: assistants[0],
lastUser: users[users.length - 1],
lastAssistant: assistants[assistants.length - 1],
};
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Both request shapes JanitorAI sends, and a continued conversation.
* Run with `npm test` — no test framework, just node's built-in runner.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { selectMessageSlots } from "../src/lib/messages.ts";
const CARD =
"<.>\n<Gaius's Persona>a tired innkeeper</Gaius's Persona><UserPersona>.</UserPersona>";
const GREETING = "The clink of bottles is the only greeting you get.";
const NAME = "{{user}}: Gaius";
/** What most clients send: a throwaway "." user turn before the greeting. */
const withProbe = [
{ role: "system", content: CARD },
{ role: "user", content: "." },
{ role: "assistant", content: GREETING },
{ role: "user", content: NAME },
];
/** What some clients send: no "." turn, so every index shifts by one. */
const withoutProbe = [
{ role: "system", content: CARD },
{ role: "assistant", content: GREETING },
{ role: "user", content: NAME },
];
test("both shapes resolve to the same slots", () => {
for (const [label, messages] of [
["with probe", withProbe],
["without probe", withoutProbe],
] as const) {
const slots = selectMessageSlots(messages);
assert.equal(slots.system?.content, CARD, `${label}: card`);
assert.equal(slots.greeting?.content, GREETING, `${label}: greeting`);
assert.equal(slots.lastUser?.content, NAME, `${label}: name slot`);
}
});
test("the greeting stays the first assistant turn as a chat continues", () => {
const continued = [
...withProbe,
{ role: "assistant", content: "Character updated (v2)." },
{ role: "user", content: "{{user}}: [sucker:conv=abc123]" },
];
const slots = selectMessageSlots(continued);
assert.equal(slots.greeting?.content, GREETING);
// Sucker's own reply, where a conversation token would be found.
assert.equal(slots.lastAssistant?.content, "Character updated (v2).");
assert.equal(slots.lastUser?.content, "{{user}}: [sucker:conv=abc123]");
});
test("a card with no role on the system turn still resolves", () => {
const slots = selectMessageSlots([
{ content: CARD },
{ role: "assistant", content: GREETING },
{ role: "user", content: NAME },
]);
assert.equal(slots.system?.content, CARD);
});
test("nothing throws on empty or malformed input", () => {
for (const input of [[], undefined, [{}, { role: "user" }]]) {
assert.doesNotThrow(() => selectMessageSlots(input as never));
}
assert.equal(selectMessageSlots([]).greeting, undefined);
});