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.
- hanus/__init__.py +5 -0
- hanus/__main__.py +10 -0
- hanus/action_handlers.py +76 -0
- hanus/action_parser.py +82 -0
- hanus/agent_runner.py +1445 -0
- hanus/analysis/__init__.py +5 -0
- hanus/analysis/debt.py +702 -0
- hanus/analysis/dependencies.py +475 -0
- hanus/cache/__init__.py +5 -0
- hanus/cache/response_cache.py +560 -0
- hanus/config.py +401 -0
- hanus/connectors/__init__.py +19 -0
- hanus/connectors/base.py +114 -0
- hanus/connectors/claude_connector.py +146 -0
- hanus/connectors/gemini_connector.py +141 -0
- hanus/connectors/glm_connector.py +160 -0
- hanus/connectors/ollama_connector.py +174 -0
- hanus/connectors/openai_connector.py +122 -0
- hanus/connectors/registry.py +26 -0
- hanus/context/__init__.py +7 -0
- hanus/context/manager.py +837 -0
- hanus/context/selective.py +626 -0
- hanus/error_recovery/__init__.py +5 -0
- hanus/error_recovery/auto_fix.py +605 -0
- hanus/hooks/__init__.py +5 -0
- hanus/hooks/manager.py +247 -0
- hanus/instincts/__init__.py +44 -0
- hanus/instincts/cli.py +372 -0
- hanus/instincts/detector.py +281 -0
- hanus/instincts/evolver.py +361 -0
- hanus/instincts/manager.py +343 -0
- hanus/instincts/types.py +253 -0
- hanus/logger.py +81 -0
- hanus/memory/__init__.py +8 -0
- hanus/memory/manager.py +265 -0
- hanus/memory/types.py +119 -0
- hanus/monitor.py +341 -0
- hanus/parallel/__init__.py +5 -0
- hanus/parallel/executor.py +300 -0
- hanus/permissions.py +182 -0
- hanus/plan/__init__.py +8 -0
- hanus/plan/mode.py +267 -0
- hanus/plan/models.py +152 -0
- hanus/plugin_manager.py +754 -0
- hanus/plugin_registry.py +391 -0
- hanus/plugins/__init__.py +1 -0
- hanus/plugins/arena.py +630 -0
- hanus/plugins/code_review.py +123 -0
- hanus/plugins/cortex.py +1750 -0
- hanus/plugins/deps_check.py +27 -0
- hanus/plugins/git_ops.py +33 -0
- hanus/plugins/metasploit.py +530 -0
- hanus/plugins/notes.py +583 -0
- hanus/plugins/search_code.py +59 -0
- hanus/plugins/searchsploit.py +495 -0
- hanus/plugins/strategist.py +175 -0
- hanus/plugins/webui.py +5200 -0
- hanus/profiles.py +479 -0
- hanus/profiles_builtin/__init__.py +0 -0
- hanus/profiles_builtin/architect/profile.yaml +12 -0
- hanus/profiles_builtin/architect/system_prompt.txt +71 -0
- hanus/profiles_builtin/deep/profile.yaml +12 -0
- hanus/profiles_builtin/deep/system_prompt.txt +66 -0
- hanus/profiles_builtin/developer/__init__.py +0 -0
- hanus/profiles_builtin/developer/profile.yaml +9 -0
- hanus/profiles_builtin/developer/system_prompt.txt +176 -0
- hanus/profiles_builtin/speed/profile.yaml +12 -0
- hanus/profiles_builtin/speed/system_prompt.txt +51 -0
- hanus/project_tools.py +177 -0
- hanus/query_engine.py +1594 -0
- hanus/rules/__init__.py +237 -0
- hanus/search/__init__.py +5 -0
- hanus/search/semantic.py +596 -0
- hanus/session_manager.py +547 -0
- hanus/skill_manager.py +702 -0
- hanus/skills/__init__.py +4 -0
- hanus/subagent/__init__.py +8 -0
- hanus/subagent/agents/__init__.py +253 -0
- hanus/subagent/manager.py +309 -0
- hanus/subagent/types.py +266 -0
- hanus/suggestions/__init__.py +5 -0
- hanus/suggestions/proactive.py +451 -0
- hanus/tasks/__init__.py +8 -0
- hanus/tasks/manager.py +330 -0
- hanus/tasks/models.py +106 -0
- hanus/terminal_prompt.py +166 -0
- hanus/tools.py +1849 -0
- hanus/ui.py +939 -0
- hanuscode-1.0.0.dist-info/METADATA +1151 -0
- hanuscode-1.0.0.dist-info/RECORD +93 -0
- hanuscode-1.0.0.dist-info/WHEEL +5 -0
- hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
- hanuscode-1.0.0.dist-info/top_level.txt +1 -0
hanus/session_manager.py
ADDED
|
@@ -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
|