Coverage for src / osiris_cli / session.py: 0%

266 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-31 05:01 +0200

1import json 

2from pathlib import Path 

3from datetime import datetime 

4from typing import List, Dict, Optional, Any 

5from .config import settings, CONFIG_DIR 

6from .osiris_config import get_osiris_config 

7 

8SESSION_DIR = CONFIG_DIR / "sessions" 

9 

10class SessionManager: 

11 def __init__(self): 

12 SESSION_DIR.mkdir(parents=True, exist_ok=True) 

13 self.current_session_id = datetime.now().strftime("%Y%m%d_%H%M%S") 

14 self.messages: List[Dict[str, Any]] = [] 

15 self.context_files: List[str] = [] 

16 self.allowed_tools: List[str] = [] 

17 self.forbidden_tools: List[str] = [] 

18 self.total_tokens = 0 

19 self.last_command_palette_filter = "" 

20 self.tags: List[str] = [] # Session tags for organization 

21 self.description: str = "" # Session description 

22 self.provider: str = "" # Provider used for this session 

23 self.model: str = "" # Model used for this session 

24 self.activity_log: List[Dict[str, str]] = [] 

25 self.plan_history: List[Dict[str, str]] = [] 

26 self._capabilities: Dict[str, Dict[str, bool]] = {} 

27 self._tool_policy_allowed = None 

28 self._reset_tool_policy() 

29 

30 def _reset_tool_policy(self): 

31 config = get_osiris_config() 

32 self.allowed_tools = list(config.allowed_tools) 

33 self.forbidden_tools = list(config.blocked_tools) 

34 self._capabilities.clear() 

35 

36 def add_message(self, role: str, content: str, tool_calls=None): 

37 msg: Dict[str, Any] = {"role": role, "content": content} 

38 if tool_calls: 

39 # Serialize tool calls for JSON storage if they are objects 

40 # Assuming tool_calls is a list of objects from OpenAI SDK 

41 # We need to store them as dicts 

42 try: 

43 serialized_calls = [] 

44 for tc in tool_calls: 

45 if hasattr(tc, 'id') and hasattr(tc, 'type'): 

46 # OpenAI object format 

47 call_dict = { 

48 "id": tc.id, 

49 "type": tc.type, 

50 "function": { 

51 "name": tc.function.name, 

52 "arguments": tc.function.arguments 

53 } 

54 } 

55 else: 

56 # Already dict format 

57 call_dict = tc 

58 serialized_calls.append(call_dict) 

59 msg["tool_calls"] = serialized_calls 

60 except (AttributeError, TypeError): 

61 # Handle any format issues 

62 msg["tool_calls"] = [] 

63 self.messages.append(msg) 

64 self.save_session() 

65 

66 def add_tool_output(self, tool_call_id: str, name: str, output: str): 

67 msg = { 

68 "role": "tool", 

69 "tool_call_id": tool_call_id, 

70 "name": name, 

71 "content": str(output) 

72 } 

73 self.messages.append(msg) 

74 self.save_session() 

75 

76 def add_activity(self, description: str): 

77 self.activity_log.append({ 

78 "timestamp": datetime.now().isoformat(), 

79 "description": description 

80 }) 

81 self.save_session() 

82 

83 def add_plan_snapshot(self, plan_text: str): 

84 self.plan_history.append({ 

85 "timestamp": datetime.now().isoformat(), 

86 "plan": plan_text 

87 }) 

88 self.save_session() 

89 

90 def add_usage(self, tokens: int): 

91 if tokens: 

92 self.total_tokens += tokens 

93 self.save_session() 

94 

95 def _generate_preview(self) -> str: 

96 """Generate a preview of the session content""" 

97 for msg in reversed(self.messages): 

98 if msg.get("role") == "user" and msg.get("content"): 

99 preview = msg["content"][:60].replace("\n", " ") 

100 return f"[{self.total_tokens}t] {preview}" 

101 return f"[{self.total_tokens}t] Empty session" 

102 

103 def save_session(self, tags: Optional[List[str]] = None, description: str = ""): 

104 """Enhanced save session with metadata""" 

105 if tags: 

106 self.tags = tags 

107 if description: 

108 self.description = description 

109 

110 file_path = SESSION_DIR / f"{self.current_session_id}.json" 

111 data = { 

112 "id": self.current_session_id, 

113 "timestamp": datetime.now().isoformat(), 

114 "model": getattr(settings, 'default_model', 'unknown'), 

115 "provider": getattr(settings, 'provider', 'unknown'), 

116 "context_files": self.context_files, 

117 "messages": self.messages, 

118 "allowed_tools": self.allowed_tools, 

119 "forbidden_tools": self.forbidden_tools, 

120 "total_tokens": self.total_tokens, 

121 "last_command_palette_filter": self.last_command_palette_filter, 

122 "tags": self.tags, 

123 "description": self.description, 

124 "message_count": len(self.messages), 

125 "tool_calls": sum(1 for msg in self.messages if msg.get('role') == 'tool'), 

126 "preview": self._generate_preview(), 

127 "activity_log": self.activity_log, 

128 "plan_history": self.plan_history 

129 } 

