this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add OpenAI Codex CLI session support (#1)

* Add SessionSource type and update SessionFile interface

- Add SessionSource discriminator type ('claude' | 'codex')
- Move SessionFile interface to types.ts with source field
- Export findGitRoot() for reuse by codex-detector
- Add findAllSessions() to merge Claude and Codex sessions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add Codex CLI session support

- Add codex-detector.ts to discover sessions in ~/.codex/sessions/
- Add codex-reader.ts to parse Codex JSONL format
- Map Codex tools to Claude equivalents (apply_patch→Edit, shell_command→Bash)
- Handle both function_call and custom_tool_call payload types
- Extract file paths from apply_patch unified diff format

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add source column to session_summaries table

- Add migration to add source column (defaults to 'claude')
- Update saveSessionSummary() to accept and store source
- Track whether session came from Claude Code or Codex CLI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Dispatch to correct parser based on session source

- Import parseCodexSessionFile from codex-reader
- Check sessionFile.source to dispatch to Claude or Codex parser
- Pass source to saveSessionSummary for database tracking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Make verbose output default for process command

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Consolidate dev server into single bun dev command

- Add scripts/dev.ts to run API and Vite together
- Update package.json dev script
- Properly handle process cleanup on SIGINT/SIGTERM

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Restore tools display in ProjectCard component

- Add tools aggregation to useMemo hook
- Add Tools section with purple styling
- Import Wrench icon from lucide-react

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update documentation for Codex CLI support

- Update CLAUDE.md with multi-source architecture details
- Add gotcha about Codex function_call vs custom_tool_call types
- Update README.md to mention both Claude Code and Codex CLI
- Document session locations and project unification

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix PR review issues

- getSessionStats now uses findAllSessions() to include Codex sessions
- upsertProjectFromSession: total_sessions calculated from actual session count (fixes --force inflation)
- upsertProjectFromSession: first_session_date now uses MIN on conflict

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by

Alice
Claude Opus 4.5
and committed by
GitHub
f0aada1b bffa1be3

+782 -66
+13 -4
CLAUDE.md
··· 1 1 # Worklog Project 2 2 3 - A tool that summarizes Claude Code sessions into a daily worklog. 3 + A tool that summarizes Claude Code and OpenAI Codex CLI sessions into a daily worklog. 4 4 5 5 ## Architecture 6 6 ··· 9 9 - **Web** (`src/web/`): React frontend + Express API 10 10 - **DB**: SQLite at `data/worklog.db` 11 11 12 - Vite dev server (5173) proxies `/api` to backend (3456). 12 + Run `bun dev` for development (hot reload on :5173, API on :3456). 13 + 14 + ### Multi-Source Session Support 15 + 16 + Both Claude Code and Codex CLI sessions are supported: 17 + - **Claude**: `~/.claude/projects/{encoded-path}/*.jsonl` 18 + - **Codex**: `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` 19 + 20 + Projects are unified by git root - same repo worked on with both CLIs shows as one project. The `source` column in `session_summaries` tracks origin. 13 21 14 22 ## Key Design Decisions 15 23 ··· 26 34 - **Haiku double-encoding**: Even with `mode: 'tool'`, Haiku sometimes returns double-encoded JSON where the entire response is a string with escaped quotes. The `tryRecoverMalformedResponse()` function in `summarizer.ts` handles this by regex-extracting fields from the malformed output. If you see "Session details unavailable", check the error logs for recoverable data. 27 35 - Kill any stale process on port 3456 before running `bun cli serve` 28 36 - **Monorepo path detection**: Claude's path encoding is lossy (`/` → `-`), so `taper-calculator-apps-web` could mean a dashed name or nested dirs. The code probes the filesystem right-to-left to find which interpretation exists, then uses git root as canonical project. 37 + - **Codex tool types**: Codex uses both `function_call` AND `custom_tool_call` in response_items. The `apply_patch` tool uses `custom_tool_call`, while `shell_command` uses `function_call`. Both must be handled in `codex-reader.ts`. 29 38 30 39 ## Summary Quality 31 40 ··· 42 51 bun cli process --week this # Process this week only 43 52 bun cli regenerate # Regenerate missing daily summaries 44 53 bun cli regenerate --force # Regenerate ALL daily summaries 45 - bun cli serve # Serve web UI on :3456 46 - bun dev # Vite dev server on :5173 54 + bun dev # Dev server with hot reload (:5173) 55 + bun cli serve # Production server (:3456) 47 56 ```
+9 -5
README.md
··· 1 1 # Worklog 2 2 3 - Automatically generates a daily worklog from your Claude Code sessions. See what you actually accomplished, not what you looked at. 3 + Automatically generates a daily worklog from your Claude Code and OpenAI Codex CLI sessions. See what you actually accomplished, not what you looked at. 4 4 5 5 <p> 6 6 <img src="screenshot1.png" width="49%" /> ··· 12 12 13 13 ## What it does 14 14 15 - - Scans Claude Code session files from `~/.claude/projects/` 15 + - Scans session files from Claude Code (`~/.claude/projects/`) and Codex CLI (`~/.codex/sessions/`) 16 16 - Filters to only sessions where code was actually changed (Write/Edit) 17 17 - Summarizes each session using Claude Haiku 18 18 - Generates daily summaries grouped by project 19 19 - Provides a web UI to browse your work history 20 + - Unifies projects by git root - same repo worked on with both CLIs shows as one project 20 21 21 22 ## Setup 22 23 ··· 39 40 ## Commands 40 41 41 42 ```bash 42 - bun cli process # Process new sessions 43 + bun cli process # Process new sessions (verbose by default) 43 44 bun cli process --force # Reprocess all sessions 44 45 bun cli process -d today # Process today only 45 46 bun cli process -w thisweek # Process this week only 46 - bun cli process -v # Verbose output (show parsing details) 47 47 bun cli status # Show stats 48 48 bun cli serve # Production server on :3456 49 49 bun cli regenerate # Regenerate daily summaries ··· 60 60 61 61 ## How it works 62 62 63 - **Session location**: Looks for Claude Code sessions in `~/.claude/projects/` and `~/.config/claude/projects/`. To use a custom path, set `CLAUDE_CONFIG_DIR` (comma-separated for multiple). See `getClaudePaths()` in `src/core/session-detector.ts`. 63 + **Session location**: Looks for sessions in: 64 + - Claude Code: `~/.claude/projects/` and `~/.config/claude/projects/` 65 + - Codex CLI: `~/.codex/sessions/YYYY/MM/DD/` 66 + 67 + To use a custom Claude path, set `CLAUDE_CONFIG_DIR` (comma-separated for multiple). See `src/core/session-detector.ts` and `src/core/codex-detector.ts`. 64 68 65 69 **Project path detection**: Claude encodes paths with dashes (`-Users-USERNAME-src-a-myproject`), which is lossy. The tool has special handling for: 66 70 - `~/src/a/` - active projects
+1 -2
package.json
··· 4 4 "type": "module", 5 5 "scripts": { 6 6 "cli": "bun run src/cli/index.ts", 7 - "dev": "bun run src/cli/index.ts serve & vite", 8 - "dev:grab": "bunx @react-grab/claude-code@latest & bun run dev", 7 + "dev": "bun run scripts/dev.ts", 9 8 "build": "vite build", 10 9 "serve": "bun run src/cli/index.ts serve" 11 10 },
+47
scripts/dev.ts
··· 1 + #!/usr/bin/env bun 2 + /** 3 + * Development server - runs API + Vite with hot reload 4 + * Usage: bun run scripts/dev.ts 5 + */ 6 + 7 + import { spawn } from 'bun'; 8 + 9 + console.log('Starting development servers...\n'); 10 + 11 + // Start API server 12 + const api = spawn({ 13 + cmd: ['bun', 'run', 'src/cli/index.ts', 'serve'], 14 + stdout: 'inherit', 15 + stderr: 'inherit', 16 + env: { ...process.env, FORCE_COLOR: '1' }, 17 + }); 18 + 19 + // Give API a moment to start 20 + await Bun.sleep(500); 21 + 22 + // Start Vite dev server 23 + const vite = spawn({ 24 + cmd: ['bunx', 'vite', '--host'], 25 + stdout: 'inherit', 26 + stderr: 'inherit', 27 + env: { ...process.env, FORCE_COLOR: '1' }, 28 + }); 29 + 30 + console.log('\n📡 API server: http://localhost:3456'); 31 + console.log('🔥 Dev server: http://localhost:5173 (use this one)\n'); 32 + 33 + // Handle cleanup 34 + process.on('SIGINT', () => { 35 + api.kill(); 36 + vite.kill(); 37 + process.exit(0); 38 + }); 39 + 40 + process.on('SIGTERM', () => { 41 + api.kill(); 42 + vite.kill(); 43 + process.exit(0); 44 + }); 45 + 46 + // Wait for both 47 + await Promise.all([api.exited, vite.exited]);
+1 -1
src/cli/index.ts
··· 29 29 case 'process': 30 30 await processCommand({ 31 31 force: values.force ?? false, 32 - verbose: values.verbose ?? false, 32 + verbose: values.verbose ?? true, // Default to verbose 33 33 date: values.date, 34 34 week: values.week, 35 35 });
+18 -8
src/cli/process.ts
··· 1 - import { findUnprocessedSessions, type SessionFile } from '../core/session-detector'; 1 + import { findUnprocessedSessions } from '../core/session-detector'; 2 2 import { parseSessionFile } from '../core/session-reader'; 3 + import { parseCodexSessionFile } from '../core/codex-reader'; 4 + import type { SessionFile } from '../types'; 3 5 import { summarizeSession, generateDailyBragSummary } from '../core/summarizer'; 4 6 import { 5 7 markFileProcessed, ··· 8 10 getDatesWithoutBragSummary, 9 11 getSessionsForDate, 10 12 getNewProjectsForDate, 13 + upsertProjectFromSession, 11 14 } from '../core/db'; 12 15 13 16 interface ProcessOptions { ··· 238 241 skipped: boolean; 239 242 filtered: boolean; 240 243 }> { 241 - // Parse the session file 242 - const parsed = await parseSessionFile( 243 - sessionFile.path, 244 - sessionFile.projectPath, 245 - sessionFile.projectName 246 - ); 244 + // Parse the session file (dispatch based on source) 245 + const parsed = sessionFile.source === 'codex' 246 + ? await parseCodexSessionFile( 247 + sessionFile.path, 248 + sessionFile.projectPath, 249 + sessionFile.projectName 250 + ) 251 + : await parseSessionFile( 252 + sessionFile.path, 253 + sessionFile.projectPath, 254 + sessionFile.projectName 255 + ); 247 256 248 257 if (verbose) { 249 258 console.log(` Parsed: ${parsed.messages.length} messages, ${Object.keys(parsed.stats.toolCalls).length} tool types`); ··· 317 326 } 318 327 319 328 // Save to database 320 - saveSessionSummary(parsed, summary); 329 + saveSessionSummary(parsed, summary, sessionFile.source); 330 + upsertProjectFromSession(parsed.projectPath, parsed.projectName, parsed.date); 321 331 markFileProcessed(sessionFile.path, sessionFile.fileHash); 322 332 323 333 return {
+124
src/core/codex-detector.ts
··· 1 + import { join } from 'path'; 2 + import { homedir } from 'os'; 3 + import { existsSync, readdirSync, statSync, readFileSync } from 'fs'; 4 + import { findGitRoot } from './session-detector'; 5 + import type { SessionFile } from '../types'; 6 + 7 + /** 8 + * Get Codex config directory if it exists 9 + */ 10 + export function getCodexPaths(): string[] { 11 + const codexPath = join(homedir(), '.codex'); 12 + if (existsSync(join(codexPath, 'sessions'))) { 13 + return [codexPath]; 14 + } 15 + return []; 16 + } 17 + 18 + /** 19 + * Extract metadata from the first line (session_meta) of a Codex session file 20 + */ 21 + function extractCodexSessionMeta( 22 + filePath: string 23 + ): { cwd: string; sessionId: string; gitBranch: string } | null { 24 + try { 25 + const content = readFileSync(filePath, 'utf-8'); 26 + const firstLine = content.split('\n')[0]; 27 + if (!firstLine) return null; 28 + 29 + const entry = JSON.parse(firstLine); 30 + if (entry.type === 'session_meta' && entry.payload?.cwd) { 31 + return { 32 + cwd: entry.payload.cwd, 33 + sessionId: entry.payload.id || '', 34 + gitBranch: entry.payload.git?.branch || '', 35 + }; 36 + } 37 + } catch { 38 + // Invalid JSON or missing fields 39 + } 40 + return null; 41 + } 42 + 43 + /** 44 + * Get project name from path, using git root if available 45 + */ 46 + function getProjectInfo(cwd: string): { projectPath: string; projectName: string } { 47 + // Try to find git root for canonical project identity 48 + const gitRoot = findGitRoot(cwd); 49 + if (gitRoot) { 50 + return { 51 + projectPath: gitRoot, 52 + projectName: gitRoot.split('/').pop() || 'unknown', 53 + }; 54 + } 55 + 56 + // Fallback to cwd itself 57 + return { 58 + projectPath: cwd, 59 + projectName: cwd.split('/').pop() || 'unknown', 60 + }; 61 + } 62 + 63 + /** 64 + * Find all Codex session files 65 + * Directory structure: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl 66 + */ 67 + export function findAllCodexSessionFiles(): SessionFile[] { 68 + const sessions: SessionFile[] = []; 69 + 70 + for (const codexPath of getCodexPaths()) { 71 + const sessionsDir = join(codexPath, 'sessions'); 72 + if (!existsSync(sessionsDir)) continue; 73 + 74 + // Walk YYYY/MM/DD structure 75 + const years = readdirSync(sessionsDir).filter((f) => /^\d{4}$/.test(f)); 76 + 77 + for (const year of years) { 78 + const yearPath = join(sessionsDir, year); 79 + if (!statSync(yearPath).isDirectory()) continue; 80 + 81 + const months = readdirSync(yearPath).filter((f) => /^\d{2}$/.test(f)); 82 + 83 + for (const month of months) { 84 + const monthPath = join(yearPath, month); 85 + if (!statSync(monthPath).isDirectory()) continue; 86 + 87 + const days = readdirSync(monthPath).filter((f) => /^\d{2}$/.test(f)); 88 + 89 + for (const day of days) { 90 + const dayPath = join(monthPath, day); 91 + if (!statSync(dayPath).isDirectory()) continue; 92 + 93 + const files = readdirSync(dayPath).filter((f) => f.endsWith('.jsonl')); 94 + 95 + for (const file of files) { 96 + const filePath = join(dayPath, file); 97 + const fileStat = statSync(filePath); 98 + 99 + // Extract project info from session_meta 100 + const meta = extractCodexSessionMeta(filePath); 101 + if (!meta?.cwd) continue; 102 + 103 + const { projectPath, projectName } = getProjectInfo(meta.cwd); 104 + 105 + sessions.push({ 106 + path: filePath, 107 + projectPath, 108 + projectName, 109 + sessionId: meta.sessionId || file.replace('.jsonl', ''), 110 + modifiedAt: fileStat.mtime, 111 + fileHash: '', // Computed lazily 112 + source: 'codex', 113 + }); 114 + } 115 + } 116 + } 117 + } 118 + } 119 + 120 + // Sort by modification time (newest first) 121 + sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime()); 122 + 123 + return sessions; 124 + }
+300
src/core/codex-reader.ts
··· 1 + import { createReadStream } from 'fs'; 2 + import * as readline from 'readline'; 3 + import type { ParsedSession, ParsedMessage, ToolUse, SessionStats } from '../types'; 4 + 5 + // Codex JSONL entry types 6 + interface CodexEntry { 7 + timestamp: string; 8 + type: 'session_meta' | 'event_msg' | 'response_item' | 'turn_context'; 9 + payload: unknown; 10 + } 11 + 12 + interface CodexSessionMeta { 13 + id: string; 14 + cwd: string; 15 + cli_version?: string; 16 + model_provider?: string; 17 + git?: { 18 + branch: string; 19 + commit_hash?: string; 20 + repository_url?: string; 21 + }; 22 + } 23 + 24 + interface CodexEventMsg { 25 + type: 'user_message' | 'agent_message' | 'agent_reasoning' | 'token_count'; 26 + message?: string; 27 + info?: { 28 + total_token_usage?: { 29 + input_tokens: number; 30 + output_tokens: number; 31 + cached_input_tokens?: number; 32 + reasoning_output_tokens?: number; 33 + }; 34 + }; 35 + } 36 + 37 + interface CodexResponseItem { 38 + type: 'message' | 'function_call' | 'function_call_output' | 'custom_tool_call' | 'reasoning'; 39 + role?: string; 40 + content?: Array<{ type: string; text?: string }>; 41 + name?: string; 42 + input?: string; // For function_call/custom_tool_call (apply_patch content) 43 + arguments?: string; // For function_call (shell args as JSON) 44 + call_id?: string; 45 + output?: string; 46 + status?: string; // For custom_tool_call 47 + } 48 + 49 + /** 50 + * Get the "effective date" for a timestamp using a 3am boundary. 51 + * Work done before 3am counts as the previous day (aligns with sleep cycle). 52 + */ 53 + function getEffectiveDate(timestamp: string): string { 54 + const d = new Date(timestamp); 55 + d.setHours(d.getHours() - 3); 56 + return d.toISOString().split('T')[0]; 57 + } 58 + 59 + /** 60 + * Stream-parse a Codex JSONL session file 61 + */ 62 + async function* parseCodexJSONLStream( 63 + filePath: string 64 + ): AsyncGenerator<CodexEntry> { 65 + const rl = readline.createInterface({ 66 + input: createReadStream(filePath), 67 + crlfDelay: Infinity, 68 + }); 69 + 70 + for await (const line of rl) { 71 + if (!line.trim()) continue; 72 + try { 73 + yield JSON.parse(line) as CodexEntry; 74 + } catch { 75 + // Skip invalid JSON lines 76 + } 77 + } 78 + } 79 + 80 + /** 81 + * Extract file paths from apply_patch unified diff format 82 + * Format: "*** Add File: path" or "*** Update File: path" or "*** Delete File: path" 83 + */ 84 + function extractFilesFromPatch(patchContent: string): string[] { 85 + const files: string[] = []; 86 + const regex = /\*\*\* (?:Add|Update|Delete) File:\s*(.+)/g; 87 + let match; 88 + while ((match = regex.exec(patchContent)) !== null) { 89 + const filePath = match[1].trim(); 90 + if (filePath && !files.includes(filePath)) { 91 + files.push(filePath); 92 + } 93 + } 94 + return files; 95 + } 96 + 97 + /** 98 + * Map Codex tool names to Claude-equivalent names for consistent tracking 99 + */ 100 + function mapCodexToolName(name: string): string { 101 + const mapping: Record<string, string> = { 102 + shell: 'Bash', 103 + shell_command: 'Bash', 104 + apply_patch: 'Edit', 105 + update_plan: 'TodoWrite', 106 + }; 107 + return mapping[name] || name; 108 + } 109 + 110 + /** 111 + * Summarize tool input for display (truncate long content) 112 + */ 113 + function summarizeCodexToolInput(name: string, payload: CodexResponseItem): string { 114 + const MAX_LENGTH = 200; 115 + 116 + if ((name === 'shell' || name === 'shell_command') && payload.arguments) { 117 + try { 118 + const args = JSON.parse(payload.arguments); 119 + const cmd = Array.isArray(args.command) ? args.command.join(' ') : String(args.command || ''); 120 + return truncate(cmd, MAX_LENGTH); 121 + } catch { 122 + return truncate(payload.arguments, MAX_LENGTH); 123 + } 124 + } 125 + 126 + if (name === 'apply_patch' && payload.input) { 127 + // Extract first file path from patch 128 + const files = extractFilesFromPatch(payload.input); 129 + if (files.length > 0) { 130 + return files.length === 1 ? files[0] : `${files[0]} (+${files.length - 1} more)`; 131 + } 132 + return truncate(payload.input, MAX_LENGTH); 133 + } 134 + 135 + return ''; 136 + } 137 + 138 + function truncate(str: string, maxLength: number): string { 139 + if (str.length <= maxLength) return str; 140 + return str.slice(0, maxLength - 3) + '...'; 141 + } 142 + 143 + /** 144 + * Extract text content from Codex message content array 145 + */ 146 + function extractTextFromContent(content: Array<{ type: string; text?: string }> | undefined): string { 147 + if (!content || !Array.isArray(content)) return ''; 148 + const texts: string[] = []; 149 + for (const item of content) { 150 + if (item.type === 'text' && item.text) { 151 + texts.push(item.text); 152 + } 153 + } 154 + return texts.join('\n'); 155 + } 156 + 157 + /** 158 + * Parse a Codex session file into the unified ParsedSession format 159 + */ 160 + export async function parseCodexSessionFile( 161 + filePath: string, 162 + projectPath: string, 163 + projectName: string 164 + ): Promise<ParsedSession> { 165 + const messages: ParsedMessage[] = []; 166 + const toolCalls: Record<string, number> = {}; 167 + let sessionId = ''; 168 + let gitBranch = ''; 169 + let startTime = ''; 170 + let endTime = ''; 171 + let totalInputTokens = 0; 172 + let totalOutputTokens = 0; 173 + let userMessages = 0; 174 + let assistantMessages = 0; 175 + 176 + const filesChanged = new Set<string>(); 177 + 178 + for await (const entry of parseCodexJSONLStream(filePath)) { 179 + // Track timestamps 180 + if (!startTime || entry.timestamp < startTime) startTime = entry.timestamp; 181 + if (!endTime || entry.timestamp > endTime) endTime = entry.timestamp; 182 + 183 + // Handle session_meta (first line) 184 + if (entry.type === 'session_meta') { 185 + const meta = entry.payload as CodexSessionMeta; 186 + sessionId = meta.id || ''; 187 + gitBranch = meta.git?.branch || ''; 188 + continue; 189 + } 190 + 191 + // Handle event_msg (user/assistant text messages) 192 + if (entry.type === 'event_msg') { 193 + const payload = entry.payload as CodexEventMsg; 194 + 195 + if (payload.type === 'user_message') { 196 + userMessages++; 197 + messages.push({ 198 + type: 'user', 199 + timestamp: entry.timestamp, 200 + text: payload.message || '', 201 + toolUses: [], 202 + }); 203 + } else if (payload.type === 'agent_message') { 204 + assistantMessages++; 205 + messages.push({ 206 + type: 'assistant', 207 + timestamp: entry.timestamp, 208 + text: payload.message || '', 209 + toolUses: [], 210 + }); 211 + } else if (payload.type === 'token_count' && payload.info?.total_token_usage) { 212 + // Track final token counts (total_token_usage accumulates) 213 + const usage = payload.info.total_token_usage; 214 + totalInputTokens = usage.input_tokens + (usage.cached_input_tokens || 0); 215 + totalOutputTokens = usage.output_tokens + (usage.reasoning_output_tokens || 0); 216 + } 217 + continue; 218 + } 219 + 220 + // Handle response_item (function calls and custom tool calls) 221 + if (entry.type === 'response_item') { 222 + const payload = entry.payload as CodexResponseItem; 223 + 224 + // Function calls and custom tool calls (equivalent to Claude tool_use) 225 + if ((payload.type === 'function_call' || payload.type === 'custom_tool_call') && payload.name) { 226 + const mappedName = mapCodexToolName(payload.name); 227 + toolCalls[mappedName] = (toolCalls[mappedName] || 0) + 1; 228 + 229 + // Extract files from apply_patch 230 + if (payload.name === 'apply_patch' && payload.input) { 231 + const files = extractFilesFromPatch(payload.input); 232 + files.forEach((f) => filesChanged.add(f)); 233 + } 234 + 235 + const toolUse: ToolUse = { 236 + name: mappedName, 237 + input: summarizeCodexToolInput(payload.name, payload), 238 + rawInput: payload as unknown as Record<string, unknown>, 239 + }; 240 + 241 + // Add as assistant message with tool use 242 + assistantMessages++; 243 + messages.push({ 244 + type: 'assistant', 245 + timestamp: entry.timestamp, 246 + text: '', 247 + toolUses: [toolUse], 248 + }); 249 + } 250 + 251 + // Agent text messages from response_item 252 + if (payload.type === 'message' && payload.role === 'assistant' && payload.content) { 253 + const text = extractTextFromContent(payload.content); 254 + if (text) { 255 + assistantMessages++; 256 + messages.push({ 257 + type: 'assistant', 258 + timestamp: entry.timestamp, 259 + text, 260 + toolUses: [], 261 + }); 262 + } 263 + } 264 + } 265 + } 266 + 267 + // Fallback to filename for sessionId 268 + if (!sessionId) { 269 + sessionId = filePath.split('/').pop()?.replace('.jsonl', '') || 'unknown'; 270 + } 271 + 272 + // Provide default timestamps if none found 273 + const now = new Date().toISOString(); 274 + if (!startTime) startTime = now; 275 + if (!endTime) endTime = startTime; 276 + 277 + // Derive date from endTime with 3am boundary 278 + const date = getEffectiveDate(endTime); 279 + 280 + const stats: SessionStats = { 281 + userMessages, 282 + assistantMessages, 283 + toolCalls, 284 + totalInputTokens, 285 + totalOutputTokens, 286 + }; 287 + 288 + return { 289 + sessionId, 290 + filePath, 291 + projectPath, 292 + projectName, 293 + gitBranch, 294 + startTime, 295 + endTime, 296 + date, 297 + messages, 298 + stats, 299 + }; 300 + }
+185 -34
src/core/db.ts
··· 5 5 DBSessionSummary, 6 6 DBDailySummary, 7 7 DBProcessedFile, 8 + DBProject, 8 9 SessionSummary, 9 10 ParsedSession, 10 11 SessionStats, 12 + SessionSource, 11 13 DayListItem, 12 14 DayDetail, 13 15 ProjectDetail, 14 16 SessionDetail, 15 - ProjectStatus, 16 17 ProjectListItem, 18 + ProjectStatus, 17 19 } from '../types'; 18 20 19 21 const DATA_DIR = join(import.meta.dir, '../../data'); ··· 69 71 file_hash TEXT NOT NULL, 70 72 processed_at TEXT NOT NULL 71 73 ); 74 + 75 + CREATE TABLE IF NOT EXISTS projects ( 76 + id INTEGER PRIMARY KEY, 77 + project_path TEXT UNIQUE NOT NULL, 78 + project_name TEXT NOT NULL, 79 + status TEXT NOT NULL DEFAULT 'in_progress', 80 + first_session_date TEXT NOT NULL, 81 + last_session_date TEXT NOT NULL, 82 + total_sessions INTEGER NOT NULL DEFAULT 0, 83 + created_at TEXT NOT NULL, 84 + updated_at TEXT NOT NULL 85 + ); 86 + 87 + CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status); 72 88 `); 89 + 90 + // Run migrations 91 + runMigrations(); 92 + 93 + // Backfill projects from existing sessions if needed 94 + backfillProjectsIfNeeded(); 95 + } 96 + 97 + /** 98 + * Run database migrations 99 + */ 100 + function runMigrations(): void { 101 + const database = db!; 102 + 103 + // Check if source column exists 104 + const columns = database 105 + .query<{ name: string }, []>(`PRAGMA table_info(session_summaries)`) 106 + .all(); 107 + 108 + const hasSourceColumn = columns.some((col) => col.name === 'source'); 109 + 110 + if (!hasSourceColumn) { 111 + console.log('Migration: Adding source column to session_summaries...'); 112 + database.exec(` 113 + ALTER TABLE session_summaries ADD COLUMN source TEXT DEFAULT 'claude'; 114 + `); 115 + console.log('Migration complete.'); 116 + } 73 117 } 74 118 75 119 // Processed files tracking ··· 94 138 // Session summaries 95 139 export function saveSessionSummary( 96 140 session: ParsedSession, 97 - summary: SessionSummary 141 + summary: SessionSummary, 142 + source: SessionSource = 'claude' 98 143 ): void { 99 144 const database = getDb(); 100 145 database.run( 101 146 `INSERT OR REPLACE INTO session_summaries 102 147 (session_id, project_path, project_name, git_branch, start_time, end_time, date, 103 - short_summary, accomplishments, tools_used, files_changed, stats, processed_at) 104 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 148 + short_summary, accomplishments, tools_used, files_changed, stats, source, processed_at) 149 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 105 150 [ 106 151 session.sessionId, 107 152 session.projectPath, ··· 115 160 JSON.stringify(summary.toolsUsed), 116 161 JSON.stringify(summary.filesChanged), 117 162 JSON.stringify(session.stats), 163 + source, 118 164 new Date().toISOString(), 119 165 ] 120 166 ); ··· 311 357 .map((p) => p.project_path); 312 358 } 313 359 314 - // Project management 360 + // ============ Project Status Tracking ============ 361 + 362 + /** 363 + * Backfill projects table from existing session data (one-time migration) 364 + */ 365 + function backfillProjectsIfNeeded(): void { 366 + const database = db!; 367 + 368 + // Check if projects table is empty but sessions exist 369 + const projectCount = 370 + database 371 + .query<{ count: number }, []>('SELECT COUNT(*) as count FROM projects') 372 + .get()?.count || 0; 373 + 374 + const sessionCount = 375 + database 376 + .query<{ count: number }, []>( 377 + 'SELECT COUNT(*) as count FROM session_summaries' 378 + ) 379 + .get()?.count || 0; 380 + 381 + if (projectCount === 0 && sessionCount > 0) { 382 + console.log('Backfilling projects table from session data...'); 383 + const now = new Date().toISOString(); 384 + 385 + database.run( 386 + ` 387 + INSERT OR IGNORE INTO projects ( 388 + project_path, project_name, status, 389 + first_session_date, last_session_date, total_sessions, 390 + created_at, updated_at 391 + ) 392 + SELECT 393 + project_path, 394 + MAX(project_name), 395 + 'in_progress', 396 + MIN(date), 397 + MAX(date), 398 + COUNT(*), 399 + ?, 400 + ? 401 + FROM session_summaries 402 + GROUP BY project_path 403 + `, 404 + [now, now] 405 + ); 406 + 407 + const filled = 408 + database 409 + .query<{ count: number }, []>('SELECT COUNT(*) as count FROM projects') 410 + .get()?.count || 0; 411 + console.log(`Created ${filled} project records.`); 412 + } 413 + } 414 + 415 + /** 416 + * Upsert project when processing a session 417 + */ 418 + export function upsertProjectFromSession( 419 + projectPath: string, 420 + projectName: string, 421 + sessionDate: string 422 + ): void { 423 + const database = getDb(); 424 + const now = new Date().toISOString(); 425 + 426 + database.run( 427 + ` 428 + INSERT INTO projects ( 429 + project_path, project_name, status, 430 + first_session_date, last_session_date, total_sessions, 431 + created_at, updated_at 432 + ) 433 + VALUES (?, ?, 'in_progress', ?, ?, 1, ?, ?) 434 + ON CONFLICT(project_path) DO UPDATE SET 435 + project_name = COALESCE(NULLIF(excluded.project_name, ''), project_name), 436 + first_session_date = MIN(first_session_date, excluded.first_session_date), 437 + last_session_date = MAX(last_session_date, excluded.last_session_date), 438 + total_sessions = (SELECT COUNT(*) FROM session_summaries WHERE project_path = excluded.project_path), 439 + updated_at = excluded.updated_at 440 + `, 441 + [projectPath, projectName, sessionDate, sessionDate, now, now] 442 + ); 443 + } 444 + 445 + /** 446 + * Get all projects with optional status filter 447 + */ 315 448 export function getProjects(status?: ProjectStatus): ProjectListItem[] { 316 449 const database = getDb(); 317 450 const today = new Date().toISOString().split('T')[0]; 318 451 319 - const query = status 320 - ? `SELECT * FROM projects WHERE status = ? ORDER BY last_session_date DESC` 321 - : `SELECT * FROM projects ORDER BY last_session_date DESC`; 452 + let query = ` 453 + SELECT 454 + project_path, 455 + project_name, 456 + status, 457 + first_session_date, 458 + last_session_date, 459 + total_sessions, 460 + CAST(julianday(?) - julianday(last_session_date) AS INTEGER) as days_since_last 461 + FROM projects 462 + WHERE project_name != '~' 463 + `; 464 + 465 + const params: string[] = [today]; 466 + 467 + if (status) { 468 + query += ' AND status = ?'; 469 + params.push(status); 470 + } 471 + 472 + query += ' ORDER BY last_session_date DESC'; 322 473 323 - const rows = status 324 - ? database.query<{ 474 + const rows = database 475 + .query< 476 + { 325 477 project_path: string; 326 478 project_name: string; 327 - status: string; 328 - total_sessions: number; 479 + status: ProjectStatus; 480 + first_session_date: string; 329 481 last_session_date: string; 330 - }, [string]>(query).all(status) 331 - : database.query<{ 332 - project_path: string; 333 - project_name: string; 334 - status: string; 335 482 total_sessions: number; 336 - last_session_date: string; 337 - }, []>(query).all(); 338 - 339 - return rows.map((row) => { 340 - const lastDate = new Date(row.last_session_date); 341 - const todayDate = new Date(today); 342 - const diffTime = todayDate.getTime() - lastDate.getTime(); 343 - const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); 483 + days_since_last: number; 484 + }, 485 + string[] 486 + >(query) 487 + .all(...params); 344 488 345 - return { 346 - path: row.project_path, 347 - name: row.project_name, 348 - status: row.status as ProjectStatus, 349 - totalSessions: row.total_sessions, 350 - daysSinceLastSession: diffDays, 351 - }; 352 - }); 489 + return rows.map((row) => ({ 490 + path: row.project_path, 491 + name: row.project_name, 492 + status: row.status, 493 + firstSessionDate: row.first_session_date, 494 + lastSessionDate: row.last_session_date, 495 + totalSessions: row.total_sessions, 496 + daysSinceLastSession: row.days_since_last || 0, 497 + })); 353 498 } 354 499 355 - export function updateProjectStatus(projectPath: string, status: ProjectStatus): boolean { 500 + /** 501 + * Update a project's status 502 + */ 503 + export function updateProjectStatus( 504 + projectPath: string, 505 + status: ProjectStatus 506 + ): boolean { 356 507 const database = getDb(); 357 508 const result = database.run( 358 509 `UPDATE projects SET status = ?, updated_at = ? WHERE project_path = ?`,
+19 -11
src/core/session-detector.ts
··· 3 3 import { existsSync, readdirSync, statSync, readFileSync } from 'fs'; 4 4 import { createHash } from 'crypto'; 5 5 import { isFileProcessed } from './db'; 6 + import { findAllCodexSessionFiles } from './codex-detector'; 7 + import type { SessionFile } from '../types'; 6 8 7 9 /** 8 10 * Find the git root for a given path. 9 11 * Returns the path if it's a git root, or walks up to find one. 10 12 * Returns null if no git root is found. 11 13 */ 12 - function findGitRoot(path: string): string | null { 14 + export function findGitRoot(path: string): string | null { 13 15 let current = path; 14 16 const root = '/'; 15 17 ··· 25 27 return null; 26 28 } 27 29 28 - export interface SessionFile { 29 - path: string; 30 - projectPath: string; 31 - projectName: string; 32 - sessionId: string; 33 - modifiedAt: Date; 34 - fileHash: string; 35 - } 36 30 37 31 /** 38 32 * Get possible Claude config directories ··· 302 296 sessionId, 303 297 modifiedAt: fileStat.mtime, 304 298 fileHash: '', // Computed lazily 299 + source: 'claude', 305 300 }); 306 301 } 307 302 } ··· 319 314 export async function findUnprocessedSessions( 320 315 force = false 321 316 ): Promise<SessionFile[]> { 322 - const allSessions = findAllSessionFiles(); 317 + const allSessions = findAllSessions(); 323 318 324 319 if (force) { 325 320 // Compute hashes for all files ··· 365 360 } 366 361 367 362 /** 363 + * Find all sessions from all supported sources (Claude + Codex) 364 + */ 365 + export function findAllSessions(): SessionFile[] { 366 + const claudeSessions = findAllSessionFiles(); 367 + const codexSessions = findAllCodexSessionFiles(); 368 + 369 + const all = [...claudeSessions, ...codexSessions]; 370 + all.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime()); 371 + 372 + return all; 373 + } 374 + 375 + /** 368 376 * Get stats about session files 369 377 */ 370 378 export function getSessionStats(): { ··· 372 380 totalProjects: number; 373 381 claudePaths: string[]; 374 382 } { 375 - const allSessions = findAllSessionFiles(); 383 + const allSessions = findAllSessions(); 376 384 const projects = new Set(allSessions.map((s) => s.projectPath)); 377 385 378 386 return {
+46
src/types.ts
··· 1 + // Session source discriminator 2 + export type SessionSource = 'claude' | 'codex'; 3 + 4 + // Session file discovered by detector 5 + export interface SessionFile { 6 + path: string; 7 + projectPath: string; 8 + projectName: string; 9 + sessionId: string; 10 + modifiedAt: Date; 11 + fileHash: string; 12 + source: SessionSource; 13 + } 14 + 1 15 // Raw JSONL entry from Claude Code session files 2 16 export interface RawSessionEntry { 3 17 type: 'user' | 'assistant'; ··· 90 104 tools_used: string; // JSON array 91 105 files_changed: string; // JSON array 92 106 stats: string; // JSON object 107 + source: SessionSource; // claude or codex 93 108 processed_at: string; 94 109 } 95 110 ··· 152 167 toolsUsed: string[]; 153 168 stats: SessionStats; 154 169 } 170 + 171 + // Project status tracking 172 + // Extensible - add more statuses here as needed 173 + export type ProjectStatus = 174 + | 'shipped' 175 + | 'in_progress' 176 + | 'abandoned' 177 + | 'one_off' 178 + | 'experiment'; 179 + 180 + export interface DBProject { 181 + id: number; 182 + project_path: string; 183 + project_name: string; 184 + status: ProjectStatus; 185 + first_session_date: string; 186 + last_session_date: string; 187 + total_sessions: number; 188 + created_at: string; 189 + updated_at: string; 190 + } 191 + 192 + export interface ProjectListItem { 193 + path: string; 194 + name: string; 195 + status: ProjectStatus; 196 + firstSessionDate: string; 197 + lastSessionDate: string; 198 + totalSessions: number; 199 + daysSinceLastSession: number; 200 + }
+19 -1
src/web/app/components/ProjectCard.tsx
··· 1 1 import React, { useMemo } from 'react'; 2 - import { Folder, FileCode } from 'lucide-react'; 2 + import { Folder, FileCode, Wrench } from 'lucide-react'; 3 3 4 4 interface SessionDetail { 5 5 sessionId: string; ··· 26 26 const aggregated = useMemo(() => { 27 27 const allAccomplishments: string[] = []; 28 28 const allFiles = new Set<string>(); 29 + const allTools = new Set<string>(); 29 30 30 31 for (const session of project.sessions) { 31 32 allAccomplishments.push(...session.accomplishments); 32 33 session.filesChanged.forEach((f) => allFiles.add(f)); 34 + session.toolsUsed.forEach((t) => allTools.add(t)); 33 35 } 34 36 35 37 // Dedupe accomplishments (rough - exact match only) ··· 38 40 return { 39 41 accomplishments: uniqueAccomplishments, 40 42 files: [...allFiles], 43 + tools: [...allTools], 41 44 }; 42 45 }, [project.sessions]); 43 46 ··· 78 81 {aggregated.files.length > 8 && ( 79 82 <span className="text-xs px-2 py-1 text-slate-400">+{aggregated.files.length - 8} more</span> 80 83 )} 84 + </div> 85 + </div> 86 + )} 87 + 88 + {aggregated.tools.length > 0 && ( 89 + <div className="pt-2 border-t border-gray-100"> 90 + <span className="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-1 mb-1.5"> 91 + <Wrench size={12} /> Tools 92 + </span> 93 + <div className="flex flex-wrap gap-1.5"> 94 + {aggregated.tools.map((tool, i) => ( 95 + <span key={i} className="text-xs px-2 py-1 bg-purple-50 text-purple-600 rounded border border-purple-100"> 96 + {tool} 97 + </span> 98 + ))} 81 99 </div> 82 100 </div> 83 101 )}