2828from utils .session_stats import compute_stats , _format_duration
2929from utils .md_exporter import session_to_markdown
3030from utils .json_exporter import session_to_json
31+ from utils .exclusion_rules import (
32+ resolve_exclusion_rules_path ,
33+ load_rules ,
34+ build_searchable_text ,
35+ is_excluded_by_rules ,
36+ )
3137
3238
3339STATE_DIR = os .path .join (os .path .expanduser ("~" ), ".claude-code-chat-browser" )
@@ -285,11 +291,15 @@ def cmd_export(args):
285291 project_filter = getattr (args , "project" , None )
286292 fmt = getattr (args , "format" , None ) or "md"
287293 session_filter = getattr (args , "session" , None )
294+ exclusion_rules_path = getattr (args , "exclude_rules" , None )
288295
289296 if not os .path .isdir (base_dir ):
290297 _die (f"Claude Code projects directory not found: { base_dir } " )
291298
292- last_export = _load_state () if since == "last" else {}
299+ rules = load_rules (resolve_exclusion_rules_path (exclusion_rules_path ))
300+
301+ state = _load_state () if since == "last" else {}
302+ last_export = state .get ("sessions" , {})
293303
294304 # Single session export
295305 if session_filter :
@@ -338,6 +348,18 @@ def cmd_export(args):
338348 skipped += 1
339349 continue
340350
351+ if rules :
352+ meta = session ["metadata" ]
353+ searchable = build_searchable_text (
354+ project_name = project .get ("display_name" ) or project ["name" ],
355+ session_title = session ["title" ],
356+ model_names = list (meta .get ("models_used" ) or []),
357+ content_snippet = _session_text_for_exclusion (session ),
358+ )
359+ if is_excluded_by_rules (rules , searchable ):
360+ skipped += 1
361+ continue
362+
341363 stats = compute_stats (session )
342364 meta = session ["metadata" ]
343365 ts = meta .get ("first_timestamp" , "" )
@@ -420,8 +442,8 @@ def cmd_export(args):
420442 zf .writestr ("manifest.jsonl" , manifest_str )
421443 print (f"Exported { exported } file(s) to { zip_path } " )
422444
423- _save_state (last_export )
424- print ("Export state saved. " )
445+ _save_state (last_export , count = len ( manifest ), out_dir = out_dir )
446+ print (f"State saved to { STATE_FILE } " )
425447
426448
427449def _export_single (session : dict , stats : dict , fmt : str , out_dir : str ):
@@ -445,6 +467,19 @@ def _export_single(session: dict, stats: dict, fmt: str, out_dir: str):
445467 print (f"Exported: { fpath } " )
446468
447469
470+ # ==================== Helpers ====================
471+
472+
473+ def _session_text_for_exclusion (session : dict ) -> str :
474+ """Extract plain text from all session messages for exclusion rule matching."""
475+ parts = []
476+ for msg in session .get ("messages" , []):
477+ text = msg .get ("text" ) or ""
478+ if isinstance (text , str ) and text .strip ():
479+ parts .append (text )
480+ return "\n \n " .join (parts )
481+
482+
448483# ==================== Argument Parser ====================
449484
450485
@@ -469,6 +504,14 @@ def build_parser() -> argparse.ArgumentParser:
469504 default = None , help = "Export format (default: md)" )
470505 parser .add_argument ("--session" , default = None ,
471506 help = "Export/stats for single session (UUID prefix)" )
507+ parser .add_argument (
508+ "--exclude-rules" , "-e" ,
509+ default = None ,
510+ metavar = "PATH" ,
511+ dest = "exclude_rules" ,
512+ help = "Path to exclusion rules file (sensitive sessions are omitted). "
513+ "If omitted, uses ~/.claude-code-chat-browser/exclusion-rules.txt if present." ,
514+ )
472515
473516 subparsers = parser .add_subparsers (dest = "command" )
474517
@@ -506,13 +549,18 @@ def build_parser() -> argparse.ArgumentParser:
506549 help = "Filter by project name" )
507550 export_p .add_argument ("--base-dir" , default = None ,
508551 help = "Override Claude Code projects directory" )
552+ export_p .add_argument (
553+ "--exclude-rules" , "-e" ,
554+ default = None ,
555+ metavar = "PATH" ,
556+ dest = "exclude_rules" ,
557+ help = "Path to exclusion rules file (sensitive sessions are omitted). "
558+ "If omitted, uses ~/.claude-code-chat-browser/exclusion-rules.txt if present." ,
559+ )
509560
510561 return parser
511562
512563
513- # ==================== Helpers ====================
514-
515-
516564def _find_session (session_id : str , base_dir : str ) -> str | None :
517565 """Scan all projects for a session matching this UUID (or prefix).
518566 Fails if the prefix matches more than one session."""
@@ -534,14 +582,41 @@ def _find_session(session_id: str, base_dir: str) -> str | None:
534582
535583
536584def _load_state () -> dict :
537- if os .path .isfile (STATE_FILE ):
538- with open (STATE_FILE , "r" ) as f :
539- return json .load (f )
540- return {}
541-
542-
543- def _save_state (state : dict ):
585+ """Load export state, migrating legacy flat format to the current schema.
586+
587+ Current schema::
588+
589+ {
590+ "lastExportTime": "2026-02-25T12:00:00",
591+ "exportedCount": 42,
592+ "exportDir": "/path/to/out",
593+ "sessions": {"<session-uuid>": <mtime-float>, ...}
594+ }
595+
596+ Legacy schema (written by older versions)::
597+
598+ {"<session-uuid>": <mtime-float>, ...}
599+ """
600+ if not os .path .isfile (STATE_FILE ):
601+ return {}
602+ with open (STATE_FILE , "r" ) as f :
603+ data = json .load (f )
604+ # Migrate: if the file has neither "sessions" nor "lastExportTime" it is
605+ # the old flat dict of session_id → mtime.
606+ if "sessions" not in data and "lastExportTime" not in data :
607+ return {"sessions" : data }
608+ return data
609+
610+
611+ def _save_state (sessions : dict , count : int , out_dir : str ):
612+ """Persist export state with standardised fields matching cursor-chat-browser."""
544613 os .makedirs (STATE_DIR , exist_ok = True )
614+ state = {
615+ "lastExportTime" : datetime .now ().isoformat (),
616+ "exportedCount" : count ,
617+ "exportDir" : out_dir ,
618+ "sessions" : sessions ,
619+ }
545620 with open (STATE_FILE , "w" ) as f :
546621 json .dump (state , f , indent = 2 )
547622
0 commit comments