127 lines
3.9 KiB
JavaScript
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 };
|