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,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";
|
||||
|
||||
Reference in New Issue
Block a user