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:
@@ -87,7 +87,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
||||
|
||||
- **Access (the crew is shared):** bots, workspaces and computers belong to the install, not to a user (their `user_id` is only who created them, not a foreign key). Nobody reaches a bot until a super admin or admin grants it: `bot_users` (a worker assigned to a user, for its own chat) and `workspace_users` (a user added to a workspace, to chat with every worker in it there). `crewRole` (`src/utils/rbac/crew-role.ts`): `super_admin` reaches everything and is the only one who ever sees, chats with or edits the coordinator; `admin` reaches every worker and workspace; anyone else (`member`) only their grants. `canManageCrew` (super admins and admins) creates, edits and deletes workers and workspaces, sets their models and pictures, and hands out access (the Bots and Workspaces groups on a user's settings page, `user-access-groups.tsx`, each added or taken away at once by `api/admin/users/add-access` and `remove-access` through `setUserGrant`); the structural DB helpers check it themselves (`assertCrewManager`, `src/functions/backend/db/access/`). The SQL conditions live in `src/utils/crew-access/` (`chatBotsSql`, `workspacesSql`, `workspaceBotsSql`, `computersSql`, tested against SQLite) and back `grabChatBot` (its own chat), `grabUserBot` (anywhere: its own chat or a workspace), `grabUserWorkspace(s)`, `grabWorkspaceBot`, `grabBots` and `grabUserComputer(s)`. Conversations are per user (`bot_sessions.user_id`): a job runs for the user whose session it's in (`runBotJob` reads it), with that user's memory and only what they reach. A bot's face and status line are per user too (`bot_user_faces`, set by `setBotFace` for the job's user, `grabJobUserId`; no row is idle), so needs-you or working only shows to the user the job is for; every bot read for a page overlays the viewer's (`botFaceSql`, `src/utils/bot-face-sql.ts`). Live updates go to the session's user (`pushToSessionUser`), a face change to its user, and a bot change (its computer) to everyone who sees it (`pushBotUpdated`, `grabBotAudience`, each with their own face and active task); its status line never names a task (`BotTaskStatusLine`). Bot and workspace pictures are served to whoever sees them (`grabSharedPictureMedia`). A member with nothing granted lands on an empty `/admin/bots`; a workspace's dashboard is open to everyone in it, its settings link and computers' settings links only to `canManageCrew`. `src/scripts/share-crew.ts` (safe to rerun) moved an install from one crew per user to this.
|
||||
- **Tables:** `bots` (`kind` worker|coordinator, `hue`, `role`, `system_prompt` (the bot's core instructions, markdown)), `bot_user_faces` (`face`, `status_line` per bot and user), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`.
|
||||
- **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` from the first user message, `summary`, `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). A new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`, written once with the text harness). The header's sessions icon (`src/components/general/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). The rail lists the open bot's latest `AppData.RailRecentSessions` sessions under its row (`rail-bot-sessions.tsx`), and once there are more, "All sessions" opens the same modal. Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes.
|
||||
- **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` and `summary` written by the bot from the first exchange, `summary_message_count` (how many messages the summary covers), `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). Once a session's first exchange is in, `name-bot-session.ts` has the bot name it (the title the rail and list show) and write its summary, after the reply is pushed; a new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`), rewritten first when it covers fewer messages than that session ended with. The header's sessions icon (`src/components/general/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). The rail lists the open bot's latest `AppData.RailRecentSessions` sessions under its row (`rail-bot-sessions.tsx`), and once there are more, "All sessions" opens the same modal. Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes.
|
||||
- **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one.
|
||||
- **Routes:** `/admin/bots` ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page.
|
||||
- **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears a session's unread when a message arrives in the open conversation; `session-messages.ts` returns a session's latest messages, which the open conversation polls every `AppData.BotReplyCatchUpMs` while a reply is due (`use-reply-catch-up`), so a reply whose live push was missed (socket down) still lands without a reload (replayed as a `server:bot-message`, so the rail and unread take it too); `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models.
|
||||
|
||||
+7
-2
@@ -305,14 +305,19 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||
},
|
||||
{
|
||||
fieldName: "title",
|
||||
fieldDescription: `From the first user message`,
|
||||
fieldDescription: `Written by the bot from the session's first exchange`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "summary",
|
||||
fieldDescription: `What happened in the session (markdown), written by the bot when the next one starts and carried into it`,
|
||||
fieldDescription: `What happened in the session (markdown), written by the bot and refreshed as it goes, then carried into the next one`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "summary_message_count",
|
||||
fieldDescription: `How many of the session's messages the summary covers, so a stale one is rewritten before it's carried into the next session`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "previous_session_id",
|
||||
fieldDescription: `The session this one carries on from (its summary goes into the prompt). Not a foreign key`,
|
||||
|
||||
+6
-2
@@ -232,13 +232,17 @@ export type BUN_SQLITE_COBBLE_BOT_SESSIONS = {
|
||||
*/
|
||||
workspace_id?: number | "";
|
||||
/**
|
||||
* From the first user message
|
||||
* Written by the bot from the session's first exchange
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* What happened in the session (markdown), written by the bot when the next one starts and carried into it
|
||||
* What happened in the session (markdown), written by the bot and refreshed as it goes, then carried into the next one
|
||||
*/
|
||||
summary?: string;
|
||||
/**
|
||||
* How many of the session's messages the summary covers, so a stale one is rewritten before it's carried into the next session
|
||||
*/
|
||||
summary_message_count?: number | "";
|
||||
/**
|
||||
* The session this one carries on from (its summary goes into the prompt). Not a foreign key
|
||||
*/
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -238,6 +238,12 @@ export const AppData = {
|
||||
*/
|
||||
BotSessionSummaryMessages: 80,
|
||||
BotSessionSummaryMaxChars: 4000,
|
||||
/**
|
||||
* The first messages of a session the bot reads to name and summarize
|
||||
* it once the first reply is in, and how long its title may be.
|
||||
*/
|
||||
BotSessionNameMessages: 4,
|
||||
BotSessionTitleMaxChars: 80,
|
||||
/**
|
||||
* Sessions listed per page in a conversation's sessions modal.
|
||||
*/
|
||||
|
||||
@@ -61,9 +61,21 @@ export const WorkspaceLearningNote = `# Learning in this workspace
|
||||
|
||||
update_memory and update_instructions save to this workspace by default: what you learn here applies here. What you learn about the user that holds outside this workspace goes in your memory of them (update_memory, scope "user"). Save to your core memory or instructions (scope "core") only when the user asks for it to apply in every workspace, or when it's plainly about how you work everywhere, not about this workspace. Your core memory is shared with every user you work for, so nothing about this user goes there. When something learned here looks useful everywhere, suggest saving it and do it once the user agrees.`;
|
||||
|
||||
/**
|
||||
* Asks the text model for a session's name and summary, written once the
|
||||
* first exchange is in. The title labels it in the rail and the sessions
|
||||
* list; the summary previews it and is carried into the next session.
|
||||
*/
|
||||
export const SessionNamePrompt = `Name and summarize this conversation from its first exchange. Answer with a JSON object only, no markdown fence and no other text:
|
||||
{"title": "...", "summary": "..."}
|
||||
|
||||
- title: what the conversation is about, at most 60 characters, no quotes and no trailing period. Say what the user wanted, not that it is a conversation.
|
||||
- summary: what the user wanted and what was done so far. Keep names, numbers, paths and links exactly. Plain markdown bullets, no preamble.`;
|
||||
|
||||
/**
|
||||
* Asks the text model for a closed session's summary, carried into the
|
||||
* next session's prompt.
|
||||
* next session's prompt. Used when the session's own summary is stale
|
||||
* (it covers fewer messages than the session now has).
|
||||
*/
|
||||
export const SessionSummaryPrompt = `Summarize this conversation for whoever picks it up next: what the user wanted, what was done, decisions made, open questions and anything still in progress. Keep names, numbers, paths and links exactly. Plain markdown bullets, no preamble.`;
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ type Params = {
|
||||
|
||||
/**
|
||||
* Closes the conversation's active session and starts the next one, which
|
||||
* carries on from it (its summary is written when the next reply runs).
|
||||
* An active session with no messages yet is kept as it is.
|
||||
* 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.
|
||||
*/
|
||||
export default async function startBotSession({
|
||||
user_id,
|
||||
|
||||
@@ -24,8 +24,9 @@ type Params = {
|
||||
|
||||
/**
|
||||
* Appends a user message (and its files) to a session and bumps its
|
||||
* activity. The first one titles the session. Callers must check the bot
|
||||
* and session belong to the session user first.
|
||||
* activity. The session is named later, from its first exchange with the
|
||||
* bot (`nameBotSession`). Callers must check the bot and session belong to
|
||||
* the session user first.
|
||||
*/
|
||||
export default async function insertUserMessage({
|
||||
bot_id,
|
||||
@@ -66,13 +67,9 @@ export default async function insertUserMessage({
|
||||
});
|
||||
}
|
||||
|
||||
const title = (text || media[0]?.title || "").replace(/\s+/g, " ").trim().slice(0, 80);
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bot_sessions SET last_activity_at = ?,
|
||||
title = CASE WHEN COALESCE(title, '') = '' THEN ? ELSE title END
|
||||
WHERE id = ?`,
|
||||
values: [now, title, session_id],
|
||||
sql: `UPDATE bot_sessions SET last_activity_at = ? WHERE id = ?`,
|
||||
values: [now, session_id],
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { BotSessionListItem } from "@/src/types";
|
||||
import type { BotSessionChangedData, BotSessionListItem } from "@/src/types";
|
||||
import postSessionApi from "@/src/functions/frontend/post-session-api";
|
||||
import useServerEvent from "./use-server-event";
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
@@ -15,9 +16,37 @@ type Params = {
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
type ChangedSession = {
|
||||
bot_id?: number | string | null;
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a pushed message or session change belongs to this conversation:
|
||||
* the same bot, in its own chat or in the same workspace.
|
||||
*/
|
||||
function belongsToConversation({
|
||||
session,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: {
|
||||
session: ChangedSession;
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
}) {
|
||||
return (
|
||||
Number(session.bot_id) == bot_id &&
|
||||
(session.workspace_id ? Number(session.workspace_id) : null) ==
|
||||
(workspace_id ? Number(workspace_id) : null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A conversation's sessions, latest first, searched as the user types
|
||||
* (debounced). Starting or reopening a session reloads the page onto it.
|
||||
* New messages and session changes pushed live reload the list, so a
|
||||
* session started in the conversation (or named once its first reply is
|
||||
* in) shows without a reload.
|
||||
*/
|
||||
export default function useBotSessions({ bot_id, workspace_id, open, limit }: Params) {
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -25,6 +54,31 @@ export default function useBotSessions({ bot_id, workspace_id, open, limit }: Pa
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy_id, setBusyId] = useState<number | "new">();
|
||||
const [error, setError] = useState<string>();
|
||||
const [revision, setRevision] = useState(0);
|
||||
|
||||
useServerEvent({
|
||||
event: "server:bot-message",
|
||||
onData: (message: BotSessionListItem) => {
|
||||
if (
|
||||
message &&
|
||||
belongsToConversation({ session: message, bot_id, workspace_id })
|
||||
) {
|
||||
setRevision((value) => value + 1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useServerEvent({
|
||||
event: "server:bot-session",
|
||||
onData: (session: BotSessionChangedData) => {
|
||||
if (
|
||||
session &&
|
||||
belongsToConversation({ session, bot_id, workspace_id })
|
||||
) {
|
||||
setRevision((value) => value + 1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -54,7 +108,7 @@ export default function useBotSessions({ bot_id, workspace_id, open, limit }: Pa
|
||||
stale = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [open, query, bot_id, workspace_id, limit]);
|
||||
}, [open, query, bot_id, workspace_id, limit, revision]);
|
||||
|
||||
const act = async ({ id, path, body }: { id: number | "new"; path: "new-session" | "reopen-session"; body: Record<string, unknown> }) => {
|
||||
if (busy_id) return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AppData } from "@/src/data/app-data";
|
||||
import { logError } from "@/src/functions/backend/logger";
|
||||
import grabConnectedUserIds from "@/src/websocket/(functions)/grab-connected-user-ids";
|
||||
import relayChangedBots from "./relay-changed-bots";
|
||||
import relayChangedSessions from "./relay-changed-sessions";
|
||||
import relayCreatedBots from "./relay-created-bots";
|
||||
import relayCreatedWorkspaces from "./relay-created-workspaces";
|
||||
import relayNewBotMessages from "./relay-new-bot-messages";
|
||||
@@ -32,6 +33,7 @@ export default async function liveRelay() {
|
||||
message_id: await latest("bot_messages"),
|
||||
update_id: await latest("bots_queue_updates"),
|
||||
bots_at: Date.now(),
|
||||
sessions_at: Date.now(),
|
||||
};
|
||||
|
||||
let running = false;
|
||||
@@ -58,6 +60,12 @@ export default async function liveRelay() {
|
||||
await relayCreatedWorkspaces({ since: bots_since, users });
|
||||
await relayChangedBots({ since: bots_since, users });
|
||||
cursor.bots_at = now;
|
||||
|
||||
await relayChangedSessions({
|
||||
since: cursor.sessions_at - AppData.LiveRelayPollMs,
|
||||
users,
|
||||
});
|
||||
cursor.sessions_at = now;
|
||||
} catch (error) {
|
||||
await logError({ title: `Live relay failed`, error }).catch(() => {});
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||
import type { BotSessionChangedData } from "@/src/types";
|
||||
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Unix ms: sessions updated since then are pushed.
|
||||
*/
|
||||
since: number;
|
||||
users: Set<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pushes `server:bot-session` for every session created or changed since
|
||||
* `since` (named, reopened, marked read) to the connected user whose
|
||||
* conversation it's in. Pushes are idempotent, so overlapping windows only
|
||||
* repeat them.
|
||||
*/
|
||||
export default async function relayChangedSessions({ since, users }: Params) {
|
||||
if (!users.size) return;
|
||||
|
||||
const sessions =
|
||||
(
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
|
||||
sql: `SELECT id, user_id, bot_id, workspace_id FROM bot_sessions WHERE updated_at > ?`,
|
||||
values: [since],
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!session.id || !session.user_id || !users.has(String(session.user_id))) continue;
|
||||
|
||||
const data: BotSessionChangedData = {
|
||||
session_id: Number(session.id),
|
||||
bot_id: Number(session.bot_id) || 0,
|
||||
workspace_id: session.workspace_id ? Number(session.workspace_id) : null,
|
||||
};
|
||||
|
||||
sendToUser({
|
||||
user_id: session.user_id,
|
||||
data: { event: "server:bot-session", data },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* How far the relay has read: the newest message and job step it pushed,
|
||||
* and when it last looked for changed bots.
|
||||
* and when it last looked for changed bots and sessions.
|
||||
*/
|
||||
export type LiveRelayCursor = {
|
||||
message_id: number;
|
||||
update_id: number;
|
||||
bots_at: number;
|
||||
sessions_at: number;
|
||||
};
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function ComposerTools({ bot, workspace_id, initial }: Props) {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{has_tools ? (
|
||||
{has_tools && computers.length ? (
|
||||
<Span className="-mr-2 opacity-40">
|
||||
<Dot size={20} />
|
||||
</Span>
|
||||
|
||||
@@ -164,6 +164,16 @@ export type BotJobUpdateData = BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & {
|
||||
session_id: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* `server:bot-session` data: one of the user's conversation sessions was
|
||||
* started, named or otherwise changed.
|
||||
*/
|
||||
export type BotSessionChangedData = {
|
||||
session_id: number;
|
||||
bot_id: number;
|
||||
workspace_id: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* `server:bot-created` data: a bot the user can now see, the workspaces
|
||||
* it's in, and whether it belongs in their own chat list (the main rail).
|
||||
@@ -812,6 +822,11 @@ export const WebSocketEvents = [
|
||||
* `bot_messages` row)
|
||||
*/
|
||||
"server:bot-message",
|
||||
/**
|
||||
* A session in one of the user's bot conversations started, was named
|
||||
* or otherwise changed (`data`: `BotSessionChangedData`)
|
||||
*/
|
||||
"server:bot-session",
|
||||
/**
|
||||
* A bot joined the crew or one of the user's workspaces (`data`:
|
||||
* `BotCreatedData`)
|
||||
|
||||
Reference in New Issue
Block a user