Skip to content

Commit 1f79b30

Browse files
Add disk-backed session summary cache and display-name cache
Session list and landing page re-parsed or re-read every .jsonl on each cold start and on every GET /api/projects call. Add SQLite summary cache (keyed by path, mtime, rules fingerprint) so complete rows survive restart; route get_projects and get_project_sessions through it. Cache project display names by directory max mtime. Align project card session_count with session list when no exclusion rules are active.
1 parent 6465d4a commit 1f79b30

6 files changed

Lines changed: 517 additions & 23 deletions

File tree

api/projects.py

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,18 @@
77
from models.project import ProjectSessionRowDict, SessionListItemDict
88
from models.session import SessionDict
99
from utils.exclusion_rules import is_session_excluded
10+
from utils.jsonl_parser import quick_session_info
1011
from utils.session_cache import get_cached_session
1112
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions, safe_join
13+
from utils.session_summary_cache import (
14+
SummaryCacheRowDict,
15+
get_summary,
16+
put_summary,
17+
rules_fingerprint,
18+
session_row_from_summary,
19+
summary_from_peek,
20+
summary_from_session,
21+
)
1222

1323
projects_bp = Blueprint("projects", __name__)
1424

@@ -41,27 +51,37 @@ def _session_row_error(s: SessionListItemDict) -> ProjectSessionRowDict:
4151
}
4252

4353

54+
def _peek_or_cache_summary(path: str, mtime: float, rules_fp: str) -> SummaryCacheRowDict:
55+
"""Return a cached summary row or peek the file and store a partial row."""
56+
cached = get_summary(path, mtime, rules_fp)
57+
if cached is not None:
58+
return cached
59+
info = quick_session_info(path)
60+
row = summary_from_peek(info)
61+
put_summary(path, mtime, rules_fp, row)
62+
return row
63+
64+
4465
@projects_bp.route("/api/projects")
4566
def get_projects() -> FlaskReturn:
4667
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
4768
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
69+
rules = current_app.config.get("EXCLUSION_RULES") or []
70+
rules_fp = rules_fingerprint(rules)
5371

5472
for project in projects:
5573
sessions = list_sessions(project["path"])
5674
titled_count = 0
5775
latest_ts = None
5876
for s in sessions:
5977
try:
60-
info = quick_session_info(s["path"])
61-
if info["title"] == "Untitled Session":
78+
row = _peek_or_cache_summary(s["path"], s["modified"], rules_fp)
79+
if row["is_untitled"]:
80+
continue
81+
if row["is_complete"] and row["is_excluded"]:
6282
continue
6383
titled_count += 1
64-
ts = info.get("last_timestamp") or info.get("first_timestamp")
84+
ts = row.get("last_timestamp") or row.get("first_timestamp")
6585
if ts and (latest_ts is None or ts > latest_ts):
6686
latest_ts = ts
6787
except Exception:
@@ -82,21 +102,25 @@ def get_project_sessions(project_name: str) -> FlaskReturn:
82102
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)
83103
sessions = list_sessions(project_dir)
84104
rules = current_app.config.get("EXCLUSION_RULES") or []
105+
rules_fp = rules_fingerprint(rules)
85106
result: list[ProjectSessionRowDict] = []
86107
for s in sessions:
87108
try:
88-
parsed = get_cached_session(s["path"])
89-
# Skip untitled sessions (no real conversation)
90-
if parsed["title"] == "Untitled Session":
109+
cached = get_summary(s["path"], s["modified"], rules_fp)
110+
if cached is not None and cached["is_complete"]:
111+
if cached["is_untitled"] or cached["is_excluded"]:
112+
continue
113+
result.append(session_row_from_summary(s, cached))
91114
continue
92-
if is_session_excluded(rules, parsed, project_name):
115+
116+
parsed = get_cached_session(s["path"])
117+
excluded = is_session_excluded(rules, parsed, project_name)
118+
row = summary_from_session(parsed, is_excluded=excluded)
119+
put_summary(s["path"], s["modified"], rules_fp, row)
120+
if row["is_untitled"] or excluded:
93121
continue
94122
result.append(_session_row_ok(s, parsed))
95123
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.
100124
current_app.logger.exception("Failed to parse session %s", s["id"])
101125
result.append(_session_row_error(s))
102126
return json_response(result)

