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.
Files changed (86) hide show
  1. flowly_code/__init__.py +30 -0
  2. flowly_code/__main__.py +8 -0
  3. flowly_code/activity/__init__.py +1 -0
  4. flowly_code/activity/bus.py +91 -0
  5. flowly_code/activity/events.py +40 -0
  6. flowly_code/agent/__init__.py +8 -0
  7. flowly_code/agent/context.py +485 -0
  8. flowly_code/agent/loop.py +1349 -0
  9. flowly_code/agent/memory.py +109 -0
  10. flowly_code/agent/skills.py +259 -0
  11. flowly_code/agent/subagent.py +249 -0
  12. flowly_code/agent/tools/__init__.py +6 -0
  13. flowly_code/agent/tools/base.py +55 -0
  14. flowly_code/agent/tools/delegate.py +194 -0
  15. flowly_code/agent/tools/dispatch.py +840 -0
  16. flowly_code/agent/tools/docker.py +609 -0
  17. flowly_code/agent/tools/filesystem.py +280 -0
  18. flowly_code/agent/tools/mcp.py +85 -0
  19. flowly_code/agent/tools/message.py +235 -0
  20. flowly_code/agent/tools/registry.py +257 -0
  21. flowly_code/agent/tools/screenshot.py +444 -0
  22. flowly_code/agent/tools/shell.py +166 -0
  23. flowly_code/agent/tools/spawn.py +65 -0
  24. flowly_code/agent/tools/system.py +917 -0
  25. flowly_code/agent/tools/trello.py +420 -0
  26. flowly_code/agent/tools/web.py +139 -0
  27. flowly_code/agent/tools/x.py +399 -0
  28. flowly_code/bus/__init__.py +6 -0
  29. flowly_code/bus/events.py +37 -0
  30. flowly_code/bus/queue.py +81 -0
  31. flowly_code/channels/__init__.py +6 -0
  32. flowly_code/channels/base.py +121 -0
  33. flowly_code/channels/manager.py +135 -0
  34. flowly_code/channels/telegram.py +1132 -0
  35. flowly_code/cli/__init__.py +1 -0
  36. flowly_code/cli/commands.py +1831 -0
  37. flowly_code/cli/setup.py +1356 -0
  38. flowly_code/compaction/__init__.py +39 -0
  39. flowly_code/compaction/estimator.py +88 -0
  40. flowly_code/compaction/pruning.py +223 -0
  41. flowly_code/compaction/service.py +297 -0
  42. flowly_code/compaction/summarizer.py +384 -0
  43. flowly_code/compaction/types.py +71 -0
  44. flowly_code/config/__init__.py +6 -0
  45. flowly_code/config/loader.py +102 -0
  46. flowly_code/config/schema.py +324 -0
  47. flowly_code/exec/__init__.py +39 -0
  48. flowly_code/exec/approvals.py +288 -0
  49. flowly_code/exec/executor.py +184 -0
  50. flowly_code/exec/safety.py +247 -0
  51. flowly_code/exec/types.py +88 -0
  52. flowly_code/gateway/__init__.py +5 -0
  53. flowly_code/gateway/server.py +103 -0
  54. flowly_code/heartbeat/__init__.py +5 -0
  55. flowly_code/heartbeat/service.py +130 -0
  56. flowly_code/multiagent/README.md +248 -0
  57. flowly_code/multiagent/__init__.py +1 -0
  58. flowly_code/multiagent/invoke.py +210 -0
  59. flowly_code/multiagent/orchestrator.py +156 -0
  60. flowly_code/multiagent/router.py +156 -0
  61. flowly_code/multiagent/setup.py +171 -0
  62. flowly_code/pairing/__init__.py +21 -0
  63. flowly_code/pairing/store.py +343 -0
  64. flowly_code/providers/__init__.py +6 -0
  65. flowly_code/providers/base.py +69 -0
  66. flowly_code/providers/litellm_provider.py +178 -0
  67. flowly_code/providers/transcription.py +64 -0
  68. flowly_code/session/__init__.py +5 -0
  69. flowly_code/session/manager.py +249 -0
  70. flowly_code/skills/README.md +24 -0
  71. flowly_code/skills/compact/SKILL.md +27 -0
  72. flowly_code/skills/github/SKILL.md +48 -0
  73. flowly_code/skills/skill-creator/SKILL.md +371 -0
  74. flowly_code/skills/summarize/SKILL.md +67 -0
  75. flowly_code/skills/tmux/SKILL.md +121 -0
  76. flowly_code/skills/tmux/scripts/find-sessions.sh +112 -0
  77. flowly_code/skills/tmux/scripts/wait-for-text.sh +83 -0
  78. flowly_code/skills/weather/SKILL.md +49 -0
  79. flowly_code/utils/__init__.py +5 -0
  80. flowly_code/utils/helpers.py +91 -0
  81. flowly_code-1.0.0.dist-info/METADATA +724 -0
  82. flowly_code-1.0.0.dist-info/RECORD +86 -0
  83. flowly_code-1.0.0.dist-info/WHEEL +4 -0
  84. flowly_code-1.0.0.dist-info/entry_points.txt +2 -0
  85. flowly_code-1.0.0.dist-info/licenses/LICENSE +191 -0
  86. flowly_code-1.0.0.dist-info/licenses/NOTICE +74 -0
