bharatcode 0.1.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.
bharatcode/memory.py ADDED
@@ -0,0 +1,286 @@
1
+ """
2
+ Persistent memory — stores facts across sessions in structured Markdown files.
3
+ Inspired by Claude Code's auto-memory system (MEMORY.md index + topic files).
4
+
5
+ Storage layout:
6
+ ~/.bharatcode/memory/
7
+ MEMORY.md ← index, always loaded into every system prompt
8
+ file.md ← memories tagged "file" (file paths, contents)
9
+ project.md ← memories tagged "project" (architecture, decisions)
10
+ user.md ← memories tagged "user" (preferences, background)
11
+ feedback.md ← memories tagged "feedback" (what to do/avoid)
12
+ general.md ← everything else
13
+ <any_custom_tag>.md ← agent can use any tag
14
+
15
+ Each entry in a topic file:
16
+ - [id=42] [2026-06-08 10:22] content here
17
+
18
+ MEMORY.md is a one-line-per-entry index pointing to topic files.
19
+ It is loaded verbatim into the system prompt so the model always knows what exists.
20
+ Topic files are loaded fully (budget-capped at ~6K chars total) alongside the index.
21
+ """
22
+ import json
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+
26
+ MEMORY_DIR = Path.home() / ".bharatcode" / "memory"
27
+ MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
28
+ _COUNTER_FILE = MEMORY_DIR / ".counter"
29
+ _OLD_JSON = Path.home() / ".bharatcode" / "memory.json"
30
+
31
+
32
+ # ── Internal helpers ──────────────────────────────────────────────────────────
33
+
34
+ def _ensure() -> None:
35
+ MEMORY_DIR.mkdir(parents=True, exist_ok=True)
36
+ if not MEMORY_INDEX.exists():
37
+ if _OLD_JSON.exists():
38
+ _migrate_from_json()
39
+ else:
40
+ MEMORY_INDEX.write_text("# Memory Index\n\n", encoding="utf-8")
41
+
42
+
43
+ def _next_id() -> int:
44
+ """Global auto-increment counter for memory entry IDs."""
45
+ try:
46
+ n = int(_COUNTER_FILE.read_text(encoding="utf-8").strip()) + 1
47
+ except Exception:
48
+ n = 1
49
+ _COUNTER_FILE.write_text(str(n), encoding="utf-8")
50
+ return n
51
+
52
+
53
+ def _tag_file(tag: str) -> Path:
54
+ safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in tag.lower())
55
+ return MEMORY_DIR / f"{safe}.md"
56
+
57
+
58
+ def _rebuild_index() -> None:
59
+ """Rewrite MEMORY.md from the current set of topic files."""
60
+ lines = ["# Memory Index\n"]
61
+ for md in sorted(MEMORY_DIR.glob("*.md")):
62
+ if md.name == "MEMORY.md":
63
+ continue
64
+ try:
65
+ content = md.read_text(encoding="utf-8")
66
+ # first bullet line as description
67
+ preview = next(
68
+ (l.strip().lstrip("- ").split("] ", 2)[-1] # strip [id=N] [ts]
69
+ for l in content.splitlines()
70
+ if l.strip().startswith("- [")),
71
+ md.stem,
72
+ )[:120]
73
+ lines.append(f"- [{md.stem}]({md.name}) — {preview}")
74
+ except Exception:
75
+ lines.append(f"- [{md.stem}]({md.name})")
76
+ MEMORY_INDEX.write_text("\n".join(lines) + "\n", encoding="utf-8")
77
+
78
+
79
+ def _migrate_from_json() -> None:
80
+ """
81
+ One-time migration: flat memory.json → individual topic .md files.
82
+ Preserves all entries and assigns sequential IDs.
83
+ """
84
+ try:
85
+ old = json.loads(_OLD_JSON.read_text(encoding="utf-8"))
86
+ except Exception:
87
+ old = []
88
+
89
+ # Group by tag
90
+ by_tag: dict[str, list[tuple[int, str, str]]] = {}
91
+ counter = 0
92
+ for m in old:
93
+ counter += 1
94
+ tag = m.get("tag", "general")
95
+ content = (m.get("text") or m.get("content") or "").strip()
96
+ ts = (m.get("created") or "")[:16] or "migrated"
97
+ if content:
98
+ by_tag.setdefault(tag, []).append((counter, ts, content))
99
+
100
+ for tag, entries in by_tag.items():
101
+ lines = [f"# {tag.title()}\n"]
102
+ for mid, ts, content in entries:
103
+ lines.append(f"- [id={mid}] [{ts}] {content}")
104
+ _tag_file(tag).write_text("\n".join(lines) + "\n", encoding="utf-8")
105
+
106
+ # Set counter to max ID used
107
+ _COUNTER_FILE.write_text(str(counter), encoding="utf-8")
108
+ _rebuild_index()
109
+
110
+ # Back up old file
111
+ try:
112
+ _OLD_JSON.rename(_OLD_JSON.with_suffix(".json.bak"))
113
+ except Exception:
114
+ pass
115
+
116
+
117
+ # ── Public API ────────────────────────────────────────────────────────────────
118
+
119
+ def add_memory(text: str, tag: str = "general") -> dict:
120
+ """Add a memory entry. Returns dict with id, text, tag, created.
121
+ Exact-duplicate facts in the same topic file are skipped — the agent
122
+ saves memory after every task, so without this the store fills with
123
+ repeats that crowd real facts out of the context budget."""
124
+ _ensure()
125
+ ts = datetime.now().isoformat()
126
+ ts_short = ts[:16]
127
+ text = text.strip()
128
+
129
+ f = _tag_file(tag)
130
+ if f.exists() and text:
131
+ try:
132
+ if text in f.read_text(encoding="utf-8"):
133
+ return {"id": -1, "text": text, "tag": tag, "created": ts, "duplicate": True}
134
+ except Exception:
135
+ pass
136
+
137
+ mid = _next_id()
138
+ if not f.exists():
139
+ f.write_text(f"# {tag.title()}\n\n", encoding="utf-8")
140
+ with open(f, "a", encoding="utf-8") as fp:
141
+ fp.write(f"- [id={mid}] [{ts_short}] {text}\n")
142
+
143
+ _rebuild_index()
144
+ return {"id": mid, "text": text, "tag": tag, "created": ts}
145
+
146
+
147
+ def delete_memory(memory_id: int) -> bool:
148
+ """Delete a memory entry by its ID. Returns True if found and deleted."""
149
+ _ensure()
150
+ marker = f"[id={memory_id}]"
151
+ for md in MEMORY_DIR.glob("*.md"):
152
+ if md.name == "MEMORY.md":
153
+ continue
154
+ try:
155
+ content = md.read_text(encoding="utf-8")
156
+ if marker not in content:
157
+ continue
158
+ lines = content.splitlines(keepends=True)
159
+ new_lines = [l for l in lines if marker not in l]
160
+ md.write_text("".join(new_lines), encoding="utf-8")
161
+ _rebuild_index()
162
+ return True
163
+ except Exception:
164
+ pass
165
+ return False
166
+
167
+
168
+ def load_memories() -> list[dict]:
169
+ """Backward compat — return flat list of {id, text, tag, created} dicts."""
170
+ _ensure()
171
+ result = []
172
+ for md in sorted(MEMORY_DIR.glob("*.md")):
173
+ if md.name == "MEMORY.md":
174
+ continue
175
+ tag = md.stem
176
+ try:
177
+ for line in md.read_text(encoding="utf-8").splitlines():
178
+ if not line.strip().startswith("- [id="):
179
+ continue
180
+ # Parse: - [id=N] [YYYY-MM-DD HH:MM] content
181
+ rest = line.lstrip("- ")
182
+ # Extract id
183
+ id_end = rest.find("]")
184
+ try:
185
+ mid = int(rest[4:id_end]) # rest starts with "[id=N]"
186
+ except Exception:
187
+ mid = 0
188
+ rest = rest[id_end + 2:].strip() # skip "] "
189
+ # Extract timestamp
190
+ if rest.startswith("["):
191
+ ts_end = rest.find("]")
192
+ ts = rest[1:ts_end]
193
+ rest = rest[ts_end + 2:].strip()
194
+ else:
195
+ ts = ""
196
+ result.append({"id": mid, "text": rest, "tag": tag, "created": ts})
197
+ except Exception:
198
+ pass
199
+ return result
200
+
201
+
202
+ def remember(text: str, tag: str = "project") -> str:
203
+ """Tool-callable: save a memory and return confirmation."""
204
+ entry = add_memory(text, tag)
205
+ if entry.get("duplicate"):
206
+ return f"Already in memory — skipped duplicate: {text[:80]}"
207
+ return f"Memory saved (id={entry['id']}): {text[:80]}"
208
+
209
+
210
+ def memories_to_context() -> str:
211
+ """
212
+ Build the memory section injected into the system prompt.
213
+ Always includes MEMORY.md index. Then loads all topic files
214
+ (newest first) up to a ~6K char budget so large memory stores
215
+ don't flood the context.
216
+ """
217
+ _ensure()
218
+ if not MEMORY_INDEX.exists():
219
+ return ""
220
+
221
+ index = MEMORY_INDEX.read_text(encoding="utf-8").strip()
222
+ if not index or index == "# Memory Index":
223
+ return ""
224
+
225
+ parts = [f"\n\n## Persistent Memory (from past sessions)\n\n{index}"]
226
+
227
+ # Load topic files up to budget — NEWEST entries first within each file,
228
+ # so when the budget cuts anything it always cuts the OLDEST facts.
229
+ # (Topic files are append-only: newest entries live at the bottom.)
230
+ budget = 6000
231
+ for md in sorted(MEMORY_DIR.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True):
232
+ if md.name == "MEMORY.md":
233
+ continue
234
+ if budget <= 0:
235
+ break
236
+ try:
237
+ entry_lines = [
238
+ l for l in md.read_text(encoding="utf-8").splitlines()
239
+ if l.strip().startswith("- [id=")
240
+ ]
241
+ if not entry_lines:
242
+ continue
243
+ entry_lines = entry_lines[::-1][:40] # newest first, max 40 per topic
244
+ chunk = f"\n\n### {md.stem} (newest first)\n" + "\n".join(entry_lines)
245
+ if len(chunk) > budget:
246
+ chunk = chunk[:budget] + "\n ...(older entries omitted)"
247
+ parts.append(chunk)
248
+ budget -= len(chunk)
249
+ except Exception:
250
+ pass
251
+
252
+ return "".join(parts)
253
+
254
+
255
+ def show_memories(console) -> None:
256
+ """Pretty-print all memory entries for /memory list."""
257
+ _ensure()
258
+ files = [f for f in sorted(MEMORY_DIR.glob("*.md")) if f.name != "MEMORY.md"]
259
+
260
+ if not files:
261
+ console.print("[dim]No memories saved yet. The agent saves memories automatically, "
262
+ "or use: /memory add <text>[/dim]")
263
+ return
264
+
265
+ console.print(f"\n[bold]Memory[/bold] [dim]{MEMORY_DIR}[/dim]\n")
266
+ total = 0
267
+ for md in files:
268
+ try:
269
+ content = md.read_text(encoding="utf-8").strip()
270
+ entries = [l for l in content.splitlines() if l.strip().startswith("- [id=")]
271
+ if not entries:
272
+ continue
273
+ console.print(f" [bold cyan]{md.stem}[/bold cyan] [dim]({len(entries)} entries)[/dim]")
274
+ for line in entries:
275
+ # Parse display: strip [id=N] prefix, keep [ts] and content
276
+ rest = line.lstrip("- ")
277
+ id_end = rest.find("]")
278
+ mid = rest[4:id_end] if rest.startswith("[id=") else "?"
279
+ rest = rest[id_end + 2:].strip()
280
+ console.print(f" [dim]{mid:>4}[/dim] {rest}")
281
+ total += 1
282
+ console.print()
283
+ except Exception:
284
+ pass
285
+
286
+ console.print(f"[dim] {total} total entries — /memory del <id> to remove[/dim]\n")
@@ -0,0 +1,99 @@
1
+ """
2
+ Permission system — inspired by Claude Code's BashPermissionRequest component.
3
+ Ask user allow/deny/always before running bash commands.
4
+ """
5
+ import json
6
+ from pathlib import Path
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.prompt import Prompt
10
+
11
+ console = Console()
12
+
13
+ _SESSION_ALWAYS: set[str] = set() # commands allowed for this session
14
+ _ALWAYS_FILE = Path.home() / ".bharatcode" / "always_allow.json"
15
+
16
+ def _load_always() -> set[str]:
17
+ if _ALWAYS_FILE.exists():
18
+ try:
19
+ return set(json.loads(_ALWAYS_FILE.read_text()))
20
+ except Exception:
21
+ return set()
22
+ return set()
23
+
24
+ def _save_always(items: set[str]):
25
+ _ALWAYS_FILE.parent.mkdir(exist_ok=True)
26
+ _ALWAYS_FILE.write_text(json.dumps(sorted(items)))
27
+
28
+ _PERMANENT_ALWAYS: set[str] = _load_always()
29
+
30
+ def _command_key(cmd: str) -> str:
31
+ """Normalize a command for matching (first word / verb)."""
32
+ return cmd.strip().split()[0] if cmd.strip() else ""
33
+
34
+ def needs_approval(tool_name: str, args: dict, auto_approve: bool = False) -> tuple[bool, str]:
35
+ """
36
+ Returns (approved, reason).
37
+ Safe read-only tools are auto-approved.
38
+ Bash requires user confirmation unless always-allowed.
39
+ """
40
+ if auto_approve:
41
+ return True, "auto"
42
+
43
+ # Read-only tools: always OK
44
+ if tool_name in ("read_file", "glob", "grep"):
45
+ return True, "safe"
46
+
47
+ # write/edit — auto-approve (user sees the diff anyway)
48
+ if tool_name in ("write_file", "edit_file"):
49
+ return True, "safe"
50
+
51
+ # bash — check allow lists
52
+ if tool_name == "bash":
53
+ cmd = args.get("command", "")
54
+ key = _command_key(cmd)
55
+ if key in _PERMANENT_ALWAYS or key in _SESSION_ALWAYS:
56
+ return True, "always-allowed"
57
+ return False, "needs-approval"
58
+
59
+ return True, "safe"
60
+
61
+ def ask_permission(tool_name: str, args: dict) -> bool:
62
+ """
63
+ Show a permission dialog for bash commands.
64
+ Returns True if approved.
65
+ """
66
+ cmd = args.get("command", "")
67
+
68
+ console.print()
69
+ console.print(Panel(
70
+ f"[bold yellow]{cmd}[/bold yellow]",
71
+ title="[bold red] Sylithe Code wants to run a command [/bold red]",
72
+ border_style="yellow",
73
+ padding=(0, 1),
74
+ ))
75
+ console.print(
76
+ " [green]y[/green] Allow once "
77
+ "[cyan]s[/cyan] Allow for session "
78
+ "[blue]a[/blue] Always allow "
79
+ "[red]n[/red] Deny"
80
+ )
81
+
82
+ choice = Prompt.ask(" [dim]Permission[/dim]", choices=["y", "s", "a", "n"], default="y")
83
+
84
+ if choice == "n":
85
+ console.print(" [red]Denied.[/red]")
86
+ return False
87
+
88
+ key = _command_key(cmd)
89
+
90
+ if choice == "s":
91
+ _SESSION_ALWAYS.add(key)
92
+ console.print(f" [cyan]Allowed for this session: {key}[/cyan]")
93
+ elif choice == "a":
94
+ _PERMANENT_ALWAYS.add(key)
95
+ _save_always(_PERMANENT_ALWAYS)
96
+ console.print(f" [blue]Always allowed: {key}[/blue]")
97
+
98
+ console.print()
99
+ return True
bharatcode/project.py ADDED
@@ -0,0 +1,179 @@
1
+ """
2
+ Project auto-detection — inspired by Claude Code's detectRepository.
3
+ Reads package.json / requirements.txt / pom.xml / build.gradle / go.mod
4
+ and injects project context into every system prompt.
5
+ """
6
+ import json
7
+ from pathlib import Path
8
+
9
+
10
+ def detect_project(cwd: str = ".") -> dict:
11
+ """Detect project type and metadata from common config files."""
12
+ root = Path(cwd)
13
+ info = {
14
+ "type": "unknown",
15
+ "language": "unknown",
16
+ "name": root.name,
17
+ "version": "",
18
+ "deps": [],
19
+ "scripts": [],
20
+ "test_cmd": "",
21
+ "run_cmd": "",
22
+ "framework": "",
23
+ }
24
+
25
+ # Node.js
26
+ pkg = root / "package.json"
27
+ if pkg.exists():
28
+ try:
29
+ d = json.loads(pkg.read_text(encoding="utf-8"))
30
+ info.update({
31
+ "type": "node",
32
+ "language": "javascript" if not (root / "tsconfig.json").exists() else "typescript",
33
+ "name": d.get("name", root.name),
34
+ "version": d.get("version", ""),
35
+ "deps": list(d.get("dependencies", {}).keys())[:20],
36
+ "scripts": list(d.get("scripts", {}).keys()),
37
+ "test_cmd": "npm test",
38
+ "run_cmd": "npm start",
39
+ "framework": _detect_node_framework(d),
40
+ })
41
+ except Exception:
42
+ pass
43
+ return info
44
+
45
+ # Python
46
+ for pyfile in ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile"]:
47
+ if (root / pyfile).exists():
48
+ info["type"] = "python"
49
+ info["language"] = "python"
50
+ info["test_cmd"] = "pytest"
51
+ info["run_cmd"] = "python app.py"
52
+
53
+ if pyfile == "requirements.txt":
54
+ lines = (root / pyfile).read_text(encoding="utf-8").splitlines()
55
+ info["deps"] = [l.split("==")[0].split(">=")[0].strip()
56
+ for l in lines if l.strip() and not l.startswith("#")][:20]
57
+ info["framework"] = _detect_python_framework(info["deps"])
58
+
59
+ elif pyfile == "pyproject.toml":
60
+ try:
61
+ import tomllib
62
+ with open(root / pyfile, "rb") as f:
63
+ d = tomllib.load(f)
64
+ proj = d.get("project", {})
65
+ info["name"] = proj.get("name", root.name)
66
+ info["version"] = proj.get("version", "")
67
+ info["deps"] = [str(d).split("[")[0].split(">=")[0].split("==")[0].strip()
68
+ for d in proj.get("dependencies", [])][:20]
69
+ info["framework"] = _detect_python_framework(info["deps"])
70
+ scripts = d.get("project", {}).get("scripts", {})
71
+ if scripts:
72
+ info["run_cmd"] = f"python -m {list(scripts.values())[0]}"
73
+ except Exception:
74
+ pass
75
+
76
+ return info
77
+
78
+ # Java / Maven
79
+ if (root / "pom.xml").exists():
80
+ info.update({
81
+ "type": "java",
82
+ "language": "java",
83
+ "test_cmd": "mvn test",
84
+ "run_cmd": "mvn spring-boot:run",
85
+ "framework": "Spring Boot",
86
+ })
87
+ return info
88
+
89
+ # Java / Gradle
90
+ if (root / "build.gradle").exists() or (root / "build.gradle.kts").exists():
91
+ kts = (root / "build.gradle.kts").exists()
92
+ info.update({
93
+ "type": "java",
94
+ "language": "kotlin" if kts else "java",
95
+ "test_cmd": "./gradlew test",
96
+ "run_cmd": "./gradlew bootRun",
97
+ "framework": "Spring Boot",
98
+ })
99
+ return info
100
+
101
+ # Go
102
+ if (root / "go.mod").exists():
103
+ info.update({
104
+ "type": "go",
105
+ "language": "go",
106
+ "test_cmd": "go test ./...",
107
+ "run_cmd": "go run .",
108
+ })
109
+ return info
110
+
111
+ # Rust
112
+ if (root / "Cargo.toml").exists():
113
+ info.update({
114
+ "type": "rust",
115
+ "language": "rust",
116
+ "test_cmd": "cargo test",
117
+ "run_cmd": "cargo run",
118
+ })
119
+ return info
120
+
121
+ # Flutter / Dart
122
+ if (root / "pubspec.yaml").exists():
123
+ info.update({
124
+ "type": "flutter",
125
+ "language": "dart",
126
+ "test_cmd": "flutter test",
127
+ "run_cmd": "flutter run",
128
+ "framework": "Flutter",
129
+ })
130
+ return info
131
+
132
+ return info
133
+
134
+
135
+ def _detect_node_framework(pkg: dict) -> str:
136
+ deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
137
+ if "next" in deps: return "Next.js"
138
+ if "react" in deps: return "React"
139
+ if "@angular/core" in deps: return "Angular"
140
+ if "vue" in deps: return "Vue.js"
141
+ if "express" in deps: return "Express.js"
142
+ if "fastify" in deps: return "Fastify"
143
+ if "nestjs" in deps: return "NestJS"
144
+ return ""
145
+
146
+
147
+ def _detect_python_framework(deps: list[str]) -> str:
148
+ deps_lower = [d.lower() for d in deps]
149
+ if "django" in deps_lower: return "Django"
150
+ if "flask" in deps_lower: return "Flask"
151
+ if "fastapi" in deps_lower: return "FastAPI"
152
+ if "streamlit" in deps_lower: return "Streamlit"
153
+ if "celery" in deps_lower: return "Celery"
154
+ return ""
155
+
156
+
157
+ def project_context_string(cwd: str = ".") -> str:
158
+ """Returns a string to inject into the system prompt about this project."""
159
+ info = detect_project(cwd)
160
+ if info["type"] == "unknown":
161
+ return ""
162
+
163
+ lines = [f"\n\n## Auto-detected Project Context"]
164
+ lines.append(f"- **Project**: {info['name']}")
165
+ lines.append(f"- **Language**: {info['language']}")
166
+ if info.get("framework"):
167
+ lines.append(f"- **Framework**: {info['framework']}")
168
+ if info.get("version"):
169
+ lines.append(f"- **Version**: {info['version']}")
170
+ if info.get("test_cmd"):
171
+ lines.append(f"- **Test command**: `{info['test_cmd']}`")
172
+ if info.get("run_cmd"):
173
+ lines.append(f"- **Run command**: `{info['run_cmd']}`")
174
+ if info.get("deps"):
175
+ lines.append(f"- **Key dependencies**: {', '.join(info['deps'][:10])}")
176
+ if info.get("scripts"):
177
+ lines.append(f"- **Scripts**: {', '.join(info['scripts'][:8])}")
178
+
179
+ return "\n".join(lines)
@@ -0,0 +1,108 @@
1
+ """
2
+ Session persistence — saves conversation history to JSONL as it happens,
3
+ so sessions survive process crashes and can be resumed in a new terminal.
4
+
5
+ Storage layout:
6
+ ~/.bharatcode/sessions/<project_hash>/<session_id>.jsonl
7
+ ~/.bharatcode/sessions/<project_hash>/latest ← ID of most recent session
8
+
9
+ One JSON object per line. Messages appended in real-time so a crash mid-turn
10
+ only loses the in-progress messages, not the whole session.
11
+ """
12
+ import json
13
+ import uuid
14
+ import hashlib
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ _SESSIONS_ROOT = Path.home() / ".bharatcode" / "sessions"
19
+
20
+
21
+ # ── Path helpers ──────────────────────────────────────────────────────────────
22
+
23
+ def _project_dir(project_path: str) -> Path:
24
+ h = hashlib.md5(str(project_path).encode()).hexdigest()[:10]
25
+ d = _SESSIONS_ROOT / h
26
+ d.mkdir(parents=True, exist_ok=True)
27
+ return d
28
+
29
+
30
+ def new_session_id() -> str:
31
+ return uuid.uuid4().hex[:12]
32
+
33
+
34
+ def session_path(project_path: str, session_id: str) -> Path:
35
+ return _project_dir(project_path) / f"{session_id}.jsonl"
36
+
37
+
38
+ # ── Read / Write ──────────────────────────────────────────────────────────────
39
+
40
+ def append_messages(path: Path, messages: list[dict]) -> None:
41
+ """
42
+ Append messages to the JSONL file. Fire-and-forget — never raises.
43
+ Called by interactive_mode after each agent turn completes.
44
+ """
45
+ if not messages:
46
+ return
47
+ try:
48
+ with open(path, "a", encoding="utf-8") as f:
49
+ for msg in messages:
50
+ f.write(json.dumps(msg, ensure_ascii=False) + "\n")
51
+ except Exception:
52
+ pass
53
+
54
+
55
+ def load_messages(path: Path) -> list[dict]:
56
+ """Load all valid messages from a JSONL session file."""
57
+ if not path or not path.exists():
58
+ return []
59
+ messages = []
60
+ try:
61
+ for line in path.read_text(encoding="utf-8").splitlines():
62
+ line = line.strip()
63
+ if line:
64
+ try:
65
+ messages.append(json.loads(line))
66
+ except json.JSONDecodeError:
67
+ pass
68
+ except Exception:
69
+ pass
70
+ return messages
71
+
72
+
73
+ # ── Session discovery ─────────────────────────────────────────────────────────
74
+
75
+ def save_latest_pointer(project_path: str, session_id: str) -> None:
76
+ (_project_dir(project_path) / "latest").write_text(session_id, encoding="utf-8")
77
+
78
+
79
+ def list_recent(project_path: str, max_n: int = 5) -> list[dict]:
80
+ """
81
+ Return metadata for the N most recent sessions for this project.
82
+ Each entry: {session_id, path, turns, last_message, mtime_str}
83
+ """
84
+ d = _project_dir(project_path)
85
+ results = []
86
+ for f in sorted(d.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True):
87
+ messages = load_messages(f)
88
+ if not messages:
89
+ continue
90
+ user_msgs = [m for m in messages if m.get("role") == "user"]
91
+ if not user_msgs:
92
+ continue
93
+ last = user_msgs[-1].get("content", "")
94
+ last_preview = last[:70].replace("\n", " ") if last else ""
95
+ import datetime
96
+ mtime = f.stat().st_mtime
97
+ dt = datetime.datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
98
+ results.append({
99
+ "session_id": f.stem,
100
+ "path": f,
101
+ "turns": len(user_msgs),
102
+ "last_message": last_preview,
103
+ "mtime": mtime,
104
+ "mtime_str": dt,
105
+ })
106
+ if len(results) >= max_n:
107
+ break
108
+ return results