tests/test_api_integration.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
from __future__ import annotations
1111

12+
import pytest
13+
1214
from app import CSP_POLICY
1315
from tests.conftest import assert_error_response as _assert_error_shape
1416

@@ -124,3 +126,42 @@ def test_search_valid_limit(client):
124126
results = resp.get_json()
125127
assert isinstance(results, list)
126128
assert len(results) <= 5
129+
130+
131+
# --- session summary cache (disk) ---
132+
133+
134+
@pytest.fixture
135+
def summary_cache_db(tmp_path, monkeypatch):
136+
from utils.session_summary_cache import clear_cache, reset_connection_for_tests
137+
138+
db = tmp_path / "session_summary_cache.sqlite"
139+
reset_connection_for_tests(db)
140+
yield db
141+
clear_cache()
142+
143+
144+
def test_project_session_count_matches_list(client, summary_cache_db):
145+
projects = client.get("/api/projects").get_json()
146+
project = next(p for p in projects if p["name"] == "test-project")
147+
sessions = client.get("/api/projects/test-project/sessions").get_json()
148+
assert project["session_count"] == len(sessions)
149+
150+
151+
def test_project_sessions_uses_disk_cache_on_second_request(
152+
client, summary_cache_db, monkeypatch
153+
):
154+
client.get("/api/projects/test-project/sessions")
155+
calls = 0
156+
157+
def counting_get_cached(path: str):
158+
nonlocal calls
159+
calls += 1
160+
from utils.session_cache import get_cached_session as real_get
161+
162+
return real_get(path)
163+
164+
monkeypatch.setattr("api.projects.get_cached_session", counting_get_cached)
165+
client.get("/api/projects/test-project/sessions")
166+
assert calls == 0
167+

