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:
2026-10-19 18:05:57 +01:00
parent 9293a55032
commit 4d5195447b
19 changed files with 420 additions and 38 deletions
+29 -9
View File
@@ -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}`;
+89
View File
@@ -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;
}
+27
View File
@@ -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();
});
});
+35
View File
@@ -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;
}
}
+43 -9
View File
@@ -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;
+29
View File
@@ -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 },
});
}