|
|
|
|
@@ -4,17 +4,19 @@ import { randomUUID } from "crypto";
|
|
|
|
|
import {
|
|
|
|
|
appendFileSync,
|
|
|
|
|
closeSync,
|
|
|
|
|
createReadStream,
|
|
|
|
|
existsSync,
|
|
|
|
|
mkdirSync,
|
|
|
|
|
openSync,
|
|
|
|
|
readdirSync,
|
|
|
|
|
readFileSync,
|
|
|
|
|
readSync,
|
|
|
|
|
statSync,
|
|
|
|
|
writeFileSync,
|
|
|
|
|
} from "fs";
|
|
|
|
|
import { readdir, readFile, stat } from "fs/promises";
|
|
|
|
|
import { readdir, stat } from "fs/promises";
|
|
|
|
|
import { join, resolve } from "path";
|
|
|
|
|
import { createInterface } from "readline";
|
|
|
|
|
import { StringDecoder } from "string_decoder";
|
|
|
|
|
import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts";
|
|
|
|
|
import { normalizePath, resolvePath } from "../utils/paths.ts";
|
|
|
|
|
import {
|
|
|
|
|
@@ -448,29 +450,57 @@ export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultA
|
|
|
|
|
return sessionDir;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SESSION_READ_BUFFER_SIZE = 1024 * 1024;
|
|
|
|
|
|
|
|
|
|
function parseSessionEntryLine(line: string): FileEntry | null {
|
|
|
|
|
if (!line.trim()) return null;
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(line) as FileEntry;
|
|
|
|
|
} catch {
|
|
|
|
|
// Skip malformed lines
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Exported for testing */
|
|
|
|
|
export function loadEntriesFromFile(filePath: string): FileEntry[] {
|
|
|
|
|
const resolvedFilePath = normalizePath(filePath);
|
|
|
|
|
if (!existsSync(resolvedFilePath)) return [];
|
|
|
|
|
|
|
|
|
|
const content = readFileSync(resolvedFilePath, "utf8");
|
|
|
|
|
const entries: FileEntry[] = [];
|
|
|
|
|
const lines = content.trim().split("\n");
|
|
|
|
|
const fd = openSync(resolvedFilePath, "r");
|
|
|
|
|
try {
|
|
|
|
|
const decoder = new StringDecoder("utf8");
|
|
|
|
|
const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);
|
|
|
|
|
let pending = "";
|
|
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
if (!line.trim()) continue;
|
|
|
|
|
try {
|
|
|
|
|
const entry = JSON.parse(line) as FileEntry;
|
|
|
|
|
entries.push(entry);
|
|
|
|
|
} catch {
|
|
|
|
|
// Skip malformed lines
|
|
|
|
|
while (true) {
|
|
|
|
|
const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
|
|
|
|
|
if (bytesRead === 0) break;
|
|
|
|
|
|
|
|
|
|
pending += decoder.write(buffer.subarray(0, bytesRead));
|
|
|
|
|
let lineStart = 0;
|
|
|
|
|
let newlineIndex = pending.indexOf("\n", lineStart);
|
|
|
|
|
while (newlineIndex !== -1) {
|
|
|
|
|
const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex));
|
|
|
|
|
if (entry) entries.push(entry);
|
|
|
|
|
lineStart = newlineIndex + 1;
|
|
|
|
|
newlineIndex = pending.indexOf("\n", lineStart);
|
|
|
|
|
}
|
|
|
|
|
pending = pending.slice(lineStart);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pending += decoder.end();
|
|
|
|
|
const finalEntry = parseSessionEntryLine(pending);
|
|
|
|
|
if (finalEntry) entries.push(finalEntry);
|
|
|
|
|
} finally {
|
|
|
|
|
closeSync(fd);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate session header
|
|
|
|
|
if (entries.length === 0) return entries;
|
|
|
|
|
const header = entries[0];
|
|
|
|
|
if (header.type !== "session" || typeof (header as any).id !== "string") {
|
|
|
|
|
if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -542,80 +572,59 @@ function extractTextContent(message: Message): string {
|
|
|
|
|
.join(" ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getLastActivityTime(entries: FileEntry[]): number | undefined {
|
|
|
|
|
let lastActivityTime: number | undefined;
|
|
|
|
|
function getMessageActivityTime(entry: SessionMessageEntry): number | undefined {
|
|
|
|
|
const message = entry.message;
|
|
|
|
|
if (!isMessageWithContent(message)) return undefined;
|
|
|
|
|
if (message.role !== "user" && message.role !== "assistant") return undefined;
|
|
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (entry.type !== "message") continue;
|
|
|
|
|
|
|
|
|
|
const message = (entry as SessionMessageEntry).message;
|
|
|
|
|
if (!isMessageWithContent(message)) continue;
|
|
|
|
|
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
|
|
|
|
|
|
|
|
const msgTimestamp = (message as { timestamp?: number }).timestamp;
|
|
|
|
|
if (typeof msgTimestamp === "number") {
|
|
|
|
|
lastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const entryTimestamp = (entry as SessionEntryBase).timestamp;
|
|
|
|
|
if (typeof entryTimestamp === "string") {
|
|
|
|
|
const t = new Date(entryTimestamp).getTime();
|
|
|
|
|
if (!Number.isNaN(t)) {
|
|
|
|
|
lastActivityTime = Math.max(lastActivityTime ?? 0, t);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const msgTimestamp = (message as { timestamp?: number }).timestamp;
|
|
|
|
|
if (typeof msgTimestamp === "number") {
|
|
|
|
|
return msgTimestamp;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return lastActivityTime;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getSessionModifiedDate(entries: FileEntry[], header: SessionHeader, statsMtime: Date): Date {
|
|
|
|
|
const lastActivityTime = getLastActivityTime(entries);
|
|
|
|
|
if (typeof lastActivityTime === "number" && lastActivityTime > 0) {
|
|
|
|
|
return new Date(lastActivityTime);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;
|
|
|
|
|
return !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime;
|
|
|
|
|
const t = new Date(entry.timestamp).getTime();
|
|
|
|
|
return Number.isNaN(t) ? undefined : t;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
|
|
|
|
|
try {
|
|
|
|
|
const content = await readFile(filePath, "utf8");
|
|
|
|
|
const entries: FileEntry[] = [];
|
|
|
|
|
const lines = content.trim().split("\n");
|
|
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
if (!line.trim()) continue;
|
|
|
|
|
try {
|
|
|
|
|
entries.push(JSON.parse(line) as FileEntry);
|
|
|
|
|
} catch {
|
|
|
|
|
// Skip malformed lines
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entries.length === 0) return null;
|
|
|
|
|
const header = entries[0];
|
|
|
|
|
if (header.type !== "session") return null;
|
|
|
|
|
|
|
|
|
|
const stats = await stat(filePath);
|
|
|
|
|
let header: SessionHeader | null = null;
|
|
|
|
|
let messageCount = 0;
|
|
|
|
|
let firstMessage = "";
|
|
|
|
|
const allMessages: string[] = [];
|
|
|
|
|
let name: string | undefined;
|
|
|
|
|
let lastActivityTime: number | undefined;
|
|
|
|
|
|
|
|
|
|
const rl = createInterface({
|
|
|
|
|
input: createReadStream(filePath, { encoding: "utf8" }),
|
|
|
|
|
crlfDelay: Infinity,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for await (const line of rl) {
|
|
|
|
|
const entry = parseSessionEntryLine(line);
|
|
|
|
|
if (!entry) continue;
|
|
|
|
|
|
|
|
|
|
if (!header) {
|
|
|
|
|
if (entry.type !== "session") return null;
|
|
|
|
|
header = entry;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
// Extract session name (use latest, including explicit clears)
|
|
|
|
|
if (entry.type === "session_info") {
|
|
|
|
|
const infoEntry = entry as SessionInfoEntry;
|
|
|
|
|
name = infoEntry.name?.trim() || undefined;
|
|
|
|
|
name = entry.name?.trim() || undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entry.type !== "message") continue;
|
|
|
|
|
messageCount++;
|
|
|
|
|
|
|
|
|
|
const message = (entry as SessionMessageEntry).message;
|
|
|
|
|
const activityTime = getMessageActivityTime(entry);
|
|
|
|
|
if (typeof activityTime === "number") {
|
|
|
|
|
lastActivityTime = Math.max(lastActivityTime ?? 0, activityTime);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const message = entry.message;
|
|
|
|
|
if (!isMessageWithContent(message)) continue;
|
|
|
|
|
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
|
|
|
|
|
|
|
|
@@ -628,18 +637,25 @@ async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cwd = typeof (header as SessionHeader).cwd === "string" ? (header as SessionHeader).cwd : "";
|
|
|
|
|
const parentSessionPath = (header as SessionHeader).parentSession;
|
|
|
|
|
if (!header) return null;
|
|
|
|
|
|
|
|
|
|
const modified = getSessionModifiedDate(entries, header as SessionHeader, stats.mtime);
|
|
|
|
|
const cwd = typeof header.cwd === "string" ? header.cwd : "";
|
|
|
|
|
const parentSessionPath = header.parentSession;
|
|
|
|
|
const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;
|
|
|
|
|
const modified =
|
|
|
|
|
typeof lastActivityTime === "number" && lastActivityTime > 0
|
|
|
|
|
? new Date(lastActivityTime)
|
|
|
|
|
: !Number.isNaN(headerTime)
|
|
|
|
|
? new Date(headerTime)
|
|
|
|
|
: stats.mtime;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
path: filePath,
|
|
|
|
|
id: (header as SessionHeader).id,
|
|
|
|
|
id: header.id,
|
|
|
|
|
cwd,
|
|
|
|
|
name,
|
|
|
|
|
parentSessionPath,
|
|
|
|
|
created: new Date((header as SessionHeader).timestamp),
|
|
|
|
|
created: new Date(header.timestamp),
|
|
|
|
|
modified,
|
|
|
|
|
messageCount,
|
|
|
|
|
firstMessage: firstMessage || "(no messages)",
|
|
|
|
|
@@ -855,8 +871,14 @@ export class SessionManager {
|
|
|
|
|
|
|
|
|
|
private _rewriteFile(): void {
|
|
|
|
|
if (!this.persist || !this.sessionFile) return;
|
|
|
|
|
const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`;
|
|
|
|
|
writeFileSync(this.sessionFile, content);
|
|
|
|
|
const fd = openSync(this.sessionFile, "w");
|
|
|
|
|
try {
|
|
|
|
|
for (const entry of this.fileEntries) {
|
|
|
|
|
writeFileSync(fd, `${JSON.stringify(entry)}\n`);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
closeSync(fd);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isPersisted(): boolean {
|
|
|
|
|
|