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
@@ -0,0 +1,31 @@
import { AppData } from "@/src/data/app-data";
import { BotSessionMaxMessagesSettingKey } from "@/src/dict/bot-dict";
import type { SessionLengthOverview } from "@/src/types";
import readStepLimit from "@/src/utils/read-step-limit";
import grabAppSetting from "./grab-app-setting";
/**
* How many messages a session takes before the user's next message starts
* a new one: the install's own number (`bot_session_max_messages`,
* Settings > General), else `AppData.BotSessionMaxMessages`.
*/
export async function grabBotSessionMaxMessages() {
return readStepLimit({
saved: await grabAppSetting({ key: BotSessionMaxMessagesSettingKey }),
fallback: AppData.BotSessionMaxMessages,
max: AppData.BotSessionMaxMessagesMax,
});
}
/**
* The number with its default and ceiling, for the settings page.
*/
export default async function grabSessionLengthOverview() {
const overview: SessionLengthOverview = {
max_messages: await grabBotSessionMaxMessages(),
default_messages: AppData.BotSessionMaxMessages,
max: AppData.BotSessionMaxMessagesMax,
};
return overview;
}
@@ -0,0 +1,123 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import AppError from "@/src/utils/app-error";
import insertBotMessage from "../bots/insert-bot-message";
import createBotSession from "./create-bot-session";
import grabActiveBotSession from "./grab-active-bot-session";
type Params = {
/**
* The session the bot is answering in: it must be the conversation's
* active one.
*/
session_id: number | string;
user_id: number | string;
bot_id: number;
workspace_id?: number | null;
/**
* What the next session needs to carry on (markdown), written by the
* bot.
*/
summary: string;
/**
* The new session's title, written by the bot. Left out: the closed
* session's.
*/
title?: string | null;
/**
* The reply moving it: its answer goes in the new session, under the
* summary.
*/
job_id?: number | null;
};
/**
* Moves a conversation into a fresh session when the bot asks to
* (`continue_in_new_session`): the bot's summary is kept on the session it
* closes (covering all its messages, so `grabSessionCarryOver` hands it to
* the next prompt as written) and opens the new one as a bot message
* (`carried_from_session_id`), and the reply's own answer follows it
* there. The new session is titled by the bot (else after the old one).
* Returns that first message, for the caller to push.
*/
export default async function carryOverBotSession({
session_id,
user_id,
bot_id,
workspace_id,
summary,
title,
job_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
const text = summary.trim().slice(0, AppData.BotSessionSummaryMaxChars);
if (!text) throw new AppError(`The summary is empty.`);
const active = await grabActiveBotSession({ user_id, bot_id, workspace_id });
if (!active?.id || active.id != Number(session_id)) {
throw new AppError(`This session isn't the conversation's current one, so it can't be moved on from.`);
}
const message_count =
Number(
(
await BunSQLite.sql<{ n: number }>({
sql: `SELECT COUNT(*) AS n FROM bot_messages WHERE session_id = ?`,
values: [active.id],
})
).payload?.[0]?.n,
) || 0;
if (!message_count) {
throw new AppError(`This session has no messages yet: it's already fresh.`);
}
const now = Date.now();
await BunSQLite.sql({
sql: `UPDATE bot_sessions SET status = 'closed', unread = 0, summary = ?, summary_message_count = ?, updated_at = ?
WHERE id = ?`,
values: [text, message_count, now, active.id],
});
/**
* A session is named after its first exchange (`nameBotSession`), and
* this one opens with the bot's messages alone: it's named now, and
* previewed with the summary it opens with. The summary covers none
* of its own messages, so it's written again before it's carried on.
*/
const next = await createBotSession({
user_id,
bot_id,
workspace_id,
previous_session_id: active.id,
title:
title?.replace(/\s+/g, " ").trim().slice(0, AppData.BotSessionTitleMaxChars) ||
active.title ||
null,
});
await BunSQLite.sql({
sql: `UPDATE bot_sessions SET summary = ?, summary_message_count = 0 WHERE id = ?`,
values: [text, next.id!],
});
const message = await insertBotMessage({
bot_id,
session_id: Number(next.id),
workspace_id,
text,
carried_from_session_id: active.id,
});
if (job_id) {
await BunSQLite.sql({
sql: `UPDATE bots_queue SET session_id = ? WHERE id = ?`,
values: [next.id!, job_id],
});
}
return message;
}
@@ -0,0 +1,84 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import { SessionContinuePrompt } from "@/src/data/harness-prompts";
import parseSessionName from "@/src/bots-queue/parse-session-name";
import sessionNameTranscript from "@/src/bots-queue/session-name-transcript";
import generateText from "@/src/harness/text/generate-text";
import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
import AppError from "@/src/utils/app-error";
import grabBotMessages from "../bots/grab-bot-messages";
import carryOverBotSession from "./carry-over-bot-session";
import grabActiveBotSession from "./grab-active-bot-session";
import hasOpenSessionJob from "./has-open-session-job";
type Params = {
user_id: number | string;
bot_id: number;
workspace_id?: number | null;
};
/**
* Continue in new session, asked by the user from the conversation's
* menu (and the automatic rollover, `rollOverBotSession`): does what the
* bot does with `continue_in_new_session`. The bot's
* text model writes the new session's title and the summary from the
* session's latest messages, then `carryOverBotSession` closes this
* session for good and opens the new one with the summary. Refused while
* the bot is still working in the session, since its answer would land in
* the closed one. Returns the summary's message, for the caller to push.
*/
export default async function continueBotSession({
user_id,
bot_id,
workspace_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
const active = await grabActiveBotSession({ user_id, bot_id, workspace_id });
if (!active?.id) throw new AppError(`This conversation has no session to continue from yet.`);
if (await hasOpenSessionJob({ session_id: active.id })) {
throw new AppError(`The bot is still working in this session. Wait for it to finish, or stop it, then continue.`);
}
const messages = await grabBotMessages({
session_id: active.id,
limit: AppData.BotSessionSummaryMessages,
});
if (!messages.length) throw new AppError(`This session has no messages yet: it's already fresh.`);
const result = await runWithHarnessUsageScope({
scope: {
user_id,
bot_id,
workspace_id: workspace_id || null,
session_id: active.id,
job_id: null,
message_id: null,
},
task: () =>
generateText({
messages: [
{ role: "system", content: SessionContinuePrompt },
{ role: "user", content: sessionNameTranscript({ messages }) },
],
bot_id,
workspace_id,
}),
});
const written = parseSessionName({ text: result.text });
if (!written?.summary) {
throw new AppError(`Couldn't write the summary to carry over. Try again.`);
}
return await carryOverBotSession({
session_id: active.id,
user_id,
bot_id,
workspace_id,
summary: written.summary,
title: written.title,
});
}
@@ -1,9 +1,9 @@
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 { grabBotSessionMaxMessages } from "../app-settings/grab-bot-session-max-messages";
import createBotSession from "./create-bot-session";
import grabActiveBotSession from "./grab-active-bot-session";
import startBotSession from "./start-bot-session";
import rollOverBotSession from "./roll-over-bot-session";
type Params = {
/**
@@ -13,8 +13,8 @@ type Params = {
bot_id: number | string;
workspace_id?: number | string | null;
/**
* Start the next session when the active one is full
* (`AppData.BotSessionMaxMessages`). Set when the user writes, so a
* Carry the conversation into a new session when the active one is
* full (`grabBotSessionMaxMessages`, `rollOverBotSession`). Set when the user writes, so a
* message never lands in a session that's about to be cut.
*/
rollover?: boolean;
@@ -22,8 +22,8 @@ type Params = {
/**
* The user's conversation's active session, started first when there is none
* (carrying on from its latest closed one) or, with `rollover`, when the
* active one is full.
* (fresh) or, with `rollover`, carried into a new one when the active one
* is full.
*/
export default async function ensureBotSession({
user_id,
@@ -46,23 +46,15 @@ export default async function ensureBotSession({
).payload?.[0]?.n,
) || 0;
return count < AppData.BotSessionMaxMessages
return count < (await grabBotSessionMaxMessages())
? active
: await startBotSession({ user_id, bot_id, workspace_id });
: await rollOverBotSession({
user_id,
bot_id: Number(bot_id),
workspace_id: workspace_id ? Number(workspace_id) : null,
active,
});
}
const latest = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT id FROM bot_sessions WHERE user_id = ? AND bot_id = ? AND workspace_id IS ?
ORDER BY last_activity_at DESC, id DESC LIMIT 1`,
values: [user_id, bot_id, workspace_id ? Number(workspace_id) : null],
})
).payload?.[0];
return await createBotSession({
user_id,
bot_id,
workspace_id,
previous_session_id: latest?.id || null,
});
return await createBotSession({ user_id, bot_id, workspace_id });
}
@@ -0,0 +1,22 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
session_id: number | string;
user_id: number | string;
};
/**
* The session a closed session of the user's was carried into
* (`carryOverBotSession`), or null when it wasn't carried over.
*/
export default async function grabCarriedToSessionId({ session_id, user_id }: Params): Promise<number | null> {
const carried = (
await BunSQLite.sql<{ session_id: number }>({
sql: `SELECT m.session_id FROM bot_messages m JOIN bot_sessions s ON s.id = m.session_id
WHERE m.carried_from_session_id = ? AND s.user_id = ? ORDER BY m.id DESC LIMIT 1`,
values: [session_id, user_id],
})
).payload?.[0];
return carried?.session_id ? Number(carried.session_id) : null;
}
@@ -3,6 +3,9 @@ import type { ConversationHistory } from "@/src/types";
import grabActiveBotTask from "../bots/grab-active-bot-task";
import grabBotMessages from "../bots/grab-bot-messages";
import ensureBotSession from "./ensure-bot-session";
import grabCarriedToSessionId from "./grab-carried-to-session-id";
import reopenBotSession from "./reopen-bot-session";
import grabUserBotSession from "./grab-user-bot-session";
import grabUserConversation from "./grab-user-conversation";
import markBotSessionRead from "./mark-bot-session-read";
@@ -13,12 +16,20 @@ type Params = {
* The workspace the conversation is in; none for the bot's own chat.
*/
workspace_id?: number | string | null;
/**
* A session of the conversation to show instead of the active one
* (the page's `?session_id=`). One that isn't the user's, or is in
* another conversation, is ignored.
*/
session_id?: number | string | null;
};
/**
* A conversation's history, which the conversation pages load on the
* client once they render (`api/admin/bots/conversation-history`): the
* bot's active session there (marked read), its latest
* bot's active session there, or the one asked for (marked read; a closed
* one is reopened, unless it was carried over, which only shows, with
* where the conversation went on), its latest
* `AppData.ConversationFirstMessages` messages (the thread loads older
* ones as it scrolls) and the bot's active task in it. Everything else on
* the page comes with it from the server (`grabConversationProps`).
@@ -28,6 +39,7 @@ export default async function grabConversationHistory({
user_id,
bot_id,
workspace_id,
session_id,
}: Params): Promise<ConversationHistory | null> {
const conversation = await grabUserConversation({ user_id, bot_id, workspace_id }).catch(
() => null,
@@ -36,11 +48,37 @@ export default async function grabConversationHistory({
if (!conversation?.bot?.id) return null;
const open_bot_id = conversation.bot.id;
const session = await ensureBotSession({
user_id,
bot_id: open_bot_id,
workspace_id: conversation.workspace_id,
});
const conversation_workspace_id = conversation.workspace_id || null;
const asked = session_id
? await grabUserBotSession({ session_id, user_id })
: null;
const in_conversation =
asked?.id &&
Number(asked.bot_id) == Number(open_bot_id) &&
(asked.workspace_id ? Number(asked.workspace_id) : null) ==
(conversation_workspace_id ? Number(conversation_workspace_id) : null);
/**
* A closed session asked for is reopened, as clicking it in a list
* does, unless it was carried over into a newer one: that one only
* shows, with the way to where the conversation went on.
*/
const carried_to =
in_conversation && asked.status != "active"
? await grabCarriedToSessionId({ session_id: asked.id!, user_id })
: null;
const session = !in_conversation
? await ensureBotSession({
user_id,
bot_id: open_bot_id,
workspace_id: conversation.workspace_id,
})
: asked.status == "active" || carried_to
? asked
: await reopenBotSession({ session_id: asked.id!, user_id });
if (session.unread) {
await markBotSessionRead({ session_id: session.id!, user_id });
@@ -63,5 +101,6 @@ export default async function grabConversationHistory({
messages: has_more ? messages.slice(1) : messages,
has_more,
task,
moved_to_session_id: carried_to,
};
}
@@ -0,0 +1,127 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES, BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import type { PastSessionScope } from "./past-session-messages-sql";
type Params = PastSessionScope & {
/**
* Read around this message (and in its session).
*/
message_id?: number | null;
/**
* Read this session from its start, or from after `after_id`. Left
* out (with no `message_id`): the session the current one carries on
* from.
*/
session_id?: number | null;
after_id?: number | null;
/**
* Messages to return (around a message: about half before it).
*/
limit: number;
};
export type PastSessionRead = {
session: Pick<BUN_SQLITE_COBBLE_BOT_SESSIONS, "id" | "title" | "previous_session_id">;
messages: Pick<BUN_SQLITE_COBBLE_BOT_MESSAGES, "id" | "author" | "text" | "created_at">[];
/**
* More of the session comes after the last message returned.
*/
has_more: boolean;
};
/**
* Reads messages of one of the conversation's earlier sessions, oldest
* first: around a message, or a session in order. A session outside the
* conversation (another user's, another bot's, another place's) or the
* one being answered is never read.
*/
export default async function grabPastSessionMessages({
user_id,
bot_id,
workspace_id,
current_session_id,
message_id,
session_id,
after_id,
limit,
}: Params): Promise<PastSessionRead> {
const target_session_id = message_id
? (
await BunSQLite.sql<{ session_id: number }>({
sql: `SELECT session_id FROM bot_messages WHERE id = ? LIMIT 1`,
values: [message_id],
})
).payload?.[0]?.session_id
: session_id ||
(
await BunSQLite.sql<{ previous_session_id: number }>({
sql: `SELECT previous_session_id FROM bot_sessions WHERE id = ? LIMIT 1`,
values: [current_session_id],
})
).payload?.[0]?.previous_session_id;
if (!target_session_id) {
throw new AppError(
message_id
? `Message #${message_id} wasn't found.`
: `This session doesn't carry on from an earlier one. Name a session, or search with search_past_sessions.`,
);
}
const session = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT id, title, previous_session_id FROM bot_sessions
WHERE id = ? AND user_id = ? AND bot_id = ? AND workspace_id IS ? AND id != ? LIMIT 1`,
values: [
target_session_id,
user_id,
bot_id,
workspace_id ? Number(workspace_id) : null,
current_session_id,
],
})
).payload?.[0];
if (!session?.id) {
throw new AppError(`That isn't an earlier session of this conversation.`);
}
const columns = `id, author, text, created_at`;
let messages: PastSessionRead["messages"];
let has_more: boolean;
if (message_id) {
const before = Math.floor(limit / 2);
const earlier =
(
await BunSQLite.sql<PastSessionRead["messages"][number]>({
sql: `SELECT ${columns} FROM bot_messages WHERE session_id = ? AND id < ? ORDER BY id DESC LIMIT ?`,
values: [session.id, message_id, before],
})
).payload || [];
const later =
(
await BunSQLite.sql<PastSessionRead["messages"][number]>({
sql: `SELECT ${columns} FROM bot_messages WHERE session_id = ? AND id >= ? ORDER BY id ASC LIMIT ?`,
values: [session.id, message_id, limit - before + 1],
})
).payload || [];
has_more = later.length > limit - before;
messages = [...earlier.reverse(), ...later.slice(0, limit - before)];
} else {
const rows =
(
await BunSQLite.sql<PastSessionRead["messages"][number]>({
sql: `SELECT ${columns} FROM bot_messages WHERE session_id = ? AND id > ? ORDER BY id ASC LIMIT ?`,
values: [session.id, after_id || 0, limit + 1],
})
).payload || [];
has_more = rows.length > limit;
messages = rows.slice(0, limit);
}
return { session, messages, has_more };
}
@@ -0,0 +1,28 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
session_id: number | string;
limit: number;
};
export type SessionOpeningMessage = Pick<
BUN_SQLITE_COBBLE_BOT_MESSAGES,
"id" | "author" | "text" | "carried_from_session_id"
>;
/**
* A session's first messages, oldest first: what it was started for,
* however long it has gone on since. (`grabBotMessages` reads the latest.)
*/
export default async function grabSessionOpening({ session_id, limit }: Params): Promise<SessionOpeningMessage[]> {
return (
(
await BunSQLite.sql<SessionOpeningMessage>({
sql: `SELECT id, author, text, carried_from_session_id FROM bot_messages
WHERE session_id = ? ORDER BY created_at ASC, id ASC LIMIT ?`,
values: [session_id, limit],
})
).payload || []
);
}
@@ -0,0 +1,20 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
session_id: number | string;
};
/**
* Whether the bot still has work queued or running in a session, whose
* answer lands there: a session isn't carried over meanwhile.
*/
export default async function hasOpenSessionJob({ session_id }: Params) {
return Boolean(
(
await BunSQLite.sql({
sql: `SELECT id FROM bots_queue WHERE session_id = ? AND status IN ('pending', 'processing') LIMIT 1`,
values: [session_id],
})
).payload?.[0],
);
}
@@ -0,0 +1,77 @@
import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import searchPastSessionsSql, { type SearchPastSessionsParams } from "./past-session-messages-sql";
/**
* A bot's search of its earlier sessions reads only that user's
* conversation with it (in the same place), never the session it's
* answering. Run against real SQLite.
*/
const db = new Database(":memory:");
db.run(`CREATE TABLE bot_sessions (id INTEGER PRIMARY KEY, user_id INTEGER, bot_id INTEGER, workspace_id INTEGER, title TEXT)`);
db.run(`CREATE TABLE bot_messages (id INTEGER PRIMARY KEY, session_id INTEGER, author TEXT, text TEXT, created_at INTEGER)`);
db.run(`INSERT INTO bot_sessions VALUES
(1, 1, 10, NULL, 'Invoices'),
(2, 1, 10, NULL, 'Current'),
(3, 2, 10, NULL, 'Another user'),
(4, 1, 11, NULL, 'Another bot'),
(5, 1, 10, 7, 'In a workspace')`);
db.run(`INSERT INTO bot_messages (id, session_id, author, text) VALUES
(1, 1, 'user', 'Send the Acme invoice to [email protected]'),
(2, 1, 'bot', 'Done: invoice INV-42 sent to Acme.'),
(3, 2, 'user', 'What happened with the Acme invoice?'),
(4, 3, 'user', 'Acme invoice for someone else'),
(5, 4, 'user', 'Acme invoice to another bot'),
(6, 5, 'user', 'Acme invoice in the workspace'),
(7, 1, 'user', 'Discount of 100% off'),
(8, 1, 'user', 'Discount of 10 off')`);
function ids(params: Partial<SearchPastSessionsParams>) {
const { sql, values } = searchPastSessionsSql({
user_id: 1,
bot_id: 10,
current_session_id: 2,
query: "",
...params,
});
return (db.query(sql).all(...values) as { id: number }[]).map((row) => row.id);
}
describe("searchPastSessionsSql", () => {
test("finds only this conversation's earlier sessions, newest first", () => {
expect(ids({ query: "acme invoice" })).toEqual([2, 1]);
});
test("keeps the workspace's conversation apart from the bot's own chat", () => {
expect(ids({ query: "acme", workspace_id: 7, current_session_id: 99 })).toEqual([6]);
});
test("every word must match", () => {
expect(ids({ query: "invoice INV-42" })).toEqual([2]);
});
test("% matches only itself", () => {
expect(ids({ query: "100%" })).toEqual([7]);
});
test("pages with before_id", () => {
expect(ids({ query: "acme", before_id: 2 })).toEqual([1]);
});
test("cuts the text around the match", () => {
const { sql, values } = searchPastSessionsSql({
user_id: 1,
bot_id: 10,
current_session_id: 2,
query: "billing",
snippet_chars: 12,
});
const row = db.query(sql).get(...values) as { snippet: string; session_title: string };
expect(row.snippet).toContain("billing");
expect(row.snippet.length).toBeLessThanOrEqual(12);
expect(row.session_title).toBe("Invoices");
});
});
@@ -0,0 +1,88 @@
import { AppData } from "@/src/data/app-data";
/**
* Which earlier sessions are read: one user's conversation with one bot,
* in its own chat or one workspace, without the session being answered.
*/
export type PastSessionScope = {
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
/**
* The session the bot is answering in: its messages are already in
* the prompt.
*/
current_session_id: number | string;
};
export type SearchPastSessionsParams = PastSessionScope & {
/**
* Words to find in the messages; every one must be there.
*/
query: string;
/**
* Only messages older than this one (paging).
*/
before_id?: number | string | null;
limit?: number;
snippet_chars?: number;
};
/**
* Finds messages of the conversation's earlier sessions, newest first,
* with their session's title and the text around the first word's match
* (cut in SQL). Every word must match, in any case; `%` and `_` match
* only themselves.
*/
export default function searchPastSessionsSql({
user_id,
bot_id,
workspace_id,
current_session_id,
query,
before_id,
limit = AppData.PastSessionSearchLimit,
snippet_chars = AppData.PastSessionSnippetChars,
}: SearchPastSessionsParams) {
const words = query.trim().split(/\s+/).filter(Boolean).slice(0, 8);
const first = words[0] || "";
const lead = Math.floor(snippet_chars / 3);
const values: (string | number | null)[] = [
first,
lead,
snippet_chars,
Number(user_id),
Number(bot_id),
workspace_id ? Number(workspace_id) : null,
Number(current_session_id),
];
const where = [
`s.user_id = ?`,
`s.bot_id = ?`,
`s.workspace_id IS ?`,
`s.id != ?`,
];
for (const word of words) {
where.push(`m.text LIKE ? ESCAPE '\\'`);
values.push(`%${word.replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
}
if (before_id) {
where.push(`m.id < ?`);
values.push(Number(before_id));
}
values.push(Math.min(Math.max(Math.floor(limit) || 1, 1), 100));
return {
sql: `SELECT m.id, m.session_id, m.author, m.created_at, s.title AS session_title,
substr(m.text, max(1, instr(lower(m.text), lower(?)) - ?), ?) AS snippet
FROM bot_messages m JOIN bot_sessions s ON s.id = m.session_id
WHERE ${where.join(" AND ")}
ORDER BY m.id DESC
LIMIT ?`,
values,
};
}
@@ -11,7 +11,8 @@ type Params = {
/**
* Makes a closed session of one of the user's conversations the active
* one again, so the bot carries on from where it left off. The session
* that was active is closed (an empty one is deleted).
* that was active is closed (an empty one is deleted). A session the bot
* moved the conversation on from is never reopened.
*/
export default async function reopenBotSession({
session_id,
@@ -25,6 +26,22 @@ export default async function reopenBotSession({
if (session.status == "active") return session;
/**
* The bot moved the conversation on from it (`continue_in_new_session`):
* reopened, the conversation would go on without what was said since,
* and the user's last word there was already answered in the new one.
*/
const carried_on = (
await BunSQLite.sql({
sql: `SELECT id FROM bot_messages WHERE carried_from_session_id = ? LIMIT 1`,
values: [session.id],
})
).payload?.[0];
if (carried_on) {
throw new AppError(`This conversation moved on to a newer session, so this one stays closed. You can still read it.`);
}
const now = Date.now();
const workspace_id = session.workspace_id || null;
@@ -0,0 +1,57 @@
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
import { logError } from "@/src/functions/backend/logger";
import carryOverBotSession from "./carry-over-bot-session";
import continueBotSession from "./continue-bot-session";
import grabActiveBotSession from "./grab-active-bot-session";
import hasOpenSessionJob from "./has-open-session-job";
type Params = {
user_id: number | string;
bot_id: number;
workspace_id?: number | null;
/**
* The conversation's active session, now full.
*/
active: BUN_SQLITE_COBBLE_BOT_SESSIONS;
};
/**
* The automatic rollover at the session length, when the user writes: the
* conversation is carried into a new session the same way the bot or
* Continue in new session carry it (`continueBotSession`: a written title
* and summary, the full session closed for good). A model that can't
* write the summary now falls back to the summary the session already
* has. The full session is kept (and the rollover tried again on the
* next message) while the bot still works in it, or when there's nothing
* to carry. Returns the session the user's message goes in.
*/
export default async function rollOverBotSession({
user_id,
bot_id,
workspace_id,
active,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
if (await hasOpenSessionJob({ session_id: active.id! })) return active;
const message = await continueBotSession({ user_id, bot_id, workspace_id }).catch(async (error: unknown) => {
await logError({ title: `Session #${active.id} rollover summary not written`, error }).catch(() => {});
if (!active.summary?.trim()) return null;
return await carryOverBotSession({
session_id: active.id!,
user_id,
bot_id,
workspace_id,
summary: active.summary,
title: active.title,
}).catch(() => null);
});
if (!message) return active;
await pushBotNotice({ bot_id, message, quiet_push: true });
return (await grabActiveBotSession({ user_id, bot_id, workspace_id })) || active;
}
@@ -13,10 +13,11 @@ type Params = {
};
/**
* Closes the conversation's active session and starts the next one, which
* carries on from it (its summary goes into the next reply's prompt,
* rewritten first when it covers fewer messages than the session ended
* with). An active session with no messages yet is kept as it is.
* New session: closes the conversation's active session and starts a
* fresh one, linked to nothing and carrying nothing over. Carrying a
* conversation on is `carryOverBotSession` (the bot, Continue in new
* session, the rollover). An active session with no messages yet is kept
* as it is.
*/
export default async function startBotSession({
user_id,
@@ -41,10 +42,5 @@ export default async function startBotSession({
});
}
return await createBotSession({
user_id,
bot_id,
workspace_id,
previous_session_id: active?.id || null,
});
return await createBotSession({ user_id, bot_id, workspace_id });
}
@@ -28,6 +28,11 @@ type Params = {
* message in later turns (`botMessagesToHarness`).
*/
tool_log?: HarnessToolUse[];
/**
* Opens a session the conversation moved into
* (`carryOverBotSession`): the session it carries on from.
*/
carried_from_session_id?: number;
};
/**
@@ -43,6 +48,7 @@ export default async function insertBotMessage({
interim,
media_ids = [],
tool_log,
carried_from_session_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
const now = Date.now();
const needs_you_flag = needs_you ? 1 : 0;
@@ -64,6 +70,7 @@ export default async function insertBotMessage({
needs_you: needs_you_flag,
interim: interim_flag,
tool_log: tool_log_json,
carried_from_session_id,
},
],
});
@@ -96,6 +103,7 @@ export default async function insertBotMessage({
needs_you: needs_you_flag,
interim: interim_flag,
tool_log: tool_log_json,
carried_from_session_id,
created_at: now,
updated_at: now,
};
@@ -7,6 +7,7 @@ type Params = {
*/
path: | "list-sessions"
| "new-session"
| "continue-session"
| "reopen-session"
| "session-messages"
| "older-messages";