diff --git a/package.json b/package.json
index 470e35d..13d6fe6 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "next lint"
+ "lint": "next lint",
+ "test": "node --experimental-strip-types --test \"tests/**/*.test.ts\""
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.2",
diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts
index e3b1999..8fec27f 100644
--- a/src/app/api/proxy/route.ts
+++ b/src/app/api/proxy/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
+import { selectMessageSlots, type Message } from "@/lib/messages";
interface CardVersion {
version: number;
@@ -64,10 +65,6 @@ function cleanupExpiredCards() {
);
}
-interface Message {
- content: string;
-}
-
// Extracted shape used during POST handling
interface ExtractedCard {
trackingName: string;
@@ -118,9 +115,18 @@ function removePersonaTags(content: string): string {
}
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
const tokenInNameSlot = parseConversationToken(nameContent);
const lastColonIndex = nameContent.lastIndexOf(": ");
@@ -130,7 +136,7 @@ function extractCardData(messages: Message[]): ExtractedCard {
? 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);
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)
- const tokenCandidateUser: string | undefined = body.messages?.[3]?.content;
- const tokenCandidateAssistant: string | undefined = body.messages?.[4]?.content;
+ // The token is either in what the user just typed, or in sucker's own
+ // previous reply. Both are found by role, so a missing "." turn or a
+ // 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 =
parseConversationToken(tokenCandidateUser || undefined) ||
parseConversationToken(tokenCandidateAssistant || undefined);
diff --git a/src/lib/messages.ts b/src/lib/messages.ts
new file mode 100644
index 0000000..1d812ce
--- /dev/null
+++ b/src/lib/messages.ts
@@ -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],
+ };
+}
diff --git a/tests/messages.test.ts b/tests/messages.test.ts
new file mode 100644
index 0000000..721a804
--- /dev/null
+++ b/tests/messages.test.ts
@@ -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 =
+ "<.>\na tired innkeeper.";
+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);
+});