claude-session-viewer 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>Claude Session Viewer</title>
8
- <script type="module" crossorigin src="/assets/index-cY4kqyQm.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-i0yZUese.css">
8
+ <script type="module" crossorigin src="/assets/index-KEbXAXOS.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DvK33tag.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Claude API configuration
3
+ * Model pricing and related settings
4
+ */
5
+ // Claude API Pricing (as of January 2025)
6
+ // Prices per 1M tokens
7
+ export const PRICING = {
8
+ 'claude-sonnet-4-5-20250929': {
9
+ input: 3.0,
10
+ output: 15.0,
11
+ cacheCreation: 3.75,
12
+ cacheRead: 0.30
13
+ },
14
+ 'claude-sonnet-4-20250514': {
15
+ input: 3.0,
16
+ output: 15.0,
17
+ cacheCreation: 3.75,
18
+ cacheRead: 0.30
19
+ },
20
+ 'claude-opus-4-20250514': {
21
+ input: 15.0,
22
+ output: 75.0,
23
+ cacheCreation: 18.75,
24
+ cacheRead: 1.50
25
+ },
26
+ 'claude-haiku-4-20250515': {
27
+ input: 0.80,
28
+ output: 4.0,
29
+ cacheCreation: 1.0,
30
+ cacheRead: 0.08
31
+ }
32
+ };
33
+ // Default pricing model
34
+ export const DEFAULT_PRICING_MODEL = 'claude-sonnet-4-5-20250929';
35
+ // Weekday names for statistics
36
+ export const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
@@ -0,0 +1,40 @@
1
+ import { readdir, stat } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ /**
5
+ * Project file system operations
6
+ * Handles reading project directories from ~/.claude/projects
7
+ */
8
+ /**
9
+ * List all project directories
10
+ */
11
+ export async function listProjects(projectsDir) {
12
+ const entries = await readdir(projectsDir);
13
+ const projects = [];
14
+ for (const entry of entries) {
15
+ const entryPath = join(projectsDir, entry);
16
+ const entryStat = await stat(entryPath);
17
+ if (entryStat.isDirectory()) {
18
+ projects.push(entry);
19
+ }
20
+ }
21
+ return projects;
22
+ }
23
+ /**
24
+ * Get project path from project name
25
+ */
26
+ export function getProjectPath(projectsDir, projectName) {
27
+ return join(projectsDir, projectName);
28
+ }
29
+ /**
30
+ * Remove user's home directory prefix from project directory name
31
+ * e.g., "-Users-hanyeol-Projects-foo" → "Projects-foo"
32
+ */
33
+ export function getProjectDisplayName(projectDirName) {
34
+ const userHomePath = homedir().split('/').filter(Boolean).join('-');
35
+ const prefix = `-${userHomePath}-`;
36
+ if (projectDirName.startsWith(prefix)) {
37
+ return projectDirName.slice(prefix.length);
38
+ }
39
+ return projectDirName;
40
+ }
@@ -0,0 +1,41 @@
1
+ import { stat } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { listProjects, getProjectDisplayName } from './repository.js';
4
+ import { getProjectSessions, sortSessionsByTimestamp } from '../sessions/service.js';
5
+ /**
6
+ * Project service
7
+ * Orchestrates project and session loading
8
+ */
9
+ /**
10
+ * Sort project groups by last activity (most recent first)
11
+ */
12
+ export function sortProjectsByActivity(projects) {
13
+ return projects.sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime());
14
+ }
15
+ /**
16
+ * Load all projects with their sessions
17
+ */
18
+ export async function getAllProjectsWithSessions(projectsDir) {
19
+ const projects = await listProjects(projectsDir);
20
+ const projectGroups = [];
21
+ for (const project of projects) {
22
+ const projectPath = join(projectsDir, project);
23
+ const projectStat = await stat(projectPath);
24
+ if (projectStat.isDirectory()) {
25
+ const sessions = await getProjectSessions(projectPath);
26
+ if (sessions.length > 0) {
27
+ sortSessionsByTimestamp(sessions);
28
+ const displayName = getProjectDisplayName(project);
29
+ projectGroups.push({
30
+ name: project,
31
+ displayName,
32
+ sessionCount: sessions.length,
33
+ lastActivity: sessions[0].timestamp,
34
+ sessions
35
+ });
36
+ }
37
+ }
38
+ }
39
+ sortProjectsByActivity(projectGroups);
40
+ return projectGroups;
41
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Agent session mapping logic
3
+ * Handles the relationship between parent sessions and agent (Task) sessions
4
+ */
5
+ /**
6
+ * Collect agent session IDs and their descriptions from Task tool uses
7
+ */
8
+ export function collectAgentDescriptions(messages) {
9
+ const agentDescriptions = new Map();
10
+ const toolUseDescriptions = new Map();
11
+ const toolResultAgentIds = new Map();
12
+ // First pass: collect Task tool uses and their descriptions
13
+ for (const msg of messages) {
14
+ if (msg.type === 'assistant' && msg.message?.content && Array.isArray(msg.message.content)) {
15
+ for (const item of msg.message.content) {
16
+ if (item.type === 'tool_use' && item.name === 'Task' && item.input?.description) {
17
+ toolUseDescriptions.set(item.id, item.input.description);
18
+ }
19
+ }
20
+ }
21
+ // Collect agent IDs from tool results
22
+ const agentId = msg.agentId || msg.toolUseResult?.agentId;
23
+ if (agentId && msg.message?.content && Array.isArray(msg.message.content)) {
24
+ for (const item of msg.message.content) {
25
+ if (item.type === 'tool_result' && item.tool_use_id) {
26
+ toolResultAgentIds.set(item.tool_use_id, agentId);
27
+ }
28
+ }
29
+ }
30
+ }
31
+ // Second pass: map tool uses to agent IDs
32
+ for (const [toolUseId, description] of toolUseDescriptions.entries()) {
33
+ const agentId = toolResultAgentIds.get(toolUseId);
34
+ if (agentId) {
35
+ agentDescriptions.set(`agent-${agentId}`, description);
36
+ }
37
+ }
38
+ return agentDescriptions;
39
+ }
40
+ /**
41
+ * Attach agent sessions to a parent session
42
+ */
43
+ export function attachAgentSessionsToParent(session, agentDescriptions, agentSessionsMap) {
44
+ if (agentDescriptions.size === 0)
45
+ return;
46
+ session.agentSessions = [];
47
+ for (const [agentSessionId, description] of agentDescriptions) {
48
+ const agentSession = agentSessionsMap.get(agentSessionId);
49
+ if (agentSession) {
50
+ agentSession.title = description;
51
+ session.agentSessions.push(agentSession);
52
+ }
53
+ }
54
+ // Sort by timestamp (chronological order)
55
+ session.agentSessions.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
56
+ }
57
+ /**
58
+ * Find agent title from parent session messages
59
+ */
60
+ export function findAgentTitleFromParentMessages(messages, agentId) {
61
+ const agentDescriptions = collectAgentDescriptions(messages);
62
+ return agentDescriptions.get(`agent-${agentId}`) || null;
63
+ }
64
+ /**
65
+ * Inject agentId into Task tool_use content for display
66
+ */
67
+ export function injectAgentIdsIntoMessages(messages) {
68
+ const toolUseToAgentId = new Map();
69
+ // Build mapping from tool_use_id to agentId
70
+ for (const msg of messages) {
71
+ const agentId = msg.agentId || msg.toolUseResult?.agentId;
72
+ const content = msg.message?.content;
73
+ if (!agentId || !Array.isArray(content))
74
+ continue;
75
+ for (const item of content) {
76
+ if (item.type === 'tool_result' && item.tool_use_id) {
77
+ toolUseToAgentId.set(item.tool_use_id, agentId);
78
+ }
79
+ }
80
+ }
81
+ // Inject agentId into tool_use items
82
+ return messages.map((msg) => {
83
+ const content = msg.message?.content;
84
+ if (!Array.isArray(content))
85
+ return msg;
86
+ const updatedContent = content.map((item) => {
87
+ if (item.type !== 'tool_use' || item.name !== 'Task' || !item.id)
88
+ return item;
89
+ const agentId = toolUseToAgentId.get(item.id);
90
+ return agentId ? { ...item, agentId } : item;
91
+ });
92
+ return {
93
+ ...msg,
94
+ message: {
95
+ ...msg.message,
96
+ content: updatedContent
97
+ }
98
+ };
99
+ });
100
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Agent session mapping logic
3
+ * Handles the relationship between parent sessions and agent (Task) sessions
4
+ */
5
+ /**
6
+ * Collect agent session IDs and their descriptions from Task tool uses
7
+ */
8
+ export function collectAgentDescriptions(messages) {
9
+ const agentDescriptions = new Map();
10
+ const toolUseDescriptions = new Map();
11
+ const toolResultAgentIds = new Map();
12
+ // First pass: collect Task tool uses and their descriptions
13
+ for (const msg of messages) {
14
+ if (msg.type === 'assistant' && msg.message?.content && Array.isArray(msg.message.content)) {
15
+ for (const item of msg.message.content) {
16
+ if (item.type === 'tool_use' && item.name === 'Task' && item.input?.description) {
17
+ toolUseDescriptions.set(item.id, item.input.description);
18
+ }
19
+ }
20
+ }
21
+ // Collect agent IDs from tool results
22
+ const agentId = msg.agentId || msg.toolUseResult?.agentId;
23
+ if (agentId && msg.message?.content && Array.isArray(msg.message.content)) {
24
+ for (const item of msg.message.content) {
25
+ if (item.type === 'tool_result' && item.tool_use_id) {
26
+ toolResultAgentIds.set(item.tool_use_id, agentId);
27
+ }
28
+ }
29
+ }
30
+ }
31
+ // Second pass: map tool uses to agent IDs
32
+ for (const [toolUseId, description] of toolUseDescriptions.entries()) {
33
+ const agentId = toolResultAgentIds.get(toolUseId);
34
+ if (agentId) {
35
+ agentDescriptions.set(`agent-${agentId}`, description);
36
+ }
37
+ }
38
+ return agentDescriptions;
39
+ }
40
+ /**
41
+ * Attach agent sessions to a parent session
42
+ */
43
+ export function attachAgentSessionsToParent(session, agentDescriptions, agentSessionsMap) {
44
+ if (agentDescriptions.size === 0)
45
+ return;
46
+ session.agentSessions = [];
47
+ for (const [agentSessionId, description] of agentDescriptions) {
48
+ const agentSession = agentSessionsMap.get(agentSessionId);
49
+ if (agentSession) {
50
+ agentSession.title = description;
51
+ session.agentSessions.push(agentSession);
52
+ }
53
+ }
54
+ // Sort by timestamp (chronological order)
55
+ session.agentSessions.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
56
+ }
57
+ /**
58
+ * Find agent title from parent session messages
59
+ */
60
+ export function findAgentTitleFromParentMessages(messages, agentId) {
61
+ const agentDescriptions = collectAgentDescriptions(messages);
62
+ return agentDescriptions.get(`agent-${agentId}`) || null;
63
+ }
64
+ /**
65
+ * Inject agentId into Task tool_use content for display
66
+ */
67
+ export function injectAgentIdsIntoMessages(messages) {
68
+ const toolUseToAgentId = new Map();
69
+ // Build mapping from tool_use_id to agentId
70
+ for (const msg of messages) {
71
+ const agentId = msg.agentId || msg.toolUseResult?.agentId;
72
+ const content = msg.message?.content;
73
+ if (!agentId || !Array.isArray(content))
74
+ continue;
75
+ for (const item of content) {
76
+ if (item.type === 'tool_result' && item.tool_use_id) {
77
+ toolUseToAgentId.set(item.tool_use_id, agentId);
78
+ }
79
+ }
80
+ }
81
+ // Inject agentId into tool_use items
82
+ return messages.map((msg) => {
83
+ const content = msg.message?.content;
84
+ if (!Array.isArray(content))
85
+ return msg;
86
+ const updatedContent = content.map((item) => {
87
+ if (item.type !== 'tool_use' || item.name !== 'Task' || !item.id)
88
+ return item;
89
+ const agentId = toolUseToAgentId.get(item.id);
90
+ return agentId ? { ...item, agentId } : item;
91
+ });
92
+ return {
93
+ ...msg,
94
+ message: {
95
+ ...msg.message,
96
+ content: updatedContent
97
+ }
98
+ };
99
+ });
100
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Session filtering rules
3
+ * Business logic for determining which sessions to include/exclude
4
+ */
5
+ /**
6
+ * Check if a session should be skipped
7
+ * Rule: Sessions with only 1 assistant message are skipped
8
+ */
9
+ export function shouldSkipSession(messages) {
10
+ return messages.length === 1 && messages[0].type === 'assistant';
11
+ }
12
+ /**
13
+ * Check if a session ID represents an agent session
14
+ */
15
+ export function isAgentSession(sessionId) {
16
+ return sessionId.startsWith('agent-');
17
+ }
18
+ /**
19
+ * Check if a file is empty
20
+ */
21
+ export function isEmptyFile(fileSize) {
22
+ return fileSize === 0;
23
+ }
@@ -0,0 +1,33 @@
1
+ import { readdir, stat } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { parseJsonl } from '../../utils/jsonl.js';
4
+ /**
5
+ * Read a session file and return parsed messages with metadata
6
+ */
7
+ export async function readSessionFile(filePath) {
8
+ const messages = await parseJsonl(filePath);
9
+ const fileStat = await stat(filePath);
10
+ return {
11
+ messages,
12
+ timestamp: fileStat.mtime.toISOString()
13
+ };
14
+ }
15
+ /**
16
+ * List all session files in a project directory
17
+ */
18
+ export async function listSessionFiles(projectPath) {
19
+ const files = await readdir(projectPath);
20
+ const sessionFiles = [];
21
+ for (const file of files) {
22
+ if (file.endsWith('.jsonl')) {
23
+ const filePath = join(projectPath, file);
24
+ const fileStat = await stat(filePath);
25
+ sessionFiles.push({
26
+ filename: file,
27
+ size: fileStat.size,
28
+ path: filePath
29
+ });
30
+ }
31
+ }
32
+ return sessionFiles;
33
+ }
@@ -0,0 +1,86 @@
1
+ import { join } from 'path';
2
+ import { listSessionFiles, readSessionFile } from './repository.js';
3
+ import { shouldSkipSession, isAgentSession, isEmptyFile } from './filters.js';
4
+ import { extractSessionTitle } from './title.js';
5
+ import { collectAgentDescriptions, attachAgentSessionsToParent } from './agents.js';
6
+ import { getProjectDisplayName } from '../projects/repository.js';
7
+ /**
8
+ * Session service
9
+ * Orchestrates session loading, filtering, and organization
10
+ */
11
+ /**
12
+ * Sort sessions by timestamp (most recent first)
13
+ */
14
+ export function sortSessionsByTimestamp(sessions) {
15
+ return sessions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
16
+ }
17
+ /**
18
+ * Load all sessions from a project directory
19
+ */
20
+ export async function getProjectSessions(projectPath) {
21
+ const sessionFiles = await listSessionFiles(projectPath);
22
+ const allSessions = [];
23
+ const agentSessionsMap = new Map();
24
+ const projectName = getProjectDisplayName(projectPath.split('/').pop() || 'unknown');
25
+ // First pass: load all sessions
26
+ for (const { filename, size, path } of sessionFiles) {
27
+ if (isEmptyFile(size))
28
+ continue;
29
+ const sessionId = filename.replace('.jsonl', '');
30
+ try {
31
+ const { messages, timestamp } = await readSessionFile(path);
32
+ if (shouldSkipSession(messages))
33
+ continue;
34
+ const session = {
35
+ id: sessionId,
36
+ project: projectName,
37
+ timestamp,
38
+ messages,
39
+ messageCount: messages.length,
40
+ title: extractSessionTitle(messages),
41
+ isAgent: isAgentSession(sessionId)
42
+ };
43
+ if (session.isAgent) {
44
+ agentSessionsMap.set(sessionId, session);
45
+ }
46
+ else {
47
+ allSessions.push(session);
48
+ }
49
+ }
50
+ catch (error) {
51
+ console.error(`Error parsing ${filename}:`, error);
52
+ }
53
+ }
54
+ // Second pass: attach agent sessions to parents
55
+ for (const session of allSessions) {
56
+ const agentDescriptions = collectAgentDescriptions(session.messages);
57
+ attachAgentSessionsToParent(session, agentDescriptions, agentSessionsMap);
58
+ }
59
+ return allSessions;
60
+ }
61
+ /**
62
+ * Load agent sessions from files
63
+ */
64
+ export async function loadAgentSessionsFromFiles(projectPath, projectName, agentDescriptions) {
65
+ const agentSessions = [];
66
+ for (const [agentSessionId, description] of agentDescriptions) {
67
+ const agentFile = join(projectPath, `${agentSessionId}.jsonl`);
68
+ try {
69
+ const { messages, timestamp } = await readSessionFile(agentFile);
70
+ agentSessions.push({
71
+ id: agentSessionId,
72
+ project: projectName,
73
+ timestamp,
74
+ messages,
75
+ messageCount: messages.length,
76
+ title: description,
77
+ isAgent: true
78
+ });
79
+ }
80
+ catch {
81
+ // Skip if file not found
82
+ }
83
+ }
84
+ agentSessions.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
85
+ return agentSessions;
86
+ }
@@ -0,0 +1,52 @@
1
+ import { cleanText } from '../../utils/text.js';
2
+ import { MAX_TITLE_LENGTH } from '../../constants.js';
3
+ /**
4
+ * Session title extraction rules
5
+ * Business logic for extracting meaningful titles from session messages
6
+ */
7
+ /**
8
+ * Extract first text content from message content (array or string)
9
+ */
10
+ function extractFirstText(content) {
11
+ if (Array.isArray(content)) {
12
+ for (const item of content) {
13
+ if (item.type === 'text' && item.text) {
14
+ const cleaned = cleanText(item.text);
15
+ if (cleaned)
16
+ return cleaned;
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+ if (typeof content === 'string') {
22
+ const cleaned = cleanText(content);
23
+ return cleaned || null;
24
+ }
25
+ return null;
26
+ }
27
+ /**
28
+ * Extract session title from messages
29
+ * Priority 1: queue-operation / enqueue message
30
+ * Priority 2: first user message
31
+ */
32
+ export function extractSessionTitle(messages) {
33
+ // Priority 1: queue-operation / enqueue message
34
+ for (const msg of messages) {
35
+ if (msg.type === 'queue-operation' && msg.operation === 'enqueue' && msg.content) {
36
+ const firstText = extractFirstText(msg.content);
37
+ if (firstText) {
38
+ return firstText.substring(0, MAX_TITLE_LENGTH).trim();
39
+ }
40
+ }
41
+ }
42
+ // Priority 2: first user message
43
+ for (const msg of messages) {
44
+ if (msg.type === 'user' && msg.message?.content) {
45
+ const firstText = extractFirstText(msg.message.content);
46
+ if (firstText) {
47
+ return firstText.substring(0, MAX_TITLE_LENGTH).trim();
48
+ }
49
+ }
50
+ }
51
+ return 'Untitled Session';
52
+ }
@@ -0,0 +1,11 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+ /**
4
+ * Project-specific constants
5
+ */
6
+ // Directory paths
7
+ export const CLAUDE_DIR = join(homedir(), '.claude');
8
+ // Server configuration
9
+ export const DEFAULT_PORT = 9090;
10
+ // Session configuration
11
+ export const MAX_TITLE_LENGTH = 100;