cherry-docs 0.2.0__py3-none-any.whl

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.
Files changed (42) hide show
  1. app/__init__.py +0 -0
  2. app/repo_scope.py +24 -0
  3. app/services/__init__.py +0 -0
  4. app/services/agent_protocol.py +59 -0
  5. app/services/auto_promote_sessions.py +245 -0
  6. app/services/capture_adapters.py +89 -0
  7. app/services/capture_core.py +164 -0
  8. app/services/internal_memory_agent.py +214 -0
  9. app/services/memory_evidence.py +89 -0
  10. app/services/memory_extraction_normalize.py +134 -0
  11. app/services/memory_lifecycle.py +258 -0
  12. app/services/memory_profiles.py +88 -0
  13. app/services/memory_providers.py +113 -0
  14. app/services/memory_retrieval.py +327 -0
  15. app/services/memory_retrieval_scoring.py +106 -0
  16. app/services/memory_retrieval_text.py +113 -0
  17. app/services/memory_similarity.py +135 -0
  18. app/services/privacy.py +72 -0
  19. app/services/promoted_memory_answer.py +157 -0
  20. app/services/promoted_memory_pipeline.py +194 -0
  21. app/services/promoted_memory_store.py +57 -0
  22. cherry_docs-0.2.0.dist-info/METADATA +143 -0
  23. cherry_docs-0.2.0.dist-info/RECORD +42 -0
  24. cherry_docs-0.2.0.dist-info/WHEEL +5 -0
  25. cherry_docs-0.2.0.dist-info/entry_points.txt +4 -0
  26. cherry_docs-0.2.0.dist-info/top_level.txt +3 -0
  27. cherrydocs/__init__.py +3 -0
  28. cherrydocs/cli.py +213 -0
  29. cherrydocs/hook.py +27 -0
  30. cherrydocs/mcp.py +22 -0
  31. scripts/__init__.py +0 -0
  32. scripts/auto_promote_capture.py +63 -0
  33. scripts/check_size_limits.py +115 -0
  34. scripts/ci_auto_capture.py +289 -0
  35. scripts/claude_hooks/__init__.py +0 -0
  36. scripts/claude_hooks/state_manager.py +526 -0
  37. scripts/coverage_regression_gate.py +121 -0
  38. scripts/eval_projects.py +247 -0
  39. scripts/install.py +212 -0
  40. scripts/pr_gate_report.py +282 -0
  41. scripts/promptfoo_regression_gate.py +176 -0
  42. scripts/render_agent_prompts.py +57 -0
