diff --git a/CHANGELOG.md b/CHANGELOG.md
index d446e0438..e4eb2aaba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,15 @@ to authenticate. In the same spirit, the handoff panel explains once when a cowo
work on (it runs as its own agent, outside this deployment's loop) instead of offering switches the
server can only refuse; its existing grants stay visible so they can still be revoked.
+### A conversation is given a name of its own
+
+A channel was labelled with the names of the Bots in it, so every conversation with the same
+coworker read the same on the roster, and the only thing telling two of them apart was a preview of
+whatever was said last. That preview is usually the tail of an answer, which says nothing about the
+question that prompted it. Once a conversation has an opening exchange, the deployment's own model
+is asked for a few words naming what it is about, and the roster draws those words in place of the
+preview. A deployment with no model key configured names nothing and looks exactly as it did before.
+
### A channel stops showing a working indicator once its turn has ended
Sending a message and then opening another channel before the reply arrived left the first channel
diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx
index d70023f20..845c6b720 100644
--- a/app/src/components/app-sidebar/app-sidebar.tsx
+++ b/app/src/components/app-sidebar/app-sidebar.tsx
@@ -89,15 +89,16 @@ const MAX_ANIMATED_ROWS = 60;
/**
* The roster, narrowed to what the person typed.
*
- * Matches the channel's name and the last thing said in it, because those are the two things the
- * row actually shows — searching against something invisible returns results a person cannot
- * account for. Message history beyond the last line is not here to search: it lives in the thread
- * store, and reaching for it is a server endpoint rather than a filter.
+ * Matches the channel's name, its summary, and the last message, because those are the things the
+ * row can actually show — searching against something invisible returns results a person cannot
+ * account for. The last message is included because it is still what the second line draws until the
+ * conversation has been named. Message history beyond that line is not here to search: it lives in
+ * the thread store, and reaching for it is a server endpoint rather than a filter.
*
* An empty query returns the input array unchanged rather than a copy, so typing and clearing does
* not hand `AnimatePresence` a new array identity and restage the whole list.
*/
-function matchingChannels(
+export function matchingChannels(
channels: ChannelSummary[] | undefined,
query: string,
): ChannelSummary[] {
@@ -109,7 +110,7 @@ function matchingChannels(
return channels;
}
return channels.filter((channel) =>
- [channel.name, channel.lastMessage].some((field) =>
+ [channel.name, channel.summary, channel.lastMessage].some((field) =>
field?.toLowerCase().includes(needle),
),
);
@@ -190,6 +191,7 @@ function ChannelRow({
channelId={channel.id}
participantIds={channel.agentIds}
name={channel.name}
+ summary={channel.summary ?? undefined}
lastMessage={channel.lastMessage ?? undefined}
lastMessageAt={
channel.lastMessageAt
diff --git a/app/src/components/app-sidebar/channel.tsx b/app/src/components/app-sidebar/channel.tsx
index 194962d37..4c90414ac 100644
--- a/app/src/components/app-sidebar/channel.tsx
+++ b/app/src/components/app-sidebar/channel.tsx
@@ -26,6 +26,7 @@ import {
deleteChannelMutationOptions,
setChannelPinnedMutationOptions,
} from "@/lib/channels/mutations";
+import { useTypedReveal } from "@/lib/typed-reveal";
import { ChannelAvatar } from "../channels/avatar";
/**
@@ -39,6 +40,7 @@ export const Channel = memo(function Channel({
channelId,
participantIds,
name,
+ summary,
lastMessage,
lastMessageAt,
pinned,
@@ -48,6 +50,9 @@ export const Channel = memo(function Channel({
channelId: string;
participantIds: string[];
name: string;
+ /** What the conversation is about, once named. The channel name only says which Bot it is. */
+ summary?: string;
+ /** Shown on that same line until the conversation has been named. */
lastMessage?: string;
lastMessageAt?: string;
pinned: boolean;
@@ -63,6 +68,8 @@ export const Channel = memo(function Channel({
select: (params) =>
(params as { channelId?: string }).channelId === channelId,
});
+ /* Types in only on arrival; an already-named row draws it outright. */
+ const revealed = useTypedReveal(summary);
const setPinned = useMutation(setChannelPinnedMutationOptions(queryClient));
const deleteChannel = useMutation(deleteChannelMutationOptions(queryClient));
const [confirming, setConfirming] = useState(false);
@@ -133,7 +140,12 @@ export const Channel = memo(function Channel({
- {lastMessage}
+ {/* Falls back to the last message, so the line never blanks while naming runs. */}
+ {revealed.text ?? lastMessage}
+ {revealed.typing ? (
+ /* Solid, not blinking: this is over in under half a second. */
+
+ ) : null}
{unread ? (
/* State about the message beats state about the row, so it sits first. */
diff --git a/app/src/lib/channels/queries.ts b/app/src/lib/channels/queries.ts
index 4a62ce053..3f9c97248 100644
--- a/app/src/lib/channels/queries.ts
+++ b/app/src/lib/channels/queries.ts
@@ -16,8 +16,10 @@ export type AgentChannel = {
active: boolean;
};
-/** A channel plus the last thing said in it, which is what the roster renders. */
+/** A channel plus what the roster renders about it. */
export type ChannelSummary = AgentChannel & {
+ /** A few words about the conversation, or null. The roster falls back to `name`. */
+ summary: string | null;
lastMessage: string | null;
/** ISO-8601, or null for a channel nobody has used yet. */
lastMessageAt: string | null;
diff --git a/app/src/lib/channels/use-channel-events.ts b/app/src/lib/channels/use-channel-events.ts
index 87a87c41e..51958ebb8 100644
--- a/app/src/lib/channels/use-channel-events.ts
+++ b/app/src/lib/channels/use-channel-events.ts
@@ -31,6 +31,8 @@ export type ChannelActivityEvent = {
lastMessage: string | null;
lastMessageAt: string | null;
lastMessageAgentId: string | null;
+ /** The channel's newly written summary. Absent on an ordinary activity event. */
+ summary?: string;
/** The channel is gone from every member's roster. Absent on an ordinary activity event. */
deleted?: true;
/**
@@ -95,6 +97,16 @@ export function applyChannelEvent(
const previous = page.channels[index];
if (!previous) return data;
+ /* One field, and no re-sort: naming a conversation is not something anybody said in it. */
+ if (activity.summary !== undefined) {
+ if (previous.summary === activity.summary) return data;
+ const channels = page.channels.slice();
+ channels[index] = { ...previous, summary: activity.summary };
+ const pages = data.pages.slice();
+ pages[holdingPage] = { ...page, channels };
+ return { ...data, pages };
+ }
+
/*
* A pin patches the one field it is about.
*
diff --git a/app/src/lib/typed-reveal.ts b/app/src/lib/typed-reveal.ts
new file mode 100644
index 000000000..a7b33400b
--- /dev/null
+++ b/app/src/lib/typed-reveal.ts
@@ -0,0 +1,68 @@
+import { useReducedMotion } from "motion/react";
+import { useEffect, useRef, useState } from "react";
+
+/**
+ * Types a conversation's name in as it arrives, so the line reads as learning something rather than
+ * as a glitch. Only on arrival: otherwise opening the app sets the whole roster typing at once.
+ */
+
+/** Per character, not per title, so every length types at the same speed. Capped so none outstays. */
+export const TYPING_SECONDS_PER_CHARACTER = 0.014;
+export const TYPING_MAX_SECONDS = 0.45;
+
+export function typingSeconds(length: number): number {
+ return Math.min(length * TYPING_SECONDS_PER_CHARACTER, TYPING_MAX_SECONDS);
+}
+
+/** Naming is none-to-some. A name replacing another name is not this, and must not replay. */
+export function isNaming(
+ previous: string | undefined,
+ next: string | undefined,
+): boolean {
+ return !previous && Boolean(next);
+}
+
+export type TypedReveal = {
+ /** What to draw right now: the whole name, or as much of it as has landed. */
+ text: string | undefined;
+ /** Whether the characters are still arriving, so the caller can show a cursor. */
+ typing: boolean;
+};
+
+export function useTypedReveal(summary: string | undefined): TypedReveal {
+ const reduce = useReducedMotion();
+ const previous = useRef(summary);
+ const firstRun = useRef(true);
+ const [typed, setTyped] = useState
(null);
+
+ useEffect(() => {
+ const from = previous.current;
+ previous.current = summary;
+ // A row that arrives already named is not a naming.
+ const mounting = firstRun.current;
+ firstRun.current = false;
+ if (mounting || !summary || !isNaming(from, summary)) return;
+ // The gentler version of a reveal made entirely of movement is no movement.
+ if (reduce) return;
+
+ let frame = 0;
+ const started = performance.now();
+ const total = typingSeconds(summary.length) * 1000;
+ // Linear, because this is constant motion. An eased typewriter reads as a machine hesitating.
+ const step = (now: number) => {
+ const progress = Math.min((now - started) / total, 1);
+ setTyped(Math.round(progress * summary.length));
+ if (progress < 1) {
+ frame = requestAnimationFrame(step);
+ return;
+ }
+ // Null, not the full length, so later renders draw the string rather than re-slicing it.
+ setTyped(null);
+ };
+ frame = requestAnimationFrame(step);
+ return () => cancelAnimationFrame(frame);
+ }, [summary, reduce]);
+
+ if (typed === null || !summary) return { text: summary, typing: false };
+ return { text: summary.slice(0, typed), typing: true };
+}
diff --git a/app/tests/channel-event-patch.test.ts b/app/tests/channel-event-patch.test.ts
index 6f073ea6e..7dba1dbef 100644
--- a/app/tests/channel-event-patch.test.ts
+++ b/app/tests/channel-event-patch.test.ts
@@ -222,3 +222,65 @@ describe("a busy signal", () => {
);
});
});
+
+/**
+ * A conversation the server has just named.
+ *
+ * Written by a sweep some seconds after the message that prompted it, so it arrives on its own long
+ * after that message was announced.
+ */
+describe("a summary", () => {
+ test("patches only the summary, leaving the last message alone", () => {
+ const data = cache([
+ channel("a", {
+ lastMessage: "Said something.",
+ lastMessageAt: "2024-04-01T00:00:00.000Z",
+ lastMessageAgentId: "agent-1",
+ }),
+ ]);
+
+ const patched = applyChannelEvent(
+ data,
+ event({ channelId: "a", summary: "Expense categories" }),
+ );
+
+ expect(patched).not.toBe("unknown");
+ if (patched === "unknown") return;
+ expect(patched.pages[0]?.channels[0]).toEqual({
+ ...(data.pages[0]?.channels[0] as ChannelSummary),
+ summary: "Expense categories",
+ });
+ });
+
+ test("does not move the row it names", () => {
+ // Naming a conversation is not something anybody said in it. A row that jumped to the top
+ // seconds after the message that put it there would read as a second message arriving.
+ const data = cache([
+ channel("recent", { lastMessageAt: "2024-04-02T00:00:00.000Z" }),
+ channel("older", { lastMessageAt: "2024-04-01T00:00:00.000Z" }),
+ ]);
+
+ const patched = applyChannelEvent(
+ data,
+ event({ channelId: "older", summary: "Something older" }),
+ );
+
+ expect(patched).not.toBe("unknown");
+ if (patched === "unknown") return;
+ expect(patched.pages[0]?.channels.map((row) => row.id)).toEqual([
+ "recent",
+ "older",
+ ]);
+ });
+
+ test("returns the same cache when the row already says so", () => {
+ const data = cache([channel("a", { summary: "Expense categories" })]);
+
+ expect(
+ applyChannelEvent(
+ data,
+ event({ channelId: "a", summary: "Expense categories" }),
+ ),
+ ).toBe(data);
+ });
+});
diff --git a/app/tests/channel-menu-mutations.test.ts b/app/tests/channel-menu-mutations.test.ts
index a3404f938..ddc191023 100644
--- a/app/tests/channel-menu-mutations.test.ts
+++ b/app/tests/channel-menu-mutations.test.ts
@@ -102,6 +102,7 @@ test("marking read PUTs the read route and patches lastReadAt in place", async (
agentIds: ["agent-1"],
threadId: "thread-1",
active: true,
+ summary: null,
lastMessage: "hello",
lastMessageAt: "2026-08-25T12:00:00.000Z",
lastMessageAgentId: "agent-1",
@@ -146,6 +147,7 @@ test("a message stamped by a clock ahead of ours still reads as seen after marki
agentIds: ["agent-1"],
threadId: "thread-1",
active: true,
+ summary: null,
lastMessage: "hello",
lastMessageAt: futureLastMessageAt,
lastMessageAgentId: "agent-1",
diff --git a/app/tests/channel-order.test.ts b/app/tests/channel-order.test.ts
index ad2836965..25e3c3bd4 100644
--- a/app/tests/channel-order.test.ts
+++ b/app/tests/channel-order.test.ts
@@ -3,13 +3,18 @@ import { pinnedFirst } from "../src/components/app-sidebar/app-sidebar";
import type { ChannelSummary } from "../src/lib/channels/queries";
/** A minimal but fully-typed channel summary, so tests build real objects rather than casts. */
-function channel(id: string, pinned: boolean): ChannelSummary {
+function channel(
+ id: string,
+ pinned: boolean,
+ summary: string | null = null,
+): ChannelSummary {
return {
id,
name: id,
agentIds: [],
threadId: `thread-${id}`,
active: true,
+ summary,
lastMessage: null,
lastMessageAt: null,
lastMessageAgentId: null,
@@ -51,3 +56,22 @@ test("leaves an all-unpinned roster in its original order", () => {
expect(pinnedFirst(channels).map((c) => c.id)).toEqual(["a", "b", "c"]);
});
+
+test("a title changes nothing about where a row sits", () => {
+ // Naming a conversation is not activity in it. Whatever the roster order was, it is the same
+ // order once titles arrive, or rows would appear to jump for no reason anybody could see.
+ const untitled = [
+ channel("a", false),
+ channel("b", true),
+ channel("c", false),
+ ];
+ const titled = [
+ channel("a", false, "Expense categories"),
+ channel("b", true, "Quarterly revenue"),
+ channel("c", false, "Vendor onboarding"),
+ ];
+
+ expect(pinnedFirst(titled).map((c) => c.id)).toEqual(
+ pinnedFirst(untitled).map((c) => c.id),
+ );
+});
diff --git a/app/tests/channel-search.test.ts b/app/tests/channel-search.test.ts
new file mode 100644
index 000000000..c01f60c85
--- /dev/null
+++ b/app/tests/channel-search.test.ts
@@ -0,0 +1,57 @@
+import { expect, test } from "bun:test";
+import { matchingChannels } from "../src/components/app-sidebar/app-sidebar";
+import type { ChannelSummary } from "../src/lib/channels/queries";
+
+/** A roster row as the search box sees it. */
+function channel(overrides: Partial): ChannelSummary {
+ return {
+ id: "channel-1",
+ name: "Knowledge",
+ agentIds: ["agent-1"],
+ threadId: "thread-1",
+ active: true,
+ summary: null,
+ lastMessage: "The three flights and the hotel do.",
+ lastMessageAt: "2026-08-25T12:00:00.000Z",
+ lastMessageAgentId: "agent-1",
+ createdAt: "2026-08-25T11:00:00.000Z",
+ pinned: false,
+ lastReadAt: null,
+ ...overrides,
+ };
+}
+
+test("an untitled channel is still found by its Bot's name", () => {
+ // The fallback the row draws is the name, so the name has to stay searchable or a conversation
+ // that has not been named yet becomes unreachable from the search box.
+ const rows = [channel({ id: "untitled" })];
+
+ expect(matchingChannels(rows, "knowl").map((row) => row.id)).toEqual([
+ "untitled",
+ ]);
+});
+
+test("a titled channel is found by a word in its title", () => {
+ const rows = [channel({ id: "titled", summary: "Travel receipt rules" })];
+
+ expect(matchingChannels(rows, "receipt").map((row) => row.id)).toEqual([
+ "titled",
+ ]);
+});
+
+test("a word from the last message still matches", () => {
+ // The second line falls back to the last message until a conversation is named, so that text is
+ // still something the roster can show and therefore still something search must find.
+ const rows = [channel({ id: "titled", summary: "Travel receipt rules" })];
+
+ expect(matchingChannels(rows, "hotel").map((row) => row.id)).toEqual([
+ "titled",
+ ]);
+});
+
+test("an empty query returns the very same array, not a copy", () => {
+ // Identity matters: a new array on every keystroke restages the whole animated list.
+ const rows = [channel({})];
+
+ expect(matchingChannels(rows, " ")).toBe(rows);
+});
diff --git a/app/tests/channel-unread.test.ts b/app/tests/channel-unread.test.ts
index 40192e3e8..a4f9e3cd5 100644
--- a/app/tests/channel-unread.test.ts
+++ b/app/tests/channel-unread.test.ts
@@ -13,6 +13,7 @@ function channel(overrides: Partial): ChannelSummary {
agentIds: ["agent-1"],
threadId: "thread-1",
active: true,
+ summary: null,
lastMessage: "hello",
lastMessageAt: "2026-08-25T12:00:00.000Z",
lastMessageAgentId: "agent-1",
@@ -60,3 +61,27 @@ test("the open channel is never unread, however unseen its activity", () => {
expect(isUnread(channel({}), "channel-2")).toBe(true);
expect(isUnread(channel({}), undefined)).toBe(true);
});
+
+/*
+ * The roster row draws the summary and no longer draws the last message, so the fields the unread
+ * rule reads are now invisible on screen. That makes them easy for a later change to believe unused.
+ * These two pin the dependency: the dot is a fact about the message, never about the title.
+ */
+test("a titled channel is still unseen on its Bot's message", () => {
+ expect(
+ hasUnseenActivity(
+ channel({ summary: "Expense categories", lastMessage: null }),
+ ),
+ ).toBe(true);
+});
+
+test("a title does not make a read channel unseen again", () => {
+ expect(
+ hasUnseenActivity(
+ channel({
+ summary: "Expense categories",
+ lastReadAt: "2026-08-25T12:30:00.000Z",
+ }),
+ ),
+ ).toBe(false);
+});
diff --git a/app/tests/typed-reveal.test.ts b/app/tests/typed-reveal.test.ts
new file mode 100644
index 000000000..127333a26
--- /dev/null
+++ b/app/tests/typed-reveal.test.ts
@@ -0,0 +1,34 @@
+import { expect, test } from "bun:test";
+import {
+ isNaming,
+ TYPING_MAX_SECONDS,
+ typingSeconds,
+} from "../src/lib/typed-reveal";
+
+test("a line that gains a name is a naming", () => {
+ expect(isNaming(undefined, "Travel receipt rules")).toBe(true);
+});
+
+test("a row that arrives already named is not", () => {
+ // Every row on every load takes this path. Treating it as an arrival is what would set the whole
+ // roster typing at once when somebody opens the app.
+ expect(isNaming("Travel receipt rules", "Travel receipt rules")).toBe(false);
+});
+
+test("a name replaced by another name is not an arrival either", () => {
+ // Nothing does this today. If something ever does, replaying the arrival would misdescribe it.
+ expect(isNaming("An older subject", "A newer subject")).toBe(false);
+});
+
+test("losing a name is not an arrival", () => {
+ expect(isNaming("Travel receipt rules", undefined)).toBe(false);
+});
+
+test("longer names type for longer, at the same speed", () => {
+ // Constant motion: twice the characters, twice the time, until the ceiling.
+ expect(typingSeconds(20)).toBeCloseTo(typingSeconds(10) * 2, 5);
+});
+
+test("a very long name is capped rather than outstaying the moment", () => {
+ expect(typingSeconds(500)).toBe(TYPING_MAX_SECONDS);
+});
diff --git a/server/drizzle/0026_channel_summary.sql b/server/drizzle/0026_channel_summary.sql
new file mode 100644
index 000000000..479d90410
--- /dev/null
+++ b/server/drizzle/0026_channel_summary.sql
@@ -0,0 +1,3 @@
+ALTER TABLE "channels" ADD COLUMN "summary" text;--> statement-breakpoint
+ALTER TABLE "channels" ADD COLUMN "summary_at" timestamp with time zone;--> statement-breakpoint
+CREATE INDEX "channels_awaiting_summary_idx" ON "channels" USING btree ("id") WHERE "channels"."summary" is null and "channels"."deleted_at" is null;
\ No newline at end of file
diff --git a/server/drizzle/meta/0026_snapshot.json b/server/drizzle/meta/0026_snapshot.json
new file mode 100644
index 000000000..62db75127
--- /dev/null
+++ b/server/drizzle/meta/0026_snapshot.json
@@ -0,0 +1,3048 @@
+{
+ "id": "fb3704b5-21f5-4d73-a80d-88fad27b6b85",
+ "prevId": "d96da430-35aa-475c-a060-f55151269d6d",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.accounts": {
+ "name": "accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "accounts_provider_account_idx": {
+ "name": "accounts_provider_account_idx",
+ "columns": [
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "accounts_user_id_users_id_fk": {
+ "name": "accounts_user_id_users_id_fk",
+ "tableFrom": "accounts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agents": {
+ "name": "agents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "agent_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "configuration": {
+ "name": "configuration",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "package_id": {
+ "name": "package_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override": {
+ "name": "override",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "agents_package_id_deployment_packages_id_fk": {
+ "name": "agents_package_id_deployment_packages_id_fk",
+ "tableFrom": "agents",
+ "tableTo": "deployment_packages",
+ "columnsFrom": [
+ "package_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_events": {
+ "name": "audit_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "audit_events_created_at_idx": {
+ "name": "audit_events_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_events_type_time_idx": {
+ "name": "audit_events_type_time_idx",
+ "columns": [
+ {
+ "expression": "event_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_events_actor_time_idx": {
+ "name": "audit_events_actor_time_idx",
+ "columns": [
+ {
+ "expression": "actor_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_events_target_time_idx": {
+ "name": "audit_events_target_time_idx",
+ "columns": [
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channel_agents": {
+ "name": "channel_agents",
+ "schema": "",
+ "columns": {
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "channel_agents_channel_id_channels_id_fk": {
+ "name": "channel_agents_channel_id_channels_id_fk",
+ "tableFrom": "channel_agents",
+ "tableTo": "channels",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "channel_agents_agent_id_agents_id_fk": {
+ "name": "channel_agents_agent_id_agents_id_fk",
+ "tableFrom": "channel_agents",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "channel_agents_channel_id_agent_id_pk": {
+ "name": "channel_agents_channel_id_agent_id_pk",
+ "columns": [
+ "channel_id",
+ "agent_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channel_memberships": {
+ "name": "channel_memberships",
+ "schema": "",
+ "columns": {
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pinned_at": {
+ "name": "pinned_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_read_at": {
+ "name": "last_read_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "channel_memberships_channel_id_channels_id_fk": {
+ "name": "channel_memberships_channel_id_channels_id_fk",
+ "tableFrom": "channel_memberships",
+ "tableTo": "channels",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "channel_memberships_user_id_users_id_fk": {
+ "name": "channel_memberships_user_id_users_id_fk",
+ "tableFrom": "channel_memberships",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "channel_memberships_channel_id_user_id_pk": {
+ "name": "channel_memberships_channel_id_user_id_pk",
+ "columns": [
+ "channel_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channels": {
+ "name": "channels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "suggested_prompts": {
+ "name": "suggested_prompts",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "allowed_groups": {
+ "name": "allowed_groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "package_id": {
+ "name": "package_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override": {
+ "name": "override",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary_at": {
+ "name": "summary_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message": {
+ "name": "last_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message_at": {
+ "name": "last_message_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message_agent_id": {
+ "name": "last_message_agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "channels_recent_activity_idx": {
+ "name": "channels_recent_activity_idx",
+ "columns": [
+ {
+ "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "channels_awaiting_summary_idx": {
+ "name": "channels_awaiting_summary_idx",
+ "columns": [
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "channels_package_id_deployment_packages_id_fk": {
+ "name": "channels_package_id_deployment_packages_id_fk",
+ "tableFrom": "channels",
+ "tableTo": "deployment_packages",
+ "columnsFrom": [
+ "package_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "channels_last_message_agent_id_agents_id_fk": {
+ "name": "channels_last_message_agent_id_agents_id_fk",
+ "tableFrom": "channels",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "last_message_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.credentials": {
+ "name": "credentials",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "kind": {
+ "name": "kind",
+ "type": "credential_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "encrypted_value": {
+ "name": "encrypted_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_id": {
+ "name": "key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "credentials_active_key_idx": {
+ "name": "credentials_active_key_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"credentials\".\"revoked_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.deployment_packages": {
+ "name": "deployment_packages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_path": {
+ "name": "source_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "loaded_at": {
+ "name": "loaded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "deployment_packages_tenant_id_unique": {
+ "name": "deployment_packages_tenant_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "tenant_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.intelligence_channel_mappings": {
+ "name": "intelligence_channel_mappings",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "intelligence_channel_mappings_thread_idx": {
+ "name": "intelligence_channel_mappings_thread_idx",
+ "columns": [
+ {
+ "expression": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "intelligence_channel_mappings_user_id_users_id_fk": {
+ "name": "intelligence_channel_mappings_user_id_users_id_fk",
+ "tableFrom": "intelligence_channel_mappings",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "intelligence_channel_mappings_channel_id_channels_id_fk": {
+ "name": "intelligence_channel_mappings_channel_id_channels_id_fk",
+ "tableFrom": "intelligence_channel_mappings",
+ "tableTo": "channels",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "intelligence_channel_mappings_user_id_channel_id_pk": {
+ "name": "intelligence_channel_mappings_user_id_channel_id_pk",
+ "columns": [
+ "user_id",
+ "channel_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.revoked_access": {
+ "name": "revoked_access",
+ "schema": "",
+ "columns": {
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "revoked_by": {
+ "name": "revoked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sso_providers": {
+ "name": "sso_providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_config": {
+ "name": "oidc_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "saml_config": {
+ "name": "saml_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sso_providers_user_id_users_id_fk": {
+ "name": "sso_providers_user_id_users_id_fk",
+ "tableFrom": "sso_providers",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sso_providers_provider_id_unique": {
+ "name": "sso_providers_provider_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "provider_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_roles": {
+ "name": "user_roles",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_roles_user_id_users_id_fk": {
+ "name": "user_roles_user_id_users_id_fk",
+ "tableFrom": "user_roles",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_roles_user_id_role_pk": {
+ "name": "user_roles_user_id_role_pk",
+ "columns": [
+ "user_id",
+ "role"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "groups": {
+ "name": "groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "onboarding_step": {
+ "name": "onboarding_step",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "onboarding_completed_at": {
+ "name": "onboarding_completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verifications": {
+ "name": "verifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.action_policy": {
+ "name": "action_policy",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deny": {
+ "name": "deny",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "allow": {
+ "name": "allow",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.computer_page_frame": {
+ "name": "computer_page_frame",
+ "schema": "",
+ "columns": {
+ "computer_id": {
+ "name": "computer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tool_call_id": {
+ "name": "tool_call_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "frame": {
+ "name": "frame",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "captured_at": {
+ "name": "captured_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "computer_page_frame_captured_idx": {
+ "name": "computer_page_frame_captured_idx",
+ "columns": [
+ {
+ "expression": "captured_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "computer_page_frame_computer_id_tool_call_id_pk": {
+ "name": "computer_page_frame_computer_id_tool_call_id_pk",
+ "columns": [
+ "computer_id",
+ "tool_call_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.computer_snapshot": {
+ "name": "computer_snapshot",
+ "schema": "",
+ "columns": {
+ "computer_id": {
+ "name": "computer_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "snapshot_id": {
+ "name": "snapshot_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "elements": {
+ "name": "elements",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "taken_at": {
+ "name": "taken_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "session": {
+ "name": "session",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_preferences": {
+ "name": "agent_preferences",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hidden_at": {
+ "name": "hidden_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "agent_preferences_user_id_users_id_fk": {
+ "name": "agent_preferences_user_id_users_id_fk",
+ "tableFrom": "agent_preferences",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_preferences_agent_id_agents_id_fk": {
+ "name": "agent_preferences_agent_id_agents_id_fk",
+ "tableFrom": "agent_preferences",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "agent_preferences_user_id_agent_id_pk": {
+ "name": "agent_preferences_user_id_agent_id_pk",
+ "columns": [
+ "user_id",
+ "agent_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_profiles": {
+ "name": "agent_profiles",
+ "schema": "",
+ "columns": {
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role_description": {
+ "name": "role_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "avatar_seed": {
+ "name": "avatar_seed",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "agent_visibility",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "callback_token_hash": {
+ "name": "callback_token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "callback_token_issued_at": {
+ "name": "callback_token_issued_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "agent_profiles_visibility_deleted_idx": {
+ "name": "agent_profiles_visibility_deleted_idx",
+ "columns": [
+ {
+ "expression": "visibility",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_profiles_agent_id_agents_id_fk": {
+ "name": "agent_profiles_agent_id_agents_id_fk",
+ "tableFrom": "agent_profiles",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_profiles_owner_user_id_users_id_fk": {
+ "name": "agent_profiles_owner_user_id_users_id_fk",
+ "tableFrom": "agent_profiles",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.routine_runs": {
+ "name": "routine_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "routine_id": {
+ "name": "routine_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "routine_run_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "routine_runs_by_routine_idx": {
+ "name": "routine_runs_by_routine_idx",
+ "columns": [
+ {
+ "expression": "routine_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "routine_runs_routine_id_routines_id_fk": {
+ "name": "routine_runs_routine_id_routines_id_fk",
+ "tableFrom": "routine_runs",
+ "tableTo": "routines",
+ "columnsFrom": [
+ "routine_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.routines": {
+ "name": "routines",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "instruction": {
+ "name": "instruction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cron": {
+ "name": "cron",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'UTC'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "next_run_at": {
+ "name": "next_run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "routines_due_idx": {
+ "name": "routines_due_idx",
+ "columns": [
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "next_run_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routines_by_owner_idx": {
+ "name": "routines_by_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "routines_owner_user_id_users_id_fk": {
+ "name": "routines_owner_user_id_users_id_fk",
+ "tableFrom": "routines",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "routines_agent_id_agents_id_fk": {
+ "name": "routines_agent_id_agents_id_fk",
+ "tableFrom": "routines",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.component_exclusions": {
+ "name": "component_exclusions",
+ "schema": "",
+ "columns": {
+ "component_name": {
+ "name": "component_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "withheld_by": {
+ "name": "withheld_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "component_exclusions_component_name_components_name_fk": {
+ "name": "component_exclusions_component_name_components_name_fk",
+ "tableFrom": "component_exclusions",
+ "tableTo": "components",
+ "columnsFrom": [
+ "component_name"
+ ],
+ "columnsTo": [
+ "name"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "component_exclusions_agent_id_agents_id_fk": {
+ "name": "component_exclusions_agent_id_agents_id_fk",
+ "tableFrom": "component_exclusions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "component_exclusions_component_name_agent_id_pk": {
+ "name": "component_exclusions_component_name_agent_id_pk",
+ "columns": [
+ "component_name",
+ "agent_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.component_functions": {
+ "name": "component_functions",
+ "schema": "",
+ "columns": {
+ "component_name": {
+ "name": "component_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "function_name": {
+ "name": "function_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "granted_by": {
+ "name": "granted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "component_functions_component_name_components_name_fk": {
+ "name": "component_functions_component_name_components_name_fk",
+ "tableFrom": "component_functions",
+ "tableTo": "components",
+ "columnsFrom": [
+ "component_name"
+ ],
+ "columnsTo": [
+ "name"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "component_functions_component_name_function_name_pk": {
+ "name": "component_functions_component_name_function_name_pk",
+ "columns": [
+ "component_name",
+ "function_name"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.components": {
+ "name": "components",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "draft_description": {
+ "name": "draft_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "published_description": {
+ "name": "published_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published": {
+ "name": "published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_servers": {
+ "name": "mcp_servers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "vendor": {
+ "name": "vendor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provenance": {
+ "name": "provenance",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'first-party'"
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tools_refreshed_at": {
+ "name": "tools_refreshed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "mcp_servers_credential_id_credentials_id_fk": {
+ "name": "mcp_servers_credential_id_credentials_id_fk",
+ "tableFrom": "mcp_servers",
+ "tableTo": "credentials",
+ "columnsFrom": [
+ "credential_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_tools": {
+ "name": "mcp_tools",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "input_schema": {
+ "name": "input_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "mcp_tools_server_id_mcp_servers_id_fk": {
+ "name": "mcp_tools_server_id_mcp_servers_id_fk",
+ "tableFrom": "mcp_tools",
+ "tableTo": "mcp_servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "mcp_tools_server_id_name_pk": {
+ "name": "mcp_tools_server_id_name_pk",
+ "columns": [
+ "server_id",
+ "name"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_user_credentials": {
+ "name": "mcp_user_credentials",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "mcp_user_credentials_user_idx": {
+ "name": "mcp_user_credentials_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "mcp_user_credentials_server_id_mcp_servers_id_fk": {
+ "name": "mcp_user_credentials_server_id_mcp_servers_id_fk",
+ "tableFrom": "mcp_user_credentials",
+ "tableTo": "mcp_servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mcp_user_credentials_user_id_users_id_fk": {
+ "name": "mcp_user_credentials_user_id_users_id_fk",
+ "tableFrom": "mcp_user_credentials",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mcp_user_credentials_credential_id_credentials_id_fk": {
+ "name": "mcp_user_credentials_credential_id_credentials_id_fk",
+ "tableFrom": "mcp_user_credentials",
+ "tableTo": "credentials",
+ "columnsFrom": [
+ "credential_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "mcp_user_credentials_server_id_user_id_pk": {
+ "name": "mcp_user_credentials_server_id_user_id_pk",
+ "columns": [
+ "server_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_grants": {
+ "name": "plugin_grants",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ref": {
+ "name": "ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "granted_by": {
+ "name": "granted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "plugin_grants_agent_idx": {
+ "name": "plugin_grants_agent_idx",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plugin_grants_agent_id_agents_id_fk": {
+ "name": "plugin_grants_agent_id_agents_id_fk",
+ "tableFrom": "plugin_grants",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "plugin_grants_kind_ref_agent_id_pk": {
+ "name": "plugin_grants_kind_ref_agent_id_pk",
+ "columns": [
+ "kind",
+ "ref",
+ "agent_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sandboxed_components": {
+ "name": "sandboxed_components",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "draft_description": {
+ "name": "draft_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_html": {
+ "name": "draft_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_css": {
+ "name": "draft_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_js_functions": {
+ "name": "draft_js_functions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_argument_schema": {
+ "name": "draft_argument_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "published_description": {
+ "name": "published_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_html": {
+ "name": "published_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_css": {
+ "name": "published_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_js_functions": {
+ "name": "published_js_functions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_argument_schema": {
+ "name": "published_argument_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sample_arguments": {
+ "name": "sample_arguments",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "published": {
+ "name": "published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "authored_by": {
+ "name": "authored_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.skill_tools": {
+ "name": "skill_tools",
+ "schema": "",
+ "columns": {
+ "skill_id": {
+ "name": "skill_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ref": {
+ "name": "ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "declared_by": {
+ "name": "declared_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "skill_tools_ref_idx": {
+ "name": "skill_tools_ref_idx",
+ "columns": [
+ {
+ "expression": "ref",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "skill_tools_skill_id_skills_id_fk": {
+ "name": "skill_tools_skill_id_skills_id_fk",
+ "tableFrom": "skill_tools",
+ "tableTo": "skills",
+ "columnsFrom": [
+ "skill_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "skill_tools_skill_id_ref_pk": {
+ "name": "skill_tools_skill_id_ref_pk",
+ "columns": [
+ "skill_id",
+ "ref"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.skills": {
+ "name": "skills",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "instructions": {
+ "name": "instructions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin": {
+ "name": "origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'yours'"
+ },
+ "installed_by": {
+ "name": "installed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "skills_slug_key": {
+ "name": "skills_slug_key",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "skills_owner_idx": {
+ "name": "skills_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "skills_owner_user_id_users_id_fk": {
+ "name": "skills_owner_user_id_users_id_fk",
+ "tableFrom": "skills",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.work_items": {
+ "name": "work_items",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_at": {
+ "name": "run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "claimed_by": {
+ "name": "claimed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_until": {
+ "name": "lease_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "work_items_claimable_idx": {
+ "name": "work_items_claimable_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "run_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "work_items_kind_key_pk": {
+ "name": "work_items_kind_key_pk",
+ "columns": [
+ "kind",
+ "key"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.agent_type": {
+ "name": "agent_type",
+ "schema": "public",
+ "values": [
+ "built_in",
+ "remote_ag_ui"
+ ]
+ },
+ "public.credential_kind": {
+ "name": "credential_kind",
+ "schema": "public",
+ "values": [
+ "model",
+ "connector",
+ "agent",
+ "mcp",
+ "mcp_oauth_client",
+ "mcp_user_token"
+ ]
+ },
+ "public.role": {
+ "name": "role",
+ "schema": "public",
+ "values": [
+ "admin",
+ "user"
+ ]
+ },
+ "public.agent_visibility": {
+ "name": "agent_visibility",
+ "schema": "public",
+ "values": [
+ "public",
+ "private"
+ ]
+ },
+ "public.routine_run_status": {
+ "name": "routine_run_status",
+ "schema": "public",
+ "values": [
+ "succeeded",
+ "failed",
+ "skipped"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json
index 0a0fb4633..3c1aa00fa 100644
--- a/server/drizzle/meta/_journal.json
+++ b/server/drizzle/meta/_journal.json
@@ -183,6 +183,13 @@
"when": 1787926472382,
"tag": "0025_backfill_existing_users_have_onboarded",
"breakpoints": true
+ },
+ {
+ "idx": 26,
+ "version": "7",
+ "when": 1788271863590,
+ "tag": "0026_channel_summary",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/server/src/channels/events.ts b/server/src/channels/events.ts
index 586efa701..ebdd59665 100644
--- a/server/src/channels/events.ts
+++ b/server/src/channels/events.ts
@@ -26,6 +26,8 @@ export type ChannelActivityEvent = {
lastMessage: string | null;
lastMessageAt: string | null;
lastMessageAgentId: string | null;
+ /** Newly written summary, on its own event: the sweep runs seconds after the activity it follows. */
+ summary?: string;
/** The channel is hidden from every member's roster. Absent on an ordinary activity event. */
deleted?: true;
/**
diff --git a/server/src/channels/routes.ts b/server/src/channels/routes.ts
index 58c3864e9..808177cb7 100644
--- a/server/src/channels/routes.ts
+++ b/server/src/channels/routes.ts
@@ -33,6 +33,7 @@ import {
type ChannelEventHub,
} from "./events";
import { upgradeWebSocket } from "./socket";
+import { oneLine } from "./text";
import type { ThreadIdentity } from "./thread-identity";
export type AgentChannel = {
@@ -45,6 +46,8 @@ export type AgentChannel = {
/** A channel plus the last thing said in it, which is what a roster renders. */
export type ChannelSummary = AgentChannel & {
+ /** A few words about the conversation, or null. Readers fall back to the channel's name. */
+ summary: string | null;
lastMessage: string | null;
lastMessageAt: Date | null;
lastMessageAgentId: string | null;
@@ -211,20 +214,9 @@ const PRIVATE_AGENT_CHANNEL_DESCRIPTION = "Private agent channel.";
const MAX_CHANNEL_NAME_CODE_POINTS = 120;
const MAX_ACTIVITY_CODE_POINTS = 200;
-/**
- * Reduce a message to one line of plain text.
- *
- * A preview is rendered as text wherever a roster appears, so control characters have nothing to do
- * there: at best they are invisible, at worst a terminal escape somebody put in a message follows it
- * into a log. Newlines collapse to spaces because a preview is one line by definition.
- */
+/** Reduce a message to the one line a roster draws. See `oneLine` for why it is shared. */
function previewOf(text: string) {
- // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point.
- const flattened = text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
- const collapsed = flattened.replace(/\s+/g, " ");
- const codePoints = Array.from(collapsed);
- if (codePoints.length <= MAX_ACTIVITY_CODE_POINTS) return collapsed;
- return `${codePoints.slice(0, MAX_ACTIVITY_CODE_POINTS - 1).join("")}…`;
+ return oneLine(text, MAX_ACTIVITY_CODE_POINTS);
}
function channelName(names: string[]) {
@@ -479,6 +471,7 @@ export function createChannelStore(
agentId: channelAgents.agentId,
threadId: intelligenceChannelMappings.threadId,
deletedAt: agentProfiles.deletedAt,
+ channelSummary: channels.summary,
lastMessage: channels.lastMessage,
lastMessageAt: channels.lastMessageAt,
lastMessageAgentId: channels.lastMessageAgentId,
@@ -537,6 +530,7 @@ export function createChannelStore(
agentIds: [row.agentId],
threadId: row.threadId,
active: row.deletedAt === null,
+ summary: row.channelSummary,
lastMessage: row.lastMessage,
lastMessageAt: row.lastMessageAt,
lastMessageAgentId: row.lastMessageAgentId,
@@ -1169,6 +1163,7 @@ function channelDto(channel: AgentChannel): AgentChannel {
function channelSummaryDto(channel: ChannelSummary) {
return {
...channelDto(channel),
+ summary: channel.summary,
lastMessage: channel.lastMessage,
// Serialised as ISO-8601 so the browser gets a string it can sort and format.
lastMessageAt: channel.lastMessageAt?.toISOString() ?? null,
diff --git a/server/src/channels/summary.ts b/server/src/channels/summary.ts
new file mode 100644
index 000000000..81fc92152
--- /dev/null
+++ b/server/src/channels/summary.ts
@@ -0,0 +1,337 @@
+/**
+ * A few words saying what a conversation is about, since a channel's name is only its Bots' names.
+ *
+ * Not a headless turn: `run-turn.ts` takes the thread lock, which would refuse the person's own next
+ * message with 409 for the lock's TTL. `getThreadMessages` takes no lock, so this reads and asks.
+ *
+ * Offer then claim, like `work/culler.ts`. The offer is derived from the table rather than from an
+ * event, so a missed sweep costs two seconds where a missed event would cost the name entirely.
+ */
+import { and, asc, eq, isNull, sql } from "drizzle-orm";
+import type { Database } from "../db/client";
+import {
+ channelMemberships,
+ channels,
+ intelligenceChannelMappings,
+} from "../db/schema";
+import { DEFAULT_MAX_ATTEMPTS, type WorkQueue } from "../work/queue";
+import { CHANNEL_ACTIVITY_TOPIC, type ChannelActivityEvent } from "./events";
+import { oneLine } from "./text";
+
+export const CHANNEL_SUMMARY_KIND = "channel.summary";
+
+/** A title long enough to truncate says no more than the preview it replaced. */
+const MAX_SUMMARY_CODE_POINTS = 60;
+
+/** How much of the opening exchange the model is shown. Enough to see the topic, not the whole run. */
+const MAX_EXCERPT_CODE_POINTS = 600;
+
+/** A seam, so a test drives every path with no key and no network. Null means nothing worth writing. */
+export type ChannelTitler = (excerpt: string) => Promise;
+
+/** The one method of the Intelligence client this file uses. Narrowed for the same reason. */
+export type ThreadTranscript = {
+ getThreadMessages(params: { threadId: string; userId: string }): Promise<{
+ messages: { role: string; content?: unknown }[];
+ }>;
+};
+
+export type ChannelSummaryOptions = {
+ database: Database;
+ queue: WorkQueue;
+ transcript: ThreadTranscript;
+ title: ChannelTitler;
+ /** Who this replica is, for the lease. */
+ owner: string;
+ leaseMs?: number;
+ maxAttempts?: number;
+ /** How many channels one pass will offer, and how many it will claim. */
+ limit?: number;
+};
+
+export type SummaryReport = {
+ considered: number;
+ written: string[];
+ skipped: { channelId: string; reason: string }[];
+};
+
+/**
+ * Offer every conversation that has been spoken in and has no name yet.
+ *
+ * `channels_awaiting_summary_idx` keeps this off a full scan; `offer` is idempotent on (kind, key),
+ * so every replica and every later pass collapse onto one row.
+ */
+export async function offerChannelsAwaitingSummary(
+ options: Pick,
+): Promise<{ offered: string[] }> {
+ const rows = await options.database
+ .select({ id: channels.id })
+ .from(channels)
+ .where(
+ and(
+ isNull(channels.summary),
+ isNull(channels.deletedAt),
+ sql`${channels.lastMessageAt} is not null`,
+ ),
+ )
+ .limit(options.limit ?? 20);
+
+ const offered: string[] = [];
+ for (const row of rows) {
+ await options.queue.offer({
+ kind: CHANNEL_SUMMARY_KIND,
+ key: row.id,
+ payload: { channelId: row.id },
+ });
+ offered.push(row.id);
+ }
+ return { offered };
+}
+
+/** Name what this replica can claim. The lease is renewed per item: a model call is not instant. */
+export async function summariseClaimedChannels(
+ options: ChannelSummaryOptions,
+): Promise {
+ const leaseMs = options.leaseMs ?? 60_000;
+ const claimed = await options.queue.claim({
+ kind: CHANNEL_SUMMARY_KIND,
+ owner: options.owner,
+ leaseMs,
+ limit: options.limit ?? 5,
+ ...(options.maxAttempts === undefined
+ ? {}
+ : { maxAttempts: options.maxAttempts }),
+ });
+
+ const report: SummaryReport = {
+ considered: claimed.length,
+ written: [],
+ skipped: [],
+ };
+
+ for (const item of claimed) {
+ const channelId = String(item.payload.channelId ?? item.key);
+ if (
+ !(await options.queue.renew({
+ kind: CHANNEL_SUMMARY_KIND,
+ key: item.key,
+ owner: options.owner,
+ leaseMs,
+ }))
+ ) {
+ report.skipped.push({
+ channelId,
+ reason: "the lease went to another replica",
+ });
+ continue;
+ }
+
+ try {
+ const attempt = await summariseOne(options, channelId);
+ if (attempt === "not yet") {
+ // Put back, not finished: Intelligence has not persisted the message yet.
+ await options.queue.release({
+ kind: CHANNEL_SUMMARY_KIND,
+ key: item.key,
+ owner: options.owner,
+ delayMs: 15_000,
+ reason: "the conversation is not readable yet",
+ });
+ report.skipped.push({ channelId, reason: "not readable yet" });
+ continue;
+ }
+ await options.queue.finish({
+ kind: CHANNEL_SUMMARY_KIND,
+ key: item.key,
+ owner: options.owner,
+ });
+ if (attempt === "written") report.written.push(channelId);
+ else report.skipped.push({ channelId, reason: attempt });
+ } catch (error) {
+ // Released and pushed out: a model that just refused will refuse again, and nothing is broken meanwhile.
+ const reason =
+ error instanceof Error ? error.message : "could not be summarised";
+ await options.queue.release({
+ kind: CHANNEL_SUMMARY_KIND,
+ key: item.key,
+ owner: options.owner,
+ delayMs: 60_000,
+ reason,
+ });
+ // Said out loud when it gives up, because at the cap this loop simply never sees the channel
+ // again and every pass afterwards looks clean.
+ if (item.attempts >= (options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS)) {
+ console.warn(
+ JSON.stringify({
+ type: "channel-summary-gave-up",
+ channelId,
+ attempts: item.attempts,
+ reason,
+ }),
+ );
+ }
+ report.skipped.push({ channelId, reason });
+ }
+ }
+
+ return report;
+}
+
+/**
+ * Three words, not a boolean, so `"not yet"` is told apart from the rest: a conversation offered the
+ * instant somebody speaks may have no message in Intelligence yet, and finishing it never names it.
+ */
+type Attempt = "written" | "not yet" | "nothing to name it with";
+
+async function summariseOne(
+ options: ChannelSummaryOptions,
+ channelId: string,
+): Promise {
+ /* A queue key is not a foreign key, so work outlives its channel. Gone is final, not not-yet. */
+ const [existing] = await options.database
+ .select({ deletedAt: channels.deletedAt })
+ .from(channels)
+ .where(eq(channels.id, channelId));
+ if (!existing || existing.deletedAt !== null)
+ return "nothing to name it with";
+
+ /* Threads are per person, so the one read must be chosen: the earliest member opened it. */
+ const [owner] = await options.database
+ .select({
+ userId: channelMemberships.userId,
+ threadId: intelligenceChannelMappings.threadId,
+ })
+ .from(channelMemberships)
+ .innerJoin(
+ intelligenceChannelMappings,
+ and(
+ eq(intelligenceChannelMappings.channelId, channelMemberships.channelId),
+ eq(intelligenceChannelMappings.userId, channelMemberships.userId),
+ ),
+ )
+ .where(eq(channelMemberships.channelId, channelId))
+ .orderBy(asc(channelMemberships.createdAt), asc(channelMemberships.userId))
+ .limit(1);
+
+ // No member holds a thread for this channel yet, so there is nothing to read — yet.
+ if (!owner) return "not yet";
+
+ const excerpt = await openingOf(options.transcript, owner);
+ if (!excerpt) return "not yet";
+
+ // Final, unlike the two above: no key or nothing usable does not change a minute later.
+ const answer = await options.title(excerpt);
+ if (!answer) return "nothing to name it with";
+
+ const title = oneLine(stripWrappingQuotes(answer), MAX_SUMMARY_CODE_POINTS);
+ if (!title) return "nothing to name it with";
+
+ return await options.database.transaction(
+ async (transaction) => {
+ // Conditional, not check-then-write: a second replica changes no rows and announces nothing.
+ const applied = await transaction
+ .update(channels)
+ .set({ summary: title, summaryAt: new Date(), updatedAt: new Date() })
+ .where(and(eq(channels.id, channelId), isNull(channels.summary)))
+ .returning({ id: channels.id });
+ // Somebody else named it between the claim and this write. Their title stands, and this work
+ // is done rather than still owed.
+ if (applied.length === 0) return "written";
+
+ const members = await transaction
+ .select({ userId: channelMemberships.userId })
+ .from(channelMemberships)
+ .where(eq(channelMemberships.channelId, channelId));
+
+ // Announced inside the transaction, so it is delivered on commit and a write that rolls back
+ // is never announced — the rule every other writer in this area follows.
+ const event: ChannelActivityEvent = {
+ channelId,
+ memberIds: members.map((member) => member.userId),
+ lastMessage: null,
+ lastMessageAt: null,
+ lastMessageAgentId: null,
+ summary: title,
+ };
+ await transaction.execute(
+ sql`select pg_notify(${CHANNEL_ACTIVITY_TOPIC}, ${JSON.stringify(event)})`,
+ );
+ return "written";
+ },
+ { isolationLevel: "read committed" },
+ );
+}
+
+/* The opening exchange only: a conversation that wandered is still filed under what it opened for. */
+async function openingOf(
+ transcript: ThreadTranscript,
+ owner: { threadId: string; userId: string },
+): Promise {
+ const history = await transcript.getThreadMessages({
+ threadId: owner.threadId,
+ userId: owner.userId,
+ });
+ const question = history.messages.find((message) => message.role === "user");
+ if (!question) return null;
+ const answer = history.messages.find(
+ (message) => message.role === "assistant",
+ );
+
+ const asked = textOf(question.content);
+ if (!asked) return null;
+ const replied = answer ? textOf(answer.content) : "";
+
+ return oneLine(
+ replied ? `Asked: ${asked}\nAnswered: ${replied}` : `Asked: ${asked}`,
+ MAX_EXCERPT_CODE_POINTS,
+ );
+}
+
+/* `content` is a string or an array of parts; anything else is no text, not `[object Object]`. */
+function textOf(content: unknown): string {
+ if (typeof content === "string") return content.trim();
+ if (!Array.isArray(content)) return "";
+ return content
+ .map((part) =>
+ part && typeof part === "object" && "text" in part
+ ? String((part as { text?: unknown }).text ?? "")
+ : "",
+ )
+ .join(" ")
+ .trim();
+}
+
+/* Only a matched pair wrapping the whole answer: a title containing a quote keeps it. */
+function stripWrappingQuotes(text: string): string {
+ const trimmed = text.trim();
+ const pairs: [string, string][] = [
+ ['"', '"'],
+ ["'", "'"],
+ ["\u201c", "\u201d"],
+ ["\u00ab", "\u00bb"],
+ ];
+ for (const [open, close] of pairs) {
+ if (
+ trimmed.length > 1 &&
+ trimmed.startsWith(open) &&
+ trimmed.endsWith(close)
+ ) {
+ return trimmed.slice(1, -1).trim();
+ }
+ }
+ return trimmed;
+}
+
+/* Finished rows kept an hour, given-up rows a day. That day IS the backoff before a fresh offer. */
+export async function forgetSettledSummaries(
+ options: Pick,
+): Promise {
+ return await options.queue.purge({
+ kind: CHANNEL_SUMMARY_KIND,
+ olderThanMs: 24 * 60 * 60 * 1_000,
+ finishedOlderThanMs: 60 * 60 * 1_000,
+ ...(options.maxAttempts === undefined
+ ? {}
+ : { maxAttempts: options.maxAttempts }),
+ });
+}
diff --git a/server/src/channels/text.ts b/server/src/channels/text.ts
new file mode 100644
index 000000000..e549816ff
--- /dev/null
+++ b/server/src/channels/text.ts
@@ -0,0 +1,12 @@
+/**
+ * One line a roster can draw: control characters stripped, whitespace collapsed, cut on code points
+ * so an emoji is never split. The caller supplies the cap; a preview and a title want different ones.
+ */
+export function oneLine(text: string, maxCodePoints: number): string {
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point.
+ const flattened = text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
+ const collapsed = flattened.replace(/\s+/g, " ");
+ const codePoints = Array.from(collapsed);
+ if (codePoints.length <= maxCodePoints) return collapsed;
+ return `${codePoints.slice(0, maxCodePoints - 1).join("")}…`;
+}
diff --git a/server/src/channels/titler.ts b/server/src/channels/titler.ts
new file mode 100644
index 000000000..6032935a1
--- /dev/null
+++ b/server/src/channels/titler.ts
@@ -0,0 +1,73 @@
+/**
+ * One model call for a few words. Not a Bot and not a turn: a title is not said to anybody and is
+ * not part of the conversation it names, so there is no runtime work and no thread to hold.
+ */
+import { oneLine } from "./text";
+
+/** Where an OpenAI-compatible provider answers, overridable the way `agent-bot` overrides it. */
+const DEFAULT_BASE_URL = "https://api.openai.com/v1";
+
+/** Rules, not an example: a model copies an example's subject as readily as its shape. */
+const INSTRUCTION = [
+ "You name conversations, like a title in a sidebar.",
+ "Answer with three to six words naming what the conversation is about.",
+ "No quotation marks, no trailing period, no prefix such as 'Conversation about'.",
+ "Name the subject itself, not the fact that somebody asked about it.",
+].join(" ");
+
+export type TitlerOptions = {
+ /** The model to ask, from the tenant package. */
+ model: string;
+ /** Resolved per call, so a rotated credential is picked up without a restart. */
+ resolveApiKey: () => Promise;
+ baseUrl?: string;
+ /** Injectable so a test drives this without a network. */
+ fetchImpl?: typeof fetch;
+ /** How long one call may take before it is given up on. */
+ timeoutMs?: number;
+};
+
+/** No key resolves to null and is not retried; a provider error throws, so the queue retries it. */
+export function createChannelTitler(options: TitlerOptions) {
+ const call = options.fetchImpl ?? fetch;
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
+
+ return async (excerpt: string): Promise => {
+ const apiKey = await options.resolveApiKey();
+ if (!apiKey) return null;
+
+ const response = await call(`${baseUrl}/chat/completions`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({
+ model: options.model,
+ messages: [
+ { role: "system", content: INSTRUCTION },
+ { role: "user", content: excerpt },
+ ],
+ // Reasoning tokens come out of this budget, so a cap sized for the answer alone 400s.
+ max_completion_tokens: 512,
+ }),
+ signal: AbortSignal.timeout(options.timeoutMs ?? 20_000),
+ });
+
+ if (!response.ok) {
+ // Capped: this lands on the work item's row, and an HTML error page would land there whole.
+ const detail = oneLine(await response.text().catch(() => ""), 200);
+ throw new Error(
+ `The model refused to name a conversation: ${response.status} ${detail}`.trim(),
+ );
+ }
+
+ const body = (await response.json()) as {
+ choices?: { message?: { content?: unknown } }[];
+ };
+ const content = body.choices?.[0]?.message?.content;
+ if (typeof content !== "string") return null;
+ const answer = content.trim();
+ return answer.length > 0 ? answer : null;
+ };
+}
diff --git a/server/src/db/schema/core.ts b/server/src/db/schema/core.ts
index 44d5753a7..42875503a 100644
--- a/server/src/db/schema/core.ts
+++ b/server/src/db/schema/core.ts
@@ -259,6 +259,10 @@ export const channels = pgTable(
onDelete: "set null",
}),
override: jsonb("override"),
+ /** A few words about the conversation. Channel grain like `last_message`; null is ordinary. */
+ summary: text("summary"),
+ /** When the summary above was written, so a later change can decide whether to redo it. */
+ summaryAt: timestamp("summary_at", { withTimezone: true }),
/**
* The last thing said in this channel, denormalised so a roster is one indexed read.
*
@@ -302,6 +306,18 @@ export const channels = pgTable(
index("channels_recent_activity_idx").on(
sql`COALESCE(${table.lastMessageAt}, ${table.createdAt}) DESC`,
),
+ /**
+ * The channels still waiting for a summary.
+ *
+ * Partial, on the condition rather than the column, because the sweep that offers this work asks
+ * for exactly the rows this index holds and nothing else. Every channel that has been summarised
+ * leaves the index, so it shrinks as the deployment settles rather than growing with it: a
+ * question asked every couple of seconds on every replica should not be a scan of every
+ * conversation anybody has ever had.
+ */
+ index("channels_awaiting_summary_idx")
+ .on(table.id)
+ .where(sql`${table.summary} is null and ${table.deletedAt} is null`),
],
);
diff --git a/server/src/index.ts b/server/src/index.ts
index 6828cb9a9..b2b3bb4eb 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -31,7 +31,13 @@ import {
import { createChannelStore } from "./channels/routes";
import { websocket as channelSocket } from "./channels/socket";
import { createStallGuard } from "./channels/stall-guard";
+import {
+ forgetSettledSummaries,
+ offerChannelsAwaitingSummary,
+ summariseClaimedChannels,
+} from "./channels/summary";
import { createThreadIdentity } from "./channels/thread-identity";
+import { createChannelTitler } from "./channels/titler";
import { createSandboxedStore } from "./components/sandboxed";
import { createComponentStore } from "./components/store";
import { createComputerGateway } from "./computer/gateway";
@@ -1031,6 +1037,41 @@ repeatAfterEach(
60 * 60 * 1_000,
);
+/*
+ * Naming conversations, in the API process rather than `worker/`, which the single-image container
+ * does not run. Its own loop, so a slow model never delays a hop.
+ */
+const channelSummaries = {
+ database,
+ queue: createWorkQueue(database),
+ transcript: routineIntelligence,
+ title: createChannelTitler({
+ model: tenantPackage.model.defaultModel,
+ resolveApiKey: resolveRuntimeModelApiKey,
+ }),
+ owner: `summariser/${process.env.HOSTNAME ?? randomUUID().slice(0, 8)}`,
+};
+repeatAfterEach(async () => {
+ try {
+ await offerChannelsAwaitingSummary(channelSummaries);
+ const report = await summariseClaimedChannels(channelSummaries);
+ if (report.written.length > 0) {
+ console.info(
+ JSON.stringify({ type: "channel-summaries", written: report.written }),
+ );
+ }
+ // Same pass: one statement, deletes by age, and two replicas running it changes nothing.
+ await forgetSettledSummaries(channelSummaries);
+ } catch (error) {
+ // Never fatal, and never loud enough to drown the log: a deployment with no model configured
+ // reaches this on every pass, and it has not gone wrong, it simply has no titles.
+ console.warn(
+ "[channels] conversations could not be named:",
+ error instanceof Error ? error.message : error,
+ );
+ }
+}, 10_000);
+
const app = createApp(
config,
auth,
diff --git a/server/tests/channel-activity.integration.test.ts b/server/tests/channel-activity.integration.test.ts
index f8c857be7..610c2bbd6 100644
--- a/server/tests/channel-activity.integration.test.ts
+++ b/server/tests/channel-activity.integration.test.ts
@@ -223,6 +223,8 @@ describe("channel activity", () => {
expect((await store.list(owner)).channels).toEqual([
{
...channel,
+ // Nothing has named this conversation: the sweep that does runs outside this store.
+ summary: null,
lastMessage: "Categorized three expenses.",
lastMessageAgentId: agentId,
lastMessageAt: at,
@@ -346,6 +348,33 @@ describe("channel activity", () => {
(await store.list(owner)).channels.map((channel) => channel.id),
).toEqual([busy.id, quiet.id]);
});
+
+ test("a title rides along on the roster without touching its order", async () => {
+ const owner = await createUser();
+ const agentId = await createAgent(owner);
+ const quiet = await createChannel(owner, [agentId]);
+ const busy = await createChannel(owner, [agentId]);
+
+ await store.recordActivity(owner, busy.id, {
+ agentId,
+ at: new Date(),
+ text: "Said something.",
+ });
+ // Named the older, quieter conversation, which is the case that would expose a summary leaking
+ // into the ordering: it sorts second before and must still sort second after.
+ await database
+ .update(channels)
+ .set({ summary: "An older subject", summaryAt: new Date() })
+ .where(eq(channels.id, quiet.id));
+
+ const roster = (await store.list(owner)).channels;
+
+ expect(roster.map((channel) => channel.id)).toEqual([busy.id, quiet.id]);
+ expect(roster.map((channel) => channel.summary)).toEqual([
+ null,
+ "An older subject",
+ ]);
+ });
});
/**
diff --git a/server/tests/channel-summary.integration.test.ts b/server/tests/channel-summary.integration.test.ts
new file mode 100644
index 000000000..b71e15430
--- /dev/null
+++ b/server/tests/channel-summary.integration.test.ts
@@ -0,0 +1,461 @@
+import { afterAll, afterEach, describe, expect, test } from "bun:test";
+import { randomUUID } from "node:crypto";
+import { eq } from "drizzle-orm";
+import { createAgentProfileStore } from "../src/agents/profile-store";
+import type { AgentActor } from "../src/agents/profile-types";
+import { createChannelStore } from "../src/channels/routes";
+import {
+ CHANNEL_SUMMARY_KIND,
+ type ChannelTitler,
+ offerChannelsAwaitingSummary,
+ summariseClaimedChannels,
+ type ThreadTranscript,
+} from "../src/channels/summary";
+import { createThreadIdentity } from "../src/channels/thread-identity";
+import { createDatabase } from "../src/db/client";
+import {
+ agentProfiles,
+ agents,
+ channelMemberships,
+ channels,
+ intelligenceChannelMappings,
+ users,
+ workItems,
+} from "../src/db/schema";
+import { createWorkQueue } from "../src/work/queue";
+import { TEST_POOL } from "./support/database";
+
+const databaseUrl =
+ process.env.DATABASE_URL ??
+ "postgres://openbot:openbot@localhost:5432/openbot";
+const database = createDatabase(databaseUrl, TEST_POOL);
+const profileStore = createAgentProfileStore(
+ database,
+ new URL("https://managed.example.test/ag-ui"),
+);
+const store = createChannelStore(
+ database,
+ profileStore,
+ createThreadIdentity("test-deployment"),
+);
+const queue = createWorkQueue(database);
+
+const testPrefix = `channel-summary-${randomUUID()}`;
+const createdUserIds: string[] = [];
+const createdAgentIds: string[] = [];
+const createdChannelIds: string[] = [];
+
+afterEach(async () => {
+ for (const channelId of createdChannelIds.splice(0)) {
+ await database.delete(workItems).where(eq(workItems.key, channelId));
+ await database
+ .delete(intelligenceChannelMappings)
+ .where(eq(intelligenceChannelMappings.channelId, channelId));
+ await database.delete(channels).where(eq(channels.id, channelId));
+ }
+ for (const agentId of createdAgentIds.splice(0)) {
+ await database
+ .delete(agentProfiles)
+ .where(eq(agentProfiles.agentId, agentId));
+ await database.delete(agents).where(eq(agents.id, agentId));
+ }
+ for (const userId of createdUserIds.splice(0)) {
+ await database.delete(users).where(eq(users.id, userId));
+ }
+});
+
+afterAll(async () => {
+ await database.$client.close();
+});
+
+async function createUser(): Promise {
+ const id = `${testPrefix}-user-${randomUUID()}`;
+ await database.insert(users).values({
+ id,
+ email: `${id}@example.test`,
+ name: "Channel Summary Test User",
+ });
+ createdUserIds.push(id);
+ return { id, role: "user" };
+}
+
+async function createAgent(owner: AgentActor) {
+ const profile = await profileStore.create(owner, {
+ name: "Expense Manager",
+ title: "Finance Operations",
+ roleDescription: "Review receipts.",
+ visibility: "private",
+ });
+ createdAgentIds.push(profile.id);
+ return profile.id;
+}
+
+/** A channel somebody has actually said something in, which is the only kind worth naming. */
+async function createUsedChannel(owner: AgentActor) {
+ const agentId = await createAgent(owner);
+ const channel = await store.create(owner, [agentId]);
+ createdChannelIds.push(channel.id);
+ await store.recordActivity(owner, channel.id, {
+ text: "Which of these receipts count as travel?",
+ agentId: null,
+ at: new Date(),
+ });
+ return channel;
+}
+
+/** A transcript with one question and one answer in it, which is what a title is made from. */
+function transcriptOf(question: string, answer?: string): ThreadTranscript {
+ return {
+ getThreadMessages: async () => ({
+ messages: [
+ { role: "user", content: question },
+ ...(answer ? [{ role: "assistant", content: answer }] : []),
+ ],
+ }),
+ };
+}
+
+function titler(answer: string | null): ChannelTitler {
+ return async () => answer;
+}
+
+async function summaryOf(channelId: string) {
+ const [row] = await database
+ .select({ summary: channels.summary, summaryAt: channels.summaryAt })
+ .from(channels)
+ .where(eq(channels.id, channelId));
+ return row;
+}
+
+/**
+ * Queue one conversation, by name.
+ *
+ * Deliberately not `offerChannelsAwaitingSummary`, which offers every unnamed conversation in the
+ * database. A claim takes whatever is queued, so a test that offers the whole table and then claims
+ * will happily write a fabricated title onto somebody's real conversation when the suite is pointed
+ * at a database that has any — a development one, for instance. Tests that exercise the offer query
+ * itself assert on what it returns and never claim.
+ */
+async function offer(channelId: string) {
+ await queue.offer({ kind: CHANNEL_SUMMARY_KIND, key: channelId });
+}
+
+const options = (overrides: {
+ transcript?: ThreadTranscript;
+ title?: ChannelTitler;
+ owner?: string;
+}) => ({
+ database,
+ queue,
+ transcript: overrides.transcript ?? transcriptOf("Which receipts?"),
+ title: overrides.title ?? titler("Travel receipt rules"),
+ owner: overrides.owner ?? `test-${randomUUID()}`,
+});
+
+describe("offering conversations to be named", () => {
+ test("offers one that has been used and has no name yet", async () => {
+ const owner = await createUser();
+ const channel = await createUsedChannel(owner);
+
+ const { offered } = await offerChannelsAwaitingSummary({
+ database,
+ queue,
+ limit: 200,
+ });
+
+ expect(offered).toContain(channel.id);
+ });
+
+ test("does not offer a channel nobody has said anything in", async () => {
+ const owner = await createUser();
+ const agentId = await createAgent(owner);
+ const channel = await store.create(owner, [agentId]);
+ createdChannelIds.push(channel.id);
+
+ const { offered } = await offerChannelsAwaitingSummary({
+ database,
+ queue,
+ limit: 200,
+ });
+
+ // There is nothing for a model to read, and asking it to name an empty thread spends money to
+ // produce a guess.
+ expect(offered).not.toContain(channel.id);
+ });
+
+ test("offering the same conversation twice leaves one piece of work", async () => {
+ const owner = await createUser();
+ const channel = await createUsedChannel(owner);
+
+ await offerChannelsAwaitingSummary({ database, queue, limit: 200 });
+ await offerChannelsAwaitingSummary({ database, queue, limit: 200 });
+
+ const rows = await database
+ .select({ key: workItems.key })
+ .from(workItems)
+ .where(eq(workItems.key, channel.id));
+ expect(rows).toHaveLength(1);
+ });
+});
+
+/**
+ * What the first pass on a deployment with a history looks like.
+ *
+ * Every conversation ever used and never named is eligible at once, so the only thing standing
+ * between a deploy and a model request per historical conversation is the claim limit. These pin
+ * that limit as behaviour rather than leaving it a constant somebody can raise without noticing what
+ * it is holding back.
+ */
+describe("a backlog of unnamed conversations", () => {
+ test("names at most one batch per pass, not one per conversation", async () => {
+ const owner = await createUser();
+ const agentId = await createAgent(owner);
+ const made: string[] = [];
+ for (let index = 0; index < 6; index += 1) {
+ const channel = await store.create(owner, [agentId]);
+ createdChannelIds.push(channel.id);
+ await store.recordActivity(owner, channel.id, {
+ text: `Question ${index}`,
+ agentId: null,
+ at: new Date(),
+ });
+ made.push(channel.id);
+ }
+ for (const id of made) await offer(id);
+
+ let calls = 0;
+ const counting = options({
+ title: async () => {
+ calls += 1;
+ return "A title";
+ },
+ });
+
+ const first = await summariseClaimedChannels({ ...counting, limit: 2 });
+ expect(first.written).toHaveLength(2);
+ expect(calls).toBe(2);
+
+ const second = await summariseClaimedChannels({ ...counting, limit: 2 });
+ expect(second.written).toHaveLength(2);
+ // The next batch, not the first one again.
+ expect(second.written.some((id) => first.written.includes(id))).toBe(false);
+ expect(calls).toBe(4);
+ });
+});
+
+describe("naming a claimed conversation", () => {
+ test("writes the title and stamps when it was written", async () => {
+ const owner = await createUser();
+ const channel = await createUsedChannel(owner);
+ await offer(channel.id);
+
+ const report = await summariseClaimedChannels(
+ options({
+ transcript: transcriptOf(
+ "Which of these receipts count as travel?",
+ "The three flights and the hotel do.",
+ ),
+ title: titler("Travel receipt rules"),
+ }),
+ );
+
+ expect(report.written).toContain(channel.id);
+ const row = await summaryOf(channel.id);
+ expect(row?.summary).toBe("Travel receipt rules");
+ expect(row?.summaryAt).toBeInstanceOf(Date);
+ });
+
+ test("two replicas racing for the same conversation name it once", async () => {
+ const owner = await createUser();
+ const channel = await createUsedChannel(owner);
+ await offer(channel.id);
+
+ /*
+ * Overlapping, not sequential. Two sweeps run one after the other pass just as happily with the
+ * row-level locking removed, so they prove the conditional update and nothing about the claim.
+ * These two are in flight together on separate pooled connections, which is what `for update
+ * skip locked` is there for: the second finds nothing to take rather than waiting behind the
+ * first and then doing the work twice.
+ */
+ const [left, right] = await Promise.all([
+ summariseClaimedChannels(options({ title: titler("Left title") })),
+ summariseClaimedChannels(options({ title: titler("Right title") })),
+ ]);
+
+ expect([...left.written, ...right.written]).toEqual([channel.id]);
+ expect(left.considered + right.considered).toBe(1);
+ const written = (await summaryOf(channel.id))?.summary;
+ expect(["Left title", "Right title"]).toContain(written);
+ });
+
+ test("never overwrites a name the conversation already has", async () => {
+ const owner = await createUser();
+ const channel = await createUsedChannel(owner);
+ await database
+ .update(channels)
+ .set({ summary: "Named already", summaryAt: new Date() })
+ .where(eq(channels.id, channel.id));
+ // Offered before it was named, which is exactly the race two replicas produce.
+ await queue.offer({ kind: CHANNEL_SUMMARY_KIND, key: channel.id });
+
+ await summariseClaimedChannels(
+ options({ title: titler("A second opinion") }),
+ );
+
+ expect((await summaryOf(channel.id))?.summary).toBe("Named already");
+ });
+
+ test("leaves the conversation unnamed when the model has no answer", async () => {
+ const owner = await createUser();
+ const channel = await createUsedChannel(owner);
+ await offer(channel.id);
+
+ const report = await summariseClaimedChannels(
+ options({ title: titler(null) }),
+ );
+
+ // A deployment with no model key reaches this on every pass. Nothing is written, nothing is
+ // broken, and the roster keeps drawing the Bot's name.
+ expect(report.written).not.toContain(channel.id);
+ expect((await summaryOf(channel.id))?.summary).toBeNull();
+ });
+
+ test("comes back later when the conversation is not readable yet", async () => {
+ const owner = await createUser();
+ const channel = await createUsedChannel(owner);
+ await offer(channel.id);
+
+ // What a conversation looks like in the seconds between somebody sending a message and
+ // Intelligence holding it. Finishing the work here is how an ordinary conversation ends up
+ // never named at all.
+ const report = await summariseClaimedChannels(
+ options({
+ transcript: { getThreadMessages: async () => ({ messages: [] }) },
+ }),
+ );
+
+ expect(report.skipped).toContainEqual({
+ channelId: channel.id,
+ reason: "not readable yet",
+ });
+ const [item] = await database
+ .select({ finishedAt: workItems.finishedAt, runAt: workItems.runAt })
+ .from(workItems)
+ .where(eq(workItems.key, channel.id));
+ expect(item?.finishedAt).toBeNull();
+ expect(item?.runAt.getTime()).toBeGreaterThan(Date.now());
+ });
+
+ test("a conversation that no longer exists is dropped, not retried", async () => {
+ const owner = await createUser();
+ const channel = await createUsedChannel(owner);
+ await offer(channel.id);
+ // The channel goes while its naming is still queued. A queue key is not a foreign key, so the
+ // work outlives the thing it was about — which happens for real on a delete, and happens on
+ // every integration test that seeds a channel and tears it down.
+ await database.delete(channels).where(eq(channels.id, channel.id));
+
+ await summariseClaimedChannels(options({}));
+
+ const [item] = await database
+ .select({
+ finishedAt: workItems.finishedAt,
+ attempts: workItems.attempts,
+ })
+ .from(workItems)
+ .where(eq(workItems.key, channel.id));
+ // Finished rather than released: nothing about a deleted conversation is different in fifteen
+ // seconds, and retrying it to the attempt cap burns a model call's worth of work each time.
+ expect(item?.finishedAt).not.toBeNull();
+ expect(item?.attempts).toBe(1);
+ });
+
+ test("a soft-deleted conversation is dropped the same way", async () => {
+ const owner = await createUser();
+ const channel = await createUsedChannel(owner);
+ await offer(channel.id);
+ await database
+ .update(channels)
+ .set({ deletedAt: new Date() })
+ .where(eq(channels.id, channel.id));
+
+ await summariseClaimedChannels(options({}));
+
+ const [item] = await database
+ .select({ finishedAt: workItems.finishedAt })
+ .from(workItems)
+ .where(eq(workItems.key, channel.id));
+ expect(item?.finishedAt).not.toBeNull();
+ expect((await summaryOf(channel.id))?.summary).toBeNull();
+ });
+
+ test("stays inside the NOTIFY payload limit on a crowded channel", async () => {
+ const owner = await createUser();
+ const channel = await createUsedChannel(owner);
+ // The title is short and fixed; the term that grows is the member list the payload carries, so
+ // that is the term the test grows. `pg_notify` refuses a payload over 8000 bytes outright, and
+ // it refuses it inside the transaction that writes the title.
+ const crowd = [];
+ for (let index = 0; index < 40; index += 1) {
+ const member = await createUser();
+ crowd.push(member.id);
+ await database
+ .insert(channelMemberships)
+ .values({ channelId: channel.id, userId: member.id });
+ }
+
+ await offer(channel.id);
+ const report = await summariseClaimedChannels(
+ options({ title: titler("Travel receipt rules") }),
+ );
+
+ expect(report.written).toContain(channel.id);
+ // Recorded rather than merely survived: this is the number that says how much room is left, and
+ // it is what a later change adding a field to the event has to be measured against.
+ const payload = JSON.stringify({
+ channelId: channel.id,
+ memberIds: [owner.id, ...crowd],
+ lastMessage: null,
+ lastMessageAt: null,
+ lastMessageAgentId: null,
+ summary: "Travel receipt rules",
+ });
+ expect(payload.length).toBeLessThan(8000);
+ });
+
+ test("a model that fails leaves the work to be tried again", async () => {
+ const owner = await createUser();
+ const channel = await createUsedChannel(owner);
+ await offer(channel.id);
+
+ await summariseClaimedChannels(
+ options({
+ title: async () => {
+ throw new Error("the model is unreachable");
+ },
+ }),
+ );
+
+ expect((await summaryOf(channel.id))?.summary).toBeNull();
+ const [item] = await database
+ .select({ attempts: workItems.attempts, error: workItems.lastError })
+ .from(workItems)
+ .where(eq(workItems.key, channel.id));
+ expect(item?.attempts).toBe(1);
+ expect(item?.error).toContain("the model is unreachable");
+ });
+
+ test("flattens and shortens whatever the model answers with", async () => {
+ const owner = await createUser();
+ const channel = await createUsedChannel(owner);
+ await offer(channel.id);
+
+ await summariseClaimedChannels(
+ options({ title: titler(' "Travel\nreceipt rules" ') }),
+ );
+
+ // The quotes a model wraps a title in, the newline, and the run of spaces all go: a roster row
+ // is one line of plain text.
+ expect((await summaryOf(channel.id))?.summary).toBe("Travel receipt rules");
+ });
+});
diff --git a/server/tests/channel-titler.test.ts b/server/tests/channel-titler.test.ts
new file mode 100644
index 000000000..953be3044
--- /dev/null
+++ b/server/tests/channel-titler.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, test } from "bun:test";
+import { createChannelTitler } from "../src/channels/titler";
+
+function respondWith(body: unknown, status = 200) {
+ const calls: { url: string; body: Record }[] = [];
+ const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
+ calls.push({
+ url: String(url),
+ body: JSON.parse(String(init?.body ?? "{}")),
+ });
+ return new Response(
+ typeof body === "string" ? body : JSON.stringify(body),
+ { status },
+ );
+ }) as unknown as typeof fetch;
+ return { calls, fetchImpl };
+}
+
+describe("asking the model for a title", () => {
+ test("sends the excerpt to the deployment's own model", async () => {
+ const { calls, fetchImpl } = respondWith({
+ choices: [{ message: { content: "Travel receipt rules" } }],
+ });
+
+ const answer = await createChannelTitler({
+ model: "gpt-4.1-mini",
+ resolveApiKey: async () => "key-123",
+ fetchImpl,
+ })("Asked: which receipts count as travel?");
+
+ expect(answer).toBe("Travel receipt rules");
+ expect(calls[0]?.url).toBe("https://api.openai.com/v1/chat/completions");
+ expect(calls[0]?.body.model).toBe("gpt-4.1-mini");
+ });
+
+ test("asks nothing at all when the deployment has no key", async () => {
+ const { calls, fetchImpl } = respondWith({});
+
+ const answer = await createChannelTitler({
+ model: "gpt-4.1-mini",
+ resolveApiKey: async () => null,
+ fetchImpl,
+ })("Asked: anything?");
+
+ // Not a failure. A deployment with no model configured does not name conversations, and must
+ // not spend a request finding that out on every pass.
+ expect(answer).toBeNull();
+ expect(calls).toHaveLength(0);
+ });
+
+ test("throws when the provider refuses, so the work is tried again", async () => {
+ const { fetchImpl } = respondWith("rate limited", 429);
+
+ await expect(
+ createChannelTitler({
+ model: "gpt-4.1-mini",
+ resolveApiKey: async () => "key-123",
+ fetchImpl,
+ })("Asked: anything?"),
+ ).rejects.toThrow("429");
+ });
+
+ test("carries only one line of the provider's complaint", async () => {
+ const { fetchImpl } = respondWith(
+ "\n Too many requests\n",
+ 503,
+ );
+
+ // The message ends up on the work item as the reason it was released, so a provider that
+ // answers with a page of HTML must not put that page in the database.
+ const thrown = await createChannelTitler({
+ model: "gpt-4.1-mini",
+ resolveApiKey: async () => "key-123",
+ fetchImpl,
+ })("Asked: anything?").catch((error: Error) => error.message);
+
+ expect(thrown).not.toContain("\n");
+ });
+
+ test("an empty completion is no answer rather than an empty title", async () => {
+ const { fetchImpl } = respondWith({
+ choices: [{ message: { content: " " } }],
+ });
+
+ const answer = await createChannelTitler({
+ model: "gpt-4.1-mini",
+ resolveApiKey: async () => "key-123",
+ fetchImpl,
+ })("Asked: anything?");
+
+ expect(answer).toBeNull();
+ });
+});