overcode 0.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.
- overcode/__init__.py +5 -0
- overcode/cli.py +812 -0
- overcode/config.py +72 -0
- overcode/daemon.py +1184 -0
- overcode/daemon_claude_skill.md +180 -0
- overcode/daemon_state.py +113 -0
- overcode/data_export.py +257 -0
- overcode/dependency_check.py +227 -0
- overcode/exceptions.py +219 -0
- overcode/history_reader.py +448 -0
- overcode/implementations.py +214 -0
- overcode/interfaces.py +49 -0
- overcode/launcher.py +434 -0
- overcode/logging_config.py +193 -0
- overcode/mocks.py +152 -0
- overcode/monitor_daemon.py +808 -0
- overcode/monitor_daemon_state.py +358 -0
- overcode/pid_utils.py +225 -0
- overcode/presence_logger.py +454 -0
- overcode/protocols.py +143 -0
- overcode/session_manager.py +606 -0
- overcode/settings.py +412 -0
- overcode/standing_instructions.py +276 -0
- overcode/status_constants.py +190 -0
- overcode/status_detector.py +339 -0
- overcode/status_history.py +164 -0
- overcode/status_patterns.py +264 -0
- overcode/summarizer_client.py +136 -0
- overcode/summarizer_component.py +312 -0
- overcode/supervisor_daemon.py +1000 -0
- overcode/supervisor_layout.sh +50 -0
- overcode/tmux_manager.py +228 -0
- overcode/tui.py +2549 -0
- overcode/tui_helpers.py +495 -0
- overcode/web_api.py +279 -0
- overcode/web_server.py +138 -0
- overcode/web_templates.py +563 -0
- overcode-0.1.0.dist-info/METADATA +87 -0
- overcode-0.1.0.dist-info/RECORD +43 -0
- overcode-0.1.0.dist-info/WHEEL +5 -0
- overcode-0.1.0.dist-info/entry_points.txt +2 -0
- overcode-0.1.0.dist-info/licenses/LICENSE +21 -0
- overcode-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized status detection patterns.
|
|
3
|
+
|
|
4
|
+
This module contains all the pattern lists used by StatusDetector to identify
|
|
5
|
+
Claude's current state. Centralizing these makes them:
|
|
6
|
+
- Easier to maintain and extend
|
|
7
|
+
- Testable in isolation
|
|
8
|
+
- Potentially configurable via config file in the future
|
|
9
|
+
|
|
10
|
+
Each pattern set includes documentation about when it's used and what it matches.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import List
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class StatusPatterns:
|
|
19
|
+
"""All patterns used for status detection.
|
|
20
|
+
|
|
21
|
+
Patterns are case-insensitive unless noted otherwise.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
# Permission/confirmation prompts - HIGHEST priority
|
|
25
|
+
# These indicate Claude needs user approval before proceeding.
|
|
26
|
+
# Matched against the last few lines of output (lowercased).
|
|
27
|
+
permission_patterns: List[str] = field(default_factory=lambda: [
|
|
28
|
+
"enter to confirm",
|
|
29
|
+
"esc to reject",
|
|
30
|
+
# Note: removed "approve" - too broad, matches "auto-approve" in status bar
|
|
31
|
+
# Note: removed "permission" - too broad, matches "bypass permissions" in status bar
|
|
32
|
+
"allow this",
|
|
33
|
+
# Claude Code v2 permission dialog format
|
|
34
|
+
"do you want to proceed",
|
|
35
|
+
"❯ 1. yes", # Menu selector on first option
|
|
36
|
+
"tell claude what to do differently", # Option 3 text
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
# Active work indicators - checked when content hasn't changed
|
|
40
|
+
# These indicate Claude is busy even if the prompt appears visible.
|
|
41
|
+
# Matched against the last few lines of output (lowercased).
|
|
42
|
+
active_indicators: List[str] = field(default_factory=lambda: [
|
|
43
|
+
"web search",
|
|
44
|
+
"searching",
|
|
45
|
+
"fetching",
|
|
46
|
+
"esc to interrupt", # Shows active operation in progress
|
|
47
|
+
"thinking",
|
|
48
|
+
"✽", # Spinner character
|
|
49
|
+
# Fun thinking indicators from Claude Code
|
|
50
|
+
"razzmatazzing",
|
|
51
|
+
"fiddle-faddling",
|
|
52
|
+
"pondering",
|
|
53
|
+
"cogitating",
|
|
54
|
+
# Note: removed "tokens" - too broad, matches normal text
|
|
55
|
+
# The spinner ✽ and "esc to interrupt" are sufficient
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
# Tool execution indicators - CASE SENSITIVE
|
|
59
|
+
# These indicate Claude is executing a tool.
|
|
60
|
+
# Matched directly against lines (case-sensitive).
|
|
61
|
+
execution_indicators: List[str] = field(default_factory=lambda: [
|
|
62
|
+
"Reading",
|
|
63
|
+
"Writing",
|
|
64
|
+
"Editing",
|
|
65
|
+
"Running",
|
|
66
|
+
"Executing",
|
|
67
|
+
"Searching",
|
|
68
|
+
"Analyzing",
|
|
69
|
+
"Processing",
|
|
70
|
+
"Installing",
|
|
71
|
+
"Building",
|
|
72
|
+
"Compiling",
|
|
73
|
+
"Testing",
|
|
74
|
+
"Deploying",
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
# Waiting patterns - indicate Claude is waiting for user decision
|
|
78
|
+
# Matched against the last few lines of output (lowercased).
|
|
79
|
+
waiting_patterns: List[str] = field(default_factory=lambda: [
|
|
80
|
+
"paused",
|
|
81
|
+
"do you want",
|
|
82
|
+
"proceed",
|
|
83
|
+
"continue",
|
|
84
|
+
"yes/no",
|
|
85
|
+
"[y/n]",
|
|
86
|
+
"press any key",
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
# Prompt characters - indicate empty prompt waiting for user input
|
|
90
|
+
# These are exact matches for line content.
|
|
91
|
+
prompt_chars: List[str] = field(default_factory=lambda: [
|
|
92
|
+
">",
|
|
93
|
+
"›",
|
|
94
|
+
"❯", # Claude Code's prompt character (U+276F)
|
|
95
|
+
])
|
|
96
|
+
|
|
97
|
+
# Line prefixes to clean/remove for display
|
|
98
|
+
# These are stripped from the beginning of lines.
|
|
99
|
+
line_prefixes: List[str] = field(default_factory=lambda: [
|
|
100
|
+
"› ",
|
|
101
|
+
"> ",
|
|
102
|
+
"❯ ", # Claude Code's prompt character (U+276F)
|
|
103
|
+
"- ",
|
|
104
|
+
"• ",
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
# Status bar prefixes to filter out
|
|
108
|
+
# Lines starting with these are UI chrome, not Claude output.
|
|
109
|
+
status_bar_prefixes: List[str] = field(default_factory=lambda: [
|
|
110
|
+
"⏵⏵", # Status bar indicator (e.g., "⏵⏵ bypass permissions on")
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
# Command menu pattern - regex pattern for slash command menu lines
|
|
114
|
+
# These appear when user types a slash command and Claude shows autocomplete
|
|
115
|
+
# Format: " /command-name Description text"
|
|
116
|
+
command_menu_pattern: str = r"^\s*/[\w-]+\s{2,}\S"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Default patterns instance
|
|
120
|
+
DEFAULT_PATTERNS = StatusPatterns()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_patterns() -> StatusPatterns:
|
|
124
|
+
"""Get the status detection patterns.
|
|
125
|
+
|
|
126
|
+
Returns the default patterns. In the future, this could be
|
|
127
|
+
extended to load from a config file.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
StatusPatterns instance with all pattern lists
|
|
131
|
+
"""
|
|
132
|
+
return DEFAULT_PATTERNS
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def matches_any(text: str, patterns: List[str], case_sensitive: bool = False) -> bool:
|
|
136
|
+
"""Check if text matches any of the patterns.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
text: Text to search in
|
|
140
|
+
patterns: List of patterns to match
|
|
141
|
+
case_sensitive: Whether matching is case-sensitive
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
True if any pattern is found in text
|
|
145
|
+
"""
|
|
146
|
+
if not case_sensitive:
|
|
147
|
+
text = text.lower()
|
|
148
|
+
return any(p.lower() in text for p in patterns)
|
|
149
|
+
return any(p in text for p in patterns)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def find_matching_line(
|
|
153
|
+
lines: List[str],
|
|
154
|
+
patterns: List[str],
|
|
155
|
+
case_sensitive: bool = False,
|
|
156
|
+
reverse: bool = True
|
|
157
|
+
) -> str | None:
|
|
158
|
+
"""Find the first line that matches any pattern.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
lines: Lines to search
|
|
162
|
+
patterns: Patterns to match
|
|
163
|
+
case_sensitive: Whether matching is case-sensitive
|
|
164
|
+
reverse: Search from end to beginning
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The matching line, or None if no match
|
|
168
|
+
"""
|
|
169
|
+
search_lines = reversed(lines) if reverse else lines
|
|
170
|
+
for line in search_lines:
|
|
171
|
+
if matches_any(line, patterns, case_sensitive):
|
|
172
|
+
return line
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def is_prompt_line(line: str, patterns: StatusPatterns = None) -> bool:
|
|
177
|
+
"""Check if a line is an empty prompt waiting for input.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
line: Line to check
|
|
181
|
+
patterns: StatusPatterns to use (defaults to DEFAULT_PATTERNS)
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
True if line is an empty prompt
|
|
185
|
+
"""
|
|
186
|
+
patterns = patterns or DEFAULT_PATTERNS
|
|
187
|
+
stripped = line.strip()
|
|
188
|
+
return stripped in patterns.prompt_chars
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def is_status_bar_line(line: str, patterns: StatusPatterns = None) -> bool:
|
|
192
|
+
"""Check if a line is status bar UI chrome.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
line: Line to check
|
|
196
|
+
patterns: StatusPatterns to use (defaults to DEFAULT_PATTERNS)
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
True if line is status bar chrome
|
|
200
|
+
"""
|
|
201
|
+
patterns = patterns or DEFAULT_PATTERNS
|
|
202
|
+
stripped = line.strip()
|
|
203
|
+
return any(stripped.startswith(prefix) for prefix in patterns.status_bar_prefixes)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def is_command_menu_line(line: str, patterns: StatusPatterns = None) -> bool:
|
|
207
|
+
"""Check if a line is part of a slash command menu.
|
|
208
|
+
|
|
209
|
+
Claude Code shows a menu of commands when user types a slash.
|
|
210
|
+
Format: " /command-name Description text"
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
line: Line to check
|
|
214
|
+
patterns: StatusPatterns to use (defaults to DEFAULT_PATTERNS)
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
True if line is a command menu entry
|
|
218
|
+
"""
|
|
219
|
+
import re
|
|
220
|
+
patterns = patterns or DEFAULT_PATTERNS
|
|
221
|
+
return bool(re.match(patterns.command_menu_pattern, line))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def count_command_menu_lines(lines: List[str], patterns: StatusPatterns = None) -> int:
|
|
225
|
+
"""Count how many lines in the list are command menu lines.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
lines: Lines to check
|
|
229
|
+
patterns: StatusPatterns to use (defaults to DEFAULT_PATTERNS)
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Number of lines matching the command menu pattern
|
|
233
|
+
"""
|
|
234
|
+
patterns = patterns or DEFAULT_PATTERNS
|
|
235
|
+
return sum(1 for line in lines if is_command_menu_line(line, patterns))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def clean_line(line: str, patterns: StatusPatterns = None, max_length: int = 80) -> str:
|
|
239
|
+
"""Clean a line for display.
|
|
240
|
+
|
|
241
|
+
Removes prefixes, strips whitespace, and truncates.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
line: Line to clean
|
|
245
|
+
patterns: StatusPatterns to use (defaults to DEFAULT_PATTERNS)
|
|
246
|
+
max_length: Maximum length before truncation
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Cleaned line
|
|
250
|
+
"""
|
|
251
|
+
patterns = patterns or DEFAULT_PATTERNS
|
|
252
|
+
cleaned = line.strip()
|
|
253
|
+
|
|
254
|
+
# Remove common prefixes
|
|
255
|
+
for prefix in patterns.line_prefixes:
|
|
256
|
+
if cleaned.startswith(prefix):
|
|
257
|
+
cleaned = cleaned[len(prefix):]
|
|
258
|
+
break # Only remove one prefix
|
|
259
|
+
|
|
260
|
+
# Truncate if too long
|
|
261
|
+
if len(cleaned) > max_length:
|
|
262
|
+
cleaned = cleaned[:max_length - 3] + "..."
|
|
263
|
+
|
|
264
|
+
return cleaned
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAI API client for agent summarization.
|
|
3
|
+
|
|
4
|
+
Uses GPT-4o-mini for cost-effective, high-frequency summaries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
MODEL = "gpt-4o-mini"
|
|
17
|
+
API_URL = "https://api.openai.com/v1/chat/completions"
|
|
18
|
+
|
|
19
|
+
# Anti-oscillation prompt template
|
|
20
|
+
SUMMARIZE_PROMPT = """You are summarizing a Claude Code agent's terminal output.
|
|
21
|
+
|
|
22
|
+
## Current Terminal Content (last {lines} lines):
|
|
23
|
+
{pane_content}
|
|
24
|
+
|
|
25
|
+
## Current Status: {status}
|
|
26
|
+
|
|
27
|
+
## Previous Summary:
|
|
28
|
+
{previous_summary}
|
|
29
|
+
|
|
30
|
+
## Instructions:
|
|
31
|
+
1. Summarize what the agent has been doing in 1-2 sentences
|
|
32
|
+
2. If not running, explain the halt state (waiting for user input, permission needed, etc.)
|
|
33
|
+
3. IMPORTANT: Only provide a new summary if there's meaningful new information.
|
|
34
|
+
If the situation is essentially the same as the previous summary, respond with exactly:
|
|
35
|
+
UNCHANGED
|
|
36
|
+
|
|
37
|
+
Keep summaries concise (under 80 words). Focus on:
|
|
38
|
+
- What task/feature is being worked on
|
|
39
|
+
- Current action (reading files, writing code, running tests, etc.)
|
|
40
|
+
- If halted: why and what's needed to continue"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SummarizerClient:
|
|
44
|
+
"""Client for OpenAI API to generate agent summaries."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, api_key: Optional[str] = None):
|
|
47
|
+
"""Initialize the client.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
api_key: OpenAI API key. If None, reads from OPENAI_API_KEY env var.
|
|
51
|
+
"""
|
|
52
|
+
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
|
53
|
+
self._available = bool(self.api_key)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def available(self) -> bool:
|
|
57
|
+
"""Check if the client is available (API key present)."""
|
|
58
|
+
return self._available
|
|
59
|
+
|
|
60
|
+
def summarize(
|
|
61
|
+
self,
|
|
62
|
+
pane_content: str,
|
|
63
|
+
previous_summary: str,
|
|
64
|
+
current_status: str,
|
|
65
|
+
lines: int = 200,
|
|
66
|
+
max_tokens: int = 150,
|
|
67
|
+
) -> Optional[str]:
|
|
68
|
+
"""Get summary from GPT-4o-mini.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
pane_content: Terminal pane content to summarize
|
|
72
|
+
previous_summary: Previous summary for anti-oscillation
|
|
73
|
+
current_status: Current agent status (running, waiting_user, etc.)
|
|
74
|
+
lines: Number of lines being summarized (for prompt context)
|
|
75
|
+
max_tokens: Maximum tokens in response
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
New summary text, "UNCHANGED" if no update needed, or None on error
|
|
79
|
+
"""
|
|
80
|
+
if not self.available:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
prompt = SUMMARIZE_PROMPT.format(
|
|
84
|
+
lines=lines,
|
|
85
|
+
pane_content=pane_content,
|
|
86
|
+
status=current_status,
|
|
87
|
+
previous_summary=previous_summary or "(no previous summary)",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
payload = json.dumps({
|
|
91
|
+
"model": MODEL,
|
|
92
|
+
"max_tokens": max_tokens,
|
|
93
|
+
"temperature": 0.3, # Low temperature for consistent summaries
|
|
94
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
95
|
+
}).encode("utf-8")
|
|
96
|
+
|
|
97
|
+
req = urllib.request.Request(
|
|
98
|
+
API_URL,
|
|
99
|
+
data=payload,
|
|
100
|
+
headers={
|
|
101
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
},
|
|
104
|
+
method="POST",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
with urllib.request.urlopen(req, timeout=15.0) as response:
|
|
109
|
+
if response.status == 200:
|
|
110
|
+
result = json.loads(response.read().decode("utf-8"))
|
|
111
|
+
content = result["choices"][0]["message"]["content"]
|
|
112
|
+
return content.strip()
|
|
113
|
+
else:
|
|
114
|
+
logger.warning(
|
|
115
|
+
f"Summarizer API error: {response.status}"
|
|
116
|
+
)
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
except urllib.error.URLError as e:
|
|
120
|
+
logger.warning(f"Summarizer API error: {e.reason}")
|
|
121
|
+
return None
|
|
122
|
+
except TimeoutError:
|
|
123
|
+
logger.warning("Summarizer API timeout")
|
|
124
|
+
return None
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.warning(f"Summarizer API error: {e}")
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
def close(self) -> None:
|
|
130
|
+
"""Clean up resources (no-op for urllib)."""
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def is_available() -> bool:
|
|
135
|
+
"""Check if OPENAI_API_KEY is set in environment."""
|
|
136
|
+
return bool(os.environ.get("OPENAI_API_KEY"))
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Summarizer component for generating agent activity summaries.
|
|
3
|
+
|
|
4
|
+
Uses GPT-4o-mini to summarize what each agent has been doing and
|
|
5
|
+
their current halt state if not running.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, Optional, TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from .summarizer_client import SummarizerClient
|
|
17
|
+
from .settings import get_session_dir
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .interfaces import TmuxInterface
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class AgentSummary:
|
|
27
|
+
"""Summary for a single agent."""
|
|
28
|
+
|
|
29
|
+
text: str = ""
|
|
30
|
+
updated_at: Optional[str] = None # ISO timestamp
|
|
31
|
+
tokens_used: int = 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class SummarizerConfig:
|
|
36
|
+
"""Configuration for the summarizer."""
|
|
37
|
+
|
|
38
|
+
enabled: bool = False # Off by default
|
|
39
|
+
interval: float = 5.0 # Seconds between updates per agent
|
|
40
|
+
lines: int = 200 # Pane lines to capture
|
|
41
|
+
max_tokens: int = 150 # Max response tokens
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SummarizerComponent:
|
|
45
|
+
"""Component for generating agent activity summaries.
|
|
46
|
+
|
|
47
|
+
Follows the daemon component pattern (like PresenceComponent).
|
|
48
|
+
Gracefully degrades if OPENAI_API_KEY is not available.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
tmux_session: str,
|
|
54
|
+
tmux: "TmuxInterface" = None,
|
|
55
|
+
config: Optional[SummarizerConfig] = None,
|
|
56
|
+
):
|
|
57
|
+
"""Initialize the summarizer component.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
tmux_session: Name of the tmux session
|
|
61
|
+
tmux: TmuxInterface for pane capture (defaults to RealTmux)
|
|
62
|
+
config: SummarizerConfig (defaults to disabled)
|
|
63
|
+
"""
|
|
64
|
+
self.tmux_session = tmux_session
|
|
65
|
+
self.config = config or SummarizerConfig()
|
|
66
|
+
|
|
67
|
+
# Dependency injection for testability
|
|
68
|
+
if tmux is None:
|
|
69
|
+
from .interfaces import RealTmux
|
|
70
|
+
tmux = RealTmux()
|
|
71
|
+
self.tmux = tmux
|
|
72
|
+
|
|
73
|
+
# Initialize client (gracefully handles missing API key)
|
|
74
|
+
self._client: Optional[SummarizerClient] = None
|
|
75
|
+
if self.config.enabled and SummarizerClient.is_available():
|
|
76
|
+
self._client = SummarizerClient()
|
|
77
|
+
|
|
78
|
+
# Per-agent summaries
|
|
79
|
+
self.summaries: Dict[str, AgentSummary] = {}
|
|
80
|
+
|
|
81
|
+
# Rate limiting per session
|
|
82
|
+
self._last_update: Dict[str, datetime] = {}
|
|
83
|
+
|
|
84
|
+
# Content hashes for change detection (avoid API calls when nothing changed)
|
|
85
|
+
self._last_content_hash: Dict[str, int] = {}
|
|
86
|
+
|
|
87
|
+
# Stats
|
|
88
|
+
self.total_calls = 0
|
|
89
|
+
self.total_tokens = 0
|
|
90
|
+
|
|
91
|
+
# Control file path
|
|
92
|
+
self._control_file = get_session_dir(tmux_session) / "summarizer_control.json"
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def available(self) -> bool:
|
|
96
|
+
"""Check if summarizer is available (API key present)."""
|
|
97
|
+
return SummarizerClient.is_available()
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def enabled(self) -> bool:
|
|
101
|
+
"""Check if summarizer is currently enabled."""
|
|
102
|
+
return self.config.enabled and self._client is not None
|
|
103
|
+
|
|
104
|
+
def check_control_file(self) -> None:
|
|
105
|
+
"""Check control file for enable/disable commands."""
|
|
106
|
+
if not self._control_file.exists():
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
with open(self._control_file) as f:
|
|
111
|
+
data = json.load(f)
|
|
112
|
+
|
|
113
|
+
should_enable = data.get("enabled", False)
|
|
114
|
+
|
|
115
|
+
if should_enable and not self.config.enabled:
|
|
116
|
+
# Enable requested
|
|
117
|
+
if SummarizerClient.is_available():
|
|
118
|
+
self.config.enabled = True
|
|
119
|
+
self._client = SummarizerClient()
|
|
120
|
+
logger.info("Summarizer enabled via control file")
|
|
121
|
+
else:
|
|
122
|
+
logger.warning("Summarizer enable requested but OPENAI_API_KEY not set")
|
|
123
|
+
elif not should_enable and self.config.enabled:
|
|
124
|
+
# Disable requested
|
|
125
|
+
self.config.enabled = False
|
|
126
|
+
if self._client:
|
|
127
|
+
self._client.close()
|
|
128
|
+
self._client = None
|
|
129
|
+
logger.info("Summarizer disabled via control file")
|
|
130
|
+
|
|
131
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
132
|
+
logger.warning(f"Error reading summarizer control file: {e}")
|
|
133
|
+
|
|
134
|
+
def update(self, sessions) -> Dict[str, AgentSummary]:
|
|
135
|
+
"""Update summaries for all sessions.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
sessions: List of Session objects from SessionManager
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Dict mapping session_id to AgentSummary
|
|
142
|
+
"""
|
|
143
|
+
# Check control file for enable/disable
|
|
144
|
+
self.check_control_file()
|
|
145
|
+
|
|
146
|
+
if not self.enabled:
|
|
147
|
+
return self.summaries
|
|
148
|
+
|
|
149
|
+
for session in sessions:
|
|
150
|
+
self._update_session(session)
|
|
151
|
+
|
|
152
|
+
return self.summaries
|
|
153
|
+
|
|
154
|
+
def _update_session(self, session) -> None:
|
|
155
|
+
"""Update summary for a single session.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
session: Session object with id, tmux_window, current_status
|
|
159
|
+
"""
|
|
160
|
+
if not self._client:
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
session_id = session.id
|
|
164
|
+
now = datetime.now()
|
|
165
|
+
|
|
166
|
+
# Rate limiting - don't call API too frequently for same session
|
|
167
|
+
last_update = self._last_update.get(session_id)
|
|
168
|
+
if last_update:
|
|
169
|
+
elapsed = (now - last_update).total_seconds()
|
|
170
|
+
if elapsed < self.config.interval:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
# Skip terminated sessions
|
|
174
|
+
current_status = getattr(session, 'stats', None)
|
|
175
|
+
if current_status:
|
|
176
|
+
status = getattr(current_status, 'current_state', 'unknown')
|
|
177
|
+
if status == 'terminated':
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
# Capture pane content
|
|
181
|
+
content = self._capture_pane(session.tmux_window)
|
|
182
|
+
if not content:
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
# Check if content has actually changed (avoid unnecessary API calls)
|
|
186
|
+
content_hash = hash(content)
|
|
187
|
+
if session_id in self._last_content_hash:
|
|
188
|
+
if self._last_content_hash[session_id] == content_hash:
|
|
189
|
+
# Content hasn't changed - skip API call
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
self._last_content_hash[session_id] = content_hash
|
|
193
|
+
|
|
194
|
+
# Get previous summary for anti-oscillation
|
|
195
|
+
prev_summary = self.summaries.get(session_id)
|
|
196
|
+
prev_text = prev_summary.text if prev_summary else ""
|
|
197
|
+
|
|
198
|
+
# Get current detected status
|
|
199
|
+
status = "unknown"
|
|
200
|
+
if current_status:
|
|
201
|
+
status = getattr(current_status, 'current_state', 'unknown')
|
|
202
|
+
|
|
203
|
+
# Call API
|
|
204
|
+
try:
|
|
205
|
+
result = self._client.summarize(
|
|
206
|
+
pane_content=content,
|
|
207
|
+
previous_summary=prev_text,
|
|
208
|
+
current_status=status,
|
|
209
|
+
lines=self.config.lines,
|
|
210
|
+
max_tokens=self.config.max_tokens,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
self.total_calls += 1
|
|
214
|
+
|
|
215
|
+
if result and result.strip().upper() != "UNCHANGED":
|
|
216
|
+
# New summary
|
|
217
|
+
self.summaries[session_id] = AgentSummary(
|
|
218
|
+
text=result.strip(),
|
|
219
|
+
updated_at=now.isoformat(),
|
|
220
|
+
)
|
|
221
|
+
logger.debug(f"Updated summary for {session.name}: {result[:50]}...")
|
|
222
|
+
# If "UNCHANGED", keep the old summary
|
|
223
|
+
|
|
224
|
+
self._last_update[session_id] = now
|
|
225
|
+
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.warning(f"Summarizer error for {session.name}: {e}")
|
|
228
|
+
|
|
229
|
+
def _capture_pane(self, window: int) -> Optional[str]:
|
|
230
|
+
"""Capture pane content for summarization.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
window: tmux window index
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Pane content string or None on error
|
|
237
|
+
"""
|
|
238
|
+
try:
|
|
239
|
+
content = self.tmux.capture_pane(
|
|
240
|
+
self.tmux_session,
|
|
241
|
+
window,
|
|
242
|
+
lines=self.config.lines + 50, # Capture extra for filtering
|
|
243
|
+
)
|
|
244
|
+
if not content:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
# Strip trailing blank lines and return last N lines
|
|
248
|
+
lines = content.rstrip().split('\n')
|
|
249
|
+
meaningful_lines = lines[-self.config.lines:] if len(lines) > self.config.lines else lines
|
|
250
|
+
return '\n'.join(meaningful_lines)
|
|
251
|
+
|
|
252
|
+
except Exception as e:
|
|
253
|
+
logger.warning(f"Failed to capture pane {window}: {e}")
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
def get_summary(self, session_id: str) -> Optional[AgentSummary]:
|
|
257
|
+
"""Get summary for a specific session.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
session_id: Session ID
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
AgentSummary or None if not available
|
|
264
|
+
"""
|
|
265
|
+
return self.summaries.get(session_id)
|
|
266
|
+
|
|
267
|
+
def stop(self) -> None:
|
|
268
|
+
"""Clean up resources."""
|
|
269
|
+
if self._client:
|
|
270
|
+
self._client.close()
|
|
271
|
+
self._client = None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def get_summarizer_control_path(session: str) -> Path:
|
|
275
|
+
"""Get the summarizer control file path for a session."""
|
|
276
|
+
return get_session_dir(session) / "summarizer_control.json"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def set_summarizer_enabled(session: str, enabled: bool) -> None:
|
|
280
|
+
"""Set summarizer enabled state via control file.
|
|
281
|
+
|
|
282
|
+
The daemon reads this file each loop and adjusts accordingly.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
session: tmux session name
|
|
286
|
+
enabled: Whether to enable or disable
|
|
287
|
+
"""
|
|
288
|
+
control_path = get_summarizer_control_path(session)
|
|
289
|
+
control_path.parent.mkdir(parents=True, exist_ok=True)
|
|
290
|
+
with open(control_path, 'w') as f:
|
|
291
|
+
json.dump({"enabled": enabled}, f)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def is_summarizer_enabled(session: str) -> bool:
|
|
295
|
+
"""Check if summarizer is enabled for a session.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
session: tmux session name
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
True if enabled, False otherwise
|
|
302
|
+
"""
|
|
303
|
+
control_path = get_summarizer_control_path(session)
|
|
304
|
+
if not control_path.exists():
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
with open(control_path) as f:
|
|
309
|
+
data = json.load(f)
|
|
310
|
+
return data.get("enabled", False)
|
|
311
|
+
except (json.JSONDecodeError, OSError):
|
|
312
|
+
return False
|