flowly-code 1.0.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.
- flowly_code/__init__.py +30 -0
- flowly_code/__main__.py +8 -0
- flowly_code/activity/__init__.py +1 -0
- flowly_code/activity/bus.py +91 -0
- flowly_code/activity/events.py +40 -0
- flowly_code/agent/__init__.py +8 -0
- flowly_code/agent/context.py +485 -0
- flowly_code/agent/loop.py +1349 -0
- flowly_code/agent/memory.py +109 -0
- flowly_code/agent/skills.py +259 -0
- flowly_code/agent/subagent.py +249 -0
- flowly_code/agent/tools/__init__.py +6 -0
- flowly_code/agent/tools/base.py +55 -0
- flowly_code/agent/tools/delegate.py +194 -0
- flowly_code/agent/tools/dispatch.py +840 -0
- flowly_code/agent/tools/docker.py +609 -0
- flowly_code/agent/tools/filesystem.py +280 -0
- flowly_code/agent/tools/mcp.py +85 -0
- flowly_code/agent/tools/message.py +235 -0
- flowly_code/agent/tools/registry.py +257 -0
- flowly_code/agent/tools/screenshot.py +444 -0
- flowly_code/agent/tools/shell.py +166 -0
- flowly_code/agent/tools/spawn.py +65 -0
- flowly_code/agent/tools/system.py +917 -0
- flowly_code/agent/tools/trello.py +420 -0
- flowly_code/agent/tools/web.py +139 -0
- flowly_code/agent/tools/x.py +399 -0
- flowly_code/bus/__init__.py +6 -0
- flowly_code/bus/events.py +37 -0
- flowly_code/bus/queue.py +81 -0
- flowly_code/channels/__init__.py +6 -0
- flowly_code/channels/base.py +121 -0
- flowly_code/channels/manager.py +135 -0
- flowly_code/channels/telegram.py +1132 -0
- flowly_code/cli/__init__.py +1 -0
- flowly_code/cli/commands.py +1831 -0
- flowly_code/cli/setup.py +1356 -0
- flowly_code/compaction/__init__.py +39 -0
- flowly_code/compaction/estimator.py +88 -0
- flowly_code/compaction/pruning.py +223 -0
- flowly_code/compaction/service.py +297 -0
- flowly_code/compaction/summarizer.py +384 -0
- flowly_code/compaction/types.py +71 -0
- flowly_code/config/__init__.py +6 -0
- flowly_code/config/loader.py +102 -0
- flowly_code/config/schema.py +324 -0
- flowly_code/exec/__init__.py +39 -0
- flowly_code/exec/approvals.py +288 -0
- flowly_code/exec/executor.py +184 -0
- flowly_code/exec/safety.py +247 -0
- flowly_code/exec/types.py +88 -0
- flowly_code/gateway/__init__.py +5 -0
- flowly_code/gateway/server.py +103 -0
- flowly_code/heartbeat/__init__.py +5 -0
- flowly_code/heartbeat/service.py +130 -0
- flowly_code/multiagent/README.md +248 -0
- flowly_code/multiagent/__init__.py +1 -0
- flowly_code/multiagent/invoke.py +210 -0
- flowly_code/multiagent/orchestrator.py +156 -0
- flowly_code/multiagent/router.py +156 -0
- flowly_code/multiagent/setup.py +171 -0
- flowly_code/pairing/__init__.py +21 -0
- flowly_code/pairing/store.py +343 -0
- flowly_code/providers/__init__.py +6 -0
- flowly_code/providers/base.py +69 -0
- flowly_code/providers/litellm_provider.py +178 -0
- flowly_code/providers/transcription.py +64 -0
- flowly_code/session/__init__.py +5 -0
- flowly_code/session/manager.py +249 -0
- flowly_code/skills/README.md +24 -0
- flowly_code/skills/compact/SKILL.md +27 -0
- flowly_code/skills/github/SKILL.md +48 -0
- flowly_code/skills/skill-creator/SKILL.md +371 -0
- flowly_code/skills/summarize/SKILL.md +67 -0
- flowly_code/skills/tmux/SKILL.md +121 -0
- flowly_code/skills/tmux/scripts/find-sessions.sh +112 -0
- flowly_code/skills/tmux/scripts/wait-for-text.sh +83 -0
- flowly_code/skills/weather/SKILL.md +49 -0
- flowly_code/utils/__init__.py +5 -0
- flowly_code/utils/helpers.py +91 -0
- flowly_code-1.0.0.dist-info/METADATA +724 -0
- flowly_code-1.0.0.dist-info/RECORD +86 -0
- flowly_code-1.0.0.dist-info/WHEEL +4 -0
- flowly_code-1.0.0.dist-info/entry_points.txt +2 -0
- flowly_code-1.0.0.dist-info/licenses/LICENSE +191 -0
- flowly_code-1.0.0.dist-info/licenses/NOTICE +74 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Tool registry for dynamic tool management."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from flowly_code.agent.tools.base import Tool
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _extract_enum_values(schema: Any) -> list[Any] | None:
|
|
9
|
+
"""Extract enum-like values from a JSON schema fragment."""
|
|
10
|
+
if not isinstance(schema, dict):
|
|
11
|
+
return None
|
|
12
|
+
if isinstance(schema.get("enum"), list):
|
|
13
|
+
return list(schema["enum"])
|
|
14
|
+
if "const" in schema:
|
|
15
|
+
return [schema["const"]]
|
|
16
|
+
variants = None
|
|
17
|
+
if isinstance(schema.get("anyOf"), list):
|
|
18
|
+
variants = schema["anyOf"]
|
|
19
|
+
elif isinstance(schema.get("oneOf"), list):
|
|
20
|
+
variants = schema["oneOf"]
|
|
21
|
+
elif isinstance(schema.get("allOf"), list):
|
|
22
|
+
variants = schema["allOf"]
|
|
23
|
+
if not variants:
|
|
24
|
+
return None
|
|
25
|
+
values: list[Any] = []
|
|
26
|
+
for variant in variants:
|
|
27
|
+
extracted = _extract_enum_values(variant)
|
|
28
|
+
if extracted:
|
|
29
|
+
values.extend(extracted)
|
|
30
|
+
return values or None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _merge_property_schema(existing: Any, incoming: Any) -> Any:
|
|
34
|
+
"""Merge two property schema fragments conservatively."""
|
|
35
|
+
if existing is None:
|
|
36
|
+
return incoming
|
|
37
|
+
if incoming is None:
|
|
38
|
+
return existing
|
|
39
|
+
|
|
40
|
+
existing_enum = _extract_enum_values(existing)
|
|
41
|
+
incoming_enum = _extract_enum_values(incoming)
|
|
42
|
+
if existing_enum or incoming_enum:
|
|
43
|
+
values = []
|
|
44
|
+
seen = set()
|
|
45
|
+
for value in [*(existing_enum or []), *(incoming_enum or [])]:
|
|
46
|
+
key = repr(value)
|
|
47
|
+
if key in seen:
|
|
48
|
+
continue
|
|
49
|
+
seen.add(key)
|
|
50
|
+
values.append(value)
|
|
51
|
+
|
|
52
|
+
merged: dict[str, Any] = {}
|
|
53
|
+
for source in (existing, incoming):
|
|
54
|
+
if isinstance(source, dict):
|
|
55
|
+
for key in ("title", "description", "default"):
|
|
56
|
+
if key not in merged and key in source:
|
|
57
|
+
merged[key] = source[key]
|
|
58
|
+
if values:
|
|
59
|
+
merged["enum"] = values
|
|
60
|
+
return merged
|
|
61
|
+
|
|
62
|
+
return existing
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _normalize_tool_parameters_schema(parameters: Any) -> dict[str, Any]:
|
|
66
|
+
"""
|
|
67
|
+
Normalize tool schemas for provider compatibility.
|
|
68
|
+
|
|
69
|
+
Some providers reject top-level oneOf/anyOf/allOf in tool input schema.
|
|
70
|
+
We flatten top-level unions into a single object schema.
|
|
71
|
+
"""
|
|
72
|
+
if not isinstance(parameters, dict):
|
|
73
|
+
return {"type": "object", "properties": {}, "additionalProperties": True}
|
|
74
|
+
|
|
75
|
+
has_top_union = any(
|
|
76
|
+
isinstance(parameters.get(key), list)
|
|
77
|
+
for key in ("anyOf", "oneOf", "allOf")
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if not has_top_union:
|
|
81
|
+
# Ensure top-level object shape for function tools.
|
|
82
|
+
if "type" not in parameters and (
|
|
83
|
+
isinstance(parameters.get("properties"), dict)
|
|
84
|
+
or isinstance(parameters.get("required"), list)
|
|
85
|
+
):
|
|
86
|
+
patched = dict(parameters)
|
|
87
|
+
patched["type"] = "object"
|
|
88
|
+
return patched
|
|
89
|
+
return parameters
|
|
90
|
+
|
|
91
|
+
variants: list[Any] = []
|
|
92
|
+
for key in ("anyOf", "oneOf", "allOf"):
|
|
93
|
+
raw = parameters.get(key)
|
|
94
|
+
if isinstance(raw, list):
|
|
95
|
+
variants.extend(raw)
|
|
96
|
+
|
|
97
|
+
merged_properties: dict[str, Any] = {}
|
|
98
|
+
required_counts: dict[str, int] = {}
|
|
99
|
+
object_variants = 0
|
|
100
|
+
|
|
101
|
+
for variant in variants:
|
|
102
|
+
if not isinstance(variant, dict):
|
|
103
|
+
continue
|
|
104
|
+
props = variant.get("properties")
|
|
105
|
+
if not isinstance(props, dict):
|
|
106
|
+
continue
|
|
107
|
+
object_variants += 1
|
|
108
|
+
for prop_key, prop_schema in props.items():
|
|
109
|
+
if prop_key not in merged_properties:
|
|
110
|
+
merged_properties[prop_key] = prop_schema
|
|
111
|
+
else:
|
|
112
|
+
merged_properties[prop_key] = _merge_property_schema(
|
|
113
|
+
merged_properties[prop_key],
|
|
114
|
+
prop_schema,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
required = variant.get("required")
|
|
118
|
+
if isinstance(required, list):
|
|
119
|
+
for req_key in required:
|
|
120
|
+
if isinstance(req_key, str):
|
|
121
|
+
required_counts[req_key] = required_counts.get(req_key, 0) + 1
|
|
122
|
+
|
|
123
|
+
base_required = parameters.get("required")
|
|
124
|
+
merged_required: list[str] | None = None
|
|
125
|
+
if isinstance(base_required, list):
|
|
126
|
+
merged_required = [key for key in base_required if isinstance(key, str)]
|
|
127
|
+
elif object_variants > 0:
|
|
128
|
+
merged_required = [
|
|
129
|
+
key for key, count in required_counts.items()
|
|
130
|
+
if count == object_variants
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
normalized: dict[str, Any] = {
|
|
134
|
+
"type": "object",
|
|
135
|
+
"properties": merged_properties if merged_properties else parameters.get("properties", {}),
|
|
136
|
+
"additionalProperties": parameters.get("additionalProperties", True),
|
|
137
|
+
}
|
|
138
|
+
if isinstance(parameters.get("title"), str):
|
|
139
|
+
normalized["title"] = parameters["title"]
|
|
140
|
+
if isinstance(parameters.get("description"), str):
|
|
141
|
+
normalized["description"] = parameters["description"]
|
|
142
|
+
if merged_required:
|
|
143
|
+
normalized["required"] = merged_required
|
|
144
|
+
|
|
145
|
+
return normalized
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ToolRegistry:
|
|
149
|
+
"""
|
|
150
|
+
Registry for agent tools.
|
|
151
|
+
|
|
152
|
+
Allows dynamic registration and execution of tools.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(self):
|
|
156
|
+
self._tools: dict[str, Tool] = {}
|
|
157
|
+
|
|
158
|
+
def register(self, tool: Tool) -> None:
|
|
159
|
+
"""Register a tool."""
|
|
160
|
+
self._tools[tool.name] = tool
|
|
161
|
+
|
|
162
|
+
def unregister(self, name: str) -> None:
|
|
163
|
+
"""Unregister a tool by name."""
|
|
164
|
+
self._tools.pop(name, None)
|
|
165
|
+
|
|
166
|
+
def get(self, name: str) -> Tool | None:
|
|
167
|
+
"""Get a tool by name."""
|
|
168
|
+
return self._tools.get(name)
|
|
169
|
+
|
|
170
|
+
def has(self, name: str) -> bool:
|
|
171
|
+
"""Check if a tool is registered."""
|
|
172
|
+
return name in self._tools
|
|
173
|
+
|
|
174
|
+
def get_definitions(self) -> list[dict[str, Any]]:
|
|
175
|
+
"""Get all tool definitions in OpenAI format."""
|
|
176
|
+
definitions = [tool.to_schema() for tool in self._tools.values()]
|
|
177
|
+
normalized: list[dict[str, Any]] = []
|
|
178
|
+
for definition in definitions:
|
|
179
|
+
fn = definition.get("function")
|
|
180
|
+
if isinstance(fn, dict):
|
|
181
|
+
fn = dict(fn)
|
|
182
|
+
fn["parameters"] = _normalize_tool_parameters_schema(fn.get("parameters"))
|
|
183
|
+
definition = dict(definition)
|
|
184
|
+
definition["function"] = fn
|
|
185
|
+
normalized.append(definition)
|
|
186
|
+
return normalized
|
|
187
|
+
|
|
188
|
+
def validate_tool_call(self, name: str, params: dict[str, Any]) -> str | None:
|
|
189
|
+
"""Validate required params against normalized schema before execution."""
|
|
190
|
+
tool = self._tools.get(name)
|
|
191
|
+
if not tool:
|
|
192
|
+
return f"Error: Tool '{name}' not found"
|
|
193
|
+
|
|
194
|
+
if not isinstance(params, dict):
|
|
195
|
+
return f"Error: Invalid parameters for tool '{name}'"
|
|
196
|
+
|
|
197
|
+
schema = _normalize_tool_parameters_schema(tool.parameters)
|
|
198
|
+
required = schema.get("required")
|
|
199
|
+
if not isinstance(required, list):
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
missing: list[str] = []
|
|
203
|
+
for key in required:
|
|
204
|
+
if not isinstance(key, str):
|
|
205
|
+
continue
|
|
206
|
+
if key not in params:
|
|
207
|
+
missing.append(key)
|
|
208
|
+
continue
|
|
209
|
+
value = params.get(key)
|
|
210
|
+
if value is None:
|
|
211
|
+
missing.append(key)
|
|
212
|
+
continue
|
|
213
|
+
if isinstance(value, str) and not value.strip():
|
|
214
|
+
missing.append(key)
|
|
215
|
+
|
|
216
|
+
if missing:
|
|
217
|
+
joined = ", ".join(sorted(set(missing)))
|
|
218
|
+
return f"Error: Missing required parameter(s) for '{name}': {joined}"
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
async def execute(self, name: str, params: dict[str, Any]) -> str:
|
|
222
|
+
"""
|
|
223
|
+
Execute a tool by name with given parameters.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
name: Tool name.
|
|
227
|
+
params: Tool parameters.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Tool execution result as string.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
KeyError: If tool not found.
|
|
234
|
+
"""
|
|
235
|
+
tool = self._tools.get(name)
|
|
236
|
+
if not tool:
|
|
237
|
+
return f"Error: Tool '{name}' not found"
|
|
238
|
+
|
|
239
|
+
validation_error = self.validate_tool_call(name, params)
|
|
240
|
+
if validation_error:
|
|
241
|
+
return validation_error
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
return await tool.execute(**params)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
return f"Error executing {name}: {str(e)}"
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def tool_names(self) -> list[str]:
|
|
250
|
+
"""Get list of registered tool names."""
|
|
251
|
+
return list(self._tools.keys())
|
|
252
|
+
|
|
253
|
+
def __len__(self) -> int:
|
|
254
|
+
return len(self._tools)
|
|
255
|
+
|
|
256
|
+
def __contains__(self, name: str) -> bool:
|
|
257
|
+
return name in self._tools
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Screenshot tool for capturing screen images."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import mimetypes
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
import shutil
|
|
8
|
+
import urllib.request
|
|
9
|
+
import urllib.error
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from loguru import logger
|
|
15
|
+
|
|
16
|
+
from flowly_code.agent.tools.base import Tool
|
|
17
|
+
|
|
18
|
+
# Electron desktop app API discovery file
|
|
19
|
+
_ELECTRON_API_FILE = Path.home() / ".flowly" / "electron-api.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ScreenshotTool(Tool):
|
|
23
|
+
"""
|
|
24
|
+
Tool to capture screenshots of the screen or specific windows.
|
|
25
|
+
|
|
26
|
+
Supports macOS, Linux (with gnome-screenshot or scrot), and Windows.
|
|
27
|
+
Screenshots are saved to ~/.flowly/screenshots/ directory.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# Supported image formats
|
|
31
|
+
SUPPORTED_FORMATS = {"png", "jpg", "jpeg", "gif", "tiff"}
|
|
32
|
+
|
|
33
|
+
# Maximum file size (10MB)
|
|
34
|
+
MAX_FILE_SIZE = 10 * 1024 * 1024
|
|
35
|
+
|
|
36
|
+
def __init__(self, screenshots_dir: Path | None = None):
|
|
37
|
+
"""
|
|
38
|
+
Initialize the screenshot tool.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
screenshots_dir: Custom directory for saving screenshots.
|
|
42
|
+
Defaults to ~/.flowly/screenshots/
|
|
43
|
+
"""
|
|
44
|
+
self._screenshots_dir = screenshots_dir or (Path.home() / ".flowly" / "screenshots")
|
|
45
|
+
self._screenshots_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
self._platform = platform.system().lower()
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def name(self) -> str:
|
|
50
|
+
return "screenshot"
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def description(self) -> str:
|
|
54
|
+
return (
|
|
55
|
+
"Capture a screenshot of the entire screen or a specific display. "
|
|
56
|
+
"Returns the file path of the saved screenshot. "
|
|
57
|
+
"Use the 'message' tool with media_paths to send the screenshot to the user."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def parameters(self) -> dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"properties": {
|
|
65
|
+
"display": {
|
|
66
|
+
"type": "integer",
|
|
67
|
+
"description": "Display number to capture (0 for main display). Default: 0"
|
|
68
|
+
},
|
|
69
|
+
"filename": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"description": "Optional custom filename (without extension). Default: timestamp-based name"
|
|
72
|
+
},
|
|
73
|
+
"format": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"enum": ["png", "jpg"],
|
|
76
|
+
"description": "Image format. Default: png"
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"required": []
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async def execute(
|
|
83
|
+
self,
|
|
84
|
+
display: int = 0,
|
|
85
|
+
filename: str | None = None,
|
|
86
|
+
format: str = "png",
|
|
87
|
+
**kwargs: Any
|
|
88
|
+
) -> str:
|
|
89
|
+
"""
|
|
90
|
+
Capture a screenshot.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
display: Display number to capture (0 for main).
|
|
94
|
+
filename: Optional custom filename.
|
|
95
|
+
format: Image format (png or jpg).
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Success message with file path, or error message.
|
|
99
|
+
"""
|
|
100
|
+
# Validate format
|
|
101
|
+
format = format.lower()
|
|
102
|
+
if format not in {"png", "jpg", "jpeg"}:
|
|
103
|
+
return f"Error: Unsupported format '{format}'. Use 'png' or 'jpg'."
|
|
104
|
+
|
|
105
|
+
# Normalize jpg/jpeg
|
|
106
|
+
if format == "jpeg":
|
|
107
|
+
format = "jpg"
|
|
108
|
+
|
|
109
|
+
# Generate filename
|
|
110
|
+
if filename:
|
|
111
|
+
# Sanitize filename
|
|
112
|
+
safe_filename = "".join(c for c in filename if c.isalnum() or c in "-_")
|
|
113
|
+
if not safe_filename:
|
|
114
|
+
safe_filename = "screenshot"
|
|
115
|
+
else:
|
|
116
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
117
|
+
safe_filename = f"screenshot-{timestamp}"
|
|
118
|
+
|
|
119
|
+
output_path = self._screenshots_dir / f"{safe_filename}.{format}"
|
|
120
|
+
|
|
121
|
+
# Avoid overwriting
|
|
122
|
+
counter = 1
|
|
123
|
+
while output_path.exists():
|
|
124
|
+
output_path = self._screenshots_dir / f"{safe_filename}-{counter}.{format}"
|
|
125
|
+
counter += 1
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
# Platform-specific screenshot
|
|
129
|
+
if self._platform == "darwin":
|
|
130
|
+
result = await self._capture_macos(output_path, display)
|
|
131
|
+
elif self._platform == "linux":
|
|
132
|
+
result = await self._capture_linux(output_path, display)
|
|
133
|
+
elif self._platform == "windows":
|
|
134
|
+
result = await self._capture_windows(output_path, display)
|
|
135
|
+
else:
|
|
136
|
+
return f"Error: Unsupported platform '{self._platform}'"
|
|
137
|
+
|
|
138
|
+
if result is not None:
|
|
139
|
+
return result # Error message
|
|
140
|
+
|
|
141
|
+
# Verify file was created
|
|
142
|
+
if not output_path.exists():
|
|
143
|
+
return "Error: Screenshot file was not created"
|
|
144
|
+
|
|
145
|
+
# Check file size
|
|
146
|
+
file_size = output_path.stat().st_size
|
|
147
|
+
if file_size > self.MAX_FILE_SIZE:
|
|
148
|
+
output_path.unlink()
|
|
149
|
+
return f"Error: Screenshot too large ({file_size / 1024 / 1024:.1f}MB). Max: 10MB"
|
|
150
|
+
|
|
151
|
+
if file_size == 0:
|
|
152
|
+
output_path.unlink()
|
|
153
|
+
return "Error: Screenshot file is empty"
|
|
154
|
+
|
|
155
|
+
logger.info(f"Screenshot saved: {output_path} ({file_size / 1024:.1f}KB)")
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
f"Screenshot saved successfully.\n"
|
|
159
|
+
f"Path: {output_path}\n"
|
|
160
|
+
f"Size: {file_size / 1024:.1f}KB\n\n"
|
|
161
|
+
f"To send this screenshot to the user, use the message tool with:\n"
|
|
162
|
+
f'message(content="Here is the screenshot", media_paths=["{output_path}"])'
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.error(f"Screenshot failed: {e}")
|
|
167
|
+
return f"Error capturing screenshot: {str(e)}"
|
|
168
|
+
|
|
169
|
+
def _capture_via_electron_sync(
|
|
170
|
+
self, output_path: Path, display: int
|
|
171
|
+
) -> str | None:
|
|
172
|
+
"""
|
|
173
|
+
Try to capture screenshot via Electron desktop app's HTTP API.
|
|
174
|
+
|
|
175
|
+
The Electron app has macOS Screen Recording (TCC) permission and exposes
|
|
176
|
+
a localhost-only HTTP endpoint for screenshot capture with bearer token auth.
|
|
177
|
+
|
|
178
|
+
Returns None on success, error message on failure,
|
|
179
|
+
or "UNAVAILABLE" if Electron is not running.
|
|
180
|
+
"""
|
|
181
|
+
if not _ELECTRON_API_FILE.exists():
|
|
182
|
+
return "UNAVAILABLE"
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
api_data = json.loads(_ELECTRON_API_FILE.read_text())
|
|
186
|
+
port = int(api_data["port"])
|
|
187
|
+
token = str(api_data["token"])
|
|
188
|
+
except (ValueError, KeyError, json.JSONDecodeError, OSError):
|
|
189
|
+
return "UNAVAILABLE"
|
|
190
|
+
|
|
191
|
+
url = f"http://127.0.0.1:{port}/screenshot"
|
|
192
|
+
payload = json.dumps({
|
|
193
|
+
"display": display,
|
|
194
|
+
"format": output_path.suffix.lstrip("."),
|
|
195
|
+
"filename": output_path.stem,
|
|
196
|
+
}).encode()
|
|
197
|
+
|
|
198
|
+
req = urllib.request.Request(
|
|
199
|
+
url,
|
|
200
|
+
data=payload,
|
|
201
|
+
headers={
|
|
202
|
+
"Content-Type": "application/json",
|
|
203
|
+
"Authorization": f"Bearer {token}",
|
|
204
|
+
},
|
|
205
|
+
method="POST",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
210
|
+
data = json.loads(resp.read())
|
|
211
|
+
if data.get("success"):
|
|
212
|
+
electron_path = Path(data.get("path", ""))
|
|
213
|
+
if electron_path.exists():
|
|
214
|
+
if electron_path != output_path:
|
|
215
|
+
electron_path.rename(output_path)
|
|
216
|
+
return None # Success
|
|
217
|
+
return "Error: Electron reported success but file not found"
|
|
218
|
+
return f"Error: Electron screenshot failed - {data.get('error', 'unknown')}"
|
|
219
|
+
except urllib.error.HTTPError as e:
|
|
220
|
+
if e.code == 401:
|
|
221
|
+
logger.warning("Electron screenshot auth failed (token mismatch)")
|
|
222
|
+
return "UNAVAILABLE"
|
|
223
|
+
logger.warning(f"Electron screenshot HTTP error: {e.code}")
|
|
224
|
+
return "UNAVAILABLE"
|
|
225
|
+
except (urllib.error.URLError, OSError):
|
|
226
|
+
return "UNAVAILABLE"
|
|
227
|
+
except Exception as e:
|
|
228
|
+
logger.warning(f"Electron screenshot delegation failed: {e}")
|
|
229
|
+
return "UNAVAILABLE"
|
|
230
|
+
|
|
231
|
+
async def _capture_macos(self, output_path: Path, display: int) -> str | None:
|
|
232
|
+
"""
|
|
233
|
+
Capture screenshot on macOS.
|
|
234
|
+
|
|
235
|
+
Tries Electron desktop app delegation first (has TCC permission),
|
|
236
|
+
falls back to direct screencapture if Electron is not available.
|
|
237
|
+
|
|
238
|
+
Returns None on success, error message on failure.
|
|
239
|
+
"""
|
|
240
|
+
# Try Electron desktop app first (has Screen Recording permission)
|
|
241
|
+
import asyncio
|
|
242
|
+
|
|
243
|
+
electron_result = await asyncio.to_thread(
|
|
244
|
+
self._capture_via_electron_sync, output_path, display
|
|
245
|
+
)
|
|
246
|
+
if electron_result is None:
|
|
247
|
+
logger.info("Screenshot captured via Electron desktop app")
|
|
248
|
+
return None # Success
|
|
249
|
+
if electron_result != "UNAVAILABLE":
|
|
250
|
+
return electron_result # Electron was available but capture failed
|
|
251
|
+
|
|
252
|
+
# Fallback: direct screencapture (works if this process has TCC permission)
|
|
253
|
+
logger.debug("Electron not available, falling back to direct screencapture")
|
|
254
|
+
|
|
255
|
+
if not shutil.which("screencapture"):
|
|
256
|
+
return "Error: 'screencapture' command not found"
|
|
257
|
+
|
|
258
|
+
cmd = ["screencapture", "-x"] # -x = no sound
|
|
259
|
+
|
|
260
|
+
# Add display selection if not main display
|
|
261
|
+
if display > 0:
|
|
262
|
+
cmd.extend(["-D", str(display + 1)]) # screencapture uses 1-based indexing
|
|
263
|
+
|
|
264
|
+
cmd.append(str(output_path))
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
result = subprocess.run(
|
|
268
|
+
cmd,
|
|
269
|
+
capture_output=True,
|
|
270
|
+
text=True,
|
|
271
|
+
timeout=30
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if result.returncode != 0:
|
|
275
|
+
error = result.stderr.strip() or "Unknown error"
|
|
276
|
+
error_lower = error.lower()
|
|
277
|
+
if "could not create image from display" in error_lower:
|
|
278
|
+
return (
|
|
279
|
+
"Error: macOS screenshot failed (display capture unavailable).\n"
|
|
280
|
+
"Possible causes:\n"
|
|
281
|
+
"1) Screen Recording permission not granted to Flowly\n"
|
|
282
|
+
"2) Process is not running in an active GUI (Aqua) session\n\n"
|
|
283
|
+
"Fix:\n"
|
|
284
|
+
"- System Settings -> Privacy & Security -> Screen Recording\n"
|
|
285
|
+
"- Open Flowly Desktop app (automatic permission delegation)\n"
|
|
286
|
+
"- Or manually grant permission to the Flowly binary"
|
|
287
|
+
)
|
|
288
|
+
if "operation not permitted" in error_lower or "not authorized" in error_lower:
|
|
289
|
+
return (
|
|
290
|
+
"Error: Screen Recording permission denied.\n"
|
|
291
|
+
"Open Flowly Desktop app or grant permission via\n"
|
|
292
|
+
"System Settings -> Privacy & Security -> Screen Recording."
|
|
293
|
+
)
|
|
294
|
+
return f"Error: screencapture failed - {error}"
|
|
295
|
+
|
|
296
|
+
return None # Success
|
|
297
|
+
|
|
298
|
+
except subprocess.TimeoutExpired:
|
|
299
|
+
return "Error: Screenshot timed out after 30 seconds"
|
|
300
|
+
except Exception as e:
|
|
301
|
+
return f"Error running screencapture: {str(e)}"
|
|
302
|
+
|
|
303
|
+
async def _capture_linux(self, output_path: Path, display: int) -> str | None:
|
|
304
|
+
"""
|
|
305
|
+
Capture screenshot on Linux using gnome-screenshot, scrot, or import.
|
|
306
|
+
|
|
307
|
+
Returns None on success, error message on failure.
|
|
308
|
+
"""
|
|
309
|
+
# Try different screenshot tools in order of preference
|
|
310
|
+
if shutil.which("gnome-screenshot"):
|
|
311
|
+
cmd = ["gnome-screenshot", "-f", str(output_path)]
|
|
312
|
+
elif shutil.which("scrot"):
|
|
313
|
+
cmd = ["scrot", str(output_path)]
|
|
314
|
+
elif shutil.which("import"):
|
|
315
|
+
# ImageMagick's import
|
|
316
|
+
cmd = ["import", "-window", "root", str(output_path)]
|
|
317
|
+
elif shutil.which("grim"):
|
|
318
|
+
# For Wayland
|
|
319
|
+
cmd = ["grim", str(output_path)]
|
|
320
|
+
else:
|
|
321
|
+
return (
|
|
322
|
+
"Error: No screenshot tool found. "
|
|
323
|
+
"Install one of: gnome-screenshot, scrot, imagemagick, or grim"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
result = subprocess.run(
|
|
328
|
+
cmd,
|
|
329
|
+
capture_output=True,
|
|
330
|
+
text=True,
|
|
331
|
+
timeout=30
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if result.returncode != 0:
|
|
335
|
+
error = result.stderr.strip() or "Unknown error"
|
|
336
|
+
return f"Error: Screenshot command failed - {error}"
|
|
337
|
+
|
|
338
|
+
return None # Success
|
|
339
|
+
|
|
340
|
+
except subprocess.TimeoutExpired:
|
|
341
|
+
return "Error: Screenshot timed out after 30 seconds"
|
|
342
|
+
except Exception as e:
|
|
343
|
+
return f"Error running screenshot command: {str(e)}"
|
|
344
|
+
|
|
345
|
+
async def _capture_windows(self, output_path: Path, display: int) -> str | None:
|
|
346
|
+
"""
|
|
347
|
+
Capture screenshot on Windows using PowerShell.
|
|
348
|
+
|
|
349
|
+
Returns None on success, error message on failure.
|
|
350
|
+
"""
|
|
351
|
+
# PowerShell script to capture screen
|
|
352
|
+
# Use forward slashes to avoid PowerShell escape issues with backslashes
|
|
353
|
+
safe_path = str(output_path).replace("\\", "/")
|
|
354
|
+
ps_script = f'''
|
|
355
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
356
|
+
$screen = [System.Windows.Forms.Screen]::AllScreens[{display}]
|
|
357
|
+
$bounds = $screen.Bounds
|
|
358
|
+
$bitmap = New-Object System.Drawing.Bitmap($bounds.Width, $bounds.Height)
|
|
359
|
+
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
|
360
|
+
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
|
|
361
|
+
$bitmap.Save("{safe_path}")
|
|
362
|
+
$graphics.Dispose()
|
|
363
|
+
$bitmap.Dispose()
|
|
364
|
+
'''
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
result = subprocess.run(
|
|
368
|
+
["powershell", "-Command", ps_script],
|
|
369
|
+
capture_output=True,
|
|
370
|
+
text=True,
|
|
371
|
+
timeout=30
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if result.returncode != 0:
|
|
375
|
+
error = result.stderr.strip() or "Unknown error"
|
|
376
|
+
return f"Error: PowerShell screenshot failed - {error}"
|
|
377
|
+
|
|
378
|
+
return None # Success
|
|
379
|
+
|
|
380
|
+
except subprocess.TimeoutExpired:
|
|
381
|
+
return "Error: Screenshot timed out after 30 seconds"
|
|
382
|
+
except FileNotFoundError:
|
|
383
|
+
return "Error: PowerShell not found"
|
|
384
|
+
except Exception as e:
|
|
385
|
+
return f"Error running PowerShell: {str(e)}"
|
|
386
|
+
|
|
387
|
+
def get_screenshots_dir(self) -> Path:
|
|
388
|
+
"""Get the screenshots directory path."""
|
|
389
|
+
return self._screenshots_dir
|
|
390
|
+
|
|
391
|
+
def list_screenshots(self, limit: int = 10) -> list[Path]:
|
|
392
|
+
"""
|
|
393
|
+
List recent screenshots.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
limit: Maximum number of screenshots to return.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
List of screenshot paths, newest first.
|
|
400
|
+
"""
|
|
401
|
+
screenshots = []
|
|
402
|
+
for ext in self.SUPPORTED_FORMATS:
|
|
403
|
+
screenshots.extend(self._screenshots_dir.glob(f"*.{ext}"))
|
|
404
|
+
|
|
405
|
+
# Sort by modification time, newest first
|
|
406
|
+
screenshots.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
407
|
+
|
|
408
|
+
return screenshots[:limit]
|
|
409
|
+
|
|
410
|
+
def cleanup_old_screenshots(self, max_age_days: int = 7, max_count: int = 100) -> int:
|
|
411
|
+
"""
|
|
412
|
+
Clean up old screenshots to prevent disk space issues.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
max_age_days: Delete screenshots older than this.
|
|
416
|
+
max_count: Keep at most this many screenshots.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
Number of files deleted.
|
|
420
|
+
"""
|
|
421
|
+
from datetime import timedelta
|
|
422
|
+
|
|
423
|
+
deleted = 0
|
|
424
|
+
now = datetime.now()
|
|
425
|
+
cutoff = now - timedelta(days=max_age_days)
|
|
426
|
+
|
|
427
|
+
screenshots = self.list_screenshots(limit=1000)
|
|
428
|
+
|
|
429
|
+
for i, path in enumerate(screenshots):
|
|
430
|
+
try:
|
|
431
|
+
mtime = datetime.fromtimestamp(path.stat().st_mtime)
|
|
432
|
+
|
|
433
|
+
# Delete if too old or beyond max count
|
|
434
|
+
if mtime < cutoff or i >= max_count:
|
|
435
|
+
path.unlink()
|
|
436
|
+
deleted += 1
|
|
437
|
+
logger.debug(f"Deleted old screenshot: {path}")
|
|
438
|
+
except Exception as e:
|
|
439
|
+
logger.warning(f"Failed to delete {path}: {e}")
|
|
440
|
+
|
|
441
|
+
if deleted > 0:
|
|
442
|
+
logger.info(f"Cleaned up {deleted} old screenshots")
|
|
443
|
+
|
|
444
|
+
return deleted
|