@ebowwa/claudecodehistory 1.5.1 → 1.6.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.
@@ -0,0 +1,800 @@
1
+ //! Claude Code History Parser - Rust Implementation
2
+ //!
3
+ //! High-performance Rust implementation of Claude Code conversation history parser
4
+ //! with native Node.js/Bun bindings via napi-rs.
5
+ //!
6
+ //! # Features
7
+ //!
8
+ //! - Memory-mapped file I/O for fast large file handling
9
+ //! - Parallel processing with rayon
10
+ //! - Zero-copy parsing where possible
11
+ //! - Full TypeScript API compatibility
12
+ //!
13
+ //! # Example
14
+ //!
15
+ //! ```rust
16
+ //! use claudecodehistory_rs::{ClaudeCodeHistoryService, HistoryQueryOptions};
17
+ //!
18
+ //! let service = ClaudeCodeHistoryService::new(None);
19
+ //! let history = service.get_conversation_history(HistoryQueryOptions::default());
20
+ //! ```
21
+
22
+ pub mod parser;
23
+ pub mod types;
24
+ pub mod utils;
25
+ pub mod search;
26
+
27
+ pub use types::*;
28
+ pub use utils::*;
29
+ pub use search::*;
30
+
31
+ use std::path::PathBuf;
32
+ use std::process::Command;
33
+ use walkdir::WalkDir;
34
+
35
+ /// Main service class for accessing Claude Code conversation history
36
+ ///
37
+ /// This is the Rust equivalent of `ClaudeCodeHistoryService` from the TypeScript implementation.
38
+ pub struct ClaudeCodeHistoryService {
39
+ claude_dir: PathBuf,
40
+ }
41
+
42
+ impl ClaudeCodeHistoryService {
43
+ /// Create a new history service instance
44
+ ///
45
+ /// # Arguments
46
+ /// * `claude_dir` - Optional path to Claude directory (defaults to ~/.claude)
47
+ pub fn new(claude_dir: Option<&str>) -> Self {
48
+ let claude_dir = match claude_dir {
49
+ Some(path) => PathBuf::from(path),
50
+ None => dirs::home_dir()
51
+ .expect("Could not find home directory")
52
+ .join(".claude"),
53
+ };
54
+
55
+ Self { claude_dir }
56
+ }
57
+
58
+ /// Get conversation history with optional filtering and pagination
59
+ ///
60
+ /// # Arguments
61
+ /// * `options` - Query options including session_id, date range, pagination
62
+ pub fn get_conversation_history(
63
+ &self,
64
+ options: HistoryQueryOptions,
65
+ ) -> Result<PaginatedConversationResponse, Box<dyn std::error::Error>> {
66
+ let limit = options.limit.unwrap_or(20);
67
+ let offset = options.offset.unwrap_or(0);
68
+
69
+ // Normalize dates
70
+ let start_date = options
71
+ .start_date
72
+ .as_ref()
73
+ .map(|d| normalize_date(d, false, options.timezone.as_deref()));
74
+ let end_date = options
75
+ .end_date
76
+ .as_ref()
77
+ .map(|d| normalize_date(d, true, options.timezone.as_deref()));
78
+
79
+ // Determine allowed message types
80
+ let allowed_types = options.message_types.clone().unwrap_or_else(|| {
81
+ vec![MessageType::User]
82
+ });
83
+
84
+ // Load all entries
85
+ let mut all_entries = self.load_history_entries(start_date.as_deref(), end_date.as_deref())?;
86
+
87
+ // Filter by session ID if specified
88
+ if let Some(ref session_id) = options.session_id {
89
+ all_entries.retain(|e| &e.session_id == session_id);
90
+ }
91
+
92
+ // Filter by message types
93
+ all_entries.retain(|e| allowed_types.contains(&e.entry_type));
94
+
95
+ // Filter by date range
96
+ if let Some(ref start) = start_date {
97
+ all_entries.retain(|e| e.timestamp >= *start);
98
+ }
99
+ if let Some(ref end) = end_date {
100
+ all_entries.retain(|e| e.timestamp <= *end);
101
+ }
102
+
103
+ // Sort by timestamp (newest first)
104
+ all_entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
105
+
106
+ // Calculate pagination
107
+ let total_count = all_entries.len();
108
+ let has_more = offset + limit < total_count;
109
+ let paginated_entries: Vec<_> = all_entries.into_iter().skip(offset).take(limit).collect();
110
+
111
+ Ok(PaginatedConversationResponse {
112
+ entries: paginated_entries,
113
+ pagination: Pagination {
114
+ total_count,
115
+ limit,
116
+ offset,
117
+ has_more,
118
+ },
119
+ })
120
+ }
121
+
122
+ /// Search conversations for a query string
123
+ ///
124
+ /// # Arguments
125
+ /// * `query` - Search query string
126
+ /// * `options` - Search options including filters
127
+ pub fn search_conversations(
128
+ &self,
129
+ query: &str,
130
+ options: SearchOptions,
131
+ ) -> Result<Vec<ConversationEntry>, Box<dyn std::error::Error>> {
132
+ let limit = options.limit.unwrap_or(30);
133
+
134
+ // Normalize dates
135
+ let start_date = options
136
+ .start_date
137
+ .as_ref()
138
+ .map(|d| normalize_date(d, false, options.timezone.as_deref()));
139
+ let end_date = options
140
+ .end_date
141
+ .as_ref()
142
+ .map(|d| normalize_date(d, true, options.timezone.as_deref()));
143
+
144
+ // Load all entries
145
+ let all_entries = self.load_history_entries(start_date.as_deref(), end_date.as_deref())?;
146
+
147
+ let query_lower = query.to_lowercase();
148
+
149
+ let mut matched_entries: Vec<_> = all_entries
150
+ .into_iter()
151
+ .filter(|e| e.content.to_lowercase().contains(&query_lower))
152
+ .collect();
153
+
154
+ // Filter by project path if specified
155
+ if let Some(ref project_path) = options.project_path {
156
+ matched_entries.retain(|e| &e.project_path == project_path);
157
+ }
158
+
159
+ // Filter by date range
160
+ if let Some(ref start) = start_date {
161
+ matched_entries.retain(|e| e.timestamp >= *start);
162
+ }
163
+ if let Some(ref end) = end_date {
164
+ matched_entries.retain(|e| e.timestamp <= *end);
165
+ }
166
+
167
+ // Sort and limit
168
+ matched_entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
169
+ matched_entries.truncate(limit);
170
+
171
+ Ok(matched_entries)
172
+ }
173
+
174
+ /// List all projects with Claude Code history
175
+ pub fn list_projects(&self) -> Result<Vec<ProjectInfo>, Box<dyn std::error::Error>> {
176
+ let projects_dir = self.claude_dir.join("projects");
177
+
178
+ if !projects_dir.exists() {
179
+ return Ok(Vec::new());
180
+ }
181
+
182
+ let mut projects: Vec<ProjectInfo> = Vec::new();
183
+
184
+ for entry in WalkDir::new(&projects_dir).max_depth(1) {
185
+ let entry = entry?;
186
+ let path = entry.path();
187
+
188
+ if path == projects_dir {
189
+ continue;
190
+ }
191
+
192
+ if path.is_dir() {
193
+ let project_dir = path.file_name().unwrap().to_string_lossy();
194
+ let decoded_path = decode_project_path(&project_dir);
195
+
196
+ let mut session_count = 0;
197
+ let mut message_count = 0;
198
+ let mut last_activity = String::from("1970-01-01T00:00:00.000Z");
199
+
200
+ // Count sessions and messages
201
+ for file_entry in WalkDir::new(path).max_depth(1) {
202
+ let file_entry = file_entry?;
203
+ let file_path = file_entry.path();
204
+
205
+ if file_path.extension().map_or(false, |ext| ext == "jsonl") {
206
+ session_count += 1;
207
+
208
+ // Get file modification time
209
+ if let Ok(metadata) = std::fs::metadata(file_path) {
210
+ if let Ok(modified) = metadata.modified() {
211
+ let modified_str = modified
212
+ .duration_since(std::time::UNIX_EPOCH)
213
+ .map(|d| {
214
+ let dt = chrono::DateTime::from_timestamp(d.as_secs() as i64, 0)
215
+ .unwrap_or_default();
216
+ dt.to_rfc3339()
217
+ })
218
+ .unwrap_or_default();
219
+
220
+ if modified_str > last_activity {
221
+ last_activity = modified_str;
222
+ }
223
+ }
224
+ }
225
+
226
+ // Count messages (fast line count)
227
+ if let Ok(content) = std::fs::read_to_string(file_path) {
228
+ message_count += content.lines().filter(|l| !l.trim().is_empty()).count();
229
+ }
230
+ }
231
+ }
232
+
233
+ projects.push(ProjectInfo {
234
+ project_path: decoded_path,
235
+ session_count,
236
+ message_count,
237
+ last_activity_time: last_activity,
238
+ });
239
+ }
240
+ }
241
+
242
+ Ok(projects)
243
+ }
244
+
245
+ /// List sessions with optional filtering
246
+ ///
247
+ /// Uses the fast parser for improved performance
248
+ pub fn list_sessions(
249
+ &self,
250
+ options: SessionListOptions,
251
+ ) -> Result<Vec<SessionInfo>, Box<dyn std::error::Error>> {
252
+ let projects_dir = self.claude_dir.join("projects");
253
+
254
+ if !projects_dir.exists() {
255
+ return Ok(Vec::new());
256
+ }
257
+
258
+ // Normalize dates
259
+ let start_date = options
260
+ .start_date
261
+ .as_ref()
262
+ .map(|d| normalize_date(d, false, options.timezone.as_deref()));
263
+ let end_date = options
264
+ .end_date
265
+ .as_ref()
266
+ .map(|d| normalize_date(d, true, options.timezone.as_deref()));
267
+
268
+ let mut sessions: Vec<SessionInfo> = Vec::new();
269
+
270
+ for entry in WalkDir::new(&projects_dir).max_depth(1) {
271
+ let entry = entry?;
272
+ let project_path = entry.path();
273
+
274
+ if project_path == projects_dir {
275
+ continue;
276
+ }
277
+
278
+ if project_path.is_dir() {
279
+ let project_dir = project_path.file_name().unwrap().to_string_lossy();
280
+ let decoded_path = decode_project_path(&project_dir);
281
+
282
+ // Filter by project path if specified
283
+ if let Some(ref filter_path) = options.project_path {
284
+ if &decoded_path != filter_path {
285
+ continue;
286
+ }
287
+ }
288
+
289
+ // Use fast parser
290
+ let parsed_entries = parser::parse_dir_fast(&project_path.to_string_lossy());
291
+
292
+ // Group by session_id
293
+ let mut session_map: std::collections::HashMap<String, Vec<_>> =
294
+ std::collections::HashMap::new();
295
+
296
+ for entry in parsed_entries {
297
+ if entry.session_id.is_empty() {
298
+ continue;
299
+ }
300
+
301
+ // Apply date filtering
302
+ if let Some(ref start) = start_date {
303
+ if entry.timestamp < *start {
304
+ continue;
305
+ }
306
+ }
307
+ if let Some(ref end) = end_date {
308
+ if entry.timestamp > *end {
309
+ continue;
310
+ }
311
+ }
312
+
313
+ session_map
314
+ .entry(entry.session_id.clone())
315
+ .or_default()
316
+ .push(entry);
317
+ }
318
+
319
+ // Convert to SessionInfo
320
+ for (session_id, mut entries) in session_map {
321
+ if entries.is_empty() {
322
+ continue;
323
+ }
324
+
325
+ // Sort by timestamp
326
+ entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
327
+
328
+ let session_start = entries.last().unwrap().timestamp.clone();
329
+ let session_end = entries.first().unwrap().timestamp.clone();
330
+
331
+ // Filter by date range
332
+ if let Some(ref start) = &start_date {
333
+ if &session_end < start {
334
+ continue;
335
+ }
336
+ }
337
+ if let Some(ref end) = &end_date {
338
+ if &session_start > end {
339
+ continue;
340
+ }
341
+ }
342
+
343
+ // Count message types
344
+ let user_count = entries
345
+ .iter()
346
+ .filter(|e| e.role.to_lowercase() == "user")
347
+ .count();
348
+ let assistant_count = entries
349
+ .iter()
350
+ .filter(|e| e.role.to_lowercase() == "assistant")
351
+ .count();
352
+
353
+ // Calculate duration
354
+ let start_dt = parse_timestamp(&session_start);
355
+ let end_dt = parse_timestamp(&session_end);
356
+ let duration_ms = (end_dt - start_dt).num_milliseconds();
357
+
358
+ // Find first user message
359
+ let first_user_entry = entries
360
+ .iter()
361
+ .rev()
362
+ .find(|e| e.role.to_lowercase() == "user");
363
+ let first_user_message = first_user_entry.and_then(|e| {
364
+ if e.content.len() > 100 {
365
+ Some(format!("{}...", &e.content[..100]))
366
+ } else {
367
+ Some(e.content.clone())
368
+ }
369
+ });
370
+
371
+ // Extract project name
372
+ let project_name = decoded_path.split('/').last().unwrap_or(&decoded_path);
373
+
374
+ sessions.push(SessionInfo {
375
+ session_id,
376
+ project_path: decoded_path.clone(),
377
+ start_time: session_start,
378
+ end_time: session_end,
379
+ message_count: entries.len(),
380
+ user_message_count: user_count,
381
+ assistant_message_count: assistant_count,
382
+ first_user_message,
383
+ duration_ms: Some(duration_ms as u64),
384
+ duration_formatted: Some(format_duration(duration_ms)),
385
+ has_errors: Some(false), // Would need to scan for errors
386
+ project_name: Some(project_name.to_string()),
387
+ });
388
+ }
389
+ }
390
+ }
391
+
392
+ // Sort by start time (newest first)
393
+ sessions.sort_by(|a, b| b.start_time.cmp(&a.start_time));
394
+
395
+ Ok(sessions)
396
+ }
397
+
398
+ /// Get recent activity across all projects
399
+ pub fn get_recent_activity(
400
+ &self,
401
+ options: RecentActivityOptions,
402
+ ) -> Result<Vec<RecentActivityItem>, Box<dyn std::error::Error>> {
403
+ let limit = options.limit.unwrap_or(10);
404
+ let include_summaries = options.include_summaries.unwrap_or(true);
405
+
406
+ // Get all sessions
407
+ let all_sessions = self.list_sessions(SessionListOptions::default())?;
408
+
409
+ // Take most recent
410
+ let recent_sessions: Vec<_> = all_sessions.into_iter().take(limit).collect();
411
+
412
+ let mut activities: Vec<RecentActivityItem> = Vec::new();
413
+
414
+ for session in recent_sessions {
415
+ let done = if include_summaries {
416
+ self.generate_session_summary(&session.session_id).ok()
417
+ } else {
418
+ None
419
+ };
420
+
421
+ activities.push(RecentActivityItem {
422
+ session_id: session.session_id,
423
+ project_path: session.project_path,
424
+ project_name: session.project_name.unwrap_or_default(),
425
+ timestamp: session.start_time.clone(),
426
+ asked: session.first_user_message.unwrap_or_else(|| "No user message found".to_string()),
427
+ done,
428
+ time_ago: get_time_ago(&parse_timestamp(&session.start_time)),
429
+ });
430
+ }
431
+
432
+ Ok(activities)
433
+ }
434
+
435
+ /// Get the current active Claude Code session
436
+ pub fn get_current_session(&self) -> Result<Option<CurrentSessionInfo>, Box<dyn std::error::Error>> {
437
+ let history_path = self.claude_dir.join("history.jsonl");
438
+
439
+ if !history_path.exists() {
440
+ return Ok(None);
441
+ }
442
+
443
+ // Read last line
444
+ let content = std::fs::read_to_string(&history_path)?;
445
+ let last_line = content.lines().last();
446
+
447
+ match last_line {
448
+ Some(line) if !line.is_empty() => {
449
+ let entry: serde_json::Value = serde_json::from_str(line)?;
450
+
451
+ let timestamp = entry["timestamp"].as_str().map(|s| s.to_string())
452
+ .or_else(|| entry["timestamp"].as_i64().map(|t| {
453
+ chrono::DateTime::from_timestamp(t, 0)
454
+ .unwrap_or_default()
455
+ .to_rfc3339()
456
+ }));
457
+
458
+ Ok(Some(CurrentSessionInfo {
459
+ session_id: entry["sessionId"].as_str().unwrap_or("").to_string(),
460
+ timestamp: timestamp.unwrap_or_default(),
461
+ project_path: entry["project"].as_str().map(|s| s.to_string()),
462
+ display: entry["display"].as_str().map(|s| s.to_string()),
463
+ }))
464
+ }
465
+ _ => Ok(None),
466
+ }
467
+ }
468
+
469
+ /// Get session by process ID
470
+ pub fn get_session_by_pid(&self, pid: u32) -> Result<Option<SessionProcessInfo>, Box<dyn std::error::Error>> {
471
+ // Use ps command to get process info
472
+ let output = Command::new("ps")
473
+ .args(["-p", &pid.to_string(), "-o", "pid,ppid,command"])
474
+ .output()?;
475
+
476
+ let stdout = String::from_utf8_lossy(&output.stdout);
477
+ let lines: Vec<_> = stdout.lines().collect();
478
+
479
+ if lines.len() < 2 {
480
+ return Ok(None);
481
+ }
482
+
483
+ let parts: Vec<_> = lines[1].split_whitespace().collect();
484
+ if parts.len() < 3 {
485
+ return Ok(None);
486
+ }
487
+
488
+ let process_pid: u32 = parts[0].parse()?;
489
+ let command = parts[2..].join(" ");
490
+
491
+ // Check if this is a Claude Code process
492
+ if !command.to_lowercase().contains("claude") {
493
+ return Ok(None);
494
+ }
495
+
496
+ // Extract session ID
497
+ let session_id = self.extract_session_id_from_process(process_pid)?;
498
+
499
+ // Check if process is alive
500
+ let alive = self.is_process_alive(process_pid);
501
+
502
+ Ok(Some(SessionProcessInfo {
503
+ session_id,
504
+ pid: process_pid,
505
+ command,
506
+ alive,
507
+ }))
508
+ }
509
+
510
+ /// List all session UUIDs from session-env directory
511
+ pub fn list_all_session_uuids(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
512
+ let session_env_dir = self.claude_dir.join("session-env");
513
+
514
+ if !session_env_dir.exists() {
515
+ return Ok(Vec::new());
516
+ }
517
+
518
+ let uuids: Vec<String> = std::fs::read_dir(&session_env_dir)?
519
+ .filter_map(|entry| entry.ok())
520
+ .filter(|entry| entry.file_type().map_or(false, |t| t.is_dir()))
521
+ .filter_map(|entry| {
522
+ let name = entry.file_name().to_string_lossy().to_string();
523
+ // UUID v4 pattern
524
+ let uuid_pattern = regex::Regex::new(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$").unwrap();
525
+ if uuid_pattern.is_match(&name) {
526
+ Some(name)
527
+ } else {
528
+ None
529
+ }
530
+ })
531
+ .collect();
532
+
533
+ Ok(uuids)
534
+ }
535
+
536
+ // Private helper methods
537
+
538
+ fn load_history_entries(
539
+ &self,
540
+ start_date: Option<&str>,
541
+ end_date: Option<&str>,
542
+ ) -> Result<Vec<ConversationEntry>, Box<dyn std::error::Error>> {
543
+ let projects_dir = self.claude_dir.join("projects");
544
+ let mut entries: Vec<ConversationEntry> = Vec::new();
545
+
546
+ if !projects_dir.exists() {
547
+ return Ok(entries);
548
+ }
549
+
550
+ for entry in WalkDir::new(&projects_dir).max_depth(1) {
551
+ let entry = entry?;
552
+ let project_path = entry.path();
553
+
554
+ if project_path == projects_dir {
555
+ continue;
556
+ }
557
+
558
+ if project_path.is_dir() {
559
+ let project_dir = project_path.file_name().unwrap().to_string_lossy();
560
+
561
+ for file_entry in WalkDir::new(project_path).max_depth(1) {
562
+ let file_entry = file_entry?;
563
+ let file_path = file_entry.path();
564
+
565
+ if file_path.extension().map_or(false, |ext| ext == "jsonl") {
566
+ // Pre-filter by file modification time
567
+ if self.should_skip_file(file_path, start_date, end_date)? {
568
+ continue;
569
+ }
570
+
571
+ let session_entries = parser::parse_jsonl_file(
572
+ file_path,
573
+ &project_dir,
574
+ start_date,
575
+ end_date,
576
+ );
577
+ entries.extend(session_entries);
578
+ }
579
+ }
580
+ }
581
+ }
582
+
583
+ Ok(entries)
584
+ }
585
+
586
+ fn should_skip_file(
587
+ &self,
588
+ file_path: &std::path::Path,
589
+ start_date: Option<&str>,
590
+ end_date: Option<&str>,
591
+ ) -> Result<bool, Box<dyn std::error::Error>> {
592
+ if start_date.is_none() && end_date.is_none() {
593
+ return Ok(false);
594
+ }
595
+
596
+ let metadata = std::fs::metadata(file_path)?;
597
+ let modified = metadata.modified()?;
598
+ let created = metadata.created()?;
599
+
600
+ let modified_str = modified
601
+ .duration_since(std::time::UNIX_EPOCH)
602
+ .map(|d| {
603
+ chrono::DateTime::from_timestamp(d.as_secs() as i64, 0)
604
+ .unwrap_or_default()
605
+ .to_rfc3339()
606
+ })
607
+ .unwrap_or_default();
608
+
609
+ let created_str = created
610
+ .duration_since(std::time::UNIX_EPOCH)
611
+ .map(|d| {
612
+ chrono::DateTime::from_timestamp(d.as_secs() as i64, 0)
613
+ .unwrap_or_default()
614
+ .to_rfc3339()
615
+ })
616
+ .unwrap_or_default();
617
+
618
+ let oldest = if created_str < modified_str {
619
+ &created_str
620
+ } else {
621
+ &modified_str
622
+ };
623
+
624
+ let newest = &modified_str;
625
+
626
+ // Skip if file's oldest time is after end_date
627
+ if let Some(end) = end_date {
628
+ if oldest.as_str() > end {
629
+ return Ok(true);
630
+ }
631
+ }
632
+
633
+ // Skip if file's newest time is before start_date
634
+ if let Some(start) = start_date {
635
+ if newest.as_str() < start {
636
+ return Ok(true);
637
+ }
638
+ }
639
+
640
+ Ok(false)
641
+ }
642
+
643
+ fn generate_session_summary(&self, session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
644
+ let result = self.get_conversation_history(HistoryQueryOptions {
645
+ session_id: Some(session_id.to_string()),
646
+ limit: Some(50),
647
+ message_types: Some(vec![MessageType::Assistant]),
648
+ ..Default::default()
649
+ })?;
650
+
651
+ if result.entries.is_empty() {
652
+ return Ok(String::new());
653
+ }
654
+
655
+ // Get first assistant response
656
+ let first_assistant = result.entries.last();
657
+
658
+ match first_assistant {
659
+ Some(entry) => {
660
+ let content = &entry.content;
661
+ if content.len() > 150 {
662
+ Ok(format!("{}...", &content[..150]))
663
+ } else {
664
+ Ok(content.clone())
665
+ }
666
+ }
667
+ None => Ok(String::new()),
668
+ }
669
+ }
670
+
671
+ fn extract_session_id_from_process(&self, _pid: u32) -> Result<String, Box<dyn std::error::Error>> {
672
+ // Try to get session ID from current session
673
+ if let Ok(Some(session)) = self.get_current_session() {
674
+ return Ok(session.session_id);
675
+ }
676
+ Ok(String::new())
677
+ }
678
+
679
+ fn is_process_alive(&self, pid: u32) -> bool {
680
+ // Send signal 0 to check if process exists
681
+ #[cfg(feature = "libc")]
682
+ unsafe {
683
+ libc::kill(pid as i32, 0) == 0
684
+ }
685
+ #[cfg(not(feature = "libc"))]
686
+ {
687
+ // Fallback: use kill command
688
+ std::process::Command::new("kill")
689
+ .args(["-0", &pid.to_string()])
690
+ .status()
691
+ .map(|s| s.success())
692
+ .unwrap_or(false)
693
+ }
694
+ }
695
+ }
696
+
697
+ // NAPI bindings for Node.js/Bun
698
+ #[cfg(feature = "napi")]
699
+ mod napi_bindings {
700
+ use super::*;
701
+ use napi_derive::napi;
702
+
703
+ #[napi(js_name = "ClaudeCodeHistoryService")]
704
+ pub struct JsClaudeCodeHistoryService {
705
+ inner: ClaudeCodeHistoryService,
706
+ }
707
+
708
+ #[napi]
709
+ impl JsClaudeCodeHistoryService {
710
+ #[napi(constructor)]
711
+ pub fn new(claude_dir: Option<String>) -> Self {
712
+ Self {
713
+ inner: ClaudeCodeHistoryService::new(claude_dir.as_deref()),
714
+ }
715
+ }
716
+
717
+ #[napi]
718
+ pub async fn get_conversation_history(
719
+ &self,
720
+ options: Option<HistoryQueryOptions>,
721
+ ) -> napi::Result<PaginatedConversationResponse> {
722
+ self.inner
723
+ .get_conversation_history(options.unwrap_or_default())
724
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
725
+ }
726
+
727
+ #[napi]
728
+ pub async fn search_conversations(
729
+ &self,
730
+ query: String,
731
+ options: Option<SearchOptions>,
732
+ ) -> napi::Result<Vec<ConversationEntry>> {
733
+ self.inner
734
+ .search_conversations(&query, options.unwrap_or_default())
735
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
736
+ }
737
+
738
+ #[napi]
739
+ pub async fn list_projects(&self) -> napi::Result<Vec<ProjectInfo>> {
740
+ self.inner
741
+ .list_projects()
742
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
743
+ }
744
+
745
+ #[napi]
746
+ pub async fn list_sessions(
747
+ &self,
748
+ options: Option<SessionListOptions>,
749
+ ) -> napi::Result<Vec<SessionInfo>> {
750
+ self.inner
751
+ .list_sessions(options.unwrap_or_default())
752
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
753
+ }
754
+
755
+ #[napi]
756
+ pub async fn get_recent_activity(
757
+ &self,
758
+ options: Option<RecentActivityOptions>,
759
+ ) -> napi::Result<Vec<RecentActivityItem>> {
760
+ self.inner
761
+ .get_recent_activity(options.unwrap_or_default())
762
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
763
+ }
764
+
765
+ #[napi]
766
+ pub async fn get_current_session(&self) -> napi::Result<Option<CurrentSessionInfo>> {
767
+ self.inner
768
+ .get_current_session()
769
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
770
+ }
771
+
772
+ #[napi]
773
+ pub async fn get_session_by_pid(&self, pid: u32) -> napi::Result<Option<SessionProcessInfo>> {
774
+ self.inner
775
+ .get_session_by_pid(pid)
776
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
777
+ }
778
+
779
+ #[napi]
780
+ pub async fn list_all_session_uuids(&self) -> napi::Result<Vec<String>> {
781
+ self.inner
782
+ .list_all_session_uuids()
783
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
784
+ }
785
+ }
786
+
787
+ // Fast parser exports (matching @ebowwa/jsonl-hft API)
788
+ #[napi]
789
+ pub fn parse_dir_fast(dir_path: String) -> napi::Result<Vec<serde_json::Value>> {
790
+ let entries = parser::parse_dir_fast(&dir_path);
791
+ entries
792
+ .into_iter()
793
+ .map(|e| serde_json::to_value(e).map_err(|e| napi::Error::from_reason(e.to_string())))
794
+ .collect()
795
+ }
796
+ }
797
+
798
+ // Re-export napi bindings when feature is enabled
799
+ #[cfg(feature = "napi")]
800
+ pub use napi_bindings::*;