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:
2026-10-26 05:35:52 +01:00
co-authored by Claude Opus 5.5
parent 1e6833b5d8
commit cc109bc361
64 changed files with 2074 additions and 128 deletions
+2 -3
View File
@@ -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: [
+12 -10
View File
@@ -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
+6 -2
View File
@@ -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
+59 -1
View File
@@ -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");
});
});
+21
View File
@@ -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");
}