mrstack 1.1.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.
- mrstack/__init__.py +4 -0
- mrstack/_data/config/com.mrstack.claude-telegram.plist +25 -0
- mrstack/_data/config/mcp-config.example.json +23 -0
- mrstack/_data/config/start-daemon.sh +53 -0
- mrstack/_data/config/start.sh +29 -0
- mrstack/_data/schedulers/manage-jobs.sh +87 -0
- mrstack/_data/schedulers/morning-briefing.sh +29 -0
- mrstack/_data/schedulers/register-jobs.py +182 -0
- mrstack/_data/schedulers/run-threads-briefing.sh +36 -0
- mrstack/_data/schedulers/weekly-review.sh +26 -0
- mrstack/_data/templates/DESIGN-GUIDE.md +160 -0
- mrstack/_data/templates/alert.md +56 -0
- mrstack/_data/templates/evening-summary.md +73 -0
- mrstack/_data/templates/jarvis-alert.md +64 -0
- mrstack/_data/templates/morning-briefing.md +53 -0
- mrstack/_data/templates/weekly-review.md +79 -0
- mrstack/_overlay/api/dashboard.py +223 -0
- mrstack/_overlay/api/templates/dashboard.html +328 -0
- mrstack/_overlay/bot/handlers/callback.py +1432 -0
- mrstack/_overlay/bot/handlers/command.py +1541 -0
- mrstack/_overlay/bot/utils/keyboards.py +125 -0
- mrstack/_overlay/bot/utils/ui_components.py +166 -0
- mrstack/_overlay/claude/session.py +341 -0
- mrstack/_overlay/jarvis/__init__.py +77 -0
- mrstack/_overlay/jarvis/coach.py +122 -0
- mrstack/_overlay/jarvis/context_engine.py +463 -0
- mrstack/_overlay/jarvis/pattern_learner.py +255 -0
- mrstack/_overlay/jarvis/persona.py +84 -0
- mrstack/_overlay/jarvis/platform.py +182 -0
- mrstack/_overlay/knowledge/__init__.py +6 -0
- mrstack/_overlay/knowledge/manager.py +464 -0
- mrstack/_overlay/knowledge/memory_index.py +180 -0
- mrstack/cli.py +330 -0
- mrstack/constants.py +77 -0
- mrstack/daemon.py +325 -0
- mrstack/patcher.py +169 -0
- mrstack/wizard.py +271 -0
- mrstack-1.1.0.dist-info/METADATA +640 -0
- mrstack-1.1.0.dist-info/RECORD +42 -0
- mrstack-1.1.0.dist-info/WHEEL +4 -0
- mrstack-1.1.0.dist-info/entry_points.txt +2 -0
- mrstack-1.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Inline keyboard builders for agentic mode.
|
|
2
|
+
|
|
3
|
+
Provides context-aware keyboard generation based on Claude response content.
|
|
4
|
+
Callback data format: ``agentic:<action>`` or ``job:<action>:<name>``.
|
|
5
|
+
All callbacks stay under Telegram's 64-byte limit.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AgenticKeyboards:
|
|
14
|
+
"""Build inline keyboards for agentic mode responses."""
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def for_code_change(has_tests: bool = False) -> InlineKeyboardMarkup:
|
|
18
|
+
"""Keyboard for code modification responses."""
|
|
19
|
+
row1 = [
|
|
20
|
+
InlineKeyboardButton("Run Tests", callback_data="agentic:run_tests"),
|
|
21
|
+
InlineKeyboardButton("Diff", callback_data="agentic:show_diff"),
|
|
22
|
+
]
|
|
23
|
+
row2 = [
|
|
24
|
+
InlineKeyboardButton("Continue", callback_data="agentic:continue"),
|
|
25
|
+
InlineKeyboardButton("New Task", callback_data="agentic:new_task"),
|
|
26
|
+
]
|
|
27
|
+
return InlineKeyboardMarkup([row1, row2])
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def for_error(is_test_error: bool = False) -> InlineKeyboardMarkup:
|
|
31
|
+
"""Keyboard for error responses."""
|
|
32
|
+
row1 = [
|
|
33
|
+
InlineKeyboardButton("Debug", callback_data="agentic:debug"),
|
|
34
|
+
InlineKeyboardButton("Full Error", callback_data="agentic:full_error"),
|
|
35
|
+
]
|
|
36
|
+
row2 = [
|
|
37
|
+
InlineKeyboardButton("Skip", callback_data="agentic:continue"),
|
|
38
|
+
InlineKeyboardButton("New Session", callback_data="agentic:new_task"),
|
|
39
|
+
]
|
|
40
|
+
return InlineKeyboardMarkup([row1, row2])
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def for_completion() -> InlineKeyboardMarkup:
|
|
44
|
+
"""Keyboard for task completion responses."""
|
|
45
|
+
row = [
|
|
46
|
+
InlineKeyboardButton("Continue", callback_data="agentic:continue"),
|
|
47
|
+
InlineKeyboardButton("New Task", callback_data="agentic:new_task"),
|
|
48
|
+
InlineKeyboardButton("Status", callback_data="agentic:status"),
|
|
49
|
+
]
|
|
50
|
+
return InlineKeyboardMarkup([row])
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def for_search_results() -> InlineKeyboardMarkup:
|
|
54
|
+
"""Keyboard for search/grep results."""
|
|
55
|
+
row = [
|
|
56
|
+
InlineKeyboardButton("Refine", callback_data="agentic:continue"),
|
|
57
|
+
InlineKeyboardButton("New Task", callback_data="agentic:new_task"),
|
|
58
|
+
]
|
|
59
|
+
return InlineKeyboardMarkup([row])
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def for_job(job_name: str, is_active: bool) -> InlineKeyboardMarkup:
|
|
63
|
+
"""Keyboard for individual job control."""
|
|
64
|
+
toggle_label = "Pause" if is_active else "Resume"
|
|
65
|
+
row = [
|
|
66
|
+
InlineKeyboardButton(
|
|
67
|
+
toggle_label, callback_data=f"job:toggle:{job_name}"
|
|
68
|
+
),
|
|
69
|
+
InlineKeyboardButton(
|
|
70
|
+
"Run Now", callback_data=f"job:run:{job_name}"
|
|
71
|
+
),
|
|
72
|
+
]
|
|
73
|
+
return InlineKeyboardMarkup([row])
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def analyze_and_build(
|
|
77
|
+
cls,
|
|
78
|
+
content: str,
|
|
79
|
+
tools_used: Optional[List[str]] = None,
|
|
80
|
+
) -> Optional[InlineKeyboardMarkup]:
|
|
81
|
+
"""Analyze response content and return appropriate keyboard.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
content: The Claude response text.
|
|
85
|
+
tools_used: List of tool names used during the response.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
InlineKeyboardMarkup or None if no keyboard is appropriate.
|
|
89
|
+
"""
|
|
90
|
+
if not content:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
tools = set()
|
|
94
|
+
for t in (tools_used or []):
|
|
95
|
+
if isinstance(t, dict):
|
|
96
|
+
name = t.get("name")
|
|
97
|
+
if name:
|
|
98
|
+
tools.add(name)
|
|
99
|
+
elif isinstance(t, str):
|
|
100
|
+
tools.add(t)
|
|
101
|
+
content_lower = content.lower()
|
|
102
|
+
|
|
103
|
+
# Code changes detected
|
|
104
|
+
if tools & {"Write", "Edit", "MultiEdit"}:
|
|
105
|
+
has_tests = "test" in content_lower
|
|
106
|
+
return cls.for_code_change(has_tests=has_tests)
|
|
107
|
+
|
|
108
|
+
# Error/traceback detected
|
|
109
|
+
if any(
|
|
110
|
+
kw in content_lower
|
|
111
|
+
for kw in ["error", "traceback", "exception", "failed"]
|
|
112
|
+
):
|
|
113
|
+
is_test = "test" in content_lower
|
|
114
|
+
return cls.for_error(is_test_error=is_test)
|
|
115
|
+
|
|
116
|
+
# Search results
|
|
117
|
+
if tools & {"Glob", "Grep"}:
|
|
118
|
+
return cls.for_search_results()
|
|
119
|
+
|
|
120
|
+
# Any tool usage = task completed
|
|
121
|
+
if tools:
|
|
122
|
+
return cls.for_completion()
|
|
123
|
+
|
|
124
|
+
# Pure text response — no keyboard
|
|
125
|
+
return None
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Telegram UI components — cards, blockquotes, icons.
|
|
2
|
+
|
|
3
|
+
Provides rich formatting primitives built on Telegram's HTML subset.
|
|
4
|
+
Uses <blockquote> (Telegram Bot API 7.3+) and expandable blockquotes
|
|
5
|
+
(Bot API 7.11+) for collapsible detail sections.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from .html_format import escape_html
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Icons:
|
|
14
|
+
"""Centralized emoji constants for consistent UI."""
|
|
15
|
+
|
|
16
|
+
SUCCESS = "\u2705"
|
|
17
|
+
ERROR = "\u274c"
|
|
18
|
+
WARNING = "\u26a0\ufe0f"
|
|
19
|
+
INFO = "\u2139\ufe0f"
|
|
20
|
+
WORKING = "\u23f3"
|
|
21
|
+
CODE = "\U0001f4cb"
|
|
22
|
+
FILE = "\U0001f4c4"
|
|
23
|
+
FOLDER = "\U0001f4c2"
|
|
24
|
+
GIT = "\U0001f500"
|
|
25
|
+
SESSION = "\U0001f4a1"
|
|
26
|
+
COST = "\U0001f4b0"
|
|
27
|
+
SCHEDULE = "\U0001f4c5"
|
|
28
|
+
MEMORY = "\U0001f9e0"
|
|
29
|
+
JARVIS = "\U0001f916"
|
|
30
|
+
VOICE = "\U0001f3a4"
|
|
31
|
+
CLIPBOARD = "\U0001f4cb"
|
|
32
|
+
TOGGLE_ON = "\U0001f7e2"
|
|
33
|
+
TOGGLE_OFF = "\u26aa"
|
|
34
|
+
TOOL = "\U0001f527"
|
|
35
|
+
ROCKET = "\U0001f680"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def blockquote(text: str) -> str:
|
|
39
|
+
"""Wrap text in a Telegram blockquote."""
|
|
40
|
+
return f"<blockquote>{text}</blockquote>"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def expandable(text: str) -> str:
|
|
44
|
+
"""Wrap text in an expandable (collapsible) blockquote.
|
|
45
|
+
|
|
46
|
+
Falls back to regular blockquote on older Telegram clients.
|
|
47
|
+
"""
|
|
48
|
+
return f"<blockquote expandable>{text}</blockquote>"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def status_card(
|
|
52
|
+
title: str,
|
|
53
|
+
items: List[Tuple[str, str]],
|
|
54
|
+
footer: Optional[str] = None,
|
|
55
|
+
) -> str:
|
|
56
|
+
"""Build a status card with icon-label pairs.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
title: Card header (bold).
|
|
60
|
+
items: List of (icon_or_label, value) tuples.
|
|
61
|
+
footer: Optional footer text (italic).
|
|
62
|
+
"""
|
|
63
|
+
lines = [f"<b>{escape_html(title)}</b>\n"]
|
|
64
|
+
for label, value in items:
|
|
65
|
+
lines.append(f"{label} {escape_html(value)}")
|
|
66
|
+
body = "\n".join(lines)
|
|
67
|
+
|
|
68
|
+
if footer:
|
|
69
|
+
body += f"\n\n<i>{escape_html(footer)}</i>"
|
|
70
|
+
|
|
71
|
+
return blockquote(body)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def job_card(jobs: list) -> str:
|
|
75
|
+
"""Build a formatted job list card.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
jobs: List of dicts with keys: name, cron, active, model (optional).
|
|
79
|
+
"""
|
|
80
|
+
header = f"{Icons.SCHEDULE} <b>Schedule ({len(jobs)})</b>\n"
|
|
81
|
+
lines = []
|
|
82
|
+
for job in jobs:
|
|
83
|
+
icon = Icons.TOGGLE_ON if job.get("active") else Icons.TOGGLE_OFF
|
|
84
|
+
name = escape_html(job.get("name", "?"))
|
|
85
|
+
cron = escape_html(job.get("cron", "?"))
|
|
86
|
+
model = job.get("model", "")
|
|
87
|
+
model_tag = f" <i>{escape_html(model)}</i>" if model else ""
|
|
88
|
+
lines.append(f"{icon} <code>{name}</code> {cron}{model_tag}")
|
|
89
|
+
|
|
90
|
+
return header + blockquote("\n".join(lines))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def error_card(
|
|
94
|
+
title: str,
|
|
95
|
+
summary: str,
|
|
96
|
+
details: Optional[str] = None,
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Build an error card with optional expandable details.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
title: Error title (bold with error icon).
|
|
102
|
+
summary: Brief error description.
|
|
103
|
+
details: Optional detailed info (shown in expandable blockquote).
|
|
104
|
+
"""
|
|
105
|
+
text = f"{Icons.ERROR} <b>{escape_html(title)}</b>\n\n{escape_html(summary)}"
|
|
106
|
+
|
|
107
|
+
if details:
|
|
108
|
+
safe_details = escape_html(details)
|
|
109
|
+
# Truncate extremely long details
|
|
110
|
+
if len(safe_details) > 2000:
|
|
111
|
+
safe_details = safe_details[:2000] + "\n... (truncated)"
|
|
112
|
+
text += f"\n\n{expandable(safe_details)}"
|
|
113
|
+
|
|
114
|
+
return text
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def code_card(
|
|
118
|
+
code: str,
|
|
119
|
+
language: str = "",
|
|
120
|
+
filename: str = "",
|
|
121
|
+
) -> str:
|
|
122
|
+
"""Build a code display card.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
code: The code content.
|
|
126
|
+
language: Optional language for syntax highlighting.
|
|
127
|
+
filename: Optional filename header.
|
|
128
|
+
"""
|
|
129
|
+
header = ""
|
|
130
|
+
if filename:
|
|
131
|
+
header = f"{Icons.FILE} <code>{escape_html(filename)}</code>\n\n"
|
|
132
|
+
|
|
133
|
+
escaped_code = escape_html(code)
|
|
134
|
+
if language:
|
|
135
|
+
code_block = f'<pre><code class="language-{escape_html(language)}">{escaped_code}</code></pre>'
|
|
136
|
+
else:
|
|
137
|
+
code_block = f"<pre><code>{escaped_code}</code></pre>"
|
|
138
|
+
|
|
139
|
+
return f"{header}{code_block}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def toggle_card(feature: str, enabled: bool, description: str) -> str:
|
|
143
|
+
"""Build a feature toggle status card.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
feature: Feature name.
|
|
147
|
+
enabled: Current state.
|
|
148
|
+
description: Brief description of the feature.
|
|
149
|
+
"""
|
|
150
|
+
icon = Icons.TOGGLE_ON if enabled else Icons.TOGGLE_OFF
|
|
151
|
+
state = "ON" if enabled else "OFF"
|
|
152
|
+
return f"{icon} <b>{escape_html(feature)}: {state}</b>\n{escape_html(description)}"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def help_card(sections: List[Tuple[str, List[str]]]) -> str:
|
|
156
|
+
"""Build a help display with sections.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
sections: List of (section_title, [item_lines]) tuples.
|
|
160
|
+
"""
|
|
161
|
+
parts = []
|
|
162
|
+
for title, items in sections:
|
|
163
|
+
section_lines = [f"<b>{escape_html(title)}</b>"]
|
|
164
|
+
section_lines.extend(items)
|
|
165
|
+
parts.append(blockquote("\n".join(section_lines)))
|
|
166
|
+
return "\n\n".join(parts)
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Claude Code session management.
|
|
2
|
+
|
|
3
|
+
Features:
|
|
4
|
+
- Session state tracking
|
|
5
|
+
- Multi-project support
|
|
6
|
+
- Session persistence
|
|
7
|
+
- Cleanup policies
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
import structlog
|
|
16
|
+
|
|
17
|
+
from ..config.settings import Settings
|
|
18
|
+
from .sdk_integration import ClaudeResponse
|
|
19
|
+
|
|
20
|
+
logger = structlog.get_logger()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _to_utc(dt: datetime) -> datetime:
|
|
24
|
+
"""Normalize datetime to timezone-aware UTC.
|
|
25
|
+
|
|
26
|
+
Backward compatibility: legacy persisted sessions may contain naive
|
|
27
|
+
timestamps; treat naive values as UTC.
|
|
28
|
+
"""
|
|
29
|
+
if dt.tzinfo is None:
|
|
30
|
+
return dt.replace(tzinfo=UTC)
|
|
31
|
+
return dt.astimezone(UTC)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ClaudeSession:
|
|
36
|
+
"""Claude Code session state."""
|
|
37
|
+
|
|
38
|
+
session_id: str
|
|
39
|
+
user_id: int
|
|
40
|
+
project_path: Path
|
|
41
|
+
created_at: datetime
|
|
42
|
+
last_used: datetime
|
|
43
|
+
total_cost: float = 0.0
|
|
44
|
+
total_turns: int = 0
|
|
45
|
+
message_count: int = 0
|
|
46
|
+
tools_used: List[str] = field(default_factory=list)
|
|
47
|
+
is_new_session: bool = False # True if session hasn't been sent to Claude Code yet
|
|
48
|
+
|
|
49
|
+
def is_expired(self, timeout_hours: int) -> bool:
|
|
50
|
+
"""Check if session has expired."""
|
|
51
|
+
age = datetime.now(UTC) - _to_utc(self.last_used)
|
|
52
|
+
return age > timedelta(hours=timeout_hours)
|
|
53
|
+
|
|
54
|
+
def update_usage(self, response: ClaudeResponse) -> None:
|
|
55
|
+
"""Update session with usage from response."""
|
|
56
|
+
self.last_used = _to_utc(datetime.now(UTC))
|
|
57
|
+
self.total_cost += response.cost
|
|
58
|
+
self.total_turns += response.num_turns
|
|
59
|
+
self.message_count += 1
|
|
60
|
+
|
|
61
|
+
# Track unique tools
|
|
62
|
+
if response.tools_used:
|
|
63
|
+
for tool in response.tools_used:
|
|
64
|
+
if isinstance(tool, dict):
|
|
65
|
+
tool_name = tool.get("name")
|
|
66
|
+
elif isinstance(tool, str):
|
|
67
|
+
tool_name = tool
|
|
68
|
+
else:
|
|
69
|
+
tool_name = None
|
|
70
|
+
if tool_name and tool_name not in self.tools_used:
|
|
71
|
+
self.tools_used.append(tool_name)
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> Dict:
|
|
74
|
+
"""Convert session to dictionary for storage."""
|
|
75
|
+
return {
|
|
76
|
+
"session_id": self.session_id,
|
|
77
|
+
"user_id": self.user_id,
|
|
78
|
+
"project_path": str(self.project_path),
|
|
79
|
+
"created_at": self.created_at.isoformat(),
|
|
80
|
+
"last_used": self.last_used.isoformat(),
|
|
81
|
+
"total_cost": self.total_cost,
|
|
82
|
+
"total_turns": self.total_turns,
|
|
83
|
+
"message_count": self.message_count,
|
|
84
|
+
"tools_used": self.tools_used,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_dict(cls, data: Dict) -> "ClaudeSession":
|
|
89
|
+
"""Create session from dictionary."""
|
|
90
|
+
return cls(
|
|
91
|
+
session_id=data["session_id"],
|
|
92
|
+
user_id=data["user_id"],
|
|
93
|
+
project_path=Path(data["project_path"]),
|
|
94
|
+
created_at=_to_utc(datetime.fromisoformat(data["created_at"])),
|
|
95
|
+
last_used=_to_utc(datetime.fromisoformat(data["last_used"])),
|
|
96
|
+
total_cost=data.get("total_cost", 0.0),
|
|
97
|
+
total_turns=data.get("total_turns", 0),
|
|
98
|
+
message_count=data.get("message_count", 0),
|
|
99
|
+
tools_used=data.get("tools_used", []),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SessionStorage:
|
|
104
|
+
"""Abstract base class for session storage."""
|
|
105
|
+
|
|
106
|
+
async def save_session(self, session: ClaudeSession) -> None:
|
|
107
|
+
"""Save session to storage."""
|
|
108
|
+
raise NotImplementedError
|
|
109
|
+
|
|
110
|
+
async def load_session(self, session_id: str) -> Optional[ClaudeSession]:
|
|
111
|
+
"""Load session from storage."""
|
|
112
|
+
raise NotImplementedError
|
|
113
|
+
|
|
114
|
+
async def delete_session(self, session_id: str) -> None:
|
|
115
|
+
"""Delete session from storage."""
|
|
116
|
+
raise NotImplementedError
|
|
117
|
+
|
|
118
|
+
async def get_user_sessions(self, user_id: int) -> List[ClaudeSession]:
|
|
119
|
+
"""Get all sessions for a user."""
|
|
120
|
+
raise NotImplementedError
|
|
121
|
+
|
|
122
|
+
async def get_all_sessions(self) -> List[ClaudeSession]:
|
|
123
|
+
"""Get all sessions."""
|
|
124
|
+
raise NotImplementedError
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class InMemorySessionStorage(SessionStorage):
|
|
128
|
+
"""In-memory session storage for development/testing."""
|
|
129
|
+
|
|
130
|
+
def __init__(self):
|
|
131
|
+
"""Initialize in-memory storage."""
|
|
132
|
+
self.sessions: Dict[str, ClaudeSession] = {}
|
|
133
|
+
|
|
134
|
+
async def save_session(self, session: ClaudeSession) -> None:
|
|
135
|
+
"""Save session to memory."""
|
|
136
|
+
self.sessions[session.session_id] = session
|
|
137
|
+
logger.debug("Session saved to memory", session_id=session.session_id)
|
|
138
|
+
|
|
139
|
+
async def load_session(self, session_id: str) -> Optional[ClaudeSession]:
|
|
140
|
+
"""Load session from memory."""
|
|
141
|
+
session = self.sessions.get(session_id)
|
|
142
|
+
if session:
|
|
143
|
+
logger.debug("Session loaded from memory", session_id=session_id)
|
|
144
|
+
return session
|
|
145
|
+
|
|
146
|
+
async def delete_session(self, session_id: str) -> None:
|
|
147
|
+
"""Delete session from memory."""
|
|
148
|
+
if session_id in self.sessions:
|
|
149
|
+
del self.sessions[session_id]
|
|
150
|
+
logger.debug("Session deleted from memory", session_id=session_id)
|
|
151
|
+
|
|
152
|
+
async def get_user_sessions(self, user_id: int) -> List[ClaudeSession]:
|
|
153
|
+
"""Get all sessions for a user."""
|
|
154
|
+
return [
|
|
155
|
+
session for session in self.sessions.values() if session.user_id == user_id
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
async def get_all_sessions(self) -> List[ClaudeSession]:
|
|
159
|
+
"""Get all sessions."""
|
|
160
|
+
return list(self.sessions.values())
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class SessionManager:
|
|
164
|
+
"""Manage Claude Code sessions."""
|
|
165
|
+
|
|
166
|
+
def __init__(self, config: Settings, storage: SessionStorage):
|
|
167
|
+
"""Initialize session manager."""
|
|
168
|
+
self.config = config
|
|
169
|
+
self.storage = storage
|
|
170
|
+
self.active_sessions: Dict[str, ClaudeSession] = {}
|
|
171
|
+
|
|
172
|
+
async def get_or_create_session(
|
|
173
|
+
self,
|
|
174
|
+
user_id: int,
|
|
175
|
+
project_path: Path,
|
|
176
|
+
session_id: Optional[str] = None,
|
|
177
|
+
) -> ClaudeSession:
|
|
178
|
+
"""Get existing session or create new one."""
|
|
179
|
+
logger.info(
|
|
180
|
+
"Getting or creating session",
|
|
181
|
+
user_id=user_id,
|
|
182
|
+
project_path=str(project_path),
|
|
183
|
+
session_id=session_id,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Check for existing session
|
|
187
|
+
if session_id and session_id in self.active_sessions:
|
|
188
|
+
session = self.active_sessions[session_id]
|
|
189
|
+
if not session.is_expired(self.config.session_timeout_hours):
|
|
190
|
+
logger.debug("Using active session", session_id=session_id)
|
|
191
|
+
return session
|
|
192
|
+
|
|
193
|
+
# Try to load from storage
|
|
194
|
+
if session_id:
|
|
195
|
+
session = await self.storage.load_session(session_id)
|
|
196
|
+
if session and not session.is_expired(self.config.session_timeout_hours):
|
|
197
|
+
self.active_sessions[session_id] = session
|
|
198
|
+
logger.info("Loaded session from storage", session_id=session_id)
|
|
199
|
+
return session
|
|
200
|
+
|
|
201
|
+
# Check user session limit
|
|
202
|
+
user_sessions = await self._get_user_sessions(user_id)
|
|
203
|
+
if len(user_sessions) >= self.config.max_sessions_per_user:
|
|
204
|
+
# Remove oldest session
|
|
205
|
+
oldest = min(user_sessions, key=lambda s: s.last_used)
|
|
206
|
+
await self.remove_session(oldest.session_id)
|
|
207
|
+
logger.info(
|
|
208
|
+
"Removed oldest session due to limit",
|
|
209
|
+
removed_session_id=oldest.session_id,
|
|
210
|
+
user_id=user_id,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Create session with empty ID — Claude will provide the real one
|
|
214
|
+
new_session = ClaudeSession(
|
|
215
|
+
session_id="",
|
|
216
|
+
user_id=user_id,
|
|
217
|
+
project_path=project_path,
|
|
218
|
+
created_at=datetime.now(UTC),
|
|
219
|
+
last_used=datetime.now(UTC),
|
|
220
|
+
is_new_session=True,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Don't save to storage yet — deferred until after Claude responds
|
|
224
|
+
# with a real session_id (via update_session)
|
|
225
|
+
|
|
226
|
+
logger.info(
|
|
227
|
+
"Created new session (pending Claude session ID)",
|
|
228
|
+
user_id=user_id,
|
|
229
|
+
project_path=str(project_path),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return new_session
|
|
233
|
+
|
|
234
|
+
async def update_session(
|
|
235
|
+
self, session: ClaudeSession, response: ClaudeResponse
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Update session with response data.
|
|
238
|
+
|
|
239
|
+
For new sessions: assigns the real session_id from Claude's response,
|
|
240
|
+
then persists to storage and adds to active_sessions.
|
|
241
|
+
For existing sessions: updates usage and re-persists.
|
|
242
|
+
"""
|
|
243
|
+
if session.is_new_session:
|
|
244
|
+
# Assign the real session ID from Claude
|
|
245
|
+
if response.session_id:
|
|
246
|
+
session.session_id = response.session_id
|
|
247
|
+
else:
|
|
248
|
+
logger.warning(
|
|
249
|
+
"Claude returned no session_id for new session; "
|
|
250
|
+
"session will not be resumable",
|
|
251
|
+
user_id=session.user_id,
|
|
252
|
+
project_path=str(session.project_path),
|
|
253
|
+
)
|
|
254
|
+
session.is_new_session = False
|
|
255
|
+
|
|
256
|
+
logger.info(
|
|
257
|
+
"New session assigned Claude session ID",
|
|
258
|
+
session_id=session.session_id,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
session.update_usage(response)
|
|
262
|
+
|
|
263
|
+
# Persist to storage and track as active
|
|
264
|
+
if session.session_id:
|
|
265
|
+
self.active_sessions[session.session_id] = session
|
|
266
|
+
await self.storage.save_session(session)
|
|
267
|
+
|
|
268
|
+
logger.debug(
|
|
269
|
+
"Session updated",
|
|
270
|
+
session_id=session.session_id,
|
|
271
|
+
total_cost=session.total_cost,
|
|
272
|
+
message_count=session.message_count,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
async def remove_session(self, session_id: str) -> None:
|
|
276
|
+
"""Remove session."""
|
|
277
|
+
if session_id in self.active_sessions:
|
|
278
|
+
del self.active_sessions[session_id]
|
|
279
|
+
|
|
280
|
+
await self.storage.delete_session(session_id)
|
|
281
|
+
logger.info("Session removed", session_id=session_id)
|
|
282
|
+
|
|
283
|
+
async def cleanup_expired_sessions(self) -> int:
|
|
284
|
+
"""Remove expired sessions."""
|
|
285
|
+
logger.info("Starting session cleanup")
|
|
286
|
+
|
|
287
|
+
all_sessions = await self.storage.get_all_sessions()
|
|
288
|
+
expired_count = 0
|
|
289
|
+
|
|
290
|
+
for session in all_sessions:
|
|
291
|
+
if session.is_expired(self.config.session_timeout_hours):
|
|
292
|
+
await self.remove_session(session.session_id)
|
|
293
|
+
expired_count += 1
|
|
294
|
+
|
|
295
|
+
logger.info("Session cleanup completed", expired_sessions=expired_count)
|
|
296
|
+
return expired_count
|
|
297
|
+
|
|
298
|
+
async def _get_user_sessions(self, user_id: int) -> List[ClaudeSession]:
|
|
299
|
+
"""Get all sessions for a user."""
|
|
300
|
+
return await self.storage.get_user_sessions(user_id)
|
|
301
|
+
|
|
302
|
+
async def get_session_info(self, session_id: str) -> Optional[Dict]:
|
|
303
|
+
"""Get session information."""
|
|
304
|
+
session = self.active_sessions.get(session_id)
|
|
305
|
+
|
|
306
|
+
if not session:
|
|
307
|
+
session = await self.storage.load_session(session_id)
|
|
308
|
+
|
|
309
|
+
if session:
|
|
310
|
+
return {
|
|
311
|
+
"session_id": session.session_id,
|
|
312
|
+
"project": str(session.project_path),
|
|
313
|
+
"created": session.created_at.isoformat(),
|
|
314
|
+
"last_used": session.last_used.isoformat(),
|
|
315
|
+
"cost": session.total_cost,
|
|
316
|
+
"turns": session.total_turns,
|
|
317
|
+
"messages": session.message_count,
|
|
318
|
+
"tools_used": session.tools_used,
|
|
319
|
+
"expired": session.is_expired(self.config.session_timeout_hours),
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
async def get_user_session_summary(self, user_id: int) -> Dict:
|
|
325
|
+
"""Get summary of user's sessions."""
|
|
326
|
+
sessions = await self._get_user_sessions(user_id)
|
|
327
|
+
|
|
328
|
+
total_cost = sum(s.total_cost for s in sessions)
|
|
329
|
+
total_messages = sum(s.message_count for s in sessions)
|
|
330
|
+
active_sessions = [
|
|
331
|
+
s for s in sessions if not s.is_expired(self.config.session_timeout_hours)
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
"user_id": user_id,
|
|
336
|
+
"total_sessions": len(sessions),
|
|
337
|
+
"active_sessions": len(active_sessions),
|
|
338
|
+
"total_cost": total_cost,
|
|
339
|
+
"total_messages": total_messages,
|
|
340
|
+
"projects": list(set(str(s.project_path) for s in sessions)),
|
|
341
|
+
}
|