@@ -0,0 +1,30 @@
1
+ # Copyright 2025-2026 Nocetic Limited
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Flowly Code - Personal AI assistant by Nocetic Limited.
17
+ """
18
+
19
+ __version__ = "1.0.0"
20
+ __logo__ = "\U0001f408"
21
+ __banner__ = """\
22
+ ,((((,
23
+ ((( ((\\
24
+ (( ((\\
25
+ (( ~ (\\ ,~. ~.
26
+ ( ~ ~\\ .~ , ~. ~ ~.
27
+ ( ~ ~. \\~ ~ , ~ ~ ~. flowly v{version}
28
+ ~ ~. ~. ~ ~. ~ ~ ~ ~ ~ AI agent framework
29
+ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~
30
+ """
@@ -0,0 +1,8 @@
1
+ """
2
+ Entry point for running flowly as a module: python -m flowly
3
+ """
4
+
5
+ from flowly_code.cli.commands import app
6
+
7
+ if __name__ == "__main__":
8
+ app()
@@ -0,0 +1 @@
1
+ """Activity streaming for real-time agent monitoring."""
@@ -0,0 +1,91 @@
1
+ """Independent activity pub/sub bus for real-time event streaming.
2
+
3
+ This is completely separate from MessageBus — it never touches inbound/outbound
4
+ message queues. If no subscribers are connected, emit() is a no-op.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from typing import AsyncGenerator
11
+
12
+ from loguru import logger
13
+
14
+ from flowly_code.activity.events import ActivityEvent
15
+
16
+
17
+ class ActivityBus:
18
+ """Per-subscriber asyncio.Queue pub/sub for activity events.
19
+
20
+ Zero-overhead guarantee: if no subscribers exist, emit() returns
21
+ immediately without creating any objects.
22
+ """
23
+
24
+ def __init__(self) -> None:
25
+ self._subscribers: dict[str, asyncio.Queue[ActivityEvent]] = {}
26
+ self._counter = 0
27
+ self._lock = asyncio.Lock()
28
+ # Track currently running agents for late-joining subscribers
29
+ self._running_agents: dict[str, ActivityEvent] = {}
30
+
31
+ @property
32
+ def has_subscribers(self) -> bool:
33
+ return len(self._subscribers) > 0
34
+
35
+ async def subscribe(
36
+ self, maxsize: int = 256
37
+ ) -> tuple[str, AsyncGenerator[ActivityEvent, None]]:
38
+ """Subscribe to activity events.
39
+
40
+ Returns (subscriber_id, async_generator). Iterate the generator
41
+ to receive events. The generator cleans up on cancellation.
42
+ """
43
+ async with self._lock:
44
+ self._counter += 1
45
+ sub_id = f"sub-{self._counter}"
46
+ queue: asyncio.Queue[ActivityEvent] = asyncio.Queue(maxsize=maxsize)
47
+ self._subscribers[sub_id] = queue
48
+ # Replay currently running agents so late joiners see them
49
+ for event in self._running_agents.values():
50
+ try:
51
+ queue.put_nowait(event)
52
+ except asyncio.QueueFull:
53
+ break
54
+
55
+ async def _gen() -> AsyncGenerator[ActivityEvent, None]:
56
+ try:
57
+ while True:
58
+ event = await queue.get()
59
+ yield event
60
+ except asyncio.CancelledError:
61
+ return
62
+ finally:
63
+ await self.unsubscribe(sub_id)
64
+
65
+ return sub_id, _gen()
66
+
67
+ async def unsubscribe(self, sub_id: str) -> None:
68
+ """Remove a subscriber."""
69
+ async with self._lock:
70
+ self._subscribers.pop(sub_id, None)
71
+
72
+ def emit(self, event: ActivityEvent) -> None:
73
+ """Publish an event to all subscribers (non-blocking).
74
+
75
+ Uses put_nowait — if a subscriber's queue is full, the event
76
+ is silently dropped for that subscriber (backpressure).
77
+ """
78
+ # Track running agents for late-joining subscribers
79
+ agent_name = getattr(event, "agent_name", None)
80
+ if agent_name and event.type == "subagent_spawn":
81
+ self._running_agents[agent_name] = event
82
+ elif agent_name and event.type == "subagent_end":
83
+ self._running_agents.pop(agent_name, None)
84
+
85
+ for sub_id, queue in list(self._subscribers.items()):
86
+ try:
87
+ queue.put_nowait(event)
88
+ except asyncio.QueueFull:
89
+ logger.debug(
90
+ f"ActivityBus: dropped event for slow subscriber {sub_id}"
91
+ )
@@ -0,0 +1,40 @@
1
+ """Activity event definitions for real-time agent monitoring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from datetime import datetime, timezone
7
+ from typing import Literal
8
+
9
+ ActivityEventType = Literal[
10
+ "llm_start",
11
+ "llm_end",
12
+ "tool_start",
13
+ "tool_end",
14
+ "iteration_start",
15
+ "iteration_end",
16
+ "subagent_spawn",
17
+ "subagent_end",
18
+ "hallucination_retry",
19
+ "error",
20
+ ]
21
+
22
+
23
+ @dataclass
24
+ class ActivityEvent:
25
+ type: ActivityEventType
26
+ timestamp: str = field(
27
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
28
+ )
29
+ agent_name: str | None = None
30
+ iteration: int | None = None
31
+ tool_name: str | None = None
32
+ tool_args_preview: str | None = None
33
+ success: bool | None = None
34
+ error_message: str | None = None
35
+ subagent_id: str | None = None
36
+ subagent_label: str | None = None
37
+
38
+ def to_dict(self) -> dict:
39
+ """Serialize to dict, dropping None values for compact JSON."""
40
+ return {k: v for k, v in asdict(self).items() if v is not None}
@@ -0,0 +1,8 @@
1
+ """Agent core module."""
2
+
3
+ from flowly_code.agent.loop import AgentLoop
4
+ from flowly_code.agent.context import ContextBuilder
5
+ from flowly_code.agent.memory import MemoryStore
6
+ from flowly_code.agent.skills import SkillsLoader
7
+
8
+ __all__ = ["AgentLoop", "ContextBuilder", "MemoryStore", "SkillsLoader"]
@@ -0,0 +1,485 @@
1
+ """Context builder for assembling agent prompts."""
2
+
3
+ import base64
4
+ import mimetypes
5
+ import platform
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from flowly_code.agent.memory import MemoryStore
10
+ from flowly_code.agent.skills import SkillsLoader
11
+
12
+
13
+ class ContextBuilder:
14
+ """
15
+ Builds the context (system prompt + messages) for the agent.
16
+
17
+ Assembles bootstrap files, memory, skills, and conversation history
18
+ into a coherent prompt for the LLM.
19
+ """
20
+
21
+ BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
22
+
23
+ def __init__(self, workspace: Path, persona: str = "default"):
24
+ self.workspace = workspace
25
+ self.persona = persona
26
+ self.memory = MemoryStore(workspace)
27
+ self.skills = SkillsLoader(workspace)
28
+
29
+ def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
30
+ """
31
+ Build the system prompt from bootstrap files, memory, and skills.
32
+
33
+ Args:
34
+ skill_names: Optional list of skills to include.
35
+
36
+ Returns:
37
+ Complete system prompt.
38
+ """
39
+ parts = []
40
+
41
+ # Core identity
42
+ parts.append(self._get_identity())
43
+
44
+ # Bootstrap files
45
+ bootstrap = self._load_bootstrap_files()
46
+ if bootstrap:
47
+ parts.append(bootstrap)
48
+
49
+ # Memory context
50
+ memory = self.memory.get_memory_context()
51
+ if memory:
52
+ parts.append(f"# Memory\n\n{memory}")
53
+
54
+ # Skills - progressive loading
55
+ # 1. Always-loaded skills: include full content
56
+ always_skills = self.skills.get_always_skills()
57
+ if always_skills:
58
+ always_content = self.skills.load_skills_for_context(always_skills)
59
+ if always_content:
60
+ parts.append(f"# Active Skills\n\n{always_content}")
61
+
62
+ # 2. Available skills: only show summary (agent uses read_file to load)
63
+ skills_summary = self.skills.build_skills_summary()
64
+ if skills_summary:
65
+ parts.append(f"""# Skills
66
+
67
+ The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
68
+ Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
69
+
70
+ {skills_summary}""")
71
+
72
+ return "\n\n---\n\n".join(parts)
73
+
74
+ def _get_identity(self) -> str:
75
+ """Get the core identity section."""
76
+ from datetime import datetime
77
+ now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
78
+ workspace_path = str(self.workspace.expanduser().resolve())
79
+
80
+ # Load persona-specific identity if available
81
+ persona_intro = ""
82
+ if self.persona and self.persona != "default":
83
+ persona_path = self.workspace / "personas" / f"{self.persona}.md"
84
+ if persona_path.exists():
85
+ persona_intro = persona_path.read_text(encoding="utf-8").strip()
86
+
87
+ if persona_intro:
88
+ identity_header = f"""# CRITICAL PERSONA OVERRIDE — READ THIS FIRST
89
+
90
+ {persona_intro}
91
+
92
+ **IMPORTANT: The persona rules above are your PRIMARY identity. Follow them in EVERY response without exception.
93
+ You are NOT Flowly. You are NOT a generic AI assistant. You ARE the character defined above.
94
+ If any instruction below mentions "Flowly", ignore that name — use your persona identity instead.**
95
+
96
+ You have access to powerful tools. Your persona defines HOW you communicate — follow it strictly."""
97
+ else:
98
+ identity_header = """# Flowly
99
+
100
+ You are Flowly, a helpful AI assistant with access to powerful tools."""
101
+
102
+ return f"""{identity_header}
103
+
104
+ ## Available Tools
105
+
106
+ You have these tools - USE THEM when the user asks for related actions:
107
+
108
+ | Tool | Description |
109
+ |------|-------------|
110
+ | screenshot | Capture screen screenshot |
111
+ | message | Send messages to Telegram/WhatsApp (with media_paths for images) |
112
+ | read_file | Read file contents |
113
+ | write_file | Write/create files |
114
+ | edit_file | Edit existing files |
115
+ | list_dir | List directory contents |
116
+ | exec | Execute ANY shell command - open apps, run scripts, control system |
117
+ | web_search | Search the web (Brave) |
118
+ | web_fetch | Fetch and read web pages |
119
+ | cron | Schedule reminders and recurring tasks |
120
+ | spawn | Create background subagents |
121
+ | docker | Manage Docker containers |
122
+ | system | Monitor system resources |
123
+ | trello | Manage Trello boards/cards (if configured) |
124
+ | voice_call | Make phone calls (if configured) |
125
+
126
+ **IMPORTANT: Use tools when the user requests a real action or external data.**
127
+ For normal conversation, answer directly without unnecessary tool calls.
128
+ When textual instructions conflict with a tool schema, follow the tool schema.
129
+
130
+ ## exec Tool - Application and System Control
131
+
132
+ The exec tool can run ANY shell command on the computer:
133
+
134
+ {self._get_exec_examples()}
135
+
136
+ Do not use `exec` unless it is actually needed for the task.
137
+
138
+ ## Current Time
139
+ {now}
140
+
141
+ ## Workspace
142
+ Your workspace is at: {workspace_path}
143
+ - Memory files: {workspace_path}/memory/MEMORY.md
144
+ - Daily notes: {workspace_path}/memory/YYYY-MM-DD.md
145
+ - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
146
+
147
+ ## Scheduling Tasks (Cron Tool)
148
+
149
+ When the user asks to be reminded, schedule something, or do something later, ALWAYS use the cron tool.
150
+
151
+ **Trigger phrases:** "remind me", "later", "in X minutes/hours", "tomorrow", "every day", "schedule", "at [time]", "after [duration]"
152
+
153
+ **Examples:**
154
+ - "Remind me in 5 minutes" → cron(action="add", name="reminder", schedule="at +5m", message="...", deliver=true)
155
+ - "Tell me the weather every day at 9am" → cron(action="add", schedule="0 9 * * *", message="Check weather", deliver=true)
156
+ - "Meeting in 1 hour" → cron(action="add", schedule="at +1h", message="Meeting reminder", deliver=true)
157
+ - "Wake me up tomorrow at 8am" → cron(action="add", schedule="at tomorrow 08:00", message="Wake up!", deliver=true)
158
+ - "Call me in 1 minute and say X" → cron(action="add", name="call-user", schedule="at +1m", tool_name="voice_call", tool_args={{"action":"call","to":"+90...","script":"..." }}, deliver=true)
159
+
160
+ **Schedule formats:**
161
+ - Relative: "at +5m", "at +1h", "at +2d" (minutes, hours, days from now)
162
+ - Time today: "at 14:30" (today at 14:30, or tomorrow if past)
163
+ - Tomorrow: "at tomorrow 09:00"
164
+ - Recurring: "every 30m", "every 1h", "every 1d"
165
+ - Cron expression: "0 9 * * *" (daily at 9:00)
166
+
167
+ **Important:** Always set deliver=true so the notification is sent back to the user!
168
+ When the user wants a future tool action (e.g., call later), prefer `tool_name` + `tool_args` for deterministic execution.
169
+
170
+ ## Trello Integration
171
+
172
+ If the trello tool is available, you can manage Trello boards, lists, and cards.
173
+
174
+ **Actions:**
175
+ - list_boards: Get all your Trello boards
176
+ - list_lists: Get all lists in a board (requires board_id)
177
+ - list_cards: Get cards in a list or board (requires list_id or board_id)
178
+ - get_card: Get card details (requires card_id)
179
+ - create_card: Create a new card (requires list_id, name)
180
+ - update_card: Update card name, description, due date, or move to another list
181
+ - add_comment: Add a comment to a card
182
+ - archive_card: Archive (close) a card
183
+ - search: Search for cards across all boards
184
+
185
+ **Examples:**
186
+ - "Show my Trello boards" → trello(action="list_boards")
187
+ - "What lists are in board X?" → trello(action="list_lists", board_id="...")
188
+ - "Create a card called 'Fix bug'" → trello(action="create_card", list_id="...", name="Fix bug")
189
+ - "Search for cards about meetings" → trello(action="search", query="meetings")
190
+
191
+ ## Docker Integration
192
+
193
+ You can manage Docker containers, images, volumes, and compose stacks.
194
+
195
+ **Container Actions:**
196
+ - ps: List containers (all=true for stopped too)
197
+ - logs: Get container logs (container, tail=100)
198
+ - start/stop/restart: Control containers
199
+ - rm: Remove a container (force=true to force)
200
+ - exec: Run a command in a container
201
+ - stats: Get resource usage (CPU, memory, network)
202
+ - inspect: Get detailed container info
203
+
204
+ **Image Actions:**
205
+ - images: List all images
206
+ - pull: Pull an image from registry
207
+
208
+ **Compose Actions:**
209
+ - compose_up: Start stack (path to docker-compose.yml, detach=true)
210
+ - compose_down: Stop stack
211
+ - compose_ps: List services
212
+ - compose_logs: Get service logs
213
+
214
+ **Maintenance:**
215
+ - volumes: List volumes
216
+ - networks: List networks
217
+ - prune: Clean up unused resources (type: containers/images/volumes/all)
218
+
219
+ **Examples:**
220
+ - "Show running containers" → docker(action="ps")
221
+ - "Show all containers" → docker(action="ps", all=true)
222
+ - "Restart nginx container" → docker(action="restart", container="nginx")
223
+ - "Show logs of my-app" → docker(action="logs", container="my-app", tail=50)
224
+ - "Run bash in container" → docker(action="exec", container="my-app", command="bash -c 'ls -la'")
225
+ - "Start my compose stack" → docker(action="compose_up", path="/path/to/docker-compose.yml")
226
+ - "Container CPU/memory usage" → docker(action="stats")
227
+
228
+ ## System Monitoring
229
+
230
+ Monitor system resources, processes, and services.
231
+
232
+ **Actions:**
233
+ - overview: Quick system overview (CPU, RAM, disk, uptime)
234
+ - cpu: Detailed CPU info and usage
235
+ - memory: RAM and swap usage
236
+ - disk: Disk usage for all mounts
237
+ - network: Network interfaces and connections
238
+ - processes: Top processes (sort_by: cpu/memory, limit: 10)
239
+ - uptime: System uptime and load averages
240
+ - info: OS, kernel, hostname info
241
+ - services: Running services (Linux systemd)
242
+ - ports: Listening ports
243
+
244
+ **Examples:**
245
+ - "How is the server doing?" → system(action="overview")
246
+ - "Show CPU usage" → system(action="cpu")
247
+ - "Check disk space" → system(action="disk")
248
+ - "What's using the most memory?" → system(action="processes", sort_by="memory")
249
+ - "Show listening ports" → system(action="ports")
250
+ - "System info" → system(action="info")
251
+
252
+ ## Voice Calls (Twilio)
253
+
254
+ If the voice_call tool is available, you can make and manage real-time phone calls.
255
+
256
+ **Actions:**
257
+ - call: Make a call and have a conversation
258
+ - speak: Say something on an active call
259
+ - end_call: End a call (with optional goodbye message)
260
+ - list_calls: List active calls
261
+
262
+ **Phone number format:** Use E.164 format (+1234567890) or national format.
263
+
264
+ **Conversation Flow:**
265
+ 1. Use action="call" to start a conversation call
266
+ 2. The user's speech is automatically transcribed and sent to you
267
+ 3. Your responses are automatically spoken to the user
268
+ 4. Use action="end_call" when the conversation is complete
269
+
270
+ **Examples:**
271
+ - "Call +905551234567" → voice_call(action="call", to="+905551234567", greeting="Hello, how can I help you?")
272
+ - "Say goodbye and hang up" → voice_call(action="end_call", call_sid="...", message="Thanks, have a great day!")
273
+ - "List active calls" → voice_call(action="list_calls")
274
+
275
+ **Important:** When a call is active, the user's speech will appear in the conversation as messages from the "voice" channel. Respond naturally and your response will be spoken to them.
276
+ During active call turns, do NOT call `voice_call(action="speak")` for normal replies.
277
+ Return plain text instead; the voice pipeline already speaks your response.
278
+
279
+ **CRITICAL - Tool Usage in Voice Calls:**
280
+ When you're in a voice call and need to use tools (like cron, web_search, etc.):
281
+ 1. FIRST tell the user what you're about to do: "Let me check that..." or "Setting up a reminder..."
282
+ 2. Execute the tool
283
+ 3. THEN tell them the result clearly: "Done, I've set the reminder. I'll notify you in 5 minutes."
284
+
285
+ The user ONLY hears your text response - they cannot see tool execution. Always verbally confirm:
286
+ - What you're doing before the tool runs
287
+ - What happened after the tool completes
288
+ - Any errors if the tool fails
289
+
290
+ Example flow:
291
+ User: "Remind me in 5 minutes"
292
+ You: (Use cron tool to set reminder)
293
+ You respond: "Done, I've set a reminder for 5 minutes from now. I'll notify you when it's time."
294
+
295
+ ## Tool Usage Style
296
+
297
+ **CRITICAL: Use tools deliberately, not automatically.**
298
+ If the user is asking a conversational or explanatory question, answer directly without tools.
299
+
300
+ When the user asks you to do something that clearly requires a tool, call it:
301
+ - "take a screenshot" / "ss" → Call screenshot() tool
302
+ - "send via telegram" → Call message() with channel="telegram"
303
+ - "read file" → Call read_file() tool
304
+ - "remind me" → Call cron() tool
305
+ - "search" → Call web_search() tool
306
+ - "check docker" → Call docker() tool
307
+ - "system status" → Call system() tool
308
+
309
+ **Tool Usage Rules:**
310
+ 1. When user asks for an action → Execute the tool FIRST, then describe the result
311
+ 2. When user asks for information → Use tools to gather info, then summarize
312
+ 3. Never say "I would use X tool" - just USE it
313
+ 4. Never refuse to use a tool if it's available and relevant
314
+ 5. For multi-step tasks, execute all steps (e.g., screenshot → message to send)
315
+ 6. Tool schema is the source of truth. If instructions and prose conflict, follow the actual tool schema/parameters.
316
+
317
+ **Examples:**
318
+ - User: "take a screenshot and send it on telegram" → screenshot() then message(channel="telegram", media_paths=[...])
319
+ - User: "read file /tmp/test.txt" → read_file(path="/tmp/test.txt")
320
+ - User: "remind me in 5 minutes" → cron(action="add", schedule="at +5m", ...)
321
+
322
+ ## Guidelines
323
+
324
+ IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
325
+ Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
326
+ For normal conversation, just respond with text - do not call the message tool.
327
+
328
+ Always be helpful, accurate, and concise. When using tools, explain what you're doing.
329
+ When remembering something, write to {workspace_path}/memory/MEMORY.md"""
330
+
331
+ def _get_exec_examples(self) -> str:
332
+ """Get platform-appropriate exec tool examples."""
333
+ if platform.system() == "Windows":
334
+ return """**Opening Applications (Windows):**
335
+ - "Open Chrome" → exec(command="start chrome")
336
+ - "Open YouTube" → exec(command="start https://youtube.com")
337
+ - "Open Notepad" → exec(command="start notepad")
338
+ - "Open Explorer" → exec(command="start explorer")
339
+
340
+ **System Commands:**
341
+ - "Volume up/down" → exec(command="powershell (New-Object -ComObject WScript.Shell).SendKeys([char]175)")
342
+ - "Close app" → exec(command="taskkill /im app.exe /f")"""
343
+ elif platform.system() == "Linux":
344
+ return """**Opening Applications (Linux):**
345
+ - "Open Chrome" → exec(command="xdg-open https://google.com")
346
+ - "Open YouTube" → exec(command="xdg-open https://youtube.com")
347
+ - "Open file manager" → exec(command="xdg-open .")
348
+
349
+ **System Commands:**
350
+ - "Close app" → exec(command="pkill -x 'App Name'")"""
351
+ else:
352
+ return """**Opening Applications (macOS):**
353
+ - "Open Chrome" → exec(command="open -a 'Google Chrome'")
354
+ - "Open YouTube" → exec(command="open https://youtube.com")
355
+ - "Open Safari" → exec(command="open -a Safari")
356
+ - "Open Finder" → exec(command="open -a Finder")
357
+ - "Open Terminal" → exec(command="open -a Terminal")
358
+
359
+ **System Commands:**
360
+ - "Volume up/down" → exec(command="osascript -e 'set volume output volume 50'")
361
+ - "Close app" → exec(command="pkill -x 'App Name'")"""
362
+
363
+ def _load_bootstrap_files(self) -> str:
364
+ """Load all bootstrap files from workspace, substituting persona for SOUL.md."""
365
+ parts = []
366
+
367
+ for filename in self.BOOTSTRAP_FILES:
368
+ # If this is SOUL.md, try loading the persona file instead
369
+ if filename == "SOUL.md" and self.persona:
370
+ persona_path = self.workspace / "personas" / f"{self.persona}.md"
371
+ if persona_path.exists():
372
+ content = persona_path.read_text(encoding="utf-8")
373
+ parts.append(f"## Persona (ACTIVE — follow strictly)\n\n{content}\n\n**Reminder: Stay in this persona for ALL responses. Never identify as Flowly or a generic assistant.**")
374
+ continue
375
+
376
+ file_path = self.workspace / filename
377
+ if file_path.exists():
378
+ content = file_path.read_text(encoding="utf-8")
379
+ parts.append(f"## {filename}\n\n{content}")
380
+
381
+ return "\n\n".join(parts) if parts else ""
382
+
383
+ def build_messages(
384
+ self,
385
+ history: list[dict[str, Any]],
386
+ current_message: str,
387
+ skill_names: list[str] | None = None,
388
+ media: list[str] | None = None,
389
+ ) -> list[dict[str, Any]]:
390
+ """
391
+ Build the complete message list for an LLM call.
392
+
393
+ Args:
394
+ history: Previous conversation messages.
395
+ current_message: The new user message.
396
+ skill_names: Optional skills to include.
397
+ media: Optional list of local file paths for images/media.
398
+
399
+ Returns:
400
+ List of messages including system prompt.
401
+ """
402
+ messages = []
403
+
404
+ # System prompt
405
+ system_prompt = self.build_system_prompt(skill_names)
406
+ messages.append({"role": "system", "content": system_prompt})
407
+
408
+ # History
409
+ messages.extend(history)
410
+
411
+ # Current message (with optional image attachments)
412
+ user_content = self._build_user_content(current_message, media)
413
+ messages.append({"role": "user", "content": user_content})
414
+
415
+ return messages
416
+
417
+ def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
418
+ """Build user message content with optional base64-encoded images."""
419
+ if not media:
420
+ return text
421
+
422
+ images = []
423
+ for path in media:
424
+ p = Path(path)
425
+ mime, _ = mimetypes.guess_type(path)
426
+ if not p.is_file() or not mime or not mime.startswith("image/"):
427
+ continue
428
+ b64 = base64.b64encode(p.read_bytes()).decode()
429
+ images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
430
+
431
+ if not images:
432
+ return text
433
+ return images + [{"type": "text", "text": text}]
434
+
435
+ def add_tool_result(
436
+ self,
437
+ messages: list[dict[str, Any]],
438
+ tool_call_id: str,
439
+ tool_name: str,
440
+ result: str
441
+ ) -> list[dict[str, Any]]:
442
+ """
443
+ Add a tool result to the message list.
444
+
445
+ Args:
446
+ messages: Current message list.
447
+ tool_call_id: ID of the tool call.
448
+ tool_name: Name of the tool.
449
+ result: Tool execution result.
450
+
451
+ Returns:
452
+ Updated message list.
453
+ """
454
+ messages.append({
455
+ "role": "tool",
456
+ "tool_call_id": tool_call_id,
457
+ "name": tool_name,
458
+ "content": result
459
+ })
460
+ return messages
461
+
462
+ def add_assistant_message(
463
+ self,
464
+ messages: list[dict[str, Any]],
465
+ content: str | None,
466
+ tool_calls: list[dict[str, Any]] | None = None
467
+ ) -> list[dict[str, Any]]:
468
+ """
469
+ Add an assistant message to the message list.
470
+
471
+ Args:
472
+ messages: Current message list.
473
+ content: Message content.
474
+ tool_calls: Optional tool calls.
475
+
476
+ Returns:
477
+ Updated message list.
478
+ """
479
+ msg: dict[str, Any] = {"role": "assistant", "content": content or ""}
480
+
481
+ if tool_calls:
482
+ msg["tool_calls"] = tool_calls
483
+
484
+ messages.append(msg)
485
+ return messages