Read the final user/assistant message from session transcripts and display it in the picker alongside the session update time. Allows quick previews of what's in each session without opening it.
190 lines
5.4 KiB
TypeScript
190 lines
5.4 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
|
|
|
export function readSessionMessages(
|
|
sessionId: string,
|
|
storePath: string | undefined,
|
|
sessionFile?: string,
|
|
): unknown[] {
|
|
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile);
|
|
|
|
const filePath = candidates.find((p) => fs.existsSync(p));
|
|
if (!filePath) return [];
|
|
|
|
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/);
|
|
const messages: unknown[] = [];
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const parsed = JSON.parse(line);
|
|
if (parsed?.message) {
|
|
messages.push(parsed.message);
|
|
}
|
|
} catch {
|
|
// ignore bad lines
|
|
}
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
export function resolveSessionTranscriptCandidates(
|
|
sessionId: string,
|
|
storePath: string | undefined,
|
|
sessionFile?: string,
|
|
agentId?: string,
|
|
): string[] {
|
|
const candidates: string[] = [];
|
|
if (sessionFile) candidates.push(sessionFile);
|
|
if (storePath) {
|
|
const dir = path.dirname(storePath);
|
|
candidates.push(path.join(dir, `${sessionId}.jsonl`));
|
|
}
|
|
if (agentId) {
|
|
candidates.push(resolveSessionTranscriptPath(sessionId, agentId));
|
|
}
|
|
candidates.push(path.join(os.homedir(), ".clawdbot", "sessions", `${sessionId}.jsonl`));
|
|
return candidates;
|
|
}
|
|
|
|
export function archiveFileOnDisk(filePath: string, reason: string): string {
|
|
const ts = new Date().toISOString().replaceAll(":", "-");
|
|
const archived = `${filePath}.${reason}.${ts}`;
|
|
fs.renameSync(filePath, archived);
|
|
return archived;
|
|
}
|
|
|
|
function jsonUtf8Bytes(value: unknown): number {
|
|
try {
|
|
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
} catch {
|
|
return Buffer.byteLength(String(value), "utf8");
|
|
}
|
|
}
|
|
|
|
export function capArrayByJsonBytes<T>(
|
|
items: T[],
|
|
maxBytes: number,
|
|
): { items: T[]; bytes: number } {
|
|
if (items.length === 0) return { items, bytes: 2 };
|
|
const parts = items.map((item) => jsonUtf8Bytes(item));
|
|
let bytes = 2 + parts.reduce((a, b) => a + b, 0) + (items.length - 1);
|
|
let start = 0;
|
|
while (bytes > maxBytes && start < items.length - 1) {
|
|
bytes -= parts[start] + 1;
|
|
start += 1;
|
|
}
|
|
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;
|
|
}
|
|
|
|
const LAST_MSG_MAX_BYTES = 16384;
|
|
const LAST_MSG_MAX_LINES = 20;
|
|
|
|
export function readLastMessagePreviewFromTranscript(
|
|
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 stat = fs.fstatSync(fd);
|
|
const size = stat.size;
|
|
if (size === 0) return null;
|
|
|
|
const readStart = Math.max(0, size - LAST_MSG_MAX_BYTES);
|
|
const readLen = Math.min(size, LAST_MSG_MAX_BYTES);
|
|
const buf = Buffer.alloc(readLen);
|
|
fs.readSync(fd, buf, 0, readLen, readStart);
|
|
|
|
const chunk = buf.toString("utf-8");
|
|
const lines = chunk.split(/\r?\n/).filter((l) => l.trim());
|
|
const tailLines = lines.slice(-LAST_MSG_MAX_LINES);
|
|
|
|
for (let i = tailLines.length - 1; i >= 0; i--) {
|
|
const line = tailLines[i];
|
|
try {
|
|
const parsed = JSON.parse(line);
|
|
const msg = parsed?.message as TranscriptMessage | undefined;
|
|
if (msg?.role === "user" || msg?.role === "assistant") {
|
|
const text = extractTextFromContent(msg.content);
|
|
if (text) return text;
|
|
}
|
|
} catch {
|
|
// skip malformed
|
|
}
|
|
}
|
|
} catch {
|
|
// file error
|
|
} finally {
|
|
if (fd !== null) fs.closeSync(fd);
|
|
}
|
|
return null;
|
|
}
|