chat: read the /chat session list from Claude's own transcript store
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>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
import { createRouter } from '../../create-router';
|
||||||
|
import { getClaudeSessionsCwd, listClaudeSessions } from './claude-sessions';
|
||||||
|
|
||||||
|
export const chatRouter = createRouter();
|
||||||
|
|
||||||
|
// GET /chat/sessions — the /chat route's conversations, read straight from Claude's own transcript
|
||||||
|
// store for the dedicated claude_sessions working directory (Claude is the source of truth).
|
||||||
|
chatRouter.get('/sessions', (ctx) => {
|
||||||
|
const email = ctx.get('user').email;
|
||||||
|
const sessions = listClaudeSessions(email, getClaudeSessionsCwd(email));
|
||||||
|
return ctx.json({ sessions });
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import { desktopRouter } from './api/desktop/rest';
|
|||||||
import { appsRouter, appServeRouter } from './api/apps';
|
import { appsRouter, appServeRouter } from './api/apps';
|
||||||
import { bugReportRouter } from './api/bug-report/bug-report';
|
import { bugReportRouter } from './api/bug-report/bug-report';
|
||||||
import { savedSessionsRouter } from './api/saved-sessions/saved-sessions';
|
import { savedSessionsRouter } from './api/saved-sessions/saved-sessions';
|
||||||
|
import { chatRouter } from './api/chat/chat';
|
||||||
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
||||||
import { broadcastPanelRefresh } from './api/terminal/websocket';
|
import { broadcastPanelRefresh } from './api/terminal/websocket';
|
||||||
import { CustomError } from './custom-errors';
|
import { CustomError } from './custom-errors';
|
||||||
@@ -103,6 +104,7 @@ protectedRouter.route('/browser', browserRouter);
|
|||||||
protectedRouter.route('/apps', appsRouter);
|
protectedRouter.route('/apps', appsRouter);
|
||||||
protectedRouter.route('/bug-report', bugReportRouter);
|
protectedRouter.route('/bug-report', bugReportRouter);
|
||||||
protectedRouter.route('/saved-sessions', savedSessionsRouter);
|
protectedRouter.route('/saved-sessions', savedSessionsRouter);
|
||||||
|
protectedRouter.route('/chat', chatRouter);
|
||||||
protectedRouter.route('/pipeline-jobs', pipelineJobsRouter);
|
protectedRouter.route('/pipeline-jobs', pipelineJobsRouter);
|
||||||
protectedRouter.route('/jobs', pipelineJobsRouter); // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
|
protectedRouter.route('/jobs', pipelineJobsRouter); // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
|
||||||
desktopRouter.use(superAdminMiddleware);
|
desktopRouter.use(superAdminMiddleware);
|
||||||
|
|||||||
Reference in New Issue
Block a user