app/__init__.py ADDED
File without changes
app/repo_scope.py ADDED
@@ -0,0 +1,24 @@
1
+ from typing import Optional
2
+
3
+
4
+ def normalize_project_id(project_id: Optional[str], default: str = "default-project") -> str:
5
+ """
6
+ Normalize repository/project identifiers to the internal dashed format.
7
+ Examples:
8
+ - github.com/owner/repo -> owner-repo
9
+ - https://github.com/owner/repo.git -> owner-repo
10
+ - git@github.com:owner/repo.git -> owner-repo
11
+ """
12
+ if not project_id:
13
+ return default
14
+
15
+ normalized = project_id.strip()
16
+ if not normalized:
17
+ return default
18
+
19
+ normalized = normalized.replace("https://", "").replace("http://", "")
20
+ normalized = normalized.replace("git@github.com:", "").replace("github.com/", "")
21
+ normalized = normalized.removesuffix(".git")
22
+ normalized = normalized.strip("/")
23
+ normalized = normalized.replace("/", "-")
24
+ return normalized.lower() or default
File without changes
@@ -0,0 +1,59 @@
1
+ """Canonical agent protocol rendering for all supported client rule files."""
2
+ from __future__ import annotations
3
+
4
+ import tomllib
5
+ from hashlib import sha256
6
+ from pathlib import Path
7
+
8
+ ROOT_DIR = Path(__file__).resolve().parents[2]
9
+ PROTOCOL_PATH = ROOT_DIR / "docs" / "agent_protocol.toml"
10
+ PROTOCOL_SOURCE = "docs/agent_protocol.toml"
11
+ PROMPT_OUTPUTS = {
12
+ ".claude/CLAUDE.md": "claude",
13
+ "AGENTS.md": "agents",
14
+ "GEMINI.md": "gemini",
15
+ ".cursorrules": "cursorrules",
16
+ ".cursor/rules/cherrydocs.mdc": "cursor_mdc",
17
+ }
18
+
19
+
20
+ def _load_protocol() -> tuple[dict, str]:
21
+ raw = PROTOCOL_PATH.read_text(encoding="utf-8")
22
+ return tomllib.loads(raw), raw
23
+
24
+
25
+ def _protocol_metadata(protocol: dict, raw: str) -> dict[str, str]:
26
+ return {
27
+ "source": PROTOCOL_SOURCE,
28
+ "version": str(protocol["meta"]["version"]),
29
+ "hash": sha256(raw.encode("utf-8")).hexdigest()[:12],
30
+ }
31
+
32
+
33
+ def _generated_comment(version: str, protocol_hash: str) -> str:
34
+ return f"<!-- Generated from {PROTOCOL_SOURCE} version={version} hash={protocol_hash}; do not edit by hand. -->"
35
+
36
+
37
+ def _render_body(protocol: dict, version: str, protocol_hash: str) -> str:
38
+ meta = protocol["meta"]
39
+ shared = protocol["shared"]
40
+ lines = [_generated_comment(version, protocol_hash), meta["title"], ""]
41
+ lines.extend(f"- {bullet}" for bullet in shared["bullets"])
42
+ return "\n".join(lines).rstrip() + "\n"
43
+
44
+
45
+ def render_platform_prompt(platform: str) -> str:
46
+ protocol, raw = _load_protocol()
47
+ meta = _protocol_metadata(protocol, raw)
48
+ body = _render_body(protocol, meta["version"], meta["hash"])
49
+ if platform == "claude":
50
+ return body
51
+ if platform in {"agents", "gemini", "cursorrules"}:
52
+ return f"# Project Rules\n\n{body}"
53
+ if platform == "cursor_mdc":
54
+ return "---\ndescription: CherryDocs project protocol\nalwaysApply: true\n---\n\n" + body
55
+ raise ValueError(f"Unsupported platform: {platform}")
56
+
57
+
58
+ def build_prompt_file_map() -> dict[str, str]:
59
+ return {path: render_platform_prompt(platform) for path, platform in PROMPT_OUTPUTS.items()}
@@ -0,0 +1,245 @@
1
+ """Background-friendly auto-promotion for captured AI sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ from app.repo_scope import normalize_project_id
13
+ from app.services.capture_core import LocalCaptureBuffer
14
+ from app.services.internal_memory_agent import MemoryModelProvider
15
+ from app.services.memory_providers import resolve_provider
16
+ from app.services.promoted_memory_pipeline import run_session_promotion
17
+ from app.services.promoted_memory_store import DEFAULT_PROMOTED_ROOT, LocalPromotedMemoryStore
18
+
19
+
20
+ class AutoPromotionPolicy(BaseModel):
21
+ model_config = ConfigDict(extra="ignore")
22
+
23
+ min_event_count: int = 3
24
+ min_candidate_confidence: float = 0.8
25
+ max_sessions: int = 10
26
+
27
+
28
+ class AutoPromotionState(BaseModel):
29
+ model_config = ConfigDict(extra="ignore")
30
+
31
+ session_id: str
32
+ project_id: str
33
+ signature: str
34
+ event_count: int
35
+ last_event_timestamp: str | None = None
36
+ last_promoted_at: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
37
+
38
+
39
+ class AutoPromotionSessionResult(BaseModel):
40
+ model_config = ConfigDict(extra="ignore")
41
+
42
+ session_id: str
43
+ action: str
44
+ reason: str = ""
45
+ promoted_count: int = 0
46
+ highlights: list[str] = Field(default_factory=list)
47
+ distillation_trace: dict[str, object] | None = None
48
+
49
+
50
+ class AutoPromotionRunReport(BaseModel):
51
+ model_config = ConfigDict(extra="ignore")
52
+
53
+ project_id: str
54
+ processed: list[AutoPromotionSessionResult] = Field(default_factory=list)
55
+ skipped: list[AutoPromotionSessionResult] = Field(default_factory=list)
56
+
57
+
58
+ def _state_dir(buffer_dir: str | Path) -> Path:
59
+ return Path(buffer_dir).expanduser().resolve() / ".promotion-state"
60
+
61
+
62
+ def _state_path(buffer_dir: str | Path, session_id: str) -> Path:
63
+ safe = session_id.replace("/", "_").replace("\\", "_")
64
+ return _state_dir(buffer_dir) / f"{safe}.json"
65
+
66
+
67
+ def _load_state(buffer_dir: str | Path, session_id: str) -> AutoPromotionState | None:
68
+ path = _state_path(buffer_dir, session_id)
69
+ if not path.exists():
70
+ return None
71
+ try:
72
+ payload = json.loads(path.read_text(encoding="utf-8"))
73
+ return AutoPromotionState.model_validate(payload)
74
+ except Exception:
75
+ return None
76
+
77
+
78
+ def _save_state(buffer_dir: str | Path, state: AutoPromotionState) -> None:
79
+ path = _state_path(buffer_dir, state.session_id)
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+ path.write_text(json.dumps(state.model_dump(mode="json"), indent=2), encoding="utf-8")
82
+
83
+
84
+ def list_capture_sessions(buffer_dir: str | Path) -> list[str]:
85
+ root = Path(buffer_dir).expanduser().resolve()
86
+ if not root.exists():
87
+ return []
88
+ return [
89
+ path.stem
90
+ for path in sorted(root.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
91
+ ]
92
+
93
+
94
+ def _session_signature(events: list[dict]) -> str:
95
+ relevant = [
96
+ {
97
+ "event_type": event.get("event_type"),
98
+ "timestamp": event.get("timestamp"),
99
+ "text": str(event.get("text") or "")[:400],
100
+ "command": event.get("command"),
101
+ "exit_code": event.get("exit_code"),
102
+ }
103
+ for event in events
104
+ ]
105
+ payload = json.dumps(relevant, sort_keys=True, ensure_ascii=False)
106
+ return hashlib.sha1(payload.encode("utf-8"), usedforsecurity=False).hexdigest()
107
+
108
+
109
+ def _new_state(project_id: str, session_id: str, events: list[dict]) -> AutoPromotionState:
110
+ timestamps = [
111
+ str(event.get("timestamp") or "").strip()
112
+ for event in events
113
+ if str(event.get("timestamp") or "").strip()
114
+ ]
115
+ return AutoPromotionState(
116
+ session_id=session_id,
117
+ project_id=project_id,
118
+ signature=_session_signature(events),
119
+ event_count=len(events),
120
+ last_event_timestamp=timestamps[-1] if timestamps else None,
121
+ )
122
+
123
+
124
+ def _session_matches_scope(events: list[dict], *, project_id: str, branch: str | None) -> bool:
125
+ if not events:
126
+ return False
127
+ normalized_project_id = normalize_project_id(project_id)
128
+ repos = {
129
+ normalize_project_id(str(event.get("repo") or ""))
130
+ for event in events
131
+ if str(event.get("repo") or "").strip()
132
+ }
133
+ if repos:
134
+ if normalized_project_id not in repos:
135
+ return False
136
+ else:
137
+ # No repo field — fall back to cwd directory name match
138
+ cwds = {
139
+ normalize_project_id(Path(str(event.get("cwd") or "")).name)
140
+ for event in events
141
+ if str(event.get("cwd") or "").strip()
142
+ }
143
+ if cwds and normalized_project_id not in cwds:
144
+ return False
145
+ if branch:
146
+ branches = {
147
+ str(event.get("branch") or "").strip()
148
+ for event in events
149
+ if str(event.get("branch") or "").strip()
150
+ }
151
+ if branches and branch not in branches:
152
+ return False
153
+ return True
154
+
155
+
156
+ def auto_promote_captured_sessions(
157
+ *,
158
+ project_id: str,
159
+ buffer_dir: str | Path = ".cherrydocs/capture",
160
+ promoted_root: str | Path = DEFAULT_PROMOTED_ROOT,
161
+ provider: MemoryModelProvider | None = None,
162
+ project_hint: str | None = None,
163
+ branch: str | None = None,
164
+ commit: str | None = None,
165
+ policy: AutoPromotionPolicy | None = None,
166
+ memory_profile: str | None = None,
167
+ ) -> AutoPromotionRunReport:
168
+ resolved_policy = policy or AutoPromotionPolicy()
169
+ resolved_provider = provider or resolve_provider()
170
+ buffer = LocalCaptureBuffer(buffer_dir)
171
+ store = LocalPromotedMemoryStore(promoted_root)
172
+ sessions = list_capture_sessions(buffer_dir)[: resolved_policy.max_sessions]
173
+
174
+ existing_records = [
175
+ r for r in store.load_records(project_id)
176
+ if not branch or not r.branch or r.branch == branch
177
+ ]
178
+ processed: list[AutoPromotionSessionResult] = []
179
+ skipped: list[AutoPromotionSessionResult] = []
180
+
181
+ for session_id in sessions:
182
+ events = buffer.read(session_id)
183
+ if not _session_matches_scope(events, project_id=project_id, branch=branch):
184
+ skipped.append(AutoPromotionSessionResult(
185
+ session_id=session_id, action="skip",
186
+ reason="session outside requested project/branch scope",
187
+ ))
188
+ continue
189
+ if len(events) < resolved_policy.min_event_count:
190
+ skipped.append(AutoPromotionSessionResult(
191
+ session_id=session_id, action="skip",
192
+ reason=f"too few events ({len(events)} < {resolved_policy.min_event_count})",
193
+ ))
194
+ continue
195
+
196
+ state = _load_state(buffer_dir, session_id)
197
+ current_state = _new_state(project_id, session_id, events)
198
+ if state and state.project_id == project_id and state.signature == current_state.signature:
199
+ skipped.append(AutoPromotionSessionResult(
200
+ session_id=session_id, action="skip",
201
+ reason="no new captured evidence since last promotion",
202
+ ))
203
+ continue
204
+
205
+ report = run_session_promotion(
206
+ events=events,
207
+ session_id=session_id,
208
+ project_id=project_id,
209
+ provider=resolved_provider,
210
+ project_hint=project_hint,
211
+ branch=branch,
212
+ commit=commit,
213
+ existing_records=existing_records,
214
+ min_confidence=resolved_policy.min_candidate_confidence,
215
+ memory_profile=memory_profile,
216
+ )
217
+ session_records = [r for r in report.session_records if r.memory_type != "noise"]
218
+ if not session_records:
219
+ skipped.append(AutoPromotionSessionResult(
220
+ session_id=session_id, action="skip",
221
+ reason="no high-confidence durable memory candidates",
222
+ ))
223
+ _save_state(buffer_dir, current_state)
224
+ continue
225
+
226
+ existing_records = store.upsert_records(project_id, report.promotion.records)
227
+ _save_state(buffer_dir, current_state)
228
+ processed.append(AutoPromotionSessionResult(
229
+ session_id=session_id,
230
+ action="promote",
231
+ promoted_count=len(session_records),
232
+ highlights=[r.summary for r in session_records[:3]],
233
+ distillation_trace=report.distillation_trace.model_dump(mode="json"),
234
+ ))
235
+
236
+ return AutoPromotionRunReport(project_id=project_id, processed=processed, skipped=skipped)
237
+
238
+
239
+ __all__ = [
240
+ "AutoPromotionPolicy",
241
+ "AutoPromotionRunReport",
242
+ "AutoPromotionSessionResult",
243
+ "auto_promote_captured_sessions",
244
+ "list_capture_sessions",
245
+ ]
@@ -0,0 +1,89 @@
1
+ """Shared append helpers for capture integrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from app.services.capture_core import (
9
+ CaptureEvent,
10
+ CaptureEventType,
11
+ LocalCaptureBuffer,
12
+ build_capture_event,
13
+ )
14
+
15
+ _TEST_COMMAND_MARKERS = (
16
+ "pytest",
17
+ "pnpm test",
18
+ "npm test",
19
+ "yarn test",
20
+ "bun test",
21
+ "vitest",
22
+ "jest",
23
+ "go test",
24
+ "cargo test",
25
+ "mix test",
26
+ "rspec",
27
+ "phpunit",
28
+ )
29
+
30
+
31
+ def infer_capture_event_type(*, tool_name: str | None = None, command: str | None = None) -> CaptureEventType:
32
+ normalized_command = " ".join(str(command or "").split()).lower()
33
+ if normalized_command and any(marker in normalized_command for marker in _TEST_COMMAND_MARKERS):
34
+ return CaptureEventType.TEST_RESULT
35
+ if str(tool_name or "").strip() == "Bash":
36
+ return CaptureEventType.SHELL_RESULT
37
+ return CaptureEventType.TOOL_RESULT
38
+
39
+
40
+ def enrich_capture_metadata(
41
+ *,
42
+ event_type: CaptureEventType,
43
+ command: str | None = None,
44
+ exit_code: int | None = None,
45
+ metadata: dict[str, Any] | None = None,
46
+ ) -> dict[str, Any]:
47
+ enriched = dict(metadata or {})
48
+ if event_type == CaptureEventType.TEST_RESULT:
49
+ enriched.setdefault("capture_kind", "verification")
50
+ enriched.setdefault("verification_kind", "test")
51
+ if exit_code is not None:
52
+ enriched.setdefault("verification_status", "passed" if exit_code == 0 else "failed")
53
+ elif event_type == CaptureEventType.SHELL_RESULT and command:
54
+ enriched.setdefault("capture_kind", "command")
55
+ return enriched
56
+
57
+
58
+ def append_capture_event(
59
+ *,
60
+ buffer_dir: str | Path,
61
+ source: str,
62
+ event_type: CaptureEventType,
63
+ session_id: str,
64
+ cwd: str,
65
+ text: str | None = None,
66
+ files: list[str] | None = None,
67
+ command: str | None = None,
68
+ exit_code: int | None = None,
69
+ metadata: dict[str, Any] | None = None,
70
+ ) -> CaptureEvent:
71
+ merged_metadata = enrich_capture_metadata(
72
+ event_type=event_type,
73
+ command=command,
74
+ exit_code=exit_code,
75
+ metadata=metadata,
76
+ )
77
+ event = build_capture_event(
78
+ source=source,
79
+ event_type=event_type,
80
+ session_id=session_id,
81
+ cwd=cwd,
82
+ text=text,
83
+ files=files,
84
+ command=command,
85
+ exit_code=exit_code,
86
+ metadata=merged_metadata,
87
+ )
88
+ LocalCaptureBuffer(buffer_dir).append(event)
89
+ return event
@@ -0,0 +1,164 @@
1
+ """Shared capture-core primitives for CLI and hook-based adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from datetime import UTC, datetime
8
+ from enum import StrEnum
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+ from app.repo_scope import normalize_project_id
15
+
16
+
17
+ class CaptureEventType(StrEnum):
18
+ SESSION_START = "session_start"
19
+ SESSION_END = "session_end"
20
+ USER_PROMPT = "user_prompt"
21
+ ASSISTANT_OUTPUT = "assistant_output"
22
+ TOOL_RESULT = "tool_result"
23
+ SHELL_RESULT = "shell_result"
24
+ TEST_RESULT = "test_result"
25
+ REMEMBER = "remember"
26
+
27
+
28
+ class CaptureEvent(BaseModel):
29
+ """Normalized event emitted by any capture adapter."""
30
+
31
+ model_config = ConfigDict(use_enum_values=True)
32
+
33
+ source: str
34
+ session_id: str = "unknown-session"
35
+ event_type: CaptureEventType
36
+ timestamp: str
37
+ cwd: str
38
+ repo: str | None = None
39
+ branch: str | None = None
40
+ text: str | None = None
41
+ files: List[str] = Field(default_factory=list)
42
+ command: str | None = None
43
+ exit_code: int | None = None
44
+ commit: str | None = None
45
+ metadata: Dict[str, Any] = Field(default_factory=dict)
46
+
47
+
48
+ def now_iso() -> str:
49
+ return datetime.now(UTC).isoformat()
50
+
51
+
52
+ def _run_git(args: List[str], cwd: str) -> str | None:
53
+ try:
54
+ proc = subprocess.run(
55
+ ["git", *args],
56
+ cwd=cwd,
57
+ capture_output=True,
58
+ text=True,
59
+ check=True,
60
+ timeout=2,
61
+ )
62
+ except (OSError, subprocess.SubprocessError):
63
+ return None
64
+ return proc.stdout.strip() or None
65
+
66
+
67
+ def capture_repo_context(cwd: str | None = None) -> Dict[str, Any]:
68
+ """Best-effort git context for a local capture event."""
69
+
70
+ resolved_cwd = str(Path(cwd or ".").resolve())
71
+ repo_root = _run_git(["rev-parse", "--show-toplevel"], resolved_cwd)
72
+ if not repo_root:
73
+ return {
74
+ "cwd": resolved_cwd,
75
+ "repo": None,
76
+ "branch": None,
77
+ "commit": None,
78
+ "files": [],
79
+ }
80
+
81
+ branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], repo_root)
82
+ commit = _run_git(["rev-parse", "HEAD"], repo_root)
83
+ remote = _run_git(["remote", "get-url", "origin"], repo_root) or _run_git(
84
+ ["config", "--get", "remote.origin.url"],
85
+ repo_root,
86
+ )
87
+ changed = _run_git(["status", "--short"], repo_root) or ""
88
+ files: List[str] = []
89
+ for line in changed.splitlines():
90
+ candidate = line[3:].strip() if len(line) >= 4 else line.strip()
91
+ if candidate:
92
+ files.append(candidate)
93
+
94
+ return {
95
+ "cwd": resolved_cwd,
96
+ "repo": normalize_project_id(remote or Path(repo_root).name),
97
+ "branch": branch,
98
+ "commit": commit,
99
+ "files": files,
100
+ }
101
+
102
+
103
+ def build_capture_event(
104
+ *,
105
+ source: str,
106
+ event_type: CaptureEventType,
107
+ session_id: str | None = None,
108
+ cwd: str | None = None,
109
+ text: str | None = None,
110
+ files: List[str] | None = None,
111
+ command: str | None = None,
112
+ exit_code: int | None = None,
113
+ metadata: Dict[str, Any] | None = None,
114
+ ) -> CaptureEvent:
115
+ """Create a normalized capture event with best-effort repo context."""
116
+
117
+ repo_context = capture_repo_context(cwd)
118
+ merged_files = list(files or repo_context.get("files") or [])
119
+ return CaptureEvent(
120
+ source=source,
121
+ session_id=(session_id or "unknown-session"),
122
+ event_type=event_type,
123
+ timestamp=now_iso(),
124
+ cwd=repo_context["cwd"],
125
+ repo=repo_context.get("repo"),
126
+ branch=repo_context.get("branch"),
127
+ text=text,
128
+ files=merged_files,
129
+ command=command,
130
+ exit_code=exit_code,
131
+ commit=repo_context.get("commit"),
132
+ metadata=dict(metadata or {}),
133
+ )
134
+
135
+
136
+ class LocalCaptureBuffer:
137
+ """Simple JSONL-backed local event buffer for the capture POC."""
138
+
139
+ def __init__(self, root: str | Path):
140
+ self.root = Path(root)
141
+
142
+ def path_for(self, session_id: str) -> Path:
143
+ safe = (session_id or "unknown-session").replace("/", "_").replace("\\", "_")
144
+ return self.root / f"{safe}.jsonl"
145
+
146
+ def append(self, event: CaptureEvent) -> Path:
147
+ path = self.path_for(event.session_id)
148
+ path.parent.mkdir(parents=True, exist_ok=True)
149
+ with path.open("a", encoding="utf-8") as handle:
150
+ handle.write(json.dumps(event.model_dump(mode="json"), ensure_ascii=False))
151
+ handle.write("\n")
152
+ return path
153
+
154
+ def read(self, session_id: str) -> List[Dict[str, Any]]:
155
+ path = self.path_for(session_id)
156
+ if not path.exists():
157
+ return []
158
+ rows: List[Dict[str, Any]] = []
159
+ for line in path.read_text(encoding="utf-8").splitlines():
160
+ line = line.strip()
161
+ if not line:
162
+ continue
163
+ rows.append(json.loads(line))
164
+ return rows