hanuscode 1.0.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 (93) hide show
  1. hanus/__init__.py +5 -0
  2. hanus/__main__.py +10 -0
  3. hanus/action_handlers.py +76 -0
  4. hanus/action_parser.py +82 -0
  5. hanus/agent_runner.py +1445 -0
  6. hanus/analysis/__init__.py +5 -0
  7. hanus/analysis/debt.py +702 -0
  8. hanus/analysis/dependencies.py +475 -0
  9. hanus/cache/__init__.py +5 -0
  10. hanus/cache/response_cache.py +560 -0
  11. hanus/config.py +401 -0
  12. hanus/connectors/__init__.py +19 -0
  13. hanus/connectors/base.py +114 -0
  14. hanus/connectors/claude_connector.py +146 -0
  15. hanus/connectors/gemini_connector.py +141 -0
  16. hanus/connectors/glm_connector.py +160 -0
  17. hanus/connectors/ollama_connector.py +174 -0
  18. hanus/connectors/openai_connector.py +122 -0
  19. hanus/connectors/registry.py +26 -0
  20. hanus/context/__init__.py +7 -0
  21. hanus/context/manager.py +837 -0
  22. hanus/context/selective.py +626 -0
  23. hanus/error_recovery/__init__.py +5 -0
  24. hanus/error_recovery/auto_fix.py +605 -0
  25. hanus/hooks/__init__.py +5 -0
  26. hanus/hooks/manager.py +247 -0
  27. hanus/instincts/__init__.py +44 -0
  28. hanus/instincts/cli.py +372 -0
  29. hanus/instincts/detector.py +281 -0
  30. hanus/instincts/evolver.py +361 -0
  31. hanus/instincts/manager.py +343 -0
  32. hanus/instincts/types.py +253 -0
  33. hanus/logger.py +81 -0
  34. hanus/memory/__init__.py +8 -0
  35. hanus/memory/manager.py +265 -0
  36. hanus/memory/types.py +119 -0
  37. hanus/monitor.py +341 -0
  38. hanus/parallel/__init__.py +5 -0
  39. hanus/parallel/executor.py +300 -0
  40. hanus/permissions.py +182 -0
  41. hanus/plan/__init__.py +8 -0
  42. hanus/plan/mode.py +267 -0
  43. hanus/plan/models.py +152 -0
  44. hanus/plugin_manager.py +754 -0
  45. hanus/plugin_registry.py +391 -0
  46. hanus/plugins/__init__.py +1 -0
  47. hanus/plugins/arena.py +630 -0
  48. hanus/plugins/code_review.py +123 -0
  49. hanus/plugins/cortex.py +1750 -0
  50. hanus/plugins/deps_check.py +27 -0
  51. hanus/plugins/git_ops.py +33 -0
  52. hanus/plugins/metasploit.py +530 -0
  53. hanus/plugins/notes.py +583 -0
  54. hanus/plugins/search_code.py +59 -0
  55. hanus/plugins/searchsploit.py +495 -0
  56. hanus/plugins/strategist.py +175 -0
  57. hanus/plugins/webui.py +5200 -0
  58. hanus/profiles.py +479 -0
  59. hanus/profiles_builtin/__init__.py +0 -0
  60. hanus/profiles_builtin/architect/profile.yaml +12 -0
  61. hanus/profiles_builtin/architect/system_prompt.txt +71 -0
  62. hanus/profiles_builtin/deep/profile.yaml +12 -0
  63. hanus/profiles_builtin/deep/system_prompt.txt +66 -0
  64. hanus/profiles_builtin/developer/__init__.py +0 -0
  65. hanus/profiles_builtin/developer/profile.yaml +9 -0
  66. hanus/profiles_builtin/developer/system_prompt.txt +176 -0
  67. hanus/profiles_builtin/speed/profile.yaml +12 -0
  68. hanus/profiles_builtin/speed/system_prompt.txt +51 -0
  69. hanus/project_tools.py +177 -0
  70. hanus/query_engine.py +1594 -0
  71. hanus/rules/__init__.py +237 -0
  72. hanus/search/__init__.py +5 -0
  73. hanus/search/semantic.py +596 -0
  74. hanus/session_manager.py +547 -0
  75. hanus/skill_manager.py +702 -0
  76. hanus/skills/__init__.py +4 -0
  77. hanus/subagent/__init__.py +8 -0
  78. hanus/subagent/agents/__init__.py +253 -0
  79. hanus/subagent/manager.py +309 -0
  80. hanus/subagent/types.py +266 -0
  81. hanus/suggestions/__init__.py +5 -0
  82. hanus/suggestions/proactive.py +451 -0
  83. hanus/tasks/__init__.py +8 -0
  84. hanus/tasks/manager.py +330 -0
  85. hanus/tasks/models.py +106 -0
  86. hanus/terminal_prompt.py +166 -0
  87. hanus/tools.py +1849 -0
  88. hanus/ui.py +939 -0
  89. hanuscode-1.0.0.dist-info/METADATA +1151 -0
  90. hanuscode-1.0.0.dist-info/RECORD +93 -0
  91. hanuscode-1.0.0.dist-info/WHEEL +5 -0
  92. hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
  93. hanuscode-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,547 @@
