Name sessions with the model and keep the rail list live
Sessions no longer take their title from the first message: once the first exchange is sent, the text model writes the title and summary, which the rail and sessions list show. The summary records how many messages it covers so a stale one is rewritten before it is carried into the next session. The sessions hook now reloads on live message and session events, so a session started by the first message, a rollover or another tab appears without a reload. New sessions are pushed as server:bot-session, both from the process that changed them and through the live relay.
This commit is contained in:
@@ -14,8 +14,10 @@ type Params = {
|
||||
|
||||
/**
|
||||
* The previous session's summary for a session's prompt (markdown), or ""
|
||||
* when it doesn't carry on from one. The summary is written the first
|
||||
* time it's needed and kept on that session.
|
||||
* when it doesn't carry on from one. The summary written as the session
|
||||
* went covers only its first exchange, so a closed session whose summary
|
||||
* is stale (it covers fewer messages than the session ended with) is
|
||||
* summarized again from its latest messages, and kept.
|
||||
*/
|
||||
export default async function grabSessionCarryOver({
|
||||
session_id,
|
||||
@@ -24,8 +26,10 @@ export default async function grabSessionCarryOver({
|
||||
signal,
|
||||
}: Params) {
|
||||
const previous = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
|
||||
sql: `SELECT p.* FROM bot_sessions s JOIN bot_sessions p ON p.id = s.previous_session_id
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS & { message_count: number }>({
|
||||
sql: `SELECT p.*,
|
||||
(SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = p.id) AS message_count
|
||||
FROM bot_sessions s JOIN bot_sessions p ON p.id = s.previous_session_id
|
||||
WHERE s.id = ? AND p.bot_id = s.bot_id LIMIT 1`,
|
||||
values: [session_id],
|
||||
})
|
||||
@@ -33,7 +37,12 @@ export default async function grabSessionCarryOver({
|
||||
|
||||
if (!previous?.id) return "";
|
||||
|
||||
if (previous.summary?.trim()) {
|
||||
const message_count = Number(previous.message_count) || 0;
|
||||
|
||||
if (
|
||||
previous.summary?.trim() &&
|
||||
Number(previous.summary_message_count) >= message_count
|
||||
) {
|
||||
return `${PreviousSessionNote}\n\n${previous.summary.trim()}`;
|
||||
}
|
||||
|
||||
@@ -56,15 +65,26 @@ export default async function grabSessionCarryOver({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
signal,
|
||||
}).catch((error: unknown) => {
|
||||
if (signal?.aborted) throw error;
|
||||
return null;
|
||||
});
|
||||
|
||||
const summary = (result.text || "").trim().slice(0, AppData.BotSessionSummaryMaxChars);
|
||||
const summary = (result?.text || "").trim().slice(0, AppData.BotSessionSummaryMaxChars);
|
||||
|
||||
if (!summary) return "";
|
||||
/**
|
||||
* A model that can't rewrite it now must not lose what the earlier
|
||||
* one already said.
|
||||
*/
|
||||
if (!summary) {
|
||||
return previous.summary?.trim()
|
||||
? `${PreviousSessionNote}\n\n${previous.summary.trim()}`
|
||||
: "";
|
||||
}
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bot_sessions SET summary = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [summary, Date.now(), previous.id],
|
||||
sql: `UPDATE bot_sessions SET summary = ?, summary_message_count = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [summary, message_count, Date.now(), previous.id],
|
||||
});
|
||||
|
||||
return `${PreviousSessionNote}\n\n${summary}`;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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 generateText from "@/src/harness/text/generate-text";
|
||||
import parseSessionName from "./parse-session-name";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
bot_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* Returns whether it wrote something.
|
||||
*/
|
||||
export default async function nameBotSession({
|
||||
session_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
signal,
|
||||
}: Params): Promise<boolean> {
|
||||
const session = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
|
||||
sql: `SELECT id, title, summary FROM bot_sessions WHERE id = ? LIMIT 1`,
|
||||
values: [session_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!session?.id) return false;
|
||||
if (session.title?.trim() && session.summary?.trim()) return false;
|
||||
|
||||
const messages = await grabBotMessages({
|
||||
session_id,
|
||||
limit: AppData.BotSessionNameMessages,
|
||||
});
|
||||
|
||||
if (
|
||||
!messages.some((message) => message.author == "user") ||
|
||||
!messages.some((message) => message.author == "bot")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const transcript = messages
|
||||
.map(
|
||||
(message) =>
|
||||
`${message.author == "user" ? "User" : "You"}: ${message.text || "(files only)"}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
const result = await generateText({
|
||||
messages: [
|
||||
{ role: "system", content: SessionNamePrompt },
|
||||
{ role: "user", content: transcript },
|
||||
],
|
||||
bot_id,
|
||||
workspace_id,
|
||||
signal,
|
||||
});
|
||||
|
||||
const name = parseSessionName({ text: result.text });
|
||||
|
||||
if (!name) return false;
|
||||
|
||||
const now = Date.now();
|
||||
const title = name.title.replace(/\s+/g, " ").trim().slice(0, AppData.BotSessionTitleMaxChars);
|
||||
const summary = name.summary.slice(0, AppData.BotSessionSummaryMaxChars);
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bot_sessions SET
|
||||
title = CASE WHEN COALESCE(title, '') = '' THEN ? ELSE title END,
|
||||
summary = ?,
|
||||
summary_message_count = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
values: [title, summary, messages.length, now, session_id],
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import parseSessionName from "./parse-session-name";
|
||||
|
||||
describe("parseSessionName", () => {
|
||||
test("reads a title and summary", () => {
|
||||
expect(parseSessionName({ text: `{"title":"Tidy invoices","summary":"- sorted them"}` })).toEqual({
|
||||
title: "Tidy invoices",
|
||||
summary: "- sorted them",
|
||||
});
|
||||
});
|
||||
|
||||
test("tolerates a code fence around the object", () => {
|
||||
expect(
|
||||
parseSessionName({
|
||||
text: "```json\n{\"title\":\"Plans\",\"summary\":\"- a trip\"}\n```",
|
||||
}),
|
||||
).toEqual({ title: "Plans", summary: "- a trip" });
|
||||
});
|
||||
|
||||
test("is null when either part is missing or the JSON is bad", () => {
|
||||
expect(parseSessionName({})).toBeNull();
|
||||
expect(parseSessionName({ text: "" })).toBeNull();
|
||||
expect(parseSessionName({ text: `{"title":"Only a title"}` })).toBeNull();
|
||||
expect(parseSessionName({ text: `{"title":"","summary":"- a trip"}` })).toBeNull();
|
||||
expect(parseSessionName({ text: "{nope" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
type Params = {
|
||||
text?: string | null;
|
||||
};
|
||||
|
||||
export type SessionName = {
|
||||
title: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the JSON a naming model answers with: its title and summary.
|
||||
* Tolerates a surrounding code fence or a sentence around the object.
|
||||
* Null when either is missing.
|
||||
*/
|
||||
export default function parseSessionName({ text }: Params): SessionName | null {
|
||||
if (!text) return null;
|
||||
|
||||
const start = text.indexOf("{");
|
||||
const end = text.lastIndexOf("}");
|
||||
|
||||
if (start < 0 || end <= start) return null;
|
||||
|
||||
try {
|
||||
const value = JSON.parse(text.slice(start, end + 1));
|
||||
|
||||
if (!value || typeof value != "object" || Array.isArray(value)) return null;
|
||||
|
||||
const title = typeof value.title == "string" ? value.title.trim() : "";
|
||||
const summary = typeof value.summary == "string" ? value.summary.trim() : "";
|
||||
|
||||
return title && summary ? { title, summary } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import pushBotNotice from "./push-bot-notice";
|
||||
import grabJobUserId from "@/src/functions/backend/db/bots-queue/grab-job-user-id";
|
||||
import runBotJob from "./run-bot-job";
|
||||
import parseJobJson from "./parse-job-json";
|
||||
import nameBotSession from "./name-bot-session";
|
||||
import pushBotSession from "./push-bot-session";
|
||||
import type { BotJobUpdateData } from "@/src/types";
|
||||
import type { BotJobContext, BotJobRunner } from "./types";
|
||||
|
||||
@@ -160,16 +162,48 @@ export default async function processBotJob({
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
await pushBotNotice({
|
||||
bot_id,
|
||||
...(await completeBotJob({
|
||||
job_id,
|
||||
worker,
|
||||
result: outcome.result,
|
||||
needs_you: outcome.needs_you,
|
||||
silent: outcome.silent,
|
||||
})),
|
||||
const completion = await completeBotJob({
|
||||
job_id,
|
||||
worker,
|
||||
result: outcome.result,
|
||||
needs_you: outcome.needs_you,
|
||||
silent: outcome.silent,
|
||||
});
|
||||
|
||||
await pushBotNotice({ bot_id, ...completion });
|
||||
|
||||
/**
|
||||
* Once the first exchange is sent, the bot names the session and
|
||||
* previews it. After the reply is pushed, so it never waits on it;
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* No job signal: the job is already complete, so its heartbeat
|
||||
* no longer holds a lock and would abort this naming turn.
|
||||
*/
|
||||
const named = await nameBotSession({
|
||||
session_id,
|
||||
bot_id,
|
||||
workspace_id: job.workspace_id,
|
||||
}).catch(async (error: unknown) => {
|
||||
await logError({ title: `Bot session #${session_id} not named`, error }).catch(
|
||||
() => {},
|
||||
);
|
||||
return false;
|
||||
});
|
||||
|
||||
if (named) {
|
||||
await pushBotSession({
|
||||
session_id,
|
||||
bot_id,
|
||||
workspace_id: job.workspace_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { BotSessionChangedData } from "@/src/types";
|
||||
import pushToSessionUser from "./push-to-session-user";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
bot_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tells the open tabs of the user a session belongs to that it started or
|
||||
* changed, so the rail and the sessions list reload it.
|
||||
*/
|
||||
export default async function pushBotSession({
|
||||
session_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params) {
|
||||
const data: BotSessionChangedData = {
|
||||
session_id: Number(session_id),
|
||||
bot_id: Number(bot_id),
|
||||
workspace_id: workspace_id ? Number(workspace_id) : null,
|
||||
};
|
||||
|
||||
await pushToSessionUser({
|
||||
session_id,
|
||||
data: { event: "server:bot-session", data },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user