diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 502af9230..3535e54ef 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -8,6 +8,7 @@ export const SessionsListParamsSchema = Type.Object( activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), includeGlobal: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()), + includeDerivedTitles: Type.Optional(Type.Boolean()), label: Type.Optional(SessionLabelString), spawnedBy: Type.Optional(NonEmptyString), agentId: Type.Optional(NonEmptyString), diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts new file mode 100644 index 000000000..b2157a06d --- /dev/null +++ b/src/gateway/session-utils.fs.test.ts @@ -0,0 +1,136 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { readFirstUserMessageFromTranscript } from "./session-utils.fs.js"; + +describe("readFirstUserMessageFromTranscript", () => { + let tmpDir: string; + let storePath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-")); + storePath = path.join(tmpDir, "sessions.json"); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("returns null when transcript file does not exist", () => { + const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath); + expect(result).toBeNull(); + }); + + test("returns first user message from transcript with string content", () => { + const sessionId = "test-session-1"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "user", content: "Hello world" } }), + JSON.stringify({ message: { role: "assistant", content: "Hi there" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("Hello world"); + }); + + test("returns first user message from transcript with array content", () => { + const sessionId = "test-session-2"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ + message: { + role: "user", + content: [{ type: "text", text: "Array message content" }], + }, + }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("Array message content"); + }); + + test("skips non-user messages to find first user message", () => { + const sessionId = "test-session-3"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "system", content: "System prompt" } }), + JSON.stringify({ message: { role: "assistant", content: "Greeting" } }), + JSON.stringify({ message: { role: "user", content: "First user question" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("First user question"); + }); + + test("returns null when no user messages exist", () => { + const sessionId = "test-session-4"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "system", content: "System prompt" } }), + JSON.stringify({ message: { role: "assistant", content: "Greeting" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBeNull(); + }); + + test("handles malformed JSON lines gracefully", () => { + const sessionId = "test-session-5"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + "not valid json", + JSON.stringify({ message: { role: "user", content: "Valid message" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("Valid message"); + }); + + test("uses sessionFile parameter when provided", () => { + const sessionId = "test-session-6"; + const customPath = path.join(tmpDir, "custom-transcript.jsonl"); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "user", content: "Custom file message" } }), + ]; + fs.writeFileSync(customPath, lines.join("\n"), "utf-8"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath); + expect(result).toBe("Custom file message"); + }); + + test("trims whitespace from message content", () => { + const sessionId = "test-session-7"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ message: { role: "user", content: " Padded message " } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("Padded message"); + }); + + test("returns null for empty content", () => { + const sessionId = "test-session-8"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ message: { role: "user", content: "" } }), + JSON.stringify({ message: { role: "user", content: "Second message" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("Second message"); + }); +}); diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index cee03bde1..d624632cb 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -79,3 +79,61 @@ export function capArrayByJsonBytes( const next = start > 0 ? items.slice(start) : items; return { items: next, bytes }; } + +const MAX_LINES_TO_SCAN = 10; + +type TranscriptMessage = { + role?: string; + content?: string | Array<{ type: string; text?: string }>; +}; + +function extractTextFromContent(content: TranscriptMessage["content"]): string | null { + if (typeof content === "string") return content.trim() || null; + if (!Array.isArray(content)) return null; + for (const part of content) { + if (part.type === "text" && typeof part.text === "string" && part.text.trim()) { + return part.text.trim(); + } + } + return null; +} + +export function readFirstUserMessageFromTranscript( + sessionId: string, + storePath: string | undefined, + sessionFile?: string, + agentId?: string, +): string | null { + const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId); + const filePath = candidates.find((p) => fs.existsSync(p)); + if (!filePath) return null; + + let fd: number | null = null; + try { + fd = fs.openSync(filePath, "r"); + const buf = Buffer.alloc(8192); + const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0); + if (bytesRead === 0) return null; + const chunk = buf.toString("utf-8", 0, bytesRead); + const lines = chunk.split(/\r?\n/).slice(0, MAX_LINES_TO_SCAN); + + for (const line of lines) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line); + const msg = parsed?.message as TranscriptMessage | undefined; + if (msg?.role === "user") { + const text = extractTextFromContent(msg.content); + if (text) return text; + } + } catch { + // skip malformed lines + } + } + } catch { + // file read error + } finally { + if (fd !== null) fs.closeSync(fd); + } + return null; +} diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index db24c17f4..d0076061e 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -6,6 +6,7 @@ import type { SessionEntry } from "../config/sessions.js"; import { capArrayByJsonBytes, classifySessionKey, + deriveSessionTitle, parseGroupKey, resolveGatewaySessionStoreTarget, resolveSessionStoreKey, @@ -91,3 +92,98 @@ describe("gateway session utils", () => { expect(target.storePath).toBe(path.resolve(storeTemplate.replace("{agentId}", "ops"))); }); }); + +describe("deriveSessionTitle", () => { + test("returns undefined for undefined entry", () => { + expect(deriveSessionTitle(undefined)).toBeUndefined(); + }); + + test("prefers displayName when set", () => { + const entry = { + sessionId: "abc123", + updatedAt: Date.now(), + displayName: "My Custom Session", + subject: "Group Chat", + } as SessionEntry; + expect(deriveSessionTitle(entry)).toBe("My Custom Session"); + }); + + test("falls back to subject when displayName is missing", () => { + const entry = { + sessionId: "abc123", + updatedAt: Date.now(), + subject: "Dev Team Chat", + } as SessionEntry; + expect(deriveSessionTitle(entry)).toBe("Dev Team Chat"); + }); + + test("uses first user message when displayName and subject missing", () => { + const entry = { + sessionId: "abc123", + updatedAt: Date.now(), + } as SessionEntry; + expect(deriveSessionTitle(entry, "Hello, how are you?")).toBe("Hello, how are you?"); + }); + + test("truncates long first user message to 60 chars with ellipsis", () => { + const entry = { + sessionId: "abc123", + updatedAt: Date.now(), + } as SessionEntry; + const longMsg = + "This is a very long message that exceeds sixty characters and should be truncated appropriately"; + const result = deriveSessionTitle(entry, longMsg); + expect(result).toBeDefined(); + expect(result!.length).toBeLessThanOrEqual(60); + expect(result!.endsWith("…")).toBe(true); + }); + + test("truncates at word boundary when possible", () => { + const entry = { + sessionId: "abc123", + updatedAt: Date.now(), + } as SessionEntry; + const longMsg = "This message has many words and should be truncated at a word boundary nicely"; + const result = deriveSessionTitle(entry, longMsg); + expect(result).toBeDefined(); + expect(result!.endsWith("…")).toBe(true); + expect(result!.includes(" ")).toBe(false); + }); + + test("falls back to sessionId prefix with date", () => { + const entry = { + sessionId: "abcd1234-5678-90ef-ghij-klmnopqrstuv", + updatedAt: new Date("2024-03-15T10:30:00Z").getTime(), + } as SessionEntry; + const result = deriveSessionTitle(entry); + expect(result).toBe("abcd1234 (2024-03-15)"); + }); + + test("falls back to sessionId prefix without date when updatedAt missing", () => { + const entry = { + sessionId: "abcd1234-5678-90ef-ghij-klmnopqrstuv", + updatedAt: 0, + } as SessionEntry; + const result = deriveSessionTitle(entry); + expect(result).toBe("abcd1234"); + }); + + test("trims whitespace from displayName", () => { + const entry = { + sessionId: "abc123", + updatedAt: Date.now(), + displayName: " Padded Name ", + } as SessionEntry; + expect(deriveSessionTitle(entry)).toBe("Padded Name"); + }); + + test("ignores empty displayName and falls through", () => { + const entry = { + sessionId: "abc123", + updatedAt: Date.now(), + displayName: " ", + subject: "Actual Subject", + } as SessionEntry; + expect(deriveSessionTitle(entry)).toBe("Actual Subject"); + }); +}); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index b17018ae3..ac067f930 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -22,6 +22,7 @@ import { parseAgentSessionKey, } from "../routing/session-key.js"; import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js"; +import { readFirstUserMessageFromTranscript } from "./session-utils.fs.js"; import type { GatewayAgentRow, GatewaySessionRow, @@ -32,6 +33,7 @@ import type { export { archiveFileOnDisk, capArrayByJsonBytes, + readFirstUserMessageFromTranscript, readSessionMessages, resolveSessionTranscriptCandidates, } from "./session-utils.fs.js"; @@ -43,6 +45,51 @@ export type { SessionsPatchResult, } from "./session-utils.types.js"; +const DERIVED_TITLE_MAX_LEN = 60; + +function formatSessionIdPrefix(sessionId: string, updatedAt?: number | null): string { + const prefix = sessionId.slice(0, 8); + if (updatedAt && updatedAt > 0) { + const d = new Date(updatedAt); + const date = d.toISOString().slice(0, 10); + return `${prefix} (${date})`; + } + return prefix; +} + +function truncateTitle(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + const cut = text.slice(0, maxLen - 1); + const lastSpace = cut.lastIndexOf(" "); + if (lastSpace > maxLen * 0.6) return cut.slice(0, lastSpace) + "…"; + return cut + "…"; +} + +export function deriveSessionTitle( + entry: SessionEntry | undefined, + firstUserMessage?: string | null, +): string | undefined { + if (!entry) return undefined; + + if (entry.displayName?.trim()) { + return entry.displayName.trim(); + } + + if (entry.subject?.trim()) { + return entry.subject.trim(); + } + + if (firstUserMessage?.trim()) { + return truncateTitle(firstUserMessage.trim(), DERIVED_TITLE_MAX_LEN); + } + + if (entry.sessionId) { + return formatSessionIdPrefix(entry.sessionId, entry.updatedAt); + } + + return undefined; +} + export function loadSessionEntry(sessionKey: string) { const cfg = loadConfig(); const sessionCfg = cfg.session; @@ -341,6 +388,7 @@ export function listSessionsFromStore(params: { const includeGlobal = opts.includeGlobal === true; const includeUnknown = opts.includeUnknown === true; + const includeDerivedTitles = opts.includeDerivedTitles === true; const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : ""; const label = typeof opts.label === "string" ? opts.label.trim() : ""; const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : ""; @@ -400,6 +448,7 @@ export function listSessionsFromStore(params: { const deliveryFields = normalizeSessionDeliveryFields(entry); return { key, + entry, kind: classifySessionKey(key, entry), label: entry?.label, displayName, @@ -429,7 +478,7 @@ export function listSessionsFromStore(params: { lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel, lastTo: deliveryFields.lastTo ?? entry?.lastTo, lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId, - } satisfies GatewaySessionRow; + }; }) .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); @@ -443,11 +492,25 @@ export function listSessionsFromStore(params: { sessions = sessions.slice(0, limit); } + const finalSessions: GatewaySessionRow[] = sessions.map((s) => { + const { entry, ...rest } = s; + let derivedTitle: string | undefined; + if (includeDerivedTitles && entry?.sessionId) { + const firstUserMsg = readFirstUserMessageFromTranscript( + entry.sessionId, + storePath, + entry.sessionFile, + ); + derivedTitle = deriveSessionTitle(entry, firstUserMsg); + } + return { ...rest, derivedTitle } satisfies GatewaySessionRow; + }); + return { ts: now, path: storePath, - count: sessions.length, + count: finalSessions.length, defaults: getSessionDefaults(cfg), - sessions, + sessions: finalSessions, }; } diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 4735a0ff4..08d1eeb3a 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -13,6 +13,7 @@ export type GatewaySessionRow = { kind: "direct" | "group" | "global" | "unknown"; label?: string; displayName?: string; + derivedTitle?: string; channel?: string; subject?: string; groupChannel?: string;