1
+ # session_manager.py
2
+ """
3
+ Persistencia de sesiones: guarda historial, costos, archivos modificados.
4
+ Soporta pausa/reanudación, undo de archivos, y timeline de cambios.
5
+ """
6
+ from __future__ import annotations
7
+ import gzip
8
+ import json
9
+ import time
10
+ from dataclasses import dataclass, field, asdict
11
+ from pathlib import Path
12
+ from typing import List, Dict, Optional, Any
13
+ from datetime import datetime
14
+ from enum import Enum
15
+
16
+
17
+ SESSIONS_DIR = Path.home() / ".hanus" / "sessions"
18
+ MAX_SESSIONS = 50
19
+
20
+
21
+ class ChangeType(Enum):
22
+ """Tipo de cambio en el timeline."""
23
+ FILE_CREATED = "file_created"
24
+ FILE_MODIFIED = "file_modified"
25
+ FILE_DELETED = "file_deleted"
26
+ COMMAND_EXECUTED = "command_executed"
27
+ COMMAND_SUCCESS = "command_success"
28
+ COMMAND_ERROR = "command_error"
29
+ TOOL_USED = "tool_used"
30
+ MESSAGE_SENT = "message_sent"
31
+ MESSAGE_RECEIVED = "message_received"
32
+ SESSION_STARTED = "session_started"
33
+ SESSION_PAUSED = "session_paused"
34
+ SESSION_RESUMED = "session_resumed"
35
+ MILESTONE = "milestone"
36
+ ERROR = "error"
37
+ NOTE = "note"
38
+
39
+
40
+ @dataclass
41
+ class FileSnapshot:
42
+ path: str
43
+ content_before: str
44
+ content_after: str = ""
45
+ timestamp: float = field(default_factory=time.time)
46
+
47
+
48
+ @dataclass
49
+ class TimelineEvent:
50
+ """Un evento en el timeline de la sesión."""
51
+ id: str
52
+ type: ChangeType
53
+ timestamp: float
54
+ description: str
55
+ details: Dict[str, Any] = field(default_factory=dict)
56
+ tags: List[str] = field(default_factory=list)
57
+ importance: int = 1 # 1=bajo, 2=medio, 3=alto
58
+
59
+ def to_dict(self) -> Dict:
60
+ return {
61
+ "id": self.id,
62
+ "type": self.type.value,
63
+ "timestamp": self.timestamp,
64
+ "description": self.description,
65
+ "details": self.details,
66
+ "tags": self.tags,
67
+ "importance": self.importance,
68
+ "time_ago": self._time_ago(),
69
+ }
70
+
71
+ def _time_ago(self) -> str:
72
+ """Retorna una representación legible del tiempo transcurrido."""
73
+ elapsed = time.time() - self.timestamp
74
+ if elapsed < 60:
75
+ return "just now"
76
+ elif elapsed < 3600:
77
+ return f"{int(elapsed / 60)}m ago"
78
+ elif elapsed < 86400:
79
+ return f"{int(elapsed / 3600)}h ago"
80
+ else:
81
+ return f"{int(elapsed / 86400)}d ago"
82
+
83
+
84
+ @dataclass
85
+ class SessionData:
86
+ session_id: str
87
+ project_dir: str
88
+ model_provider: str
89
+ model_id: str
90
+ created_at: float = field(default_factory=time.time)
91
+ updated_at: float = field(default_factory=time.time)
92
+ messages: List[Dict] = field(default_factory=list)
93
+ total_input_tokens: int = 0
94
+ total_output_tokens: int = 0
95
+ total_cost_usd: float = 0.0
96
+ files_modified: List[str] = field(default_factory=list)
97
+ commands_executed: List[str] = field(default_factory=list)
98
+ snapshots: List[Dict] = field(default_factory=list)
99
+ timeline: List[Dict] = field(default_factory=list) # Nuevo: timeline de eventos
100
+ name: str = ""
101
+ status: str = "active" # active, paused, completed
102
+ milestones: List[Dict] = field(default_factory=list) # Hitos importantes
103
+
104
+
105
+ class SessionManager:
106
+
107
+ def __init__(self, sessions_dir: Optional[Path] = None):
108
+ self.dir = sessions_dir or SESSIONS_DIR
109
+ self.dir.mkdir(parents=True, exist_ok=True)
110
+ self.current: Optional[SessionData] = None
111
+ self._event_counter = 0
112
+
113
+ def new_session(self, project_dir: str, provider: str, model_id: str) -> SessionData:
114
+ ts = time.strftime("%Y%m%d_%H%M%S")
115
+ proj_name = Path(project_dir).name
116
+ sid = f"{ts}_{proj_name}"
117
+ self.current = SessionData(
118
+ session_id=sid,
119
+ project_dir=project_dir,
120
+ model_provider=provider,
121
+ model_id=model_id,
122
+ name=f"{proj_name} — {time.strftime('%d %b %H:%M')}",
123
+ )
124
+ # Añadir evento de inicio
125
+ self._add_timeline_event(
126
+ ChangeType.SESSION_STARTED,
127
+ f"Session started: {self.current.name}",
128
+ {"project_dir": project_dir, "provider": provider, "model": model_id},
129
+ importance=3
130
+ )
131
+ return self.current
132
+
133
+ def save(self):
134
+ if not self.current:
135
+ return
136
+ self.current.updated_at = time.time()
137
+ path = self.dir / f"{self.current.session_id}.json.gz"
138
+ with gzip.open(path, "wt", encoding="utf-8") as f:
139
+ f.write(json.dumps(asdict(self.current), ensure_ascii=False))
140
+ self._prune()
141
+
142
+ def load_latest(self, project_dir: str) -> Optional[SessionData]:
143
+ proj_name = Path(project_dir).name
144
+ candidates = sorted(
145
+ self.dir.glob(f"*_{proj_name}.json.gz"),
146
+ key=lambda f: f.stat().st_mtime,
147
+ reverse=True,
148
+ )
149
+ return self._load(candidates[0]) if candidates else None
150
+
151
+ def load_by_id(self, session_id: str) -> Optional[SessionData]:
152
+ p = self.dir / f"{session_id}.json.gz"
153
+ return self._load(p) if p.exists() else None
154
+
155
+ def list_sessions(self, limit: int = 20) -> List[Dict]:
156
+ files = sorted(self.dir.glob("*.json.gz"), key=lambda f: f.stat().st_mtime, reverse=True)
157
+ result = []
158
+ for f in files[:limit]:
159
+ sd = self._load(f)
160
+ if sd:
161
+ result.append({
162
+ "id": sd.session_id,
163
+ "name": sd.name,
164
+ "project": sd.project_dir,
165
+ "model": f"{sd.model_provider}/{sd.model_id}",
166
+ "cost": sd.total_cost_usd,
167
+ "messages": len(sd.messages),
168
+ "updated": time.strftime("%d/%m %H:%M", time.localtime(sd.updated_at)),
169
+ "status": sd.status,
170
+ "files_modified": len(sd.files_modified),
171
+ "events": len(sd.timeline),
172
+ })
173
+ return result
174
+
175
+ # ══════════════════════════════════════════════════════════════════════════
176
+ # TIMELINE API
177
+ # ══════════════════════════════════════════════════════════════════════════
178
+
179
+ def record_file_created(self, path: str, content_preview: str = ""):
180
+ """Registra la creación de un archivo."""
181
+ if self.current:
182
+ self._add_timeline_event(
183
+ ChangeType.FILE_CREATED,
184
+ f"Created: {path}",
185
+ {"path": path, "preview": content_preview[:200]},
186
+ tags=["file", "create"],
187
+ importance=2
188
+ )
189
+ self.record_file_modified(path)
190
+
191
+ def record_file_modified(self, path: str, diff_preview: str = ""):
192
+ """Registra la modificación de un archivo."""
193
+ if self.current:
194
+ self._add_timeline_event(
195
+ ChangeType.FILE_MODIFIED,
196
+ f"Modified: {path}",
197
+ {"path": path, "diff": diff_preview[:200]},
198
+ tags=["file", "modify"],
199
+ importance=2
200
+ )
201
+ if path not in self.current.files_modified:
202
+ self.current.files_modified.append(path)
203
+
204
+ def record_file_deleted(self, path: str):
205
+ """Registra la eliminación de un archivo."""
206
+ if self.current:
207
+ self._add_timeline_event(
208
+ ChangeType.FILE_DELETED,
209
+ f"Deleted: {path}",
210
+ {"path": path},
211
+ tags=["file", "delete"],
212
+ importance=2
213
+ )
214
+
215
+ def record_command(self, cmd: str, success: bool = True, output_preview: str = ""):
216
+ """Registra la ejecución de un comando."""
217
+ if not self.current:
218
+ return
219
+
220
+ self.current.commands_executed.append(cmd)
221
+
222
+ event_type = ChangeType.COMMAND_SUCCESS if success else ChangeType.COMMAND_ERROR
223
+ description = f"Executed: {cmd[:50]}..." if len(cmd) > 50 else f"Executed: {cmd}"
224
+
225
+ self._add_timeline_event(
226
+ event_type,
227
+ description,
228
+ {"command": cmd, "success": success, "output": output_preview[:200]},
229
+ tags=["command", "success" if success else "error"],
230
+ importance=3 if not success else 1
231
+ )
232
+
233
+ def record_tool_use(self, tool_name: str, args: Dict, result_preview: str = ""):
234
+ """Registra el uso de una herramienta."""
235
+ if self.current:
236
+ self._add_timeline_event(
237
+ ChangeType.TOOL_USED,
238
+ f"Tool: {tool_name}",
239
+ {"tool": tool_name, "args": args, "result": result_preview[:200]},
240
+ tags=["tool", tool_name],
241
+ importance=1
242
+ )
243
+
244
+ def record_message(self, role: str, content_preview: str = ""):
245
+ """Registra un mensaje enviado o recibido."""
246
+ if not self.current:
247
+ return
248
+
249
+ event_type = ChangeType.MESSAGE_SENT if role == "user" else ChangeType.MESSAGE_RECEIVED
250
+ description = f"You: {content_preview[:50]}..." if role == "user" else f"Assistant: {content_preview[:50]}..."
251
+
252
+ self._add_timeline_event(
253
+ event_type,
254
+ description,
255
+ {"role": role, "preview": content_preview[:100]},
256
+ tags=["message", role],
257
+ importance=2
258
+ )
259
+
260
+ def record_milestone(self, title: str, description: str = ""):
261
+ """Registra un hito importante en la sesión."""
262
+ if not self.current:
263
+ return
264
+
265
+ milestone = {
266
+ "id": f"milestone_{self._event_counter}",
267
+ "title": title,
268
+ "description": description,
269
+ "timestamp": time.time(),
270
+ }
271
+ self.current.milestones.append(milestone)
272
+
273
+ self._add_timeline_event(
274
+ ChangeType.MILESTONE,
275
+ f"🎯 {title}",
276
+ {"description": description},
277
+ tags=["milestone"],
278
+ importance=3
279
+ )
280
+
281
+ def record_error(self, error: str, context: Dict = None):
282
+ """Registra un error."""
283
+ if self.current:
284
+ self._add_timeline_event(
285
+ ChangeType.ERROR,
286
+ f"Error: {error[:100]}",
287
+ {"error": error, "context": context or {}},
288
+ tags=["error"],
289
+ importance=3
290
+ )
291
+
292
+ def record_note(self, note: str):
293
+ """Registra una nota personal."""
294
+ if self.current:
295
+ self._add_timeline_event(
296
+ ChangeType.NOTE,
297
+ f"Note: {note[:100]}",
298
+ {"note": note},
299
+ tags=["note"],
300
+ importance=1
301
+ )
302
+
303
+ def get_timeline(
304
+ self,
305
+ event_types: List[str] = None,
306
+ tags: List[str] = None,
307
+ min_importance: int = 1,
308
+ limit: int = 100
309
+ ) -> List[Dict]:
310
+ """
311
+ Obtiene el timeline filtrado.
312
+
313
+ Args:
314
+ event_types: Filtrar por tipos de evento
315
+ tags: Filtrar por tags
316
+ min_importance: Importancia mínima (1-3)
317
+ limit: Máximo número de eventos
318
+
319
+ Returns:
320
+ Lista de eventos ordenados por tiempo
321
+ """
322
+ if not self.current:
323
+ return []
324
+
325
+ events = []
326
+ for event_data in self.current.timeline:
327
+ event = self._dict_to_event(event_data)
328
+
329
+ # Filtros
330
+ if event_types and event.type.value not in event_types:
331
+ continue
332
+ if tags and not any(tag in event.tags for tag in tags):
333
+ continue
334
+ if event.importance < min_importance:
335
+ continue
336
+
337
+ events.append(event.to_dict())
338
+
339
+ # Ordenar por timestamp descendente
340
+ events.sort(key=lambda e: e["timestamp"], reverse=True)
341
+
342
+ return events[:limit]
343
+
344
+ def get_timeline_summary(self) -> Dict:
345
+ """Obtiene un resumen del timeline."""
346
+ if not self.current:
347
+ return {}
348
+
349
+ events = self.current.timeline
350
+ by_type = {}
351
+ for event_data in events:
352
+ event_type = event_data.get("type", "unknown")
353
+ by_type[event_type] = by_type.get(event_type, 0) + 1
354
+
355
+ return {
356
+ "total_events": len(events),
357
+ "files_created": by_type.get("file_created", 0),
358
+ "files_modified": by_type.get("file_modified", 0),
359
+ "commands_executed": by_type.get("command_success", 0) + by_type.get("command_error", 0),
360
+ "commands_failed": by_type.get("command_error", 0),
361
+ "errors": by_type.get("error", 0),
362
+ "milestones": len(self.current.milestones),
363
+ "by_type": by_type,
364
+ }
365
+
366
+ def export_timeline(self, format: str = "markdown") -> str:
367
+ """Exporta el timeline a diferentes formatos."""
368
+ if not self.current:
369
+ return ""
370
+
371
+ events = self.get_timeline(limit=1000)
372
+
373
+ if format == "markdown":
374
+ lines = [
375
+ f"# Session Timeline: {self.current.name}",
376
+ f"\n**Session ID:** {self.current.session_id}",
377
+ f"**Created:** {datetime.fromtimestamp(self.current.created_at).isoformat()}",
378
+ f"**Status:** {self.current.status}",
379
+ f"\n## Events\n"
380
+ ]
381
+
382
+ for event in events:
383
+ time_str = datetime.fromtimestamp(event["timestamp"]).strftime("%H:%M:%S")
384
+ emoji = self._get_event_emoji(event["type"])
385
+ lines.append(f"- **{time_str}** {emoji} {event['description']}")
386
+
387
+ # Add milestones
388
+ if self.current.milestones:
389
+ lines.append("\n## Milestones\n")
390
+ for m in self.current.milestones:
391
+ time_str = datetime.fromtimestamp(m["timestamp"]).strftime("%H:%M:%S")
392
+ lines.append(f"- **{time_str}** 🎯 {m['title']}: {m['description']}")
393
+
394
+ return "\n".join(lines)
395
+
396
+ elif format == "json":
397
+ return json.dumps(events, indent=2)
398
+
399
+ return ""
400
+
401
+ # ══════════════════════════════════════════════════════════════════════════
402
+ # MÉTODOS ORIGINALES
403
+ # ══════════════════════════════════════════════════════════════════════════
404
+
405
+ def snapshot_file(self, path_str: str):
406
+ """Guarda una copia del archivo antes de modificarlo (para /undo)."""
407
+ if not self.current:
408
+ return
409
+ p = Path(path_str)
410
+ if p.exists():
411
+ try:
412
+ content = p.read_text(encoding="utf-8", errors="replace")
413
+ snap = FileSnapshot(path=str(p.resolve()), content_before=content)
414
+ self.current.snapshots.append(asdict(snap))
415
+ # Máximo 20 snapshots
416
+ if len(self.current.snapshots) > 20:
417
+ self.current.snapshots = self.current.snapshots[-20:]
418
+ except Exception:
419
+ pass
420
+
421
+ def update_costs(self, input_tokens: int, output_tokens: int, cost: float):
422
+ if self.current:
423
+ self.current.total_input_tokens += input_tokens
424
+ self.current.total_output_tokens += output_tokens
425
+ self.current.total_cost_usd += cost
426
+
427
+ def undo_last(self) -> Optional[str]:
428
+ """Revierte el último cambio de archivo. Retorna path o None."""
429
+ if not self.current or not self.current.snapshots:
430
+ return None
431
+ snap = FileSnapshot(**self.current.snapshots[-1])
432
+ p = Path(snap.path)
433
+ if snap.content_before:
434
+ p.write_text(snap.content_before, encoding="utf-8")
435
+ self.current.snapshots.pop()
436
+
437
+ # Añadir evento de undo
438
+ self._add_timeline_event(
439
+ ChangeType.FILE_MODIFIED,
440
+ f"Undo: {snap.path}",
441
+ {"path": snap.path, "action": "undo"},
442
+ tags=["file", "undo"],
443
+ importance=2
444
+ )
445
+ return snap.path
446
+ return None
447
+
448
+ def pause_session(self):
449
+ """Pausa la sesión actual."""
450
+ if self.current:
451
+ self.current.status = "paused"
452
+ self._add_timeline_event(
453
+ ChangeType.SESSION_PAUSED,
454
+ "Session paused",
455
+ importance=2
456
+ )
457
+ self.save()
458
+
459
+ def resume_session(self):
460
+ """Reanuda la sesión actual."""
461
+ if self.current:
462
+ self.current.status = "active"
463
+ self._add_timeline_event(
464
+ ChangeType.SESSION_RESUMED,
465
+ "Session resumed",
466
+ importance=2
467
+ )
468
+
469
+ # ══════════════════════════════════════════════════════════════════════════
470
+ # MÉTODOS PRIVADOS
471
+ # ══════════════════════════════════════════════════════════════════════════
472
+
473
+ def _add_timeline_event(
474
+ self,
475
+ event_type: ChangeType,
476
+ description: str,
477
+ details: Dict = None,
478
+ tags: List[str] = None,
479
+ importance: int = 1
480
+ ):
481
+ """Añade un evento al timeline."""
482
+ if not self.current:
483
+ return
484
+
485
+ self._event_counter += 1
486
+ event = TimelineEvent(
487
+ id=f"evt_{self._event_counter}_{int(time.time() * 1000)}",
488
+ type=event_type,
489
+ timestamp=time.time(),
490
+ description=description,
491
+ details=details or {},
492
+ tags=tags or [],
493
+ importance=importance
494
+ )
495
+
496
+ self.current.timeline.append(event.to_dict())
497
+
498
+ # Limitar tamaño del timeline (mantener últimos 1000 eventos)
499
+ if len(self.current.timeline) > 1000:
500
+ self.current.timeline = self.current.timeline[-1000:]
501
+
502
+ def _dict_to_event(self, data: Dict) -> TimelineEvent:
503
+ """Convierte un diccionario a TimelineEvent."""
504
+ return TimelineEvent(
505
+ id=data.get("id", ""),
506
+ type=ChangeType(data.get("type", "note")),
507
+ timestamp=data.get("timestamp", 0),
508
+ description=data.get("description", ""),
509
+ details=data.get("details", {}),
510
+ tags=data.get("tags", []),
511
+ importance=data.get("importance", 1)
512
+ )
513
+
514
+ def _get_event_emoji(self, event_type: str) -> str:
515
+ """Obtiene un emoji para un tipo de evento."""
516
+ emojis = {
517
+ "file_created": "📄",
518
+ "file_modified": "✏️",
519
+ "file_deleted": "🗑️",
520
+ "command_success": "✅",
521
+ "command_error": "❌",
522
+ "tool_used": "🔧",
523
+ "message_sent": "💬",
524
+ "message_received": "🤖",
525
+ "session_started": "▶️",
526
+ "session_paused": "⏸️",
527
+ "session_resumed": "▶️",
528
+ "milestone": "🎯",
529
+ "error": "⚠️",
530
+ "note": "📝",
531
+ }
532
+ return emojis.get(event_type, "•")
533
+
534
+ def _load(self, path: Path) -> Optional[SessionData]:
535
+ try:
536
+ with gzip.open(path, "rt", encoding="utf-8") as f:
537
+ return SessionData(**json.loads(f.read()))
538
+ except Exception:
539
+ return None
540
+
541
+ def _prune(self):
542
+ files = sorted(self.dir.glob("*.json.gz"), key=lambda f: f.stat().st_mtime, reverse=True)
543
+ for old in files[MAX_SESSIONS:]:
544
+ try:
545
+ old.unlink()
546
+ except Exception:
547
+ pass