130 with open(file_path, "w") as f: 

131 json.dump(data, f, indent=2) 

132 

133 def load_last_session(self) -> bool: 

134 """Load the most recent session. Returns True if successful.""" 

135 files = sorted(SESSION_DIR.glob("*.json"), reverse=True) 

136 if not files: 

137 return False 

138 return self.load_session(files[0].stem) 

139 

140 def delete_session(self, session_id: str) -> bool: 

141 """Delete a session by ID.""" 

142 file_path = SESSION_DIR / f"{session_id}.json" 

143 if file_path.exists(): 

144 try: 

145 file_path.unlink() 

146 return True 

147 except Exception: 

148 return False 

149 return False 

150 

151 def load_session(self, session_id: str) -> bool: 

152 """Load a specific session by ID.""" 

153 file_path = SESSION_DIR / f"{session_id}.json" 

154 if not file_path.exists(): 

155 return False 

156 

157 try: 

158 with open(file_path, "r") as f: 

159 data = json.load(f) 

160 self.current_session_id = data["id"] 

161 self.messages = data.get("messages", []) 

162 self.context_files = data.get("context_files", []) 

163 self.allowed_tools = data.get("allowed_tools", []) 

164 self.forbidden_tools = data.get("forbidden_tools", []) 

165 self.total_tokens = data.get("total_tokens", 0) 

166 self.last_command_palette_filter = data.get("last_command_palette_filter", "") 

167 self.activity_log = data.get("activity_log", []) 

168 self.plan_history = data.get("plan_history", []) 

169 return True 

170 except Exception: 

171 return False 

172 

173 def list_sessions(self) -> List[str]: 

174 """List all session IDs""" 

175 files = sorted(SESSION_DIR.glob("*.json"), reverse=True) 

176 return [f.stem for f in files[:10]] # Return top 10 

177 

178 def list_session_meta(self) -> List[Dict]: 

179 """Return list of session metadata for menus.""" 

180 files = sorted(SESSION_DIR.glob("*.json"), reverse=True) 

181 meta = [] 

182 for p in files[:25]: 

183 try: 

184 data = json.loads(p.read_text()) 

185 sid = data.get("id", p.stem) 

186 ts = data.get("timestamp", "") 

187 msgs = data.get("messages", []) 

188 toks = data.get("total_tokens", 0) 

189 preview = "" 

190 for m in msgs: 

191 if m.get("role") == "user": 

192 preview = m.get("content", "")[:60].replace("\n", " ") 

193 break 

194 # Format preview to include tokens 

195 display_preview = f"[{toks}t] {preview}" 

196 meta.append({"id": sid, "timestamp": ts, "preview": display_preview, "total_tokens": toks}) 

197 except Exception: 

198 continue 

199 return meta 

200 

201 def clear(self): 

202 self.messages = [] 

203 self.context_files = [] 

204 self._reset_tool_policy() 

205 self._capabilities.clear() 

206 self.total_tokens = 0 

207 self.last_command_palette_filter = "" 

208 self.activity_log = [] 

209 self.plan_history = [] 

210 # Start new session file 

211 self.current_session_id = datetime.now().strftime("%Y%m%d_%H%M%S") 

212 self.save_session() 

213 

214 def enforce_tool_policy(self, tool_name: str): 

215 if not self.is_tool_allowed(tool_name): 

216 raise PermissionError(f"Tool '{tool_name}' is blocked by the session policy.") 

217 

218 def forbid_tool(self, tool_name: str): 

219 if tool_name not in self.forbidden_tools: 

220 self.forbidden_tools.append(tool_name) 

221 self.save_session() 

222 

223 def remove_forbidden_tool(self, tool_name: str): 

224 if tool_name in self.forbidden_tools: 

225 self.forbidden_tools.remove(tool_name) 

226 self.save_session() 

227 

228 def remember_tool(self, tool_name: str): 

229 if tool_name not in self.allowed_tools: 

230 self.allowed_tools.append(tool_name) 

231 self.save_session() 

232 

233 def is_tool_allowed(self, tool_name: str) -> bool: 

234 if self.forbidden_tools and tool_name in self.forbidden_tools: 

235 return False 

236 if self.allowed_tools: 

237 return tool_name in self.allowed_tools 

238 return True 

239 

240 def apply_tool_policy(self, allowed: Optional[List[str]] = None, forbidden: Optional[List[str]] = None) -> bool: 

241 updated = False 

242 

243 if allowed is not None: 

244 normalized = [] 

245 for tool in allowed: 

246 if tool and tool not in normalized: 

247 normalized.append(tool) 

248 self.allowed_tools = normalized 

249 updated = True 

250 

251 if forbidden: 

252 for tool in forbidden: 

253 if tool and tool not in self.forbidden_tools: 

254 self.forbidden_tools.append(tool) 

255 updated = True 

256 

257 if updated: 

258 self.save_session() 

259 return updated 

260 

261 def provider_capability(self, provider: str, model: str, capability: str) -> bool: 

262 if not provider or not model: 

263 return True 

264 key = f"{provider}:{model}" 

265 entry = self._capabilities.get(key) 

266 if not entry: 

