agmem 0.2.0__py3-none-any.whl → 0.3.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.
memvcs/core/session.py ADDED
@@ -0,0 +1,520 @@
1
+ """
2
+ Session-Aware Auto-Commit - Smart session management with contextual commits.
3
+
4
+ This module provides intelligent session tracking and auto-commit functionality:
5
+ - Session lifecycle management (start, end, pause, resume)
6
+ - Topic-based observation grouping
7
+ - Time-window batching
8
+ - Semantic commit message generation
9
+ - Crash recovery with disk-backed buffers
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import uuid
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timedelta, timezone
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional, Tuple
19
+ import hashlib
20
+
21
+
22
+ @dataclass
23
+ class SessionConfig:
24
+ """Configuration for session behavior."""
25
+
26
+ # Time-based triggers
27
+ idle_timeout_seconds: int = 300 # End session after 5 min idle
28
+ max_session_hours: float = 8.0 # Force commit after 8 hours
29
+ min_session_seconds: int = 60 # Don't commit tiny sessions
30
+
31
+ # Batching settings
32
+ commit_interval_seconds: int = 300 # Batch commits every 5 min
33
+ max_observations_per_commit: int = 50
34
+ min_observations_for_commit: int = 3
35
+
36
+ # Topic grouping
37
+ enable_topic_grouping: bool = True
38
+ topic_similarity_threshold: float = 0.7
39
+
40
+ # Message generation
41
+ use_llm_messages: bool = True
42
+
43
+
44
+ @dataclass
45
+ class Observation:
46
+ """A single observation in a session."""
47
+
48
+ id: str
49
+ timestamp: str
50
+ tool_name: str
51
+ arguments: Dict[str, Any]
52
+ result: Optional[str] = None
53
+ topic: Optional[str] = None
54
+ memory_type: str = "episodic"
55
+
56
+ def to_dict(self) -> Dict[str, Any]:
57
+ return {
58
+ "id": self.id,
59
+ "timestamp": self.timestamp,
60
+ "tool_name": self.tool_name,
61
+ "arguments": self.arguments,
62
+ "result": self.result,
63
+ "topic": self.topic,
64
+ "memory_type": self.memory_type,
65
+ }
66
+
67
+ @classmethod
68
+ def from_dict(cls, data: Dict[str, Any]) -> "Observation":
69
+ return cls(
70
+ id=data["id"],
71
+ timestamp=data["timestamp"],
72
+ tool_name=data["tool_name"],
73
+ arguments=data.get("arguments", {}),
74
+ result=data.get("result"),
75
+ topic=data.get("topic"),
76
+ memory_type=data.get("memory_type", "episodic"),
77
+ )
78
+
79
+
80
+ @dataclass
81
+ class Session:
82
+ """A work session with observations and metadata."""
83
+
84
+ id: str
85
+ started_at: str
86
+ project_context: Optional[str] = None
87
+ observations: List[Observation] = field(default_factory=list)
88
+ topics: Dict[str, List[str]] = field(default_factory=dict) # topic -> observation_ids
89
+ last_activity: Optional[str] = None
90
+ ended_at: Optional[str] = None
91
+ commit_count: int = 0
92
+ status: str = "active" # active, paused, ended
93
+
94
+ def to_dict(self) -> Dict[str, Any]:
95
+ return {
96
+ "id": self.id,
97
+ "started_at": self.started_at,
98
+ "project_context": self.project_context,
99
+ "observations": [o.to_dict() for o in self.observations],
100
+ "topics": self.topics,
101
+ "last_activity": self.last_activity,
102
+ "ended_at": self.ended_at,
103
+ "commit_count": self.commit_count,
104
+ "status": self.status,
105
+ }
106
+
107
+ @classmethod
108
+ def from_dict(cls, data: Dict[str, Any]) -> "Session":
109
+ return cls(
110
+ id=data["id"],
111
+ started_at=data["started_at"],
112
+ project_context=data.get("project_context"),
113
+ observations=[Observation.from_dict(o) for o in data.get("observations", [])],
114
+ topics=data.get("topics", {}),
115
+ last_activity=data.get("last_activity"),
116
+ ended_at=data.get("ended_at"),
117
+ commit_count=data.get("commit_count", 0),
118
+ status=data.get("status", "active"),
119
+ )
120
+
121
+
122
+ class TopicClassifier:
123
+ """Classifies observations into topics based on tool names and arguments."""
124
+
125
+ # Topic keywords for classification
126
+ TOPIC_PATTERNS = {
127
+ "file_operations": ["write_file", "read_file", "delete_file", "move_file", "copy_file"],
128
+ "git_operations": ["git_commit", "git_push", "git_pull", "git_branch", "git_merge"],
129
+ "database": ["query", "insert", "update", "delete", "migrate", "sql"],
130
+ "testing": ["test", "pytest", "unittest", "assertion", "mock"],
131
+ "deployment": ["deploy", "build", "docker", "kubernetes", "ci_cd", "pipeline"],
132
+ "research": ["search", "fetch", "web", "api", "http", "request"],
133
+ "code_generation": ["generate", "create", "scaffold", "template"],
134
+ "refactoring": ["refactor", "rename", "extract", "inline", "move"],
135
+ "debugging": ["debug", "fix", "error", "exception", "trace"],
136
+ "documentation": ["doc", "readme", "comment", "markdown"],
137
+ }
138
+
139
+ def classify(self, tool_name: str, arguments: Dict[str, Any]) -> str:
140
+ """Classify an observation into a topic."""
141
+ tool_lower = tool_name.lower()
142
+
143
+ # Check tool name patterns
144
+ for topic, patterns in self.TOPIC_PATTERNS.items():
145
+ for pattern in patterns:
146
+ if pattern in tool_lower:
147
+ return topic
148
+
149
+ # Check argument values for hints
150
+ arg_str = json.dumps(arguments).lower()
151
+ for topic, patterns in self.TOPIC_PATTERNS.items():
152
+ for pattern in patterns:
153
+ if pattern in arg_str:
154
+ return topic
155
+
156
+ return "general"
157
+
158
+
159
+ class SessionManager:
160
+ """Manages session lifecycle, observation batching, and auto-commits."""
161
+
162
+ def __init__(self, repo_root: Path, config: Optional[SessionConfig] = None):
163
+ self.repo_root = Path(repo_root)
164
+ self.mem_dir = self.repo_root / ".mem"
165
+ self.session_file = self.mem_dir / "current_session.json"
166
+ self.config = config or SessionConfig()
167
+ self.topic_classifier = TopicClassifier()
168
+ self._session: Optional[Session] = None
169
+
170
+ @property
171
+ def session(self) -> Optional[Session]:
172
+ """Get current session, loading from disk if needed."""
173
+ if self._session is None:
174
+ self._session = self._load_session()
175
+ return self._session
176
+
177
+ def _now(self) -> str:
178
+ """Get current timestamp in ISO format."""
179
+ return datetime.now(timezone.utc).isoformat()
180
+
181
+ # --- Session Lifecycle ---
182
+
183
+ def start_session(self, project_context: Optional[str] = None) -> Session:
184
+ """Start a new session or resume existing one."""
185
+ existing = self._load_session()
186
+ if existing and existing.status == "active":
187
+ # Resume existing session
188
+ self._session = existing
189
+ return existing
190
+
191
+ # Create new session
192
+ session = Session(
193
+ id=str(uuid.uuid4())[:8],
194
+ started_at=self._now(),
195
+ project_context=project_context,
196
+ last_activity=self._now(),
197
+ )
198
+ self._session = session
199
+ self._save_session()
200
+ return session
201
+
202
+ def end_session(self, commit: bool = True) -> Optional[str]:
203
+ """End current session, optionally committing observations."""
204
+ if not self.session:
205
+ return None
206
+
207
+ self.session.ended_at = self._now()
208
+ self.session.status = "ended"
209
+
210
+ commit_hash = None
211
+ if commit and self.session.observations:
212
+ commit_hash = self._commit_session()
213
+
214
+ self._save_session()
215
+ return commit_hash
216
+
217
+ def pause_session(self) -> None:
218
+ """Pause current session (for breaks)."""
219
+ if self.session:
220
+ self.session.status = "paused"
221
+ self._save_session()
222
+
223
+ def resume_session(self) -> Optional[Session]:
224
+ """Resume a paused session."""
225
+ session = self._load_session()
226
+ if session and session.status == "paused":
227
+ session.status = "active"
228
+ session.last_activity = self._now()
229
+ self._session = session
230
+ self._save_session()
231
+ return session
232
+ return None
233
+
234
+ def discard_session(self) -> None:
235
+ """Discard current session without committing."""
236
+ if self.session_file.exists():
237
+ self.session_file.unlink()
238
+ self._session = None
239
+
240
+ # --- Observation Handling ---
241
+
242
+ def add_observation(
243
+ self,
244
+ tool_name: str,
245
+ arguments: Dict[str, Any],
246
+ result: Optional[str] = None,
247
+ ) -> Optional[str]:
248
+ """Add an observation to the current session. Returns observation ID."""
249
+ session = self.session
250
+ if not session or session.status != "active":
251
+ # Auto-start session if none active
252
+ session = self.start_session()
253
+
254
+ # Create observation
255
+ obs_id = hashlib.sha256(
256
+ f"{self._now()}{tool_name}{json.dumps(arguments)}".encode()
257
+ ).hexdigest()[:12]
258
+
259
+ topic = self.topic_classifier.classify(tool_name, arguments)
260
+ memory_type = self._infer_memory_type(tool_name)
261
+
262
+ observation = Observation(
263
+ id=obs_id,
264
+ timestamp=self._now(),
265
+ tool_name=tool_name,
266
+ arguments=arguments,
267
+ result=result,
268
+ topic=topic,
269
+ memory_type=memory_type,
270
+ )
271
+
272
+ session.observations.append(observation)
273
+ session.last_activity = self._now()
274
+
275
+ # Track topic grouping
276
+ if topic not in session.topics:
277
+ session.topics[topic] = []
278
+ session.topics[topic].append(obs_id)
279
+
280
+ self._save_session()
281
+
282
+ # Check if we should auto-commit
283
+ if self._should_commit():
284
+ self._commit_batch()
285
+
286
+ return obs_id
287
+
288
+ def _infer_memory_type(self, tool_name: str) -> str:
289
+ """Infer memory type from tool name."""
290
+ tool_lower = tool_name.lower()
291
+
292
+ episodic_keywords = ["write", "delete", "run", "execute", "commit", "deploy"]
293
+ semantic_keywords = ["search", "read", "fetch", "query", "get"]
294
+ procedural_keywords = ["generate", "create", "refactor", "template"]
295
+
296
+ for kw in episodic_keywords:
297
+ if kw in tool_lower:
298
+ return "episodic"
299
+ for kw in semantic_keywords:
300
+ if kw in tool_lower:
301
+ return "semantic"
302
+ for kw in procedural_keywords:
303
+ if kw in tool_lower:
304
+ return "procedural"
305
+
306
+ return "episodic"
307
+
308
+ def _should_commit(self) -> bool:
309
+ """Check if we should trigger an auto-commit."""
310
+ if not self.session:
311
+ return False
312
+
313
+ obs_count = len(self.session.observations)
314
+
315
+ # Buffer full
316
+ if obs_count >= self.config.max_observations_per_commit:
317
+ return True
318
+
319
+ # Check time since last commit or session start
320
+ if obs_count >= self.config.min_observations_for_commit:
321
+ last_time = self.session.last_activity or self.session.started_at
322
+ try:
323
+ last_dt = datetime.fromisoformat(last_time.replace("Z", "+00:00"))
324
+ elapsed = (datetime.now(timezone.utc) - last_dt).total_seconds()
325
+ if elapsed >= self.config.commit_interval_seconds:
326
+ return True
327
+ except Exception:
328
+ pass
329
+
330
+ return False
331
+
332
+ # --- Commit Logic ---
333
+
334
+ def _commit_batch(self) -> Optional[str]:
335
+ """Commit current batch of observations."""
336
+ if not self.session or not self.session.observations:
337
+ return None
338
+
339
+ return self._commit_session()
340
+
341
+ def _commit_session(self) -> Optional[str]:
342
+ """Commit session observations to repository."""
343
+ if not self.session or not self.session.observations:
344
+ return None
345
+
346
+ try:
347
+ from memvcs.core.repository import Repository
348
+
349
+ repo = Repository(self.repo_root)
350
+ if not repo.is_valid_repo():
351
+ return None
352
+
353
+ # Write observations as session summary
354
+ session_content = self._generate_session_content()
355
+ session_path = (
356
+ repo.current_dir / "episodic" / "sessions" / f"session-{self.session.id}.md"
357
+ )
358
+ session_path.parent.mkdir(parents=True, exist_ok=True)
359
+ session_path.write_text(session_content)
360
+
361
+ # Stage and commit
362
+ repo.stage_file(str(session_path.relative_to(repo.current_dir)))
363
+ message = self._generate_commit_message()
364
+ commit_hash = repo.commit(message)
365
+
366
+ # Update session
367
+ self.session.commit_count += 1
368
+ self.session.observations.clear()
369
+ self.session.topics.clear()
370
+ self._save_session()
371
+
372
+ return commit_hash
373
+ except Exception:
374
+ return None
375
+
376
+ def _generate_session_content(self) -> str:
377
+ """Generate markdown content for session summary."""
378
+ session = self.session
379
+ if not session:
380
+ return ""
381
+
382
+ lines = [
383
+ "---",
384
+ f'session_id: "{session.id}"',
385
+ f'started_at: "{session.started_at}"',
386
+ f"observation_count: {len(session.observations)}",
387
+ f"topics: [{', '.join(session.topics.keys())}]",
388
+ "---",
389
+ "",
390
+ f"# Session {session.id}",
391
+ "",
392
+ ]
393
+
394
+ if session.project_context:
395
+ lines.append(f"**Context:** {session.project_context}")
396
+ lines.append("")
397
+
398
+ # Group by topic
399
+ if session.topics:
400
+ lines.append("## Activity by Topic")
401
+ lines.append("")
402
+ for topic, obs_ids in session.topics.items():
403
+ lines.append(f"### {topic.replace('_', ' ').title()}")
404
+ topic_obs = [o for o in session.observations if o.id in obs_ids]
405
+ for obs in topic_obs[:5]: # Limit per topic
406
+ ts = obs.timestamp.split("T")[1][:8] if "T" in obs.timestamp else ""
407
+ lines.append(f"- [{ts}] `{obs.tool_name}`")
408
+ if len(topic_obs) > 5:
409
+ lines.append(f"- ... and {len(topic_obs) - 5} more")
410
+ lines.append("")
411
+
412
+ lines.append("## Timeline")
413
+ lines.append("")
414
+ for obs in session.observations[-10:]: # Last 10
415
+ ts = obs.timestamp.split("T")[1][:8] if "T" in obs.timestamp else ""
416
+ args_str = ", ".join(f"{k}={v}" for k, v in list(obs.arguments.items())[:2])
417
+ lines.append(f"- [{ts}] `{obs.tool_name}`: {args_str[:80]}")
418
+
419
+ return "\n".join(lines)
420
+
421
+ def _generate_commit_message(self) -> str:
422
+ """Generate commit message for session."""
423
+ session = self.session
424
+ if not session:
425
+ return "Session commit"
426
+
427
+ obs_count = len(session.observations)
428
+ topics = list(session.topics.keys())
429
+
430
+ if self.config.use_llm_messages:
431
+ # Could call LLM here for better messages
432
+ pass
433
+
434
+ # Template-based message
435
+ if len(topics) == 1:
436
+ return f"Session: {obs_count} observations ({topics[0]})"
437
+ elif len(topics) <= 3:
438
+ topics_str = ", ".join(topics)
439
+ return f"Session: {obs_count} observations ({topics_str})"
440
+ else:
441
+ return f"Session: {obs_count} observations across {len(topics)} topics"
442
+
443
+ # --- Persistence ---
444
+
445
+ def _save_session(self) -> None:
446
+ """Save current session to disk."""
447
+ if not self._session:
448
+ return
449
+
450
+ self.mem_dir.mkdir(parents=True, exist_ok=True)
451
+ with open(self.session_file, "w") as f:
452
+ json.dump(self._session.to_dict(), f, indent=2)
453
+
454
+ def _load_session(self) -> Optional[Session]:
455
+ """Load session from disk."""
456
+ if not self.session_file.exists():
457
+ return None
458
+
459
+ try:
460
+ with open(self.session_file) as f:
461
+ data = json.load(f)
462
+ return Session.from_dict(data)
463
+ except Exception:
464
+ return None
465
+
466
+ def get_status(self) -> Dict[str, Any]:
467
+ """Get current session status."""
468
+ session = self.session
469
+ if not session:
470
+ return {"active": False}
471
+
472
+ return {
473
+ "active": session.status == "active",
474
+ "session_id": session.id,
475
+ "status": session.status,
476
+ "started_at": session.started_at,
477
+ "observation_count": len(session.observations),
478
+ "topics": list(session.topics.keys()),
479
+ "commit_count": session.commit_count,
480
+ "last_activity": session.last_activity,
481
+ }
482
+
483
+
484
+ # --- CLI Helper Functions ---
485
+
486
+
487
+ def session_start(repo_root: Path, context: Optional[str] = None) -> Dict[str, Any]:
488
+ """Start a new session."""
489
+ manager = SessionManager(repo_root)
490
+ session = manager.start_session(context)
491
+ return manager.get_status()
492
+
493
+
494
+ def session_end(repo_root: Path, commit: bool = True) -> Dict[str, Any]:
495
+ """End the current session."""
496
+ manager = SessionManager(repo_root)
497
+ commit_hash = manager.end_session(commit=commit)
498
+ return {"ended": True, "commit_hash": commit_hash}
499
+
500
+
501
+ def session_status(repo_root: Path) -> Dict[str, Any]:
502
+ """Get session status."""
503
+ manager = SessionManager(repo_root)
504
+ return manager.get_status()
505
+
506
+
507
+ def session_commit(repo_root: Path) -> Dict[str, Any]:
508
+ """Force commit current observations."""
509
+ manager = SessionManager(repo_root)
510
+ if manager.session and manager.session.observations:
511
+ commit_hash = manager._commit_session()
512
+ return {"committed": True, "commit_hash": commit_hash}
513
+ return {"committed": False, "reason": "No observations to commit"}
514
+
515
+
516
+ def session_discard(repo_root: Path) -> Dict[str, Any]:
517
+ """Discard current session."""
518
+ manager = SessionManager(repo_root)
519
+ manager.discard_session()
520
+ return {"discarded": True}