55from api ._flask_types import FlaskReturn , json_response
66from api .error_codes import ErrorCode , error_response
77from models .project import ProjectSessionRowDict , SessionListItemDict
8- from models .session import SessionDict
98from utils .exclusion_rules import is_session_excluded
9+ from utils .jsonl_parser import quick_session_info
1010from utils .session_cache import get_cached_session
1111from utils .session_path import get_claude_projects_dir , list_projects , list_sessions , safe_join
12+ from utils .session_summary_cache import (
13+ SummaryCacheRowDict ,
14+ get_summary ,
15+ put_summary ,
16+ rules_fingerprint ,
17+ session_row_from_summary ,
18+ summary_from_peek ,
19+ summary_from_session ,
20+ )
1221
1322projects_bp = Blueprint ("projects" , __name__ )
1423
1524
16- def _session_row_ok (s : SessionListItemDict , parsed : SessionDict ) -> ProjectSessionRowDict :
17- meta = parsed ["metadata" ]
18- models = meta .get ("models_used" , [])
19- return {
20- "id" : s ["id" ],
21- "path" : s ["path" ],
22- "size_bytes" : s ["size_bytes" ],
23- "modified" : s ["modified" ],
24- "title" : parsed ["title" ],
25- "models" : sorted (models ) if isinstance (models , set ) else list (models ),
26- "tokens" : meta ["total_input_tokens" ] + meta ["total_output_tokens" ],
27- "tool_calls" : meta ["total_tool_calls" ],
28- "first_timestamp" : meta ["first_timestamp" ],
29- "last_timestamp" : meta ["last_timestamp" ],
30- }
31-
32-
3325def _session_row_error (s : SessionListItemDict ) -> ProjectSessionRowDict :
3426 return {
3527 "id" : s ["id" ],
@@ -41,30 +33,53 @@ def _session_row_error(s: SessionListItemDict) -> ProjectSessionRowDict:
4133 }
4234
4335
36+ def _peek_or_cache_summary (path : str , mtime : float , rules_fp : str ) -> SummaryCacheRowDict :
37+ """Return a cached summary row (any completeness) or peek the file and store a partial row.
38+
39+ Used by get_projects for fast landing-page counts. Partial rows (is_complete=False)
40+ are acceptable here — is_untitled is derived from the same first-user-text peek that
41+ quick_session_info uses; peek and full-parse agree for the vast majority of sessions
42+ (first user message within the first 80 lines). The session list path always upgrades
43+ to a complete row via get_cached_session, so session_count and list count align after
44+ the first session-list visit. With no exclusion rules the counts are identical on first
45+ visit too, because is_excluded is always False for both paths.
46+ """
47+ cached = get_summary (path , mtime , rules_fp )
48+ if cached is not None :
49+ return cached
50+ info = quick_session_info (path )
51+ row = summary_from_peek (info )
52+ put_summary (path , mtime , rules_fp , row )
53+ return row
54+
55+
4456@projects_bp .route ("/api/projects" )
4557def get_projects () -> FlaskReturn :
4658 base = current_app .config .get ("CLAUDE_PROJECTS_DIR" ) or get_claude_projects_dir ()
4759 projects = list_projects (base )
48-
49- # Enrich each project with accurate titled-session count and latest timestamp
50- # so the landing page matches what the workspace page shows.
51- # Uses quick_session_info() which peeks at files without full parsing.
52- from utils .jsonl_parser import quick_session_info
60+ rules = current_app .config .get ("EXCLUSION_RULES" ) or []
61+ rules_fp = rules_fingerprint (rules )
5362
5463 for project in projects :
5564 sessions = list_sessions (project ["path" ])
5665 titled_count = 0
5766 latest_ts = None
5867 for s in sessions :
5968 try :
60- info = quick_session_info (s ["path" ])
61- if info ["title" ] == "Untitled Session" :
69+ row = _peek_or_cache_summary (s ["path" ], s ["modified" ], rules_fp )
70+ if row ["is_untitled" ]:
71+ continue
72+ if row ["is_complete" ] and row ["is_excluded" ]:
6273 continue
6374 titled_count += 1
64- ts = info .get ("last_timestamp" ) or info .get ("first_timestamp" )
75+ ts = row .get ("last_timestamp" ) or row .get ("first_timestamp" )
6576 if ts and (latest_ts is None or ts > latest_ts ):
6677 latest_ts = ts
6778 except Exception :
79+ current_app .logger .exception (
80+ "Failed to peek session summary for project %s" ,
81+ project ["name" ],
82+ )
6883 titled_count += 1
6984 project ["session_count" ] = titled_count
7085 if latest_ts :
@@ -82,21 +97,25 @@ def get_project_sessions(project_name: str) -> FlaskReturn:
8297 return error_response (ErrorCode .INVALID_PATH , "Invalid path" , 400 )
8398 sessions = list_sessions (project_dir )
8499 rules = current_app .config .get ("EXCLUSION_RULES" ) or []
100+ rules_fp = rules_fingerprint (rules )
85101 result : list [ProjectSessionRowDict ] = []
86102 for s in sessions :
87103 try :
88- parsed = get_cached_session (s ["path" ])
89- # Skip untitled sessions (no real conversation)
90- if parsed ["title" ] == "Untitled Session" :
104+ cached = get_summary (s ["path" ], s ["modified" ], rules_fp )
105+ if cached is not None and cached ["is_complete" ]:
106+ if cached ["is_untitled" ] or cached ["is_excluded" ]:
107+ continue
108+ result .append (session_row_from_summary (s , cached ))
91109 continue
92- if is_session_excluded (rules , parsed , project_name ):
110+
111+ parsed = get_cached_session (s ["path" ])
112+ excluded = is_session_excluded (rules , parsed , project_name )
113+ row = summary_from_session (parsed , is_excluded = excluded )
114+ put_summary (s ["path" ], s ["modified" ], rules_fp , row )
115+ if row ["is_untitled" ] or excluded :
93116 continue
94- result .append (_session_row_ok (s , parsed ))
117+ result .append (session_row_from_summary (s , row ))
95118 except Exception :
96- # Full detail (class, message, traceback) to the server log via
97- # logger.exception. The per-session card carries only `error: True`
98- # — the class-name+message string was a leak (issue #25). The
99- # operator looks at the server log for triage.
100119 current_app .logger .exception ("Failed to parse session %s" , s ["id" ])
101120 result .append (_session_row_error (s ))
102121 return json_response (result )
0 commit comments