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.
- {agmem-0.2.0.dist-info → agmem-0.3.0.dist-info}/METADATA +338 -26
- {agmem-0.2.0.dist-info → agmem-0.3.0.dist-info}/RECORD +32 -16
- memvcs/__init__.py +1 -1
- memvcs/cli.py +1 -1
- memvcs/coordinator/server.py +18 -2
- memvcs/core/agents.py +411 -0
- memvcs/core/archaeology.py +410 -0
- memvcs/core/collaboration.py +435 -0
- memvcs/core/compliance.py +427 -0
- memvcs/core/compression_metrics.py +248 -0
- memvcs/core/confidence.py +379 -0
- memvcs/core/daemon.py +735 -0
- memvcs/core/delta.py +45 -23
- memvcs/core/distiller.py +3 -12
- memvcs/core/fast_similarity.py +404 -0
- memvcs/core/federated.py +13 -2
- memvcs/core/gardener.py +8 -68
- memvcs/core/pack.py +1 -1
- memvcs/core/privacy_validator.py +187 -0
- memvcs/core/private_search.py +327 -0
- memvcs/core/protocol_builder.py +198 -0
- memvcs/core/search_index.py +538 -0
- memvcs/core/semantic_graph.py +388 -0
- memvcs/core/session.py +520 -0
- memvcs/core/timetravel.py +430 -0
- memvcs/integrations/mcp_server.py +775 -4
- memvcs/integrations/web_ui/server.py +424 -0
- memvcs/integrations/web_ui/websocket.py +223 -0
- {agmem-0.2.0.dist-info → agmem-0.3.0.dist-info}/WHEEL +0 -0
- {agmem-0.2.0.dist-info → agmem-0.3.0.dist-info}/entry_points.txt +0 -0
- {agmem-0.2.0.dist-info → agmem-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {agmem-0.2.0.dist-info → agmem-0.3.0.dist-info}/top_level.txt +0 -0
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}
|