267 return True 

268 return entry.get(capability, True) 

269 

270 def mark_provider_capability(self, provider: str, model: str, capability: str, value: bool): 

271 if not provider or not model: 

272 return 

273 key = f"{provider}:{model}" 

274 entry = self._capabilities.setdefault(key, {}) 

275 entry[capability] = value 

276 

277 def provider_supports_tools(self, provider: str, model: str) -> bool: 

278 return self.provider_capability(provider, model, "tools") 

279 

280 def mark_provider_tool_support(self, provider: str, model: str, supports: bool): 

281 self.mark_provider_capability(provider, model, "tools", supports) 

282 

283 def reset_provider_capabilities(self): 

284 self._capabilities.clear() 

285 

286 def undo_last_step(self) -> bool: 

287 """ 

288 Solid Undo: Removes the last 'step' of conversation history. 

289 - If last was Assistant text -> Remove it. 

290 - If last was Tool Output -> Remove it AND the preceding Assistant Tool Call (and any sibling tool outputs). 

291 """ 

292 if not self.messages: return False 

293 

294 last = self.messages.pop() 

295 

296 # If we removed a tool output, we must clean up the entire tool interaction chain 

297 if last.get("role") == "tool": 

298 # 1. Remove any other adjacent tool outputs (parallel execution case) 

299 while self.messages and self.messages[-1].get("role") == "tool": 

300 self.messages.pop() 

301 

302 # 2. Remove the Assistant message that triggered these tools 

303 if self.messages and self.messages[-1].get("role") == "assistant" and self.messages[-1].get("tool_calls"): 

304 self.messages.pop() 

305 

306 self.save_session() 

307 return True 

308 

309 def search_sessions(self, query: str = "", tags: Optional[List[str]] = None, 

310 provider: str = "", model: str = "") -> List[Dict]: 

311 """Advanced session search with filtering""" 

312 all_sessions = self.list_session_meta() 

313 

314 if not query and not tags and not provider and not model: 

315 return all_sessions 

316 

317 filtered = [] 

318 query = query.lower() 

319 

320 for session in all_sessions: 

321 # Text search in preview, description, and ID 

322 text_match = (query in session.get('preview', '').lower() or 

323 query in session.get('description', '').lower() or 

324 query in session.get('id', '').lower()) 

325 

326 # Tag filtering 

327 tag_match = True 

328 if tags: 

329 session_tags = session.get('tags', []) 

330 tag_match = any(tag in session_tags for tag in tags) 

331 

332 # Provider filtering 

333 provider_match = True 

334 if provider: 

335 provider_match = session.get('provider', '').lower() == provider.lower() 

336 

337 # Model filtering 

338 model_match = True 

339 if model: 

340 model_match = session.get('model', '').lower() == model.lower() 

341 

342 if text_match and tag_match and provider_match and model_match: 

343 filtered.append(session) 

344 

345 return filtered 

346 

347 def add_tags(self, tags: List[str]): 

348 """Add tags to current session""" 

349 for tag in tags: 

350 if tag not in self.tags: 

351 self.tags.append(tag) 

352 

353 def remove_tags(self, tags: List[str]): 

354 """Remove tags from current session""" 

355 self.tags = [tag for tag in self.tags if tag not in tags] 

356 

357 def set_description(self, description: str): 

358 """Set session description""" 

359 self.description = description 

360 

361 def get_session_stats(self) -> Dict: 

362 """Get comprehensive session statistics""" 

363 meta = self.list_session_meta() 

364 total_sessions = len(meta) 

365 

366 if not meta: 

367 return {"total_sessions": 0} 

368 

369 providers = {} 

370 models = {} 

371 total_messages = 0 

372 total_tokens = 0 

373 total_tools = 0 

374 

375 for session in meta: 

376 provider = session.get('provider', 'unknown') 

377 model = session.get('model', 'unknown') 

378 messages = session.get('message_count', 0) 

379 tokens = session.get('total_tokens', 0) 

380 tools = session.get('tool_calls', 0) 

381 

382 providers[provider] = providers.get(provider, 0) + 1 

383 models[model] = models.get(model, 0) + 1 

384 total_messages += messages 

385 total_tokens += tokens 

386 total_tools += tools 

387 

388 return { 

389 "total_sessions": total_sessions, 

390 "providers": providers, 

391 "models": models, 

392 "total_messages": total_messages, 

393 "total_tokens": total_tokens, 

394 "total_tool_calls": total_tools, 

395 "avg_messages_per_session": total_messages / total_sessions if total_sessions > 0 else 0, 

396 "avg_tokens_per_session": total_tokens / total_sessions if total_sessions > 0 else 0 

397 } 

398 

399 def dump_context(self, target_path: Path): 

400 """Serialize current Memory Map for sub-agent pass-through.""" 

401 data = { 

402 "messages": self.messages, 

403 "context_files": self.context_files, 

404 "allowed_tools": self.allowed_tools, 

405 "total_tokens": self.total_tokens, 

406 "parent_session_id": self.current_session_id 

407 } 

408 with open(target_path, "w") as f: 

409 json.dump(data, f, indent=2) 

410 

411session = SessionManager()