truememory-mirror 1.0.0

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.
package/bin/mirror.js ADDED
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { scan } from '../src/scanner.js';
4
+ import { parseClaudeCodeSession } from '../src/parsers/claude-code.js';
5
+ import { parseCodexSession } from '../src/parsers/codex.js';
6
+ import { sanitizeMessages } from '../src/sanitizer.js';
7
+ import { unifyMessages } from '../src/unified.js';
8
+ import { submitForExtraction, pollStatus } from '../src/api-client.js';
9
+ import { exec } from 'child_process';
10
+
11
+ const VERSION = '1.0.0';
12
+ const DEFAULT_API_URL = 'https://truememory-mirror.vercel.app';
13
+
14
+ function parseArgs(argv) {
15
+ const args = {
16
+ limit: 500,
17
+ source: 'all',
18
+ json: false,
19
+ noOpen: false,
20
+ delete: null,
21
+ apiUrl: DEFAULT_API_URL,
22
+ };
23
+
24
+ for (let i = 2; i < argv.length; i++) {
25
+ switch (argv[i]) {
26
+ case '--limit':
27
+ args.limit = parseInt(argv[++i], 10) || 500;
28
+ break;
29
+ case '--source':
30
+ args.source = argv[++i] || 'all';
31
+ break;
32
+ case '--json':
33
+ args.json = true;
34
+ break;
35
+ case '--no-open':
36
+ args.noOpen = true;
37
+ break;
38
+ case '--delete':
39
+ args.delete = argv[++i];
40
+ break;
41
+ case '--api-url':
42
+ args.apiUrl = argv[++i] || DEFAULT_API_URL;
43
+ break;
44
+ case '--help':
45
+ case '-h':
46
+ printHelp();
47
+ process.exit(0);
48
+ }
49
+ }
50
+ return args;
51
+ }
52
+
53
+ function printHelp() {
54
+ write(`
55
+ TrueMemory Mirror v${VERSION}
56
+
57
+ Usage: npx truememory-mirror [options]
58
+
59
+ Options:
60
+ --limit <n> Max sessions to analyze (default: 500)
61
+ --source <type> claude, codex, or all (default: all)
62
+ --json Output raw profile JSON instead of opening browser
63
+ --no-open Don't open browser automatically
64
+ --delete <id> Delete a profile by ID
65
+ --api-url <url> API endpoint (default: ${DEFAULT_API_URL})
66
+ -h, --help Show this help
67
+ `);
68
+ }
69
+
70
+ let _jsonMode = false;
71
+
72
+ function write(text) {
73
+ if (!_jsonMode) process.stderr.write(text);
74
+ }
75
+
76
+ function clearLine() {
77
+ if (!_jsonMode) process.stderr.write('\r\x1b[K');
78
+ }
79
+
80
+ function progressBar(pct, width = 25) {
81
+ const filled = Math.round(pct * width);
82
+ const empty = width - filled;
83
+ return '█'.repeat(filled) + '░'.repeat(empty);
84
+ }
85
+
86
+ function openBrowser(url) {
87
+ const cmd = process.platform === 'darwin' ? 'open'
88
+ : process.platform === 'win32' ? 'start ""'
89
+ : 'xdg-open';
90
+ exec(`${cmd} "${url}"`, () => {});
91
+ }
92
+
93
+ async function handleDelete(id, apiUrl) {
94
+ const resp = await fetch(`${apiUrl}/api/profile/${id}`, { method: 'DELETE' });
95
+ if (resp.ok) {
96
+ write(` ✓ Profile ${id} deleted.\n`);
97
+ } else {
98
+ write(` ✗ Failed to delete profile ${id} (${resp.status})\n`);
99
+ }
100
+ }
101
+
102
+ async function main() {
103
+ const args = parseArgs(process.argv);
104
+
105
+ _jsonMode = args.json;
106
+
107
+ write(`\n TrueMemory Mirror v${VERSION}\n\n`);
108
+
109
+ if (args.delete) {
110
+ await handleDelete(args.delete, args.apiUrl);
111
+ return;
112
+ }
113
+
114
+ // Step 1: Scan
115
+ write(' Scanning for AI conversations...\n');
116
+ const sources = await scan(args.source);
117
+
118
+ let claudeSessions = [];
119
+ let codexSessions = [];
120
+
121
+ // Step 2: Parse Claude Code
122
+ if (sources.claude_code) {
123
+ const files = sources.claude_code.sessions;
124
+ write(` Parsing Claude Code sessions (${files.length} found)...\n`);
125
+ const parseLimit = Math.min(files.length, args.limit);
126
+ for (let i = 0; i < parseLimit; i++) {
127
+ try {
128
+ const session = await parseClaudeCodeSession(files[i].path);
129
+ if (session.messages.length > 0) {
130
+ claudeSessions.push(session);
131
+ }
132
+ } catch {
133
+ // skip unparseable sessions
134
+ }
135
+ }
136
+ write(` ✓ Claude Code — ${claudeSessions.length} conversations (~/.claude/)\n`);
137
+ } else {
138
+ write(' – Claude Code — not found\n');
139
+ }
140
+
141
+ // Step 3: Parse Codex
142
+ if (sources.codex) {
143
+ const files = sources.codex.sessions;
144
+ write(` Parsing Codex CLI sessions (${files.length} found)...\n`);
145
+ const parseLimit = Math.min(files.length, args.limit);
146
+ for (let i = 0; i < parseLimit; i++) {
147
+ try {
148
+ const session = await parseCodexSession(files[i].path);
149
+ if (session.messages.length > 0) {
150
+ codexSessions.push(session);
151
+ }
152
+ } catch {
153
+ // skip unparseable sessions
154
+ }
155
+ }
156
+ write(` ✓ Codex CLI — ${codexSessions.length} conversations (~/.codex/)\n`);
157
+ } else {
158
+ write(' – Codex CLI — not found\n');
159
+ }
160
+
161
+ const totalSessions = claudeSessions.length + codexSessions.length;
162
+ if (totalSessions === 0) {
163
+ write('\n No AI conversations found. Make sure you have Claude Code or Codex CLI installed.\n\n');
164
+ process.exit(1);
165
+ }
166
+
167
+ // Warn if too few messages
168
+ const totalMessages = claudeSessions.reduce((s, c) => s + c.messages.length, 0)
169
+ + codexSessions.reduce((s, c) => s + c.messages.length, 0);
170
+
171
+ if (totalMessages < 50) {
172
+ write(`\n ⚠ Found only ${totalMessages} messages. For a meaningful profile,\n`);
173
+ write(' we recommend 100+ conversations. Results may be sparse.\n');
174
+ }
175
+
176
+ write(` ${'─'.repeat(40)}\n`);
177
+ write(` Total: ${totalSessions} conversations`);
178
+ if (totalSessions > args.limit) {
179
+ write(` (using most recent ${args.limit})`);
180
+ }
181
+ write('\n\n');
182
+
183
+ // Step 4: Sanitize & unify
184
+ for (const session of claudeSessions) {
185
+ session.messages = sanitizeMessages(session.messages);
186
+ }
187
+ for (const session of codexSessions) {
188
+ session.messages = sanitizeMessages(session.messages);
189
+ }
190
+
191
+ const payload = unifyMessages(claudeSessions, codexSessions, args.limit);
192
+
193
+ if (args.json) {
194
+ process.stdout.write(JSON.stringify(payload, null, 2));
195
+ return;
196
+ }
197
+
198
+ // Step 5: Submit
199
+ write(' Uploading & extracting identity traits...\n');
200
+
201
+ let result;
202
+ try {
203
+ result = await submitForExtraction(payload, args.apiUrl);
204
+ } catch (err) {
205
+ write(`\n ✗ Upload failed: ${err.message}\n\n`);
206
+ process.exit(1);
207
+ }
208
+
209
+ // Step 6: Poll for status
210
+ try {
211
+ await pollStatus(result.profile_id, args.apiUrl, (status) => {
212
+ const pct = status.progress || 0;
213
+ const bar = progressBar(pct);
214
+ let statusText = '';
215
+
216
+ if (status.status === 'queued') {
217
+ statusText = `Position ${status.queue_position || '?'} in queue`;
218
+ } else if (status.status === 'extracting') {
219
+ statusText = `Extracting traits`;
220
+ if (status.claims_found) {
221
+ statusText += ` (${status.claims_found} claims found)`;
222
+ }
223
+ } else if (status.status === 'consolidating') {
224
+ statusText = 'Building profile';
225
+ } else if (status.status === 'predicting') {
226
+ statusText = 'Running scenario predictions';
227
+ }
228
+
229
+ clearLine();
230
+ write(` ${bar} ${Math.round(pct * 100)}% ${statusText}`);
231
+ });
232
+ } catch (err) {
233
+ write(`\n\n ✗ Extraction failed: ${err.message}\n\n`);
234
+ process.exit(1);
235
+ }
236
+
237
+ clearLine();
238
+ write(` ${progressBar(1.0)} 100%\n\n`);
239
+ write(` ✓ Your identity mirror is ready.\n`);
240
+ write(` → ${result.url}\n\n`);
241
+
242
+ if (!args.noOpen) {
243
+ openBrowser(result.url);
244
+ }
245
+ }
246
+
247
+ main().catch(err => {
248
+ process.stderr.write(`\n Error: ${err.message}\n\n`);
249
+ process.exit(1);
250
+ });
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "truememory-mirror",
3
+ "version": "1.0.0",
4
+ "description": "See yourself through your AI conversations",
5
+ "bin": {
6
+ "truememory-mirror": "./bin/mirror.js"
7
+ },
8
+ "files": ["bin/", "src/"],
9
+ "type": "module",
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
13
+ "keywords": ["ai", "identity", "personality", "claude", "codex", "truememory"],
14
+ "author": "Josh Adler <buildingjoshbetter@gmail.com>",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/buildingjoshbetter/truememory-mirror"
19
+ }
20
+ }
@@ -0,0 +1,36 @@
1
+ const POLL_INTERVAL_MS = 3000;
2
+
3
+ export async function submitForExtraction(payload, apiUrl) {
4
+ const body = JSON.stringify(payload);
5
+ const response = await fetch(`${apiUrl}/api/extract`, {
6
+ method: 'POST',
7
+ headers: { 'Content-Type': 'application/json' },
8
+ body,
9
+ });
10
+
11
+ if (!response.ok) {
12
+ const error = await response.text();
13
+ throw new Error(`API error (${response.status}): ${error}`);
14
+ }
15
+
16
+ return response.json();
17
+ }
18
+
19
+ export async function pollStatus(profileId, apiUrl, onUpdate) {
20
+ while (true) {
21
+ const response = await fetch(`${apiUrl}/api/profile/${profileId}/status`);
22
+ if (!response.ok) {
23
+ throw new Error(`Status check failed (${response.status})`);
24
+ }
25
+
26
+ const status = await response.json();
27
+ if (onUpdate) onUpdate(status);
28
+
29
+ if (status.status === 'ready') return status;
30
+ if (status.status === 'failed') {
31
+ throw new Error(`Extraction failed: ${status.error || 'unknown error'}`);
32
+ }
33
+
34
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
35
+ }
36
+ }
@@ -0,0 +1,59 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { basename, dirname } from 'path';
3
+
4
+ function deriveProject(dirPath) {
5
+ const dirName = basename(dirPath);
6
+ if (!dirName.startsWith('-')) return dirName;
7
+ return '/' + dirName.slice(1).replace(/-/g, '/');
8
+ }
9
+
10
+ function extractContent(content) {
11
+ if (typeof content === 'string') return content;
12
+ if (Array.isArray(content)) {
13
+ return content
14
+ .filter(item => item.type === 'text')
15
+ .map(item => item.text)
16
+ .join('\n');
17
+ }
18
+ return '';
19
+ }
20
+
21
+ export async function parseClaudeCodeSession(filePath) {
22
+ const raw = await readFile(filePath, 'utf-8');
23
+ const lines = raw.split('\n').filter(l => l.trim());
24
+ const project = deriveProject(dirname(filePath));
25
+ const sessionId = basename(filePath, '.jsonl');
26
+ const messages = [];
27
+
28
+ for (const line of lines) {
29
+ let obj;
30
+ try {
31
+ obj = JSON.parse(line);
32
+ } catch {
33
+ continue;
34
+ }
35
+
36
+ if (obj.type !== 'user' && obj.type !== 'assistant') continue;
37
+ if (obj.isSidechain === true) continue;
38
+
39
+ const msg = obj.message;
40
+ if (!msg) continue;
41
+
42
+ const content = extractContent(msg.content);
43
+ if (!content) continue;
44
+ if (content.includes('[[TRUEMEMORY_INTERNAL_EXTRACTION]]')) continue;
45
+
46
+ messages.push({
47
+ role: msg.role || obj.type,
48
+ content,
49
+ timestamp: obj.timestamp || '',
50
+ });
51
+ }
52
+
53
+ return {
54
+ session_id: sessionId,
55
+ source: 'claude_code',
56
+ project,
57
+ messages,
58
+ };
59
+ }
@@ -0,0 +1,70 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { basename } from 'path';
3
+
4
+ function extractOutputText(content) {
5
+ if (!Array.isArray(content)) return '';
6
+ return content
7
+ .filter(item => item.type === 'output_text')
8
+ .map(item => item.text)
9
+ .join('\n');
10
+ }
11
+
12
+ export async function parseCodexSession(filePath) {
13
+ const raw = await readFile(filePath, 'utf-8');
14
+ const lines = raw.split('\n').filter(l => l.trim());
15
+ let sessionId = basename(filePath, '.jsonl');
16
+ let project = '';
17
+ const messages = [];
18
+
19
+ for (const line of lines) {
20
+ let obj;
21
+ try {
22
+ obj = JSON.parse(line);
23
+ } catch {
24
+ continue;
25
+ }
26
+
27
+ if (obj.type === 'session_meta' && obj.payload) {
28
+ if (obj.payload.id) sessionId = obj.payload.id;
29
+ if (obj.payload.cwd) project = obj.payload.cwd;
30
+ continue;
31
+ }
32
+
33
+ if (obj.type === 'event_msg' && obj.payload?.type === 'user_message') {
34
+ const content = obj.payload.message;
35
+ if (typeof content === 'string' && content.length > 0) {
36
+ messages.push({
37
+ role: 'user',
38
+ content,
39
+ timestamp: obj.timestamp || '',
40
+ });
41
+ }
42
+ continue;
43
+ }
44
+
45
+ if (obj.type === 'response_item' && obj.payload) {
46
+ if (obj.payload.role === 'developer') continue;
47
+ if (obj.payload.role === 'user') {
48
+ const text = extractOutputText(obj.payload.content || []);
49
+ if (text.startsWith('<environment_context>')) continue;
50
+ }
51
+ if (obj.payload.role === 'assistant') {
52
+ const text = extractOutputText(obj.payload.content || []);
53
+ if (text) {
54
+ messages.push({
55
+ role: 'assistant',
56
+ content: text,
57
+ timestamp: obj.timestamp || '',
58
+ });
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ return {
65
+ session_id: sessionId,
66
+ source: 'codex',
67
+ project,
68
+ messages,
69
+ };
70
+ }
@@ -0,0 +1,64 @@
1
+ export function sanitize(text) {
2
+ // --- Paths ---
3
+ text = text.replace(/\/Users\/[^\s"']+/g, (match) => {
4
+ const parts = match.split('/');
5
+ return parts.length > 3 ? '[PATH]/' + parts.slice(-2).join('/') : '[PATH]';
6
+ });
7
+ text = text.replace(/[A-Z]:\\Users\\[^\s"']+/gi, '[PATH]');
8
+ text = text.replace(/\/home\/[^\s"']+/g, '[PATH]');
9
+ text = text.replace(/~\/[^\s"']+/g, '[PATH]');
10
+ text = text.replace(/\/Volumes\/[^\s"']+/g, '[PATH]');
11
+
12
+ // --- Emails ---
13
+ text = text.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[EMAIL]');
14
+
15
+ // --- IPs ---
16
+ text = text.replace(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?\b/g, (match) => {
17
+ if (match.startsWith('127.0.0.1') || match.startsWith('0.0.0.0') || match.startsWith('localhost')) return match;
18
+ return '[IP]';
19
+ });
20
+ text = text.replace(/\b[0-9a-fA-F]{1,4}(:[0-9a-fA-F]{1,4}){7}\b/g, '[IPv6]');
21
+ text = text.replace(/\b[0-9a-fA-F]{1,4}(:[0-9a-fA-F]{1,4})*::[0-9a-fA-F:]*\b/g, '[IPv6]');
22
+
23
+ // --- Connection strings (before generic token patterns) ---
24
+ text = text.replace(/\b(postgres|postgresql|mysql|mongodb|redis|amqp|mssql):\/\/[^\s"']+/gi, '[DB_URL]');
25
+
26
+ // --- PEM keys ---
27
+ text = text.replace(/-----BEGIN [A-Z ]*(?:PRIVATE )?KEY-----[\s\S]*?-----END [A-Z ]*(?:PRIVATE )?KEY-----/g, '[PRIVATE_KEY]');
28
+
29
+ // --- Long hex tokens ---
30
+ text = text.replace(/\b[a-fA-F0-9]{40,}\b/g, '[TOKEN]');
31
+
32
+ // --- API keys with known prefixes ---
33
+ text = text.replace(/\b(sk-ant-api03-|sk-ant-|sk-proj-|sk-or-v1-|ghp_|gho_|github_pat_|ghu_|ghs_|pk_live_|sk_live_|pk_test_|sk_test_|xoxb-|xoxp-|xapp-|AKIA[A-Z0-9]|AGE-SECRET-KEY-|glpat-|pypi-|npm_)[a-zA-Z0-9_/+=.-]{10,}/g, '[API_KEY]');
34
+ text = text.replace(/\bAIza[a-zA-Z0-9_-]{30,}\b/g, '[API_KEY]');
35
+ text = text.replace(/\b(sk-|pk-)[a-zA-Z0-9_-]{20,}\b/g, '[API_KEY]');
36
+
37
+ // --- JWTs (any alg, not just HS256) ---
38
+ text = text.replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[JWT]');
39
+
40
+ // --- UUIDs ---
41
+ text = text.replace(/\b[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}\b/g, '[UUID]');
42
+
43
+ // --- Key=value secrets (word-bounded to avoid donkey/turkey/monkey) ---
44
+ text = text.replace(/\b(password|passwd|secret|token|credential|bearer|authorization|api_key|apikey|access_key|secret_key)\s*[:=]\s*\S+/gi, '$1=[REDACTED]');
45
+
46
+ // --- SSN ---
47
+ text = text.replace(/\b\d{3}[-.]?\d{2}[-.]?\d{4}\b/g, '[SSN]');
48
+
49
+ // --- Credit card numbers ---
50
+ text = text.replace(/\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b/g, '[CARD]');
51
+
52
+ // --- Phone numbers ---
53
+ text = text.replace(/\(\d{3}\)\s?\d{3}[-.]?\d{4}/g, '[PHONE]');
54
+ text = text.replace(/\+?1?[-.\s]?\d{3}[-.\s]\d{3}[-.\s]\d{4}\b/g, '[PHONE]');
55
+
56
+ return text;
57
+ }
58
+
59
+ export function sanitizeMessages(messages) {
60
+ return messages.map(msg => ({
61
+ ...msg,
62
+ content: sanitize(msg.content),
63
+ }));
64
+ }
package/src/scanner.js ADDED
@@ -0,0 +1,62 @@
1
+ import { readdir, stat } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ async function findJsonlFiles(dir) {
6
+ const results = [];
7
+ try {
8
+ const entries = await readdir(dir, { withFileTypes: true });
9
+ for (const entry of entries) {
10
+ const fullPath = join(dir, entry.name);
11
+ if (entry.isDirectory()) {
12
+ const nested = await findJsonlFiles(fullPath);
13
+ results.push(...nested);
14
+ } else if (entry.name.endsWith('.jsonl')) {
15
+ const info = await stat(fullPath);
16
+ results.push({ path: fullPath, mtime: info.mtimeMs, size: info.size });
17
+ }
18
+ }
19
+ } catch {
20
+ // directory not readable or doesn't exist
21
+ }
22
+ return results;
23
+ }
24
+
25
+ export async function scan(sourceFilter = 'all') {
26
+ const home = homedir();
27
+ const result = { claude_code: null, codex: null };
28
+
29
+ if (sourceFilter === 'all' || sourceFilter === 'claude') {
30
+ const claudeProjectsDir = join(home, '.claude', 'projects');
31
+ try {
32
+ await stat(claudeProjectsDir);
33
+ const files = await findJsonlFiles(claudeProjectsDir);
34
+ const sessions = files
35
+ .filter(f => f.size > 100)
36
+ .sort((a, b) => b.mtime - a.mtime);
37
+ if (sessions.length > 0) {
38
+ result.claude_code = { path: claudeProjectsDir, sessions };
39
+ }
40
+ } catch {
41
+ // ~/.claude/projects doesn't exist
42
+ }
43
+ }
44
+
45
+ if (sourceFilter === 'all' || sourceFilter === 'codex') {
46
+ const codexSessionsDir = join(home, '.codex', 'sessions');
47
+ try {
48
+ await stat(codexSessionsDir);
49
+ const files = await findJsonlFiles(codexSessionsDir);
50
+ const sessions = files
51
+ .filter(f => f.size > 100)
52
+ .sort((a, b) => b.mtime - a.mtime);
53
+ if (sessions.length > 0) {
54
+ result.codex = { path: codexSessionsDir, sessions };
55
+ }
56
+ } catch {
57
+ // ~/.codex/sessions doesn't exist
58
+ }
59
+ }
60
+
61
+ return result;
62
+ }
package/src/unified.js ADDED
@@ -0,0 +1,52 @@
1
+ const MAX_PAYLOAD_BYTES = 5 * 1024 * 1024; // 5MB
2
+
3
+ export function unifyMessages(claudeSessions, codexSessions, limit = 500) {
4
+ const allSessions = [...claudeSessions, ...codexSessions];
5
+
6
+ allSessions.sort((a, b) => {
7
+ const aTime = a.messages[a.messages.length - 1]?.timestamp || '';
8
+ const bTime = b.messages[b.messages.length - 1]?.timestamp || '';
9
+ return bTime.localeCompare(aTime);
10
+ });
11
+
12
+ let selected = allSessions.slice(0, limit);
13
+
14
+ // Size guard: trim oldest sessions until payload fits
15
+ let payload = buildPayload(selected);
16
+ while (JSON.stringify(payload).length > MAX_PAYLOAD_BYTES && selected.length > 10) {
17
+ selected = selected.slice(0, Math.floor(selected.length * 0.8));
18
+ payload = buildPayload(selected);
19
+ process.stderr.write(` Payload too large, trimmed to ${selected.length} sessions\n`);
20
+ }
21
+
22
+ return payload;
23
+ }
24
+
25
+ function buildPayload(sessions) {
26
+ const messages = [];
27
+ for (const session of sessions) {
28
+ for (const msg of session.messages) {
29
+ messages.push({
30
+ role: msg.role,
31
+ content: msg.content,
32
+ timestamp: msg.timestamp,
33
+ source: session.source,
34
+ session_id: session.session_id,
35
+ project_context: session.project || '',
36
+ });
37
+ }
38
+ }
39
+
40
+ const metadata = {
41
+ total_sessions: sessions.length,
42
+ claude_code_sessions: sessions.filter(s => s.source === 'claude_code').length,
43
+ codex_sessions: sessions.filter(s => s.source === 'codex').length,
44
+ total_messages: messages.length,
45
+ date_range: {
46
+ earliest: messages.reduce((min, m) => m.timestamp && m.timestamp < min ? m.timestamp : min, messages[0]?.timestamp || ''),
47
+ latest: messages.reduce((max, m) => m.timestamp && m.timestamp > max ? m.timestamp : max, messages[0]?.timestamp || ''),
48
+ },
49
+ };
50
+
51
+ return { messages, metadata };
52
+ }