First slice of making Claude's session store the source of truth. Adds a reader (api/chat/claude-sessions.ts) that lists sessions straight from Claude's JSONL transcripts under $HOME/.claude/projects/<cwd-slug>/, plus GET /chat/sessions over a dedicated per-user claude_sessions working directory. No bookkeeping layer — Claude's files are authoritative. Verified against real transcripts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
111 lines
3.7 KiB
TypeScript
111 lines
3.7 KiB
TypeScript
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { DATA_PATH } from '../../data-path';
|
|
|
|
// ── Claude session store (source of truth) ──
|
|
// The `claude` CLI persists every session as a JSONL transcript at
|
|
// $HOME/.claude/projects/<slug>/<session-uuid>.jsonl
|
|
// where <slug> is the working directory with every non-alphanumeric char replaced by '-'.
|
|
// A user's claude process runs with HOME = DATA_PATH/<email>/home (see sidecar/claude/user-instance.ts),
|
|
// so we read transcripts from there. We never maintain our own copy — Claude's files are authoritative.
|
|
|
|
/** Dedicated working directory for /chat sessions, so they form their own Claude "project" group. */
|
|
export const getClaudeSessionsCwd = (email: string): string => join(DATA_PATH, email, 'claude_sessions');
|
|
|
|
const claudeProjectsDir = (email: string): string => join(DATA_PATH, email, 'home', '.claude', 'projects');
|
|
|
|
/** Claude's folder name for a working directory. */
|
|
export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
|
|
export type ClaudeSessionSummary = {
|
|
id: string; // session uuid (= transcript filename)
|
|
title: string; // first human message, trimmed
|
|
cwd: string;
|
|
createdAt: string; // ISO, first entry
|
|
updatedAt: string; // ISO, last entry
|
|
messageCount: number; // human + assistant turns
|
|
};
|
|
|
|
/** Pull the plain text out of a transcript entry's `message.content` (string or content-block array). */
|
|
function entryText(message: unknown): string {
|
|
if (!message || typeof message !== 'object') return '';
|
|
const content = (message as { content?: unknown }).content;
|
|
if (typeof content === 'string') return content;
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.map((block) => (block && typeof block === 'object' && (block as { type?: string }).type === 'text' ? (block as { text?: string }).text ?? '' : ''))
|
|
.join('')
|
|
.trim();
|
|
}
|
|
return '';
|
|
}
|
|
|
|
type Entry = {
|
|
type?: string;
|
|
timestamp?: string;
|
|
sessionId?: string;
|
|
cwd?: string;
|
|
message?: { role?: string; content?: unknown };
|
|
isMeta?: boolean;
|
|
};
|
|
|
|
function summarizeTranscript(filePath: string, id: string): ClaudeSessionSummary | null {
|
|
let raw: string;
|
|
try {
|
|
raw = readFileSync(filePath, 'utf-8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
let title = '';
|
|
let cwd = '';
|
|
let firstTs = '';
|
|
let lastTs = '';
|
|
let messageCount = 0;
|
|
|
|
for (const line of raw.split('\n')) {
|
|
if (!line.trim()) continue;
|
|
let entry: Entry;
|
|
try {
|
|
entry = JSON.parse(line) as Entry;
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (entry.cwd && !cwd) cwd = entry.cwd;
|
|
if (entry.timestamp) {
|
|
if (!firstTs) firstTs = entry.timestamp;
|
|
lastTs = entry.timestamp;
|
|
}
|
|
if (entry.type === 'user' || entry.type === 'assistant') {
|
|
messageCount += 1;
|
|
if (!title && entry.type === 'user' && !entry.isMeta) {
|
|
title = entryText(entry.message).split('\n')[0]!.trim();
|
|
}
|
|
}
|
|
}
|
|
|
|
const mtime = statSync(filePath).mtime.toISOString();
|
|
return {
|
|
id,
|
|
title: title || '(untitled)',
|
|
cwd,
|
|
createdAt: firstTs || mtime,
|
|
updatedAt: lastTs || mtime,
|
|
messageCount,
|
|
};
|
|
}
|
|
|
|
/** List sessions Claude has stored for a given working directory, newest first. */
|
|
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
|
|
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
|
|
if (!existsSync(dir)) return [];
|
|
|
|
const sessions: ClaudeSessionSummary[] = [];
|
|
for (const file of readdirSync(dir)) {
|
|
if (!file.endsWith('.jsonl')) continue;
|
|
const summary = summarizeTranscript(join(dir, file), file.replace(/\.jsonl$/, ''));
|
|
if (summary) sessions.push(summary);
|
|
}
|
|
return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
}
|