Files

127 lines
3.9 KiB
JavaScript

//
// sessions-store.js — reading Claude Code's own session history.
//
// Claude Code writes one JSONL transcript per session under
// ~/.claude/projects/<slug>/<session-id>.jsonl, where the slug is the project
// directory with path separators and dots flattened to dashes. Listing that
// directory is enough to offer a "resume" picker without shelling out to the
// interactive one.
//
const { log, oneLine } = require("./util.js");
/** "3m ago", "2h ago", "yesterday", "4d ago", or a date for anything older. */
function relativeTime(date) {
const seconds = Math.max(0, (Date.now() - date.getTime()) / 1000);
if (seconds < 90) return "just now";
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days === 1) return "yesterday";
if (days < 14) return `${days}d ago`;
return date.toLocaleDateString();
}
function projectSlug(path) {
return String(path).replace(/[/.]/g, "-");
}
function projectDirectory(workspacePath) {
const home = nova.environment["HOME"];
if (!home || !workspacePath) return null;
return nova.path.join(home, ".claude", "projects", projectSlug(workspacePath));
}
/** First user prompt in a transcript, used as the picker label. */
function firstPrompt(filePath) {
let file = null;
try {
file = nova.fs.open(filePath, "r");
for (let i = 0; i < 40; i++) {
const line = file.readline();
if (!line) break;
let record;
try {
record = JSON.parse(line);
} catch (_) {
continue;
}
if (record.type !== "user" || !record.message) continue;
if (record.isMeta || record.isSynthetic) continue;
const content = record.message.content;
if (typeof content === "string") {
if (content.trim()) return oneLine(content, 70);
} else if (Array.isArray(content)) {
for (const block of content) {
if (block && block.type === "text" && block.text && block.text.trim()) {
return oneLine(block.text, 70);
}
}
}
}
} catch (err) {
log("could not read session transcript:", filePath, err);
} finally {
if (file) {
try {
file.close();
} catch (_) {
/* already closed */
}
}
}
return null;
}
/**
* Recent sessions for a workspace, newest first.
* Returns [{ id, path, modifiedAt, label }].
*/
function recentSessions(workspacePath, limit = 25) {
const directory = projectDirectory(workspacePath);
if (!directory) return [];
let names;
try {
if (!nova.fs.access(directory, nova.fs.R_OK)) return [];
names = nova.fs.listdir(directory);
} catch (err) {
log("no session history:", err);
return [];
}
const sessions = [];
for (const name of names) {
if (!name.endsWith(".jsonl")) continue;
const filePath = nova.path.join(directory, name);
const stats = nova.fs.stat(filePath);
if (!stats || !stats.isFile() || stats.size === 0) continue;
sessions.push({
id: name.slice(0, -".jsonl".length),
path: filePath,
modifiedAt: stats.mtime,
size: stats.size,
});
}
sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime());
const recent = sessions.slice(0, limit);
for (const session of recent) {
session.label = firstPrompt(session.path) || session.id.slice(0, 8);
session.relative = relativeTime(session.modifiedAt);
session.shortId = session.id.slice(0, 8);
}
return recent;
}
module.exports = { recentSessions, projectDirectory, projectSlug, relativeTime };