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:
@@ -4,6 +4,7 @@ import { AppData } from "@/src/data/app-data";
|
||||
import { PreviousSessionNote, SessionSummaryPrompt } from "@/src/data/harness-prompts";
|
||||
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
||||
import generateText from "@/src/harness/text/generate-text";
|
||||
import sessionNameTranscript from "./session-name-transcript";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
@@ -53,9 +54,7 @@ export default async function grabSessionCarryOver({
|
||||
|
||||
if (!messages.length) return "";
|
||||
|
||||
const transcript = messages
|
||||
.map((message) => `${message.author == "user" ? "User" : "You"}: ${message.text || "(files only)"}`)
|
||||
.join("\n\n");
|
||||
const transcript = sessionNameTranscript({ messages });
|
||||
|
||||
const result = await generateText({
|
||||
messages: [
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { SessionNamePrompt } from "@/src/data/harness-prompts";
|
||||
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
||||
import grabSessionOpening from "@/src/functions/backend/db/bot-sessions/grab-session-opening";
|
||||
import generateText from "@/src/harness/text/generate-text";
|
||||
import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
|
||||
import parseSessionName from "./parse-session-name";
|
||||
import sessionNameTranscript from "./session-name-transcript";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
@@ -16,7 +17,8 @@ type Params = {
|
||||
|
||||
/**
|
||||
* Names a session once its first exchange is in: the bot writes the title
|
||||
* the rail and sessions list show and a short summary of it. The summary
|
||||
* the rail and sessions list show and a short summary of it, from the
|
||||
* session's first messages (however late the first reply came). The summary
|
||||
* says how many messages it covers (`summary_message_count`), so
|
||||
* `grabSessionCarryOver` rewrites it before carrying a closed session into
|
||||
* the next one. Already named sessions are left as they are.
|
||||
@@ -39,24 +41,24 @@ export default async function nameBotSession({
|
||||
if (!session?.id) return false;
|
||||
if (session.title?.trim() && session.summary?.trim()) return false;
|
||||
|
||||
const messages = await grabBotMessages({
|
||||
/**
|
||||
* The session's opening, not its latest messages: when the first
|
||||
* replies failed or were stopped, naming first runs well into the
|
||||
* session, and the title must still say what it was started for.
|
||||
*/
|
||||
const messages = await grabSessionOpening({
|
||||
session_id,
|
||||
limit: AppData.BotSessionNameMessages,
|
||||
});
|
||||
|
||||
if (
|
||||
!messages.some((message) => message.author == "user") ||
|
||||
!messages.some((message) => message.author == "bot")
|
||||
!messages.some((message) => message.author == "bot" && !message.carried_from_session_id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const transcript = messages
|
||||
.map(
|
||||
(message) =>
|
||||
`${message.author == "user" ? "User" : "You"}: ${message.text || "(files only)"}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
const transcript = sessionNameTranscript({ messages });
|
||||
|
||||
/**
|
||||
* The naming turn is a model call too, so it is recorded for this
|
||||
|
||||
@@ -226,8 +226,12 @@ export default async function processBotJob({
|
||||
* a model that can't answer now leaves the session for the next
|
||||
* reply to name.
|
||||
*/
|
||||
if (job.kind == "reply" && completion.message && job.session_id) {
|
||||
const session_id = Number(job.session_id);
|
||||
if (job.kind == "reply" && completion.message?.session_id) {
|
||||
/**
|
||||
* The message's session, not the job's as it was claimed: a
|
||||
* reply that moved the conversation on answered in the new one.
|
||||
*/
|
||||
const session_id = Number(completion.message.session_id);
|
||||
|
||||
/**
|
||||
* No job signal: the job is already complete, so its heartbeat
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
chatReplyNote,
|
||||
ComputerFilesNote,
|
||||
coordinatorReplyNote,
|
||||
longSessionNote,
|
||||
CoordinatorSoloNote,
|
||||
TasksNote,
|
||||
TaskReminderNote,
|
||||
@@ -30,6 +31,11 @@ import openBotSkills from "@/src/harness/tools/skills/open-bot-skills";
|
||||
import botMessagesToHarness from "./bot-messages-to-harness";
|
||||
import composeComputersNote from "./compose-computers-note";
|
||||
import grabSessionCarryOver from "./grab-session-carry-over";
|
||||
import continueInNewSessionTool from "@/src/harness/tools/bot/continue-in-new-session-tool";
|
||||
import searchPastSessionsTool from "@/src/harness/tools/bot/sessions/search-past-sessions-tool";
|
||||
import readPastSessionTool from "@/src/harness/tools/bot/sessions/read-past-session-tool";
|
||||
import { grabBotSessionMaxMessages } from "@/src/functions/backend/db/app-settings/grab-bot-session-max-messages";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
|
||||
import loadAttachmentParts from "./load-attachment-parts";
|
||||
import grabMessageRefNames from "@/src/functions/backend/db/bots/grab-message-ref-names";
|
||||
@@ -76,7 +82,12 @@ export default async function runBotReply({
|
||||
signal,
|
||||
report,
|
||||
}: Params): Promise<BotJobOutcome> {
|
||||
const session_id = await jobSessionId({ job });
|
||||
/**
|
||||
* Moves when the bot carries the conversation into a new session
|
||||
* (`continue_in_new_session`): what the user writes from then on
|
||||
* lands there.
|
||||
*/
|
||||
let session_id = await jobSessionId({ job });
|
||||
const workspace_id = job.workspace_id || null;
|
||||
|
||||
const history = await grabBotMessages({
|
||||
@@ -140,6 +151,39 @@ export default async function runBotReply({
|
||||
signal,
|
||||
}).catch(() => "");
|
||||
|
||||
/**
|
||||
* From halfway to the session's length, the bot is told to move on
|
||||
* at a natural break.
|
||||
*/
|
||||
const session_messages =
|
||||
Number(
|
||||
(
|
||||
await BunSQLite.sql<{ n: number }>({
|
||||
sql: `SELECT COUNT(*) AS n FROM bot_messages WHERE session_id = ?`,
|
||||
values: [session_id],
|
||||
})
|
||||
).payload?.[0]?.n,
|
||||
) || 0;
|
||||
const session_max = await grabBotSessionMaxMessages();
|
||||
const long_session_note =
|
||||
session_messages >= Math.ceil(session_max / 2)
|
||||
? longSessionNote({ messages: session_messages, max: session_max })
|
||||
: "";
|
||||
|
||||
/**
|
||||
* The conversation's earlier sessions are searchable when it has any:
|
||||
* the prompt only carries the last one's summary.
|
||||
*/
|
||||
const past_scope = { user_id, bot_id: bot.id!, workspace_id, current_session_id: session_id };
|
||||
const has_past_sessions = Boolean(
|
||||
(
|
||||
await BunSQLite.sql({
|
||||
sql: `SELECT id FROM bot_sessions WHERE user_id = ? AND bot_id = ? AND workspace_id IS ? AND id != ? LIMIT 1`,
|
||||
values: [user_id, bot.id!, workspace_id ? Number(workspace_id) : null, session_id],
|
||||
})
|
||||
).payload?.[0],
|
||||
);
|
||||
|
||||
const take_overs: string[] = [];
|
||||
/**
|
||||
* The hooks the bot proposed, and the MCP servers, tools and skills the
|
||||
@@ -238,6 +282,7 @@ export default async function runBotReply({
|
||||
system: [
|
||||
prompt,
|
||||
carry_over,
|
||||
long_session_note,
|
||||
chatReplyNote({ bot_id: bot.id }),
|
||||
botFilesNote({ bot_id: bot.id }),
|
||||
ComputerFilesNote({ computer_id: computers[0]?.id || 0, dirs, can_import: false }),
|
||||
@@ -262,6 +307,16 @@ export default async function runBotReply({
|
||||
tools: [
|
||||
...tools,
|
||||
sendMessageTool({ bot_id: bot.id, session_id: Number(session_id), workspace_id }),
|
||||
continueInNewSessionTool({
|
||||
bot_id: bot.id!,
|
||||
user_id,
|
||||
session_id: Number(session_id),
|
||||
workspace_id,
|
||||
job_id: job.id || null,
|
||||
onMove: (moved) => {
|
||||
session_id = moved.session_id;
|
||||
},
|
||||
}),
|
||||
...grabBotSecretTools({
|
||||
user_id,
|
||||
bot_id: bot.id,
|
||||
@@ -271,6 +326,9 @@ export default async function runBotReply({
|
||||
onRequest: ({ ref }) => proposals.push(ref),
|
||||
onShow: ({ ref }) => shown.push(ref),
|
||||
}),
|
||||
...(has_past_sessions
|
||||
? [searchPastSessionsTool(past_scope), readPastSessionTool(past_scope)]
|
||||
: []),
|
||||
...file_tools,
|
||||
...extensions.tools,
|
||||
...skills.tools,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import sessionNameTranscript from "./session-name-transcript";
|
||||
|
||||
describe("sessionNameTranscript", () => {
|
||||
test("reads the exchange as the user's and the bot's", () => {
|
||||
expect(
|
||||
sessionNameTranscript({
|
||||
messages: [
|
||||
{ id: 1, author: "user", text: "Create a bot" },
|
||||
{ id: 2, author: "bot", text: "Done." },
|
||||
],
|
||||
}),
|
||||
).toBe("User: Create a bot\n\nYou: Done.");
|
||||
});
|
||||
|
||||
test("marks a carried summary as the earlier session's, not the bot's words", () => {
|
||||
const transcript = sessionNameTranscript({
|
||||
messages: [
|
||||
{ id: 1, author: "bot", text: "- Crew set up", carried_from_session_id: 9 },
|
||||
{ id: 2, author: "user", text: "Continue here" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(transcript).toStartWith("(Summary of the earlier session this one carries on from)\n- Crew set up");
|
||||
expect(transcript).not.toContain("You: - Crew set up");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SessionOpeningMessage } from "@/src/functions/backend/db/bot-sessions/grab-session-opening";
|
||||
|
||||
type Params = {
|
||||
messages: SessionOpeningMessage[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A session's opening as the naming turn reads it. The summary that opens
|
||||
* a session the bot moved the conversation into is marked as the earlier
|
||||
* session's, so the title says what this one carries on with rather than
|
||||
* echoing everything the summary recaps.
|
||||
*/
|
||||
export default function sessionNameTranscript({ messages }: Params) {
|
||||
return messages
|
||||
.map((message) =>
|
||||
message.carried_from_session_id
|
||||
? `(Summary of the earlier session this one carries on from)\n${message.text || ""}`
|
||||
: `${message.author == "user" ? "User" : "You"}: ${message.text || "(files only)"}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
Reference in New Issue
Block a user