Carry conversations into new sessions with a visible summary
- Bots get continue_in_new_session: the bot writes a title and summary, the session closes for good (read-only) and the new one opens with the summary as a "Carried over from the last session" message that links back to the old session. - Continue in new session (conversation menu, sessions modal) and the automatic rollover at the session length do the same; New session now starts a fresh, unlinked session. - Session length is an app setting in Settings > General. - ?session_id= opens a session; a carried-over one shows a bar linking to where the conversation went on instead of the composer. - The thread can open the previous session above the current one (shown only, never in the prompt), and replies get search_past_sessions and read_past_session. - Sessions are named from their opening messages, not their latest, and a carried summary is labelled as the earlier session's. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import botMessagesToHarness from "@/src/bots-queue/bot-messages-to-harness";
|
||||
import continueInNewSessionTool from "./continue-in-new-session-tool";
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
function tool() {
|
||||
const summaries: string[] = [];
|
||||
const titles: (string | undefined)[] = [];
|
||||
const moved_to: number[] = [];
|
||||
const move = continueInNewSessionTool({
|
||||
bot_id: 2,
|
||||
user_id: 1,
|
||||
session_id: 100,
|
||||
move: async ({ summary, title }) => {
|
||||
summaries.push(summary);
|
||||
titles.push(title);
|
||||
return { session_id: 101 };
|
||||
},
|
||||
onMove: ({ session_id }) => moved_to.push(session_id),
|
||||
});
|
||||
|
||||
return { move, summaries, titles, moved_to };
|
||||
}
|
||||
|
||||
describe("continueInNewSessionTool", () => {
|
||||
test("moves with the bot's summary and hands the run the new session", async () => {
|
||||
const { move, summaries, moved_to } = tool();
|
||||
|
||||
const result = await move.run({ args: { summary: "- Wants the Q3 report as a PDF" }, signal });
|
||||
|
||||
expect(summaries).toEqual(["- Wants the Q3 report as a PDF"]);
|
||||
expect(moved_to).toEqual([101]);
|
||||
expect(result).toContain("new session");
|
||||
});
|
||||
|
||||
test("passes the new session's title on", async () => {
|
||||
const { move, titles } = tool();
|
||||
|
||||
await move.run({ args: { summary: "- Q3 report", title: "Q3 report PDF" }, signal });
|
||||
|
||||
expect(titles).toEqual(["Q3 report PDF"]);
|
||||
});
|
||||
|
||||
test("moves once per reply", async () => {
|
||||
const { move, summaries } = tool();
|
||||
|
||||
await move.run({ args: { summary: "- First" }, signal });
|
||||
|
||||
await expect(move.run({ args: { summary: "- Again" }, signal })).rejects.toThrow("already moved");
|
||||
expect(summaries).toEqual(["- First"]);
|
||||
});
|
||||
|
||||
test("the summary opening a session never reaches the model as a turn (the prompt carries it)", () => {
|
||||
const turns = botMessagesToHarness({
|
||||
messages: [
|
||||
{ id: 1, author: "bot", text: "- Wants the Q3 report as a PDF" },
|
||||
{ id: 2, author: "bot", text: "Fresh session, carrying on." },
|
||||
{ id: 3, author: "user", text: "Great, go on." },
|
||||
],
|
||||
});
|
||||
|
||||
expect(turns).toEqual([{ role: "user", content: "Great, go on." }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { z } from "zod";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import carryOverBotSession from "@/src/functions/backend/db/bot-sessions/carry-over-bot-session";
|
||||
import type { HarnessTool } from "../../types";
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
user_id: number | string;
|
||||
/**
|
||||
* The session being answered: the one moved on from.
|
||||
*/
|
||||
session_id: number;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* The reply's job, whose answer follows into the new session.
|
||||
*/
|
||||
job_id?: number | null;
|
||||
/**
|
||||
* Called with the new session, so the rest of the run reads and
|
||||
* writes there.
|
||||
*/
|
||||
onMove?: (params: { session_id: number }) => void;
|
||||
/**
|
||||
* Moves the conversation and pushes the summary
|
||||
* (`carryOverBotSession`, `pushBotNotice`), returning the summary's
|
||||
* message.
|
||||
*/
|
||||
move?: (params: { summary: string; title?: string }) => Promise<Pick<BUN_SQLITE_COBBLE_BOT_MESSAGES, "session_id">>;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
summary: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a reply move the conversation into a fresh session, so later replies
|
||||
* stop re-reading a long one: the bot writes what matters, which closes
|
||||
* this session and opens the next (`carryOverBotSession`). Once per run.
|
||||
*/
|
||||
export default function continueInNewSessionTool({
|
||||
bot_id,
|
||||
user_id,
|
||||
session_id,
|
||||
workspace_id,
|
||||
job_id,
|
||||
onMove,
|
||||
move = async ({ summary, title }) => {
|
||||
const message = await carryOverBotSession({ session_id, user_id, bot_id, workspace_id, summary, title, job_id });
|
||||
|
||||
await pushBotNotice({ bot_id, message, quiet_push: true });
|
||||
|
||||
return message;
|
||||
},
|
||||
}: Params): HarnessTool<Args> {
|
||||
let moved = false;
|
||||
|
||||
return {
|
||||
name: "continue_in_new_session",
|
||||
description: `Move this conversation into a fresh session, carrying over a summary you write. Every reply re-reads the session's recent messages, so a long one costs more each time. Always use it when the user asks to carry on in a new, fresh or different session ("let's continue this in another session", "start a new chat for this"): don't just agree, move. Also use it when the session has grown long and the current topic is at a natural break (say so in your answer). The summary opens the new session, shown to the user and given to you from then on in place of these messages, so put in everything needed to carry on: what the user wants, decisions made, what's done, what's in progress or open, and every name, number, path, link and ref ([bot:ID], [file:ID], ...) exactly. Call it last: your answer then goes in the new session under the summary, so keep it to a line.`,
|
||||
schema: z.object({
|
||||
summary: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(AppData.BotSessionSummaryMaxChars)
|
||||
.describe(`What the next session needs (markdown bullets, no preamble)`),
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(AppData.BotSessionTitleMaxChars)
|
||||
.optional()
|
||||
.describe(`A few words naming what the new session carries on with, shown in the session lists`),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
if (moved) {
|
||||
throw new Error(`The conversation already moved to a new session in this reply.`);
|
||||
}
|
||||
|
||||
const message = await move({ summary: args.summary, title: args.title });
|
||||
|
||||
moved = true;
|
||||
onMove?.({ session_id: Number(message.session_id) });
|
||||
|
||||
return `Moved: the new session opens with your summary. Finish with a one-line answer; it goes in the new session.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
type Params = {
|
||||
id?: number | "";
|
||||
author?: string;
|
||||
text: string;
|
||||
created_at?: number | "";
|
||||
};
|
||||
|
||||
/**
|
||||
* One message of an earlier session, as a bot reads it: its id (to read
|
||||
* around it or reply to it), when, who, and the text.
|
||||
*/
|
||||
export default function describePastMessage({ id, author, text, created_at }: Params) {
|
||||
const when = created_at ? new Date(Number(created_at)).toUTCString() : "";
|
||||
|
||||
return `#${id} ${when ? `(${when}) ` : ""}${author == "user" ? "User" : "You"}: ${text.trim() || "(files only)"}`;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import grabPastSessionMessages from "@/src/functions/backend/db/bot-sessions/grab-past-session-messages";
|
||||
import type { PastSessionScope } from "@/src/functions/backend/db/bot-sessions/past-session-messages-sql";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import describePastMessage from "./describe-past-message";
|
||||
|
||||
type Args = {
|
||||
message_id?: number;
|
||||
session_id?: number;
|
||||
after_id?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a reply read an earlier session of the conversation word for word:
|
||||
* around a message found with `search_past_sessions`, or a session in
|
||||
* order (the one this session carries on from by default).
|
||||
*/
|
||||
export default function readPastSessionTool(scope: PastSessionScope): HarnessTool<Args> {
|
||||
return {
|
||||
name: "read_past_session",
|
||||
description: `Read messages of an earlier session with this user here, oldest first. With message_id: the messages around it. Otherwise a session in order from its start (session_id, or the session this one carries on from), after_id for the next page. Read only what you need: it is long.`,
|
||||
schema: z.object({
|
||||
message_id: z.number().int().positive().optional(),
|
||||
session_id: z.number().int().positive().optional(),
|
||||
after_id: z.number().int().positive().optional(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const read = await grabPastSessionMessages({
|
||||
...scope,
|
||||
message_id: args.message_id,
|
||||
session_id: args.session_id,
|
||||
after_id: args.after_id,
|
||||
limit: AppData.PastSessionReadMessages,
|
||||
});
|
||||
|
||||
const head = `Session #${read.session.id}${read.session.title ? ` "${read.session.title}"` : ""}${read.session.previous_session_id ? `, carrying on from session #${read.session.previous_session_id}` : ""}:`;
|
||||
|
||||
if (!read.messages.length) return `${head}\n(no messages${args.after_id ? " after that" : ""})`;
|
||||
|
||||
const lines = read.messages.map((message) =>
|
||||
describePastMessage({
|
||||
...message,
|
||||
text: (message.text || "").slice(0, AppData.PastSessionReadMessageChars),
|
||||
}),
|
||||
);
|
||||
const last = read.messages[read.messages.length - 1];
|
||||
|
||||
return [
|
||||
head,
|
||||
...lines,
|
||||
read.has_more ? `\nMore follows: read again with session_id ${read.session.id} and after_id ${last?.id}.` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import searchPastSessionsSql, { type PastSessionScope } from "@/src/functions/backend/db/bot-sessions/past-session-messages-sql";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import describePastMessage from "./describe-past-message";
|
||||
|
||||
type Args = {
|
||||
query: string;
|
||||
before_id?: number;
|
||||
};
|
||||
|
||||
type Match = {
|
||||
id: number;
|
||||
session_id: number;
|
||||
author: string;
|
||||
created_at: number;
|
||||
session_title: string | null;
|
||||
snippet: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a reply search what was said in the conversation's earlier
|
||||
* sessions (this user, this bot, this place), which its prompt only has
|
||||
* as a summary.
|
||||
*/
|
||||
export default function searchPastSessionsTool(scope: PastSessionScope): HarnessTool<Args> {
|
||||
return {
|
||||
name: "search_past_sessions",
|
||||
description: `Search the messages of your earlier sessions with this user here, newest first, when you need more detail than the summary of what came before (an exact figure, a link, what was agreed). Every word in query must be in the message. Each match shows its message id and session; read_past_session reads around it.`,
|
||||
schema: z.object({
|
||||
query: z.string().trim().min(1).max(200).describe(`Words to find`),
|
||||
before_id: z.number().int().positive().optional().describe(`Only messages older than this id, for the next page`),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const { sql, values } = searchPastSessionsSql({ ...scope, query: args.query, before_id: args.before_id });
|
||||
const matches = (await BunSQLite.sql<Match>({ sql, values })).payload || [];
|
||||
|
||||
if (!matches.length) return `No messages of earlier sessions match.`;
|
||||
|
||||
const list = matches
|
||||
.map(
|
||||
(match) =>
|
||||
`${describePastMessage({ ...match, text: `...${match.snippet || ""}...` })} [session #${match.session_id}${match.session_title ? ` "${match.session_title}"` : ""}]`,
|
||||
)
|
||||
.join("\n");
|
||||
const last = matches[matches.length - 1];
|
||||
|
||||
return matches.length >= AppData.PastSessionSearchLimit
|
||||
? `${list}\n\nMore may be older: search again with before_id ${last?.id}.`
|
||||
: list;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user