alma-memory 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.
alma/progress/types.py ADDED
@@ -0,0 +1,254 @@
1
+ """
2
+ Progress Tracking Types.
3
+
4
+ Data models for tracking work items and progress.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timezone
9
+ from typing import Optional, List, Dict, Any, Literal
10
+ import uuid
11
+
12
+
13
+ WorkItemStatus = Literal[
14
+ "pending", # Not started
15
+ "in_progress", # Currently being worked on
16
+ "blocked", # Waiting on something
17
+ "review", # Completed, awaiting review
18
+ "done", # Completed and verified
19
+ "failed", # Could not complete
20
+ ]
21
+
22
+
23
+ @dataclass
24
+ class WorkItem:
25
+ """
26
+ A trackable unit of work.
27
+
28
+ Can represent features, bugs, tasks, research questions,
29
+ or any domain-specific work unit.
30
+ """
31
+
32
+ id: str
33
+ project_id: str
34
+ agent: Optional[str]
35
+
36
+ # Work item details
37
+ title: str
38
+ description: str
39
+ item_type: str # "feature", "bug", "task", "research_question", etc.
40
+ status: WorkItemStatus = "pending"
41
+ priority: int = 50 # 0-100, higher = more important
42
+
43
+ # Progress tracking
44
+ started_at: Optional[datetime] = None
45
+ completed_at: Optional[datetime] = None
46
+ time_spent_ms: int = 0
47
+ attempt_count: int = 0
48
+
49
+ # Relationships
50
+ parent_id: Optional[str] = None
51
+ blocks: List[str] = field(default_factory=list)
52
+ blocked_by: List[str] = field(default_factory=list)
53
+
54
+ # Validation
55
+ tests: List[str] = field(default_factory=list)
56
+ tests_passing: bool = False
57
+ acceptance_criteria: List[str] = field(default_factory=list)
58
+
59
+ # Timestamps
60
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
61
+ updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
62
+
63
+ # Extensible metadata
64
+ metadata: Dict[str, Any] = field(default_factory=dict)
65
+
66
+ @classmethod
67
+ def create(
68
+ cls,
69
+ project_id: str,
70
+ title: str,
71
+ description: str,
72
+ item_type: str = "task",
73
+ agent: Optional[str] = None,
74
+ priority: int = 50,
75
+ parent_id: Optional[str] = None,
76
+ **kwargs,
77
+ ) -> "WorkItem":
78
+ """Factory method to create a new work item."""
79
+ return cls(
80
+ id=str(uuid.uuid4()),
81
+ project_id=project_id,
82
+ agent=agent,
83
+ title=title,
84
+ description=description,
85
+ item_type=item_type,
86
+ priority=priority,
87
+ parent_id=parent_id,
88
+ **kwargs,
89
+ )
90
+
91
+ def start(self) -> None:
92
+ """Mark work item as started."""
93
+ self.status = "in_progress"
94
+ self.started_at = datetime.now(timezone.utc)
95
+ self.attempt_count += 1
96
+ self.updated_at = datetime.now(timezone.utc)
97
+
98
+ def complete(self, tests_passing: bool = True) -> None:
99
+ """Mark work item as completed."""
100
+ self.status = "done"
101
+ self.completed_at = datetime.now(timezone.utc)
102
+ self.tests_passing = tests_passing
103
+ if self.started_at:
104
+ self.time_spent_ms += int(
105
+ (self.completed_at - self.started_at).total_seconds() * 1000
106
+ )
107
+ self.updated_at = datetime.now(timezone.utc)
108
+
109
+ def block(self, blocked_by: Optional[str] = None, reason: str = "") -> None:
110
+ """Mark work item as blocked."""
111
+ self.status = "blocked"
112
+ if blocked_by:
113
+ self.blocked_by.append(blocked_by)
114
+ if reason:
115
+ self.metadata["block_reason"] = reason
116
+ self.updated_at = datetime.now(timezone.utc)
117
+
118
+ def fail(self, reason: str = "") -> None:
119
+ """Mark work item as failed."""
120
+ self.status = "failed"
121
+ if reason:
122
+ self.metadata["failure_reason"] = reason
123
+ self.updated_at = datetime.now(timezone.utc)
124
+
125
+ def is_actionable(self) -> bool:
126
+ """Check if work item can be worked on."""
127
+ return (
128
+ self.status in ("pending", "in_progress")
129
+ and len(self.blocked_by) == 0
130
+ )
131
+
132
+
133
+ @dataclass
134
+ class ProgressLog:
135
+ """
136
+ Session-level progress snapshot.
137
+
138
+ Records the state of progress at a point in time.
139
+ """
140
+
141
+ id: str
142
+ project_id: str
143
+ agent: str
144
+ session_id: str
145
+
146
+ # Progress counts
147
+ items_total: int
148
+ items_done: int
149
+ items_in_progress: int
150
+ items_blocked: int
151
+ items_pending: int
152
+
153
+ # Current focus
154
+ current_item_id: Optional[str]
155
+ current_action: str
156
+
157
+ # Session metrics
158
+ session_start: datetime
159
+ actions_taken: int = 0
160
+ outcomes_recorded: int = 0
161
+
162
+ # Timestamp
163
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
164
+
165
+ @classmethod
166
+ def create(
167
+ cls,
168
+ project_id: str,
169
+ agent: str,
170
+ session_id: str,
171
+ items_total: int,
172
+ items_done: int,
173
+ items_in_progress: int,
174
+ items_blocked: int,
175
+ items_pending: int,
176
+ current_item_id: Optional[str] = None,
177
+ current_action: str = "",
178
+ session_start: Optional[datetime] = None,
179
+ ) -> "ProgressLog":
180
+ """Factory method to create progress log."""
181
+ return cls(
182
+ id=str(uuid.uuid4()),
183
+ project_id=project_id,
184
+ agent=agent,
185
+ session_id=session_id,
186
+ items_total=items_total,
187
+ items_done=items_done,
188
+ items_in_progress=items_in_progress,
189
+ items_blocked=items_blocked,
190
+ items_pending=items_pending,
191
+ current_item_id=current_item_id,
192
+ current_action=current_action,
193
+ session_start=session_start or datetime.now(timezone.utc),
194
+ )
195
+
196
+
197
+ @dataclass
198
+ class ProgressSummary:
199
+ """
200
+ Summary of progress for display/reporting.
201
+
202
+ A simplified view of progress state.
203
+ """
204
+
205
+ project_id: str
206
+ agent: Optional[str]
207
+
208
+ # Counts
209
+ total: int
210
+ done: int
211
+ in_progress: int
212
+ blocked: int
213
+ pending: int
214
+ failed: int
215
+
216
+ # Percentages
217
+ completion_rate: float # 0-1
218
+ success_rate: float # done / (done + failed)
219
+
220
+ # Current focus
221
+ current_item: Optional[WorkItem]
222
+ next_suggested: Optional[WorkItem]
223
+
224
+ # Blockers
225
+ blockers: List[WorkItem]
226
+
227
+ # Time tracking
228
+ total_time_ms: int
229
+ avg_time_per_item_ms: float
230
+
231
+ # Timestamps
232
+ last_activity: Optional[datetime]
233
+ generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
234
+
235
+ @property
236
+ def completion_percentage(self) -> int:
237
+ """Get completion as percentage."""
238
+ return int(self.completion_rate * 100)
239
+
240
+ def format_summary(self) -> str:
241
+ """Format as human-readable string."""
242
+ lines = [
243
+ f"Progress: {self.done}/{self.total} ({self.completion_percentage}%)",
244
+ f" In Progress: {self.in_progress}",
245
+ f" Blocked: {self.blocked}",
246
+ f" Pending: {self.pending}",
247
+ ]
248
+ if self.current_item:
249
+ lines.append(f" Current: {self.current_item.title}")
250
+ if self.next_suggested:
251
+ lines.append(f" Next: {self.next_suggested.title}")
252
+ if self.blockers:
253
+ lines.append(f" Blockers: {len(self.blockers)} items")
254
+ return "\n".join(lines)
@@ -0,0 +1,19 @@
1
+ """
2
+ ALMA Session Management Module.
3
+
4
+ Handles session continuity, handoffs, and quick context reload.
5
+ """
6
+
7
+ from alma.session.types import (
8
+ SessionHandoff,
9
+ SessionContext,
10
+ SessionOutcome,
11
+ )
12
+ from alma.session.manager import SessionManager
13
+
14
+ __all__ = [
15
+ "SessionHandoff",
16
+ "SessionContext",
17
+ "SessionOutcome",
18
+ "SessionManager",
19
+ ]
@@ -0,0 +1,399 @@
1
+ """
2
+ Session Manager.
3
+
4
+ Manages session continuity, handoffs, and quick context reload.
5
+ """
6
+
7
+ from datetime import datetime, timezone
8
+ from typing import Optional, List, Dict, Any, Callable
9
+ import uuid
10
+ import logging
11
+
12
+ from alma.session.types import (
13
+ SessionHandoff,
14
+ SessionContext,
15
+ SessionOutcome,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class SessionManager:
22
+ """
23
+ Manage session continuity for AI agents.
24
+
25
+ Provides:
26
+ - Session start/end handling
27
+ - Handoff creation and retrieval
28
+ - Quick reload formatting
29
+ - Integration with progress tracking
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ project_id: str,
35
+ storage: Optional[Any] = None, # Will be StorageBackend when integrated
36
+ progress_tracker: Optional[Any] = None, # Will be ProgressTracker
37
+ max_handoffs: int = 50,
38
+ ):
39
+ """
40
+ Initialize session manager.
41
+
42
+ Args:
43
+ project_id: Project identifier
44
+ storage: Optional storage backend for persistence
45
+ progress_tracker: Optional progress tracker integration
46
+ max_handoffs: Maximum handoffs to keep in memory
47
+ """
48
+ self.project_id = project_id
49
+ self.storage = storage
50
+ self.progress_tracker = progress_tracker
51
+ self.max_handoffs = max_handoffs
52
+
53
+ # In-memory handoff storage (keyed by agent)
54
+ self._handoffs: Dict[str, List[SessionHandoff]] = {}
55
+
56
+ # Active sessions
57
+ self._active_sessions: Dict[str, SessionHandoff] = {}
58
+
59
+ # Context enrichers (callables that add context to sessions)
60
+ self._context_enrichers: List[Callable[[SessionContext], SessionContext]] = []
61
+
62
+ def register_enricher(
63
+ self,
64
+ enricher: Callable[[SessionContext], SessionContext],
65
+ ) -> None:
66
+ """
67
+ Register a context enricher.
68
+
69
+ Enrichers are called during session start to add context
70
+ (e.g., git status, running services, etc.)
71
+ """
72
+ self._context_enrichers.append(enricher)
73
+
74
+ def start_session(
75
+ self,
76
+ agent: str,
77
+ goal: Optional[str] = None,
78
+ session_id: Optional[str] = None,
79
+ ) -> SessionContext:
80
+ """
81
+ Start a new session, loading previous context.
82
+
83
+ Args:
84
+ agent: Agent identifier
85
+ goal: Current session goal
86
+ session_id: Optional session ID (generated if not provided)
87
+
88
+ Returns:
89
+ SessionContext with all relevant orientation data
90
+ """
91
+ session_id = session_id or str(uuid.uuid4())
92
+
93
+ # Get previous handoff
94
+ previous = self.get_latest_handoff(agent)
95
+
96
+ # Create session context
97
+ context = SessionContext.create(
98
+ project_id=self.project_id,
99
+ agent=agent,
100
+ session_id=session_id,
101
+ previous_handoff=previous,
102
+ )
103
+
104
+ # Get progress if tracker available
105
+ if self.progress_tracker:
106
+ try:
107
+ context.progress = self.progress_tracker.get_progress_summary(agent)
108
+ except Exception as e:
109
+ logger.warning(f"Could not get progress: {e}")
110
+
111
+ # Apply enrichers
112
+ for enricher in self._context_enrichers:
113
+ try:
114
+ context = enricher(context)
115
+ except Exception as e:
116
+ logger.warning(f"Context enricher failed: {e}")
117
+
118
+ # Create active session handoff for this session
119
+ current_goal = goal or (previous.current_goal if previous else "Unknown")
120
+ active_handoff = SessionHandoff.create(
121
+ project_id=self.project_id,
122
+ agent=agent,
123
+ session_id=session_id,
124
+ last_action="session_start",
125
+ current_goal=current_goal,
126
+ last_outcome="unknown",
127
+ )
128
+
129
+ # Carry over blockers from previous session
130
+ if previous and previous.blockers:
131
+ active_handoff.blockers = previous.blockers.copy()
132
+
133
+ self._active_sessions[f"{agent}:{session_id}"] = active_handoff
134
+
135
+ logger.info(
136
+ f"Started session {session_id} for agent {agent} "
137
+ f"(previous: {'yes' if previous else 'no'})"
138
+ )
139
+
140
+ return context
141
+
142
+ def get_active_handoff(
143
+ self,
144
+ agent: str,
145
+ session_id: str,
146
+ ) -> Optional[SessionHandoff]:
147
+ """Get the active handoff for current session."""
148
+ return self._active_sessions.get(f"{agent}:{session_id}")
149
+
150
+ def update_session(
151
+ self,
152
+ agent: str,
153
+ session_id: str,
154
+ action: Optional[str] = None,
155
+ outcome: Optional[SessionOutcome] = None,
156
+ decision: Optional[str] = None,
157
+ blocker: Optional[str] = None,
158
+ resolved_blocker: Optional[str] = None,
159
+ active_file: Optional[str] = None,
160
+ test_result: Optional[Dict[str, bool]] = None,
161
+ confidence: Optional[float] = None,
162
+ risk: Optional[str] = None,
163
+ ) -> Optional[SessionHandoff]:
164
+ """
165
+ Update the active session with new information.
166
+
167
+ This should be called periodically during a session to track state.
168
+
169
+ Args:
170
+ agent: Agent identifier
171
+ session_id: Session identifier
172
+ action: Latest action taken
173
+ outcome: Outcome of latest action
174
+ decision: Key decision made
175
+ blocker: New blocker encountered
176
+ resolved_blocker: Blocker that was resolved
177
+ active_file: File currently being worked on
178
+ test_result: Test name -> passing status
179
+ confidence: Updated confidence level
180
+ risk: New risk identified
181
+
182
+ Returns:
183
+ Updated SessionHandoff or None if session not found
184
+ """
185
+ key = f"{agent}:{session_id}"
186
+ handoff = self._active_sessions.get(key)
187
+
188
+ if not handoff:
189
+ logger.warning(f"No active session found for {key}")
190
+ return None
191
+
192
+ if action:
193
+ handoff.last_action = action
194
+ if outcome:
195
+ handoff.last_outcome = outcome
196
+ if decision:
197
+ handoff.add_decision(decision)
198
+ if blocker:
199
+ handoff.add_blocker(blocker)
200
+ if resolved_blocker:
201
+ handoff.remove_blocker(resolved_blocker)
202
+ if active_file and active_file not in handoff.active_files:
203
+ handoff.active_files.append(active_file)
204
+ if test_result:
205
+ for test_name, passing in test_result.items():
206
+ handoff.set_test_status(test_name, passing)
207
+ if confidence is not None:
208
+ handoff.confidence_level = max(0.0, min(1.0, confidence))
209
+ if risk and risk not in handoff.risk_flags:
210
+ handoff.risk_flags.append(risk)
211
+
212
+ return handoff
213
+
214
+ def create_handoff(
215
+ self,
216
+ agent: str,
217
+ session_id: str,
218
+ last_action: str,
219
+ last_outcome: SessionOutcome,
220
+ next_steps: Optional[List[str]] = None,
221
+ **context,
222
+ ) -> SessionHandoff:
223
+ """
224
+ Create handoff at session end.
225
+
226
+ This finalizes the session and stores the handoff for the next session.
227
+
228
+ Args:
229
+ agent: Agent identifier
230
+ session_id: Session identifier
231
+ last_action: Final action taken
232
+ last_outcome: Outcome of the session
233
+ next_steps: Planned next actions
234
+ **context: Additional context to store
235
+
236
+ Returns:
237
+ Finalized SessionHandoff
238
+ """
239
+ key = f"{agent}:{session_id}"
240
+ handoff = self._active_sessions.get(key)
241
+
242
+ if handoff:
243
+ # Finalize existing handoff
244
+ handoff.finalize(last_action, last_outcome, next_steps)
245
+ # Add any additional context
246
+ handoff.metadata.update(context)
247
+ else:
248
+ # Create new handoff (session started without start_session call)
249
+ handoff = SessionHandoff.create(
250
+ project_id=self.project_id,
251
+ agent=agent,
252
+ session_id=session_id,
253
+ last_action=last_action,
254
+ current_goal=context.get("goal", "Unknown"),
255
+ last_outcome=last_outcome,
256
+ next_steps=next_steps or [],
257
+ )
258
+ handoff.session_end = datetime.now(timezone.utc)
259
+ handoff.metadata.update(context)
260
+
261
+ # Store handoff
262
+ self._store_handoff(agent, handoff)
263
+
264
+ # Clear active session
265
+ if key in self._active_sessions:
266
+ del self._active_sessions[key]
267
+
268
+ logger.info(
269
+ f"Created handoff for session {session_id}, "
270
+ f"outcome: {last_outcome}, next_steps: {len(next_steps or [])}"
271
+ )
272
+
273
+ return handoff
274
+
275
+ def _store_handoff(self, agent: str, handoff: SessionHandoff) -> None:
276
+ """Store a handoff internally and optionally to persistent storage."""
277
+ if agent not in self._handoffs:
278
+ self._handoffs[agent] = []
279
+
280
+ self._handoffs[agent].append(handoff)
281
+
282
+ # Trim to max
283
+ if len(self._handoffs[agent]) > self.max_handoffs:
284
+ self._handoffs[agent] = self._handoffs[agent][-self.max_handoffs:]
285
+
286
+ # TODO: Persist to storage backend when integrated
287
+
288
+ def get_latest_handoff(self, agent: str) -> Optional[SessionHandoff]:
289
+ """Get the most recent handoff for an agent."""
290
+ handoffs = self._handoffs.get(agent, [])
291
+ return handoffs[-1] if handoffs else None
292
+
293
+ def get_previous_sessions(
294
+ self,
295
+ agent: str,
296
+ limit: int = 5,
297
+ ) -> List[SessionHandoff]:
298
+ """
299
+ Get recent session handoffs for an agent.
300
+
301
+ Args:
302
+ agent: Agent identifier
303
+ limit: Maximum number of handoffs to return
304
+
305
+ Returns:
306
+ List of SessionHandoff, most recent first
307
+ """
308
+ handoffs = self._handoffs.get(agent, [])
309
+ return list(reversed(handoffs[-limit:]))
310
+
311
+ def get_quick_reload(
312
+ self,
313
+ agent: str,
314
+ ) -> str:
315
+ """
316
+ Get compressed context string for quick reload.
317
+
318
+ This is a formatted string that can be quickly parsed by an agent
319
+ for rapid context restoration.
320
+
321
+ Args:
322
+ agent: Agent identifier
323
+
324
+ Returns:
325
+ Formatted quick reload string
326
+ """
327
+ handoff = self.get_latest_handoff(agent)
328
+ if not handoff:
329
+ return f"No previous session found for agent {agent}"
330
+
331
+ return handoff.format_quick_reload()
332
+
333
+ def get_all_agents(self) -> List[str]:
334
+ """Get list of all agents with session history."""
335
+ return list(self._handoffs.keys())
336
+
337
+ def get_agent_stats(self, agent: str) -> Dict[str, Any]:
338
+ """
339
+ Get session statistics for an agent.
340
+
341
+ Returns summary of session history including:
342
+ - Total sessions
343
+ - Success rate
344
+ - Average duration
345
+ - Common blockers
346
+ """
347
+ handoffs = self._handoffs.get(agent, [])
348
+ if not handoffs:
349
+ return {
350
+ "agent": agent,
351
+ "total_sessions": 0,
352
+ "success_rate": 0.0,
353
+ "avg_duration_ms": 0,
354
+ "common_blockers": [],
355
+ }
356
+
357
+ # Calculate stats
358
+ total = len(handoffs)
359
+ successes = sum(1 for h in handoffs if h.last_outcome == "success")
360
+ success_rate = successes / total if total > 0 else 0.0
361
+
362
+ durations = [h.duration_ms for h in handoffs if h.duration_ms > 0]
363
+ avg_duration = sum(durations) / len(durations) if durations else 0
364
+
365
+ # Count blockers
366
+ blocker_counts: Dict[str, int] = {}
367
+ for h in handoffs:
368
+ for blocker in h.blockers:
369
+ blocker_counts[blocker] = blocker_counts.get(blocker, 0) + 1
370
+ common_blockers = sorted(
371
+ blocker_counts.items(), key=lambda x: x[1], reverse=True
372
+ )[:5]
373
+
374
+ return {
375
+ "agent": agent,
376
+ "total_sessions": total,
377
+ "success_rate": success_rate,
378
+ "avg_duration_ms": avg_duration,
379
+ "common_blockers": [b[0] for b in common_blockers],
380
+ }
381
+
382
+ def clear_history(self, agent: Optional[str] = None) -> int:
383
+ """
384
+ Clear session history.
385
+
386
+ Args:
387
+ agent: If provided, only clear history for this agent
388
+
389
+ Returns:
390
+ Number of handoffs cleared
391
+ """
392
+ if agent:
393
+ count = len(self._handoffs.get(agent, []))
394
+ self._handoffs[agent] = []
395
+ return count
396
+ else:
397
+ count = sum(len(h) for h in self._handoffs.values())
398
+ self._handoffs.clear()
399
+ return count