diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts
index 8fec27f..77607ed 100644
--- a/src/app/api/proxy/route.ts
+++ b/src/app/api/proxy/route.ts
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
-import { selectMessageSlots, type Message } from "@/lib/messages";
+import {
+ selectMessageSlots,
+ tokenCandidates,
+ type Message,
+} from "@/lib/messages";
interface CardVersion {
version: number;
@@ -115,7 +119,7 @@ function removePersonaTags(content: string): string {
}
function extractCardData(messages: Message[]): ExtractedCard {
- const { system, greeting, lastUser } = selectMessageSlots(messages);
+ const { system, greeting, nameSlot } = selectMessageSlots(messages);
if (!system?.content) {
throw new Error("No system message: this request carries no character card.");
@@ -126,7 +130,7 @@ function extractCardData(messages: Message[]): ExtractedCard {
const first_mes = greeting.content.replace(/{user}/g, "{{user}}");
- const nameContent = lastUser?.content ?? "";
+ const nameContent = nameSlot?.content ?? "";
// If the name slot is actually a token, ignore it for naming purposes
const tokenInNameSlot = parseConversationToken(nameContent);
const lastColonIndex = nameContent.lastIndexOf(": ");
@@ -346,16 +350,14 @@ export async function POST(request: NextRequest) {
);
}
- // 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;
+ // The token may be in what the user just typed, or in sucker's own first
+ // reply from earlier in the conversation. Search every message rather than
+ // a fixed pair of slots: only the first reply carries a token, so a long
+ // chat would otherwise lose the link and duplicate the card.
const providedConversationId =
- parseConversationToken(tokenCandidateUser || undefined) ||
- parseConversationToken(tokenCandidateAssistant || undefined);
+ tokenCandidates(body.messages)
+ .map((c) => parseConversationToken(c))
+ .find((id): id is string => Boolean(id)) ?? null;
let existingCard: StoredCard | null = null;
let linkedByToken = false;
diff --git a/src/lib/messages.ts b/src/lib/messages.ts
index 1d812ce..720ff5d 100644
--- a/src/lib/messages.ts
+++ b/src/lib/messages.ts
@@ -8,13 +8,14 @@
* 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:
+ * So nothing here depends on a position. What is actually stable, in both
+ * shapes and however long the conversation runs:
*
* - 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.
+ * - the name is the first user turn AFTER the greeting — the one the user
+ * typed to start, which stays put as more turns are appended;
+ * - what the user typed most recently is the LAST user turn.
*
* If JanitorAI changes the shape again, this is the only file that should
* need to know.
@@ -30,23 +31,47 @@ export interface MessageSlots {
system: Message | undefined;
/** The character's opening message. */
greeting: Message | undefined;
- /** What the user typed most recently — the name, or a conversation token. */
+ /** The name — or conversation token — the user typed to start the chat. */
+ nameSlot: Message | undefined;
+ /** Whatever the user typed most recently. */
lastUser: Message | undefined;
- /** Sucker's own most recent reply, when there is one. */
- lastAssistant: Message | undefined;
}
-export function selectMessageSlots(messages: Message[] | undefined): MessageSlots {
+export function selectMessageSlots(
+ messages: Message[] | undefined
+): MessageSlots {
const list = Array.isArray(messages) ? messages : [];
- const assistants = list.filter((m) => m?.role === "assistant");
+ const greetingIndex = list.findIndex((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],
+ greeting: greetingIndex === -1 ? undefined : list[greetingIndex],
+ nameSlot:
+ greetingIndex === -1
+ ? undefined
+ : list.slice(greetingIndex + 1).find((m) => m?.role === "user"),
lastUser: users[users.length - 1],
- lastAssistant: assistants[assistants.length - 1],
};
}
+
+/**
+ * Message contents to search for a conversation token, most promising first.
+ *
+ * Only sucker's *first* reply carries a token — every later reply says
+ * "Character updated" or "unchanged" with no token in it. So a long
+ * conversation has to be searched all the way back rather than at its tail,
+ * or the link to the existing card is lost and a duplicate gets created.
+ * What the user just typed still wins, since pasting a token is deliberate.
+ */
+export function tokenCandidates(messages: Message[] | undefined): string[] {
+ const list = Array.isArray(messages) ? messages : [];
+ const users = list.filter((m) => m?.role === "user");
+ const mostRecentUser = users[users.length - 1];
+
+ return [mostRecentUser, ...list.filter((m) => m !== mostRecentUser)]
+ .map((m) => m?.content)
+ .filter((c): c is string => typeof c === "string" && c.length > 0);
+}
diff --git a/tests/messages.test.ts b/tests/messages.test.ts
index 721a804..bb8fba1 100644
--- a/tests/messages.test.ts
+++ b/tests/messages.test.ts
@@ -1,18 +1,19 @@
/**
- * Both request shapes JanitorAI sends, and a continued conversation.
+ * Both request shapes JanitorAI sends, and conversations that keep going.
* 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";
+import { selectMessageSlots, tokenCandidates } 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";
+const TOKEN = "[sucker:conv=abc123]";
/** What most clients send: a throwaway "." user turn before the greeting. */
-const withProbe = [
+const withProbe: { role: string; content: string }[] = [
{ role: "system", content: CARD },
{ role: "user", content: "." },
{ role: "assistant", content: GREETING },
@@ -20,12 +21,21 @@ const withProbe = [
];
/** What some clients send: no "." turn, so every index shifts by one. */
-const withoutProbe = [
+const withoutProbe: { role: string; content: string }[] = [
{ role: "system", content: CARD },
{ role: "assistant", content: GREETING },
{ role: "user", content: NAME },
];
+/** Three turns in. Only sucker's FIRST reply carries the token. */
+const continued = [
+ ...withProbe,
+ { role: "assistant", content: `New character "Gaius" created (v1). ${TOKEN}` },
+ { role: "user", content: "{{user}}: keep going" },
+ { role: "assistant", content: "Character data unchanged (v1, message 2)." },
+ { role: "user", content: "{{user}}: and again" },
+];
+
test("both shapes resolve to the same slots", () => {
for (const [label, messages] of [
["with probe", withProbe],
@@ -34,21 +44,31 @@ test("both shapes resolve to the same slots", () => {
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`);
+ assert.equal(slots.nameSlot?.content, NAME, `${label}: name`);
}
});
-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]" },
- ];
+test("the greeting and the name stay put as a conversation grows", () => {
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]");
+ // Not "keep going" or "and again": the name is what started the chat.
+ assert.equal(slots.nameSlot?.content, NAME);
+ assert.equal(slots.lastUser?.content, "{{user}}: and again");
+});
+
+test("the token is still found once later replies stop carrying it", () => {
+ const found = tokenCandidates(continued).find((c) => c.includes("sucker:conv="));
+ assert.ok(found, "a token must still be discoverable three turns in");
+ assert.match(found!, /\[sucker:conv=abc123\]/);
+});
+
+test("a token the user just pasted outranks an older one", () => {
+ const candidates = tokenCandidates([
+ ...withProbe,
+ { role: "assistant", content: `created (v1). ${TOKEN}` },
+ { role: "user", content: "{{user}}: [sucker:conv=zzz999]" },
+ ]);
+ assert.match(candidates[0], /zzz999/);
});
test("a card with no role on the system turn still resolves", () => {
@@ -63,6 +83,8 @@ test("a card with no role on the system turn still resolves", () => {
test("nothing throws on empty or malformed input", () => {
for (const input of [[], undefined, [{}, { role: "user" }]]) {
assert.doesNotThrow(() => selectMessageSlots(input as never));
+ assert.doesNotThrow(() => tokenCandidates(input as never));
}
assert.equal(selectMessageSlots([]).greeting, undefined);
+ assert.deepEqual(tokenCandidates(undefined), []);
});