feat: add heuristic session title derivation for session picker

Enable meaningful session titles via priority-based derivation:
1. displayName (user-set)
2. subject (group name)
3. First user message (truncated to 60 chars)
4. sessionId prefix + date fallback

Opt-in via includeDerivedTitles param to avoid perf impact on
regular listing. Reads only first 10 lines of transcript files.

Closes #1161
This commit is contained in:
CJ Winslow
2026-01-18 02:18:50 -08:00
committed by Peter Steinberger
parent 4fda10c508
commit 83d5e30027
6 changed files with 358 additions and 3 deletions

View File

@@ -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),

View File

@@ -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");
});
});

View File

@@ -79,3 +79,61 @@ export function capArrayByJsonBytes<T>(
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;
}

View File

@@ -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");
});
});

View File

@@ -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,
};
}

View File

@@ -13,6 +13,7 @@ export type GatewaySessionRow = {
kind: "direct" | "group" | "global" | "unknown";
label?: string;
displayName?: string;
derivedTitle?: string;
channel?: string;
subject?: string;
groupChannel?: string;