tests/test_session_path.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,30 @@ def test_get_claude_projects_dir_on_windows_runner(
3939
got = session_path.get_claude_projects_dir()
4040
expected = os.path.join(str(profile), ".claude", "projects")
4141
assert got == expected
42+
43+
44+
def test_display_name_cache_avoids_repeat_file_reads(
45+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
46+
) -> None:
47+
session_path.clear_display_name_cache()
48+
project_dir = tmp_path / "proj-hash"
49+
project_dir.mkdir()
50+
jsonl = project_dir / "session.jsonl"
51+
jsonl.write_text(
52+
'{"type":"user","cwd":"/home/user/MyProject","timestamp":"2026-01-01T00:00:00Z"}\n',
53+
encoding="utf-8",
54+
)
55+
calls = 0
56+
real_get = session_path._get_display_name
57+
58+
def counting_get_display_name(*args, **kwargs):
59+
nonlocal calls
60+
calls += 1
61+
return real_get(*args, **kwargs)
62+
63+
monkeypatch.setattr(session_path, "_get_display_name", counting_get_display_name)
64+
session_path.list_projects(str(tmp_path))
65+
first_calls = calls
66+
session_path.list_projects(str(tmp_path))
67+
assert first_calls > 0
68+
assert calls == first_calls
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Unit tests for utils.session_summary_cache."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import shutil
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
from utils.jsonl_parser import parse_session, quick_session_info
12+
from utils.session_summary_cache import (
13+
clear_cache,
14+
get_summary,
15+
put_summary,
16+
reset_connection_for_tests,
17+
rules_fingerprint,
18+
summary_from_peek,
19+
summary_from_session,
20+
)
21+
22+
FIXTURES = Path(__file__).resolve().parent / "fixtures"
23+
SAMPLE_SESSION = FIXTURES / "session_with_tools.jsonl"
24+
25+
26+
@pytest.fixture
27+
def sample_session(tmp_path: Path) -> Path:
28+
dest = tmp_path / "session.jsonl"
29+
shutil.copy(SAMPLE_SESSION, dest)
30+
return dest
31+
32+
33+
@pytest.fixture
34+
def cache_db(tmp_path: Path) -> Path:
35+
db = tmp_path / "summary.sqlite"
36+
reset_connection_for_tests(db)
37+
yield db
38+
clear_cache()
39+
40+
41+
def test_rules_fingerprint_empty() -> None:
42+
assert rules_fingerprint([]) == "none"
43+
44+
45+
def test_rules_fingerprint_stable() -> None:
46+
rules = [[("word", "secret")]]
47+
assert rules_fingerprint(rules) == rules_fingerprint(rules)
48+
49+
50+
def test_cache_miss_returns_none(sample_session: Path, cache_db: Path) -> None:
51+
path = str(sample_session)
52+
mtime = sample_session.stat().st_mtime
53+
assert get_summary(path, mtime, "none") is None
54+
55+
56+
def test_cache_hit_round_trip(sample_session: Path, cache_db: Path) -> None:
57+
path = str(sample_session)
58+
mtime = sample_session.stat().st_mtime
59+
parsed = parse_session(path)
60+
row = summary_from_session(parsed, is_excluded=False)
61+
put_summary(path, mtime, "none", row)
62+
hit = get_summary(path, mtime, "none")
63+
assert hit is not None
64+
assert hit["title"] == row["title"]
65+
assert hit["tokens"] == row["tokens"]
66+
assert hit["is_complete"] is True
67+
68+
69+
def test_cache_invalidates_on_mtime_change(sample_session: Path, cache_db: Path) -> None:
70+
path = str(sample_session)
71+
mtime = sample_session.stat().st_mtime
72+
parsed = parse_session(path)
73+
put_summary(path, mtime, "none", summary_from_session(parsed, is_excluded=False))
74+
stat = sample_session.stat()
75+
os.utime(sample_session, (stat.st_mtime + 10, stat.st_mtime + 10))
76+
new_mtime = sample_session.stat().st_mtime
77+
assert new_mtime != mtime
78+
assert get_summary(path, new_mtime, "none") is None
79+
80+
81+
def test_exclusion_key_separation(sample_session: Path, cache_db: Path) -> None:
82+
path = str(sample_session)
83+
mtime = sample_session.stat().st_mtime
84+
parsed = parse_session(path)
85+
put_summary(path, mtime, "none", summary_from_session(parsed, is_excluded=False))
86+
put_summary(path, mtime, "rules_a", summary_from_session(parsed, is_excluded=True))
87+
hit_none = get_summary(path, mtime, "none")
88+
hit_rules = get_summary(path, mtime, "rules_a")
89+
assert hit_none is not None and hit_none["is_excluded"] is False
90+
assert hit_rules is not None and hit_rules["is_excluded"] is True
91+
92+
93+
def test_peek_partial_row(sample_session: Path, cache_db: Path) -> None:
94+
path = str(sample_session)
95+
mtime = sample_session.stat().st_mtime
96+
row = summary_from_peek(quick_session_info(path))
97+
put_summary(path, mtime, "none", row)
98+
hit = get_summary(path, mtime, "none")
99+
assert hit is not None
100+
assert hit["is_complete"] is False
101+
assert hit["tokens"] == 0
102+
103+
104+
def test_lru_eviction(
105+
sample_session: Path, cache_db: Path, monkeypatch: pytest.MonkeyPatch
106+
) -> None:
107+
monkeypatch.setattr("utils.session_summary_cache.DEFAULT_MAX_ROWS", 2)
108+
content = sample_session.read_text(encoding="utf-8")
109+
paths = []
110+
for name in ("a.jsonl", "b.jsonl", "c.jsonl"):
111+
p = sample_session.parent / name
112+
p.write_text(content, encoding="utf-8")
113+
paths.append(p)
114+
115+
for p in paths[:2]:
116+
mtime = p.stat().st_mtime
117+
put_summary(
118+
str(p),
119+
mtime,
120+
"none",
121+
summary_from_peek(quick_session_info(str(p))),
122+
)
123+
get_summary(str(p), mtime, "none")
124+
125+
third = paths[2]
126+
third_mtime = third.stat().st_mtime
127+
put_summary(
128+
str(third),
129+
third_mtime,
130+
"none",
131+
summary_from_peek(quick_session_info(str(third))),
132+
)
133+
134+
first = paths[0]
135+
assert get_summary(str(first), first.stat().st_mtime, "none") is None
136+
assert get_summary(str(third), third_mtime, "none") is not None
137+
138+
139+
def test_clear_cache(sample_session: Path, cache_db: Path) -> None:
140+
path = str(sample_session)
141+
mtime = sample_session.stat().st_mtime
142+
parsed = parse_session(path)
143+
put_summary(path, mtime, "none", summary_from_session(parsed, is_excluded=False))
144+
clear_cache()
145+
reset_connection_for_tests(cache_db)
146+
assert get_summary(path, mtime, "none") is None

utils/session_path.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55
import logging
66
import os
77
import platform
8+
import threading
89

910
from models.project import ProjectDict, SessionListItemDict
1011

1112
_logger = logging.getLogger(__name__)
1213

14+
_display_name_cache: dict[str, tuple[float, str]] = {}
15+
_display_name_lock = threading.Lock()
16+
1317

1418
def safe_join(base: str, *parts: str) -> str:
1519
"""Join path components and verify the result stays under base.
@@ -31,6 +35,33 @@ def get_claude_projects_dir() -> str:
3135
return os.path.join(home, ".claude", "projects")
3236

3337

38+
def clear_display_name_cache() -> None:
39+
"""Clear the in-memory display-name cache (for tests)."""
40+
with _display_name_lock:
41+
_display_name_cache.clear()
42+
43+
44+
def _project_jsonl_max_mtime(project_dir: str, jsonl_files: list[str]) -> float:
45+
return max(os.path.getmtime(os.path.join(project_dir, jf)) for jf in jsonl_files)
46+
47+
48+
def _resolve_display_name(project_dir: str, jsonl_files: list[str], fallback: str) -> str:
49+
max_mtime = _project_jsonl_max_mtime(project_dir, jsonl_files)
50+
with _display_name_lock:
51+
hit = _display_name_cache.get(project_dir)
52+
if hit is not None and hit[0] == max_mtime:
53+
return hit[1]
54+
display_name = fallback
55+
for jf in jsonl_files:
56+
candidate = _get_display_name(os.path.join(project_dir, jf), None)
57+
if candidate is not None:
58+
display_name = candidate
59+
break
60+
with _display_name_lock:
61+
_display_name_cache[project_dir] = (max_mtime, display_name)
62+
return display_name
63+
64+
3465
def list_projects(base_dir: str | None = None) -> list[ProjectDict]:
3566
"""Scan the projects dir and return info for each one that has .jsonl files."""
3667
base = base_dir or get_claude_projects_dir()
@@ -52,13 +83,7 @@ def list_projects(base_dir: str | None = None) -> list[ProjectDict]:
5283
from datetime import datetime, timezone
5384

5485
last_modified = datetime.fromtimestamp(latest_mtime, tz=timezone.utc).isoformat()
55-
# Read cwd from sessions to get the real project path
56-
display_name = name
57-
for jf in jsonl_files:
58-
candidate = _get_display_name(os.path.join(project_dir, jf), None)
59-
if candidate is not None:
60-
display_name = candidate
61-
break
86+
display_name = _resolve_display_name(project_dir, jsonl_files, name)
6287
projects.append(
6388
{
6489
"name": name,

0 commit comments

Comments
 (0)