import { readdir } from "node:fs/promises";
import { join, resolve } from "node:path";
import { getPilotProjectChatDir } from "../../pilot/index.js";
import { readSessionLite, type SessionLiteFile } from "./SessionLiteReader.js";
const ALWAYS_ON_AUXILIARY_PREFIXES = [
"always-on-discovery:",
"always-on-workspace:",
"always-on-report:",
];
function isInternalSession(sessionId: string): boolean {
return ALWAYS_ON_AUXILIARY_PREFIXES.some((p) => sessionId.startsWith(p));
}
export type SessionInfo = {
sessionId: string;
summary: string;
lastModified: number;
fileSize?: number;
customTitle?: string;
aiTitle?: string;
firstPrompt?: string;
cwd?: string;
tag?: string;
createdAt?: number;
};
export type ListProjectSessionsOptions = {
projectRoot: string;
pilotHome: string;
limit?: number;
offset?: number;
includeInternal?: boolean;
};
export async function listProjectSessions(options: ListProjectSessionsOptions): Promise<SessionInfo[]> {
const chatDir = getPilotProjectChatDir(options.projectRoot, options.pilotHome);
let names: string[];
try {
names = await readdir(chatDir);
} catch {
return [];
}
const sessions: SessionInfo[] = [];
for (const name of names) {
if (!name.endsWith(".jsonl")) {
continue;
}
const lite = await readSessionLite(join(chatDir, name));
if (!lite) {
continue;
}
const sessionId = name.slice(0, -".jsonl".length);
if (!options.includeInternal && isInternalSession(sessionId)) {
continue;
}
const info = parseSessionInfoFromLite(sessionId, lite, options.projectRoot);
if (info) {
sessions.push(info);
}
}
sessions.sort((left, right) => right.lastModified - left.lastModified);
const offset = Math.max(0, options.offset ?? 0);
const limit = options.limit ?? sessions.length;
return sessions.slice(offset, limit === 0 ? undefined : offset + limit);
}
export function parseSessionInfoFromLite(
sessionId: string,
lite: SessionLiteFile,
projectRoot?: string,
): SessionInfo | null {
const source = `${lite.head}\n${lite.tail}`;
const customTitle = lastMetadataStringField(source, "title");
const aiTitle = lastMetadataStringField(source, "aiTitle");
const tag = lastMetadataStringField(source, "tag");
const firstPrompt = firstAcceptedInputText(lite.head);
const lastPrompt = lastAcceptedInputText(lite.tail) ?? firstPrompt;
const summary = customTitle ?? aiTitle ?? lastPrompt;
if (!summary) {
return null;
}
const firstCreatedAt = firstJsonStringField(lite.head, "createdAt");
return {
sessionId,
summary,
lastModified: lite.mtime,
fileSize: lite.size,
customTitle,
aiTitle,
firstPrompt,
cwd: projectRoot,
tag,
createdAt: firstCreatedAt ? Date.parse(firstCreatedAt) : undefined,
};
}
function firstAcceptedInputText(head: string): string | undefined {
for (const line of head.split(/\r?\n/)) {
if (!line.includes('"type":"accepted_input"')) {
continue;
}
try {
const entry = JSON.parse(line) as {
messages?: Array<{ content?: Array<{ type?: string; text?: string }> }>;
};
const text = entry.messages?.flatMap((message) => message.content ?? []).find((block) => block.type === "text")?.text;
if (text?.trim()) {
return text.trim();
}
} catch {
return undefined;
}
}
return undefined;
}
function lastAcceptedInputText(tail: string): string | undefined {
let last: string | undefined;
for (const line of tail.split(/\r?\n/)) {
if (!line.includes('"type":"accepted_input"')) {
continue;
}
try {
const entry = JSON.parse(line) as {
messages?: Array<{ content?: Array<{ type?: string; text?: string }> }>;
};
const text = entry.messages?.flatMap((message) => message.content ?? []).find((block) => block.type === "text")?.text;
if (text?.trim()) {
last = text.trim();
}
} catch {
}
}
return last;
}
function firstJsonStringField(source: string, field: string): string | undefined {
const match = source.match(new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*"((?:\\\\.|[^"])*)"`));
return match?.[1] ? unescapeJsonString(match[1]) : undefined;
}
function lastJsonStringField(source: string, field: string): string | undefined {
const regex = new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*"((?:\\\\.|[^"])*)"`, "g");
let value: string | undefined;
for (const match of source.matchAll(regex)) {
if (match[1]) {
value = unescapeJsonString(match[1]);
}
}
return value;
}
* Like {@link lastJsonStringField} but restricted to JSONL lines whose
* `"type"` is `"session_metadata"`. The old approach scanned the entire
* raw head+tail text for `"title"`, which would pick up stray `"title"`
* keys from tool-call inputs, web-search results, or activity frames —
* causing the sidebar to display an intermediate tool argument instead
* of the actual session title.
*/
function lastMetadataStringField(source: string, field: string): string | undefined {
const fieldRegex = new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*"((?:\\\\.|[^"])*)"`);
let value: string | undefined;
for (const line of source.split(/\r?\n/)) {
if (!line.includes('"session_metadata"')) continue;
const match = line.match(fieldRegex);
if (match?.[1]) {
value = unescapeJsonString(match[1]);
}
}
return value;
}
function unescapeJsonString(value: string): string {
return JSON.parse(`"${value}"`) as string;
}
function escapeRegExp(value: string): string {
return value.replace(/[\\^$*+?.()|[\]{}]/g, "\\$&");
}
export type ListAllSessionsOptions = {
pilotHome: string;
limit?: number;
offset?: number;
includeInternal?: boolean;
};
* List sessions across **all** projects under `{pilotHome}/projects/`. Each
* project directory is scanned for `.jsonl` files in its `chats/` subfolder.
* Results are sorted by lastModified descending (most-recent first), then
* paginated via `limit` / `offset`.
*/
export async function listAllSessions(options: ListAllSessionsOptions): Promise<SessionInfo[]> {
const projectsDir = resolve(options.pilotHome, "projects");
let projectIds: string[];
try {
projectIds = await readdir(projectsDir);
} catch {
return [];
}
const all: SessionInfo[] = [];
for (const projectId of projectIds) {
const chatDir = join(projectsDir, projectId, "chats");
let names: string[];
try {
names = await readdir(chatDir);
} catch {
continue;
}
for (const name of names) {
if (!name.endsWith(".jsonl")) continue;
const sessionId = name.slice(0, -".jsonl".length);
if (!options.includeInternal && isInternalSession(sessionId)) continue;
const lite = await readSessionLite(join(chatDir, name));
if (!lite) continue;
const info = parseSessionInfoFromLite(sessionId, lite);
if (info) {
info.cwd = projectId;
all.push(info);
}
}
}
all.sort((left, right) => right.lastModified - left.lastModified);
const offset = Math.max(0, options.offset ?? 0);
const limit = options.limit ?? all.length;
return all.slice(offset, limit === 0 ? undefined : offset + limit);
}
export type SearchSessionsByTitleOptions = {
projectRoot: string;
pilotHome: string;
query: string;
limit?: number;
includeInternal?: boolean;
};
* Search sessions within a project by matching `query` (case-insensitive
* substring) against `customTitle`, `aiTitle`, and `firstPrompt`. Returns
* results sorted by lastModified descending.
*/
export async function searchSessionsByTitle(options: SearchSessionsByTitleOptions): Promise<SessionInfo[]> {
const chatDir = getPilotProjectChatDir(options.projectRoot, options.pilotHome);
let names: string[];
try {
names = await readdir(chatDir);
} catch {
return [];
}
const needle = options.query.toLowerCase();
const results: SessionInfo[] = [];
for (const name of names) {
if (!name.endsWith(".jsonl")) continue;
const sessionId = name.slice(0, -".jsonl".length);
if (!options.includeInternal && isInternalSession(sessionId)) continue;
const lite = await readSessionLite(join(chatDir, name));
if (!lite) continue;
const info = parseSessionInfoFromLite(sessionId, lite, options.projectRoot);
if (!info) continue;
const haystack = [info.customTitle, info.aiTitle, info.firstPrompt]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (haystack.includes(needle)) {
results.push(info);
}
}
results.sort((left, right) => right.lastModified - left.lastModified);
return options.limit ? results.slice(0, options.limit) : results;
}