openagent-framework 0.2.6__tar.gz → 0.2.8__tar.gz
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.
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/PKG-INFO +24 -1
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/docs/README.md +23 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/__init__.py +1 -1
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/agent.py +16 -2
- openagent_framework-0.2.8/openagent/channels/__init__.py +22 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/channels/base.py +80 -0
- openagent_framework-0.2.8/openagent/channels/commands.py +172 -0
- openagent_framework-0.2.8/openagent/channels/discord.py +421 -0
- openagent_framework-0.2.8/openagent/channels/queue.py +171 -0
- openagent_framework-0.2.8/openagent/channels/telegram.py +380 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/channels/whatsapp.py +121 -44
- openagent_framework-0.2.8/openagent/prompts.py +81 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/server.py +54 -19
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent_framework.egg-info/PKG-INFO +24 -1
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent_framework.egg-info/SOURCES.txt +3 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/pyproject.toml +1 -1
- openagent_framework-0.2.6/openagent/channels/__init__.py +0 -3
- openagent_framework-0.2.6/openagent/channels/discord.py +0 -151
- openagent_framework-0.2.6/openagent/channels/telegram.py +0 -225
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/bootstrap.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/channels/senders.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/cli.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/config.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcp/__init__.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcp/client.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcp/oauth.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/chrome-devtools/.gitignore +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/chrome-devtools/package.json +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/computer-control/.gitignore +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/computer-control/package.json +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/computer-control/src/index.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/computer-control/src/main.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/computer-control/src/tools/computer.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/computer-control/src/tools/index.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/computer-control/src/utils/response.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/computer-control/src/xdotoolStringToKeys.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/computer-control/tsconfig.json +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/editor/.gitignore +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/editor/package.json +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/editor/src/index.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/editor/tsconfig.json +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/messaging/.gitignore +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/messaging/index.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/messaging/package.json +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/messaging/tsconfig.json +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/shell/.gitignore +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/shell/package.json +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/shell/src/index.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/shell/tsconfig.json +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/web-search/.gitignore +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/web-search/package.json +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/web-search/src/browser-pool.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/web-search/src/content-extractor.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/web-search/src/enhanced-content-extractor.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/web-search/src/index.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/web-search/src/rate-limiter.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/web-search/src/search-engine.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/web-search/src/types.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/web-search/src/utils.ts +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/mcps/web-search/tsconfig.json +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/memory/__init__.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/memory/db.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/memory/manager.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/models/__init__.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/models/base.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/models/claude_api.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/models/claude_cli.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/models/zhipu.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/scheduler.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/service.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/services/__init__.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/services/base.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/services/manager.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent/services/syncthing.py +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent_framework.egg-info/dependency_links.txt +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent_framework.egg-info/entry_points.txt +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent_framework.egg-info/requires.txt +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/openagent_framework.egg-info/top_level.txt +0 -0
- {openagent_framework-0.2.6 → openagent_framework-0.2.8}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openagent-framework
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.8
|
|
4
4
|
Summary: Simplified LLM agent framework with MCP, memory, and multi-channel support
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -168,6 +168,29 @@ These are handled automatically by OpenAgent — you just write `openagent.yaml`
|
|
|
168
168
|
|
|
169
169
|
---
|
|
170
170
|
|
|
171
|
+
## System prompt
|
|
172
|
+
|
|
173
|
+
OpenAgent injects a **framework-level system prompt** in front of your
|
|
174
|
+
user-defined `system_prompt` on every model call. It codifies guidelines
|
|
175
|
+
that apply to every deployment:
|
|
176
|
+
|
|
177
|
+
- How to use the memory vault (mcpvault tools, wikilinks, tags).
|
|
178
|
+
- Prefer MCP tools over `shell_exec` and ad-hoc scripts.
|
|
179
|
+
- Act autonomously under `permission_mode: bypass`; only stop for
|
|
180
|
+
ambiguous or irreversible actions.
|
|
181
|
+
- Be concise; lead with the answer, not the reasoning.
|
|
182
|
+
|
|
183
|
+
Your `system_prompt` in `openagent.yaml` should therefore stay **short
|
|
184
|
+
and project-specific** — just identity and a pointer to the memory
|
|
185
|
+
vault. Everything else (package names, host names, credentials,
|
|
186
|
+
procedures) belongs as `.md` notes inside the vault, where the agent
|
|
187
|
+
will find them via `search_notes`.
|
|
188
|
+
|
|
189
|
+
The framework prompt source lives at `openagent/prompts.py:FRAMEWORK_SYSTEM_PROMPT`
|
|
190
|
+
if you want to read or tweak the wording.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
171
194
|
## Memory
|
|
172
195
|
|
|
173
196
|
OpenAgent's long-term memory is a plain **Obsidian-compatible markdown vault**. The agent reads and writes `.md` files directly via the [`mcpvault`](https://www.npmjs.com/package/@bitbonsai/mcpvault) MCP — no custom index, no full-text engine, no proprietary format. The files on disk *are* the database.
|
|
@@ -141,6 +141,29 @@ These are handled automatically by OpenAgent — you just write `openagent.yaml`
|
|
|
141
141
|
|
|
142
142
|
---
|
|
143
143
|
|
|
144
|
+
## System prompt
|
|
145
|
+
|
|
146
|
+
OpenAgent injects a **framework-level system prompt** in front of your
|
|
147
|
+
user-defined `system_prompt` on every model call. It codifies guidelines
|
|
148
|
+
that apply to every deployment:
|
|
149
|
+
|
|
150
|
+
- How to use the memory vault (mcpvault tools, wikilinks, tags).
|
|
151
|
+
- Prefer MCP tools over `shell_exec` and ad-hoc scripts.
|
|
152
|
+
- Act autonomously under `permission_mode: bypass`; only stop for
|
|
153
|
+
ambiguous or irreversible actions.
|
|
154
|
+
- Be concise; lead with the answer, not the reasoning.
|
|
155
|
+
|
|
156
|
+
Your `system_prompt` in `openagent.yaml` should therefore stay **short
|
|
157
|
+
and project-specific** — just identity and a pointer to the memory
|
|
158
|
+
vault. Everything else (package names, host names, credentials,
|
|
159
|
+
procedures) belongs as `.md` notes inside the vault, where the agent
|
|
160
|
+
will find them via `search_notes`.
|
|
161
|
+
|
|
162
|
+
The framework prompt source lives at `openagent/prompts.py:FRAMEWORK_SYSTEM_PROMPT`
|
|
163
|
+
if you want to read or tweak the wording.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
144
167
|
## Memory
|
|
145
168
|
|
|
146
169
|
OpenAgent's long-term memory is a plain **Obsidian-compatible markdown vault**. The agent reads and writes `.md` files directly via the [`mcpvault`](https://www.npmjs.com/package/@bitbonsai/mcpvault) MCP — no custom index, no full-text engine, no proprietary format. The files on disk *are* the database.
|
|
@@ -8,6 +8,7 @@ from typing import Any, AsyncIterator, Callable, Awaitable
|
|
|
8
8
|
from openagent.models.base import BaseModel, ModelResponse
|
|
9
9
|
from openagent.memory.db import MemoryDB
|
|
10
10
|
from openagent.mcp.client import MCPRegistry, MCPTools
|
|
11
|
+
from openagent.prompts import FRAMEWORK_SYSTEM_PROMPT
|
|
11
12
|
|
|
12
13
|
logger = logging.getLogger(__name__)
|
|
13
14
|
|
|
@@ -192,7 +193,9 @@ class Agent:
|
|
|
192
193
|
"""Inner run logic, wrapped by run() for crash protection."""
|
|
193
194
|
await _status("Loading context...")
|
|
194
195
|
|
|
195
|
-
|
|
196
|
+
# Combine OpenAgent's framework-level guidelines with the user's
|
|
197
|
+
# project-specific system prompt from openagent.yaml.
|
|
198
|
+
system = self._combined_system_prompt()
|
|
196
199
|
|
|
197
200
|
# Build messages
|
|
198
201
|
if attachments:
|
|
@@ -244,6 +247,17 @@ class Agent:
|
|
|
244
247
|
|
|
245
248
|
return response.content if response else "I wasn't able to complete the request."
|
|
246
249
|
|
|
250
|
+
def _combined_system_prompt(self) -> str:
|
|
251
|
+
"""Prepend the framework-level prompt to the user's system prompt."""
|
|
252
|
+
user = (self.system_prompt or "").strip()
|
|
253
|
+
if not user:
|
|
254
|
+
return FRAMEWORK_SYSTEM_PROMPT
|
|
255
|
+
return (
|
|
256
|
+
FRAMEWORK_SYSTEM_PROMPT
|
|
257
|
+
+ "\n\n── User-specific identity and project context ──\n\n"
|
|
258
|
+
+ user
|
|
259
|
+
)
|
|
260
|
+
|
|
247
261
|
async def stream_run(
|
|
248
262
|
self,
|
|
249
263
|
message: str,
|
|
@@ -256,7 +270,7 @@ class Agent:
|
|
|
256
270
|
|
|
257
271
|
await self.initialize()
|
|
258
272
|
|
|
259
|
-
system = self.
|
|
273
|
+
system = self._combined_system_prompt()
|
|
260
274
|
messages: list[dict[str, Any]] = [{"role": "user", "content": message}]
|
|
261
275
|
|
|
262
276
|
async for chunk in self.model.stream(messages, system=system):
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from openagent.channels.base import (
|
|
2
|
+
Attachment,
|
|
3
|
+
BLOCKED_EXTENSIONS,
|
|
4
|
+
BaseChannel,
|
|
5
|
+
is_blocked_attachment,
|
|
6
|
+
parse_response_markers,
|
|
7
|
+
split_preserving_code_blocks,
|
|
8
|
+
)
|
|
9
|
+
from openagent.channels.commands import CommandDispatcher, CommandResult
|
|
10
|
+
from openagent.channels.queue import UserQueueManager
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Attachment",
|
|
14
|
+
"BLOCKED_EXTENSIONS",
|
|
15
|
+
"BaseChannel",
|
|
16
|
+
"CommandDispatcher",
|
|
17
|
+
"CommandResult",
|
|
18
|
+
"UserQueueManager",
|
|
19
|
+
"is_blocked_attachment",
|
|
20
|
+
"parse_response_markers",
|
|
21
|
+
"split_preserving_code_blocks",
|
|
22
|
+
]
|
|
@@ -74,6 +74,86 @@ def format_attachments_for_prompt(attachments: list[Attachment], caption: str =
|
|
|
74
74
|
return prefix
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
# File extensions we refuse to download from any channel for basic safety.
|
|
78
|
+
# Shell scripts are NOT on this list — an agent is expected to deal with them
|
|
79
|
+
# legitimately. This is about Windows executables and obviously malicious
|
|
80
|
+
# droppers, not about blocking all code.
|
|
81
|
+
BLOCKED_EXTENSIONS: frozenset[str] = frozenset({
|
|
82
|
+
".exe", ".bat", ".cmd", ".com", ".msi", ".scr", ".pif",
|
|
83
|
+
".vbs", ".vbe", ".jse", ".ws", ".wsf", ".wsh", ".ps1", ".hta",
|
|
84
|
+
".cpl", ".lnk", ".reg", ".jar",
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_blocked_attachment(filename: str | None) -> bool:
|
|
89
|
+
"""Return True if the filename has a blocked extension (case-insensitive)."""
|
|
90
|
+
if not filename:
|
|
91
|
+
return False
|
|
92
|
+
return Path(filename).suffix.lower() in BLOCKED_EXTENSIONS
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def split_preserving_code_blocks(text: str, max_len: int) -> list[str]:
|
|
96
|
+
"""Split *text* into chunks of ≤ ``max_len`` characters, preserving
|
|
97
|
+
fenced ``` code blocks so no chunk ends with a dangling fence.
|
|
98
|
+
|
|
99
|
+
Strategy:
|
|
100
|
+
|
|
101
|
+
1. Walk the text in windows of at most ``max_len`` chars, cutting on a
|
|
102
|
+
newline when possible.
|
|
103
|
+
2. For each chunk, count the unescaped ``` fences. If odd, close with a
|
|
104
|
+
trailing ``` and prepend ``` to the next chunk so the code style
|
|
105
|
+
carries over to the reader.
|
|
106
|
+
|
|
107
|
+
This loses the original language tag after a mid-block split — Discord
|
|
108
|
+
and Telegram render ``` (no lang) as a plain monospace block, which is
|
|
109
|
+
still the right thing for long output.
|
|
110
|
+
"""
|
|
111
|
+
if max_len <= 0:
|
|
112
|
+
return [text] if text else []
|
|
113
|
+
if len(text) <= max_len:
|
|
114
|
+
return [text] if text.strip() else []
|
|
115
|
+
|
|
116
|
+
chunks: list[str] = []
|
|
117
|
+
carry_prefix = ""
|
|
118
|
+
i = 0
|
|
119
|
+
n = len(text)
|
|
120
|
+
|
|
121
|
+
while i < n:
|
|
122
|
+
budget = max_len - len(carry_prefix)
|
|
123
|
+
if budget <= 16:
|
|
124
|
+
# carry_prefix too large vs max_len; fall back to hard cut
|
|
125
|
+
budget = max(max_len // 2, 16)
|
|
126
|
+
end = min(i + budget, n)
|
|
127
|
+
if end < n:
|
|
128
|
+
# prefer newline cut, then space, else hard cut
|
|
129
|
+
nl = text.rfind("\n", i, end)
|
|
130
|
+
if nl > i + budget // 4:
|
|
131
|
+
end = nl
|
|
132
|
+
else:
|
|
133
|
+
sp = text.rfind(" ", i, end)
|
|
134
|
+
if sp > i + budget // 4:
|
|
135
|
+
end = sp
|
|
136
|
+
|
|
137
|
+
body = text[i:end]
|
|
138
|
+
chunk = carry_prefix + body
|
|
139
|
+
|
|
140
|
+
fence_total = chunk.count("```")
|
|
141
|
+
if fence_total % 2 == 1:
|
|
142
|
+
chunk = chunk + "\n```"
|
|
143
|
+
carry_prefix = "```\n"
|
|
144
|
+
else:
|
|
145
|
+
carry_prefix = ""
|
|
146
|
+
|
|
147
|
+
if chunk.strip():
|
|
148
|
+
chunks.append(chunk)
|
|
149
|
+
|
|
150
|
+
i = end
|
|
151
|
+
while i < n and text[i] in ("\n", " "):
|
|
152
|
+
i += 1
|
|
153
|
+
|
|
154
|
+
return chunks
|
|
155
|
+
|
|
156
|
+
|
|
77
157
|
class BaseChannel(ABC):
|
|
78
158
|
"""Abstract base for messaging channels (Telegram, Discord, WhatsApp, etc.).
|
|
79
159
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Cross-channel slash command dispatcher.
|
|
2
|
+
|
|
3
|
+
All channels (Telegram, Discord, WhatsApp) share a single command
|
|
4
|
+
vocabulary via ``CommandDispatcher``. Commands are parsed with a leading
|
|
5
|
+
``/`` (e.g. ``/new``, ``/stop``, ``/status``, ``/queue clear``) and resolve
|
|
6
|
+
to a :class:`CommandResult` the channel renders in its native way.
|
|
7
|
+
|
|
8
|
+
The registered commands are:
|
|
9
|
+
|
|
10
|
+
- ``/new`` — reset the user's session (fresh context)
|
|
11
|
+
- ``/reset`` — alias of /new
|
|
12
|
+
- ``/stop`` — cancel the currently running task for the user
|
|
13
|
+
- ``/status`` — busy/idle + queue depth + session id tail
|
|
14
|
+
- ``/queue`` — show queue state; ``/queue clear`` empties it
|
|
15
|
+
- ``/help`` — list of commands
|
|
16
|
+
- ``/usage`` — Claude Code usage via ``ccusage`` (claude-cli backend only)
|
|
17
|
+
|
|
18
|
+
Unknown commands return ``None`` so the channel can either fall through to
|
|
19
|
+
normal message handling or reject them.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import logging
|
|
26
|
+
import shutil
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from openagent.agent import Agent
|
|
32
|
+
from openagent.channels.queue import UserQueueManager
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
HELP_TEXT = (
|
|
38
|
+
"Comandi disponibili:\n"
|
|
39
|
+
"• /new — nuova sessione (contesto fresco)\n"
|
|
40
|
+
"• /stop — ferma l'operazione in corso\n"
|
|
41
|
+
"• /status — stato (busy/idle + coda)\n"
|
|
42
|
+
"• /queue — messaggi in coda\n"
|
|
43
|
+
"• /queue clear — svuota la coda\n"
|
|
44
|
+
"• /usage — uso Claude Code (solo backend claude-cli)\n"
|
|
45
|
+
"• /help — questo messaggio"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class CommandResult:
|
|
51
|
+
"""Result of a command. Rendered verbatim by the channel."""
|
|
52
|
+
text: str
|
|
53
|
+
is_error: bool = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CommandDispatcher:
|
|
57
|
+
"""Parse and dispatch slash commands for a single channel instance."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, agent: Agent, queue: UserQueueManager):
|
|
60
|
+
self.agent = agent
|
|
61
|
+
self.queue = queue
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def is_command(text: str | None) -> bool:
|
|
65
|
+
return bool(text) and text.lstrip().startswith("/")
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def parse(text: str) -> tuple[str, str]:
|
|
69
|
+
"""Return (command, argument) stripped of the leading slash."""
|
|
70
|
+
body = text.lstrip()[1:]
|
|
71
|
+
parts = body.split(maxsplit=1)
|
|
72
|
+
cmd = parts[0].lower() if parts else ""
|
|
73
|
+
# Telegram-style @botname suffix: /new@mybot → /new
|
|
74
|
+
if "@" in cmd:
|
|
75
|
+
cmd = cmd.split("@", 1)[0]
|
|
76
|
+
arg = parts[1] if len(parts) > 1 else ""
|
|
77
|
+
return cmd, arg
|
|
78
|
+
|
|
79
|
+
async def dispatch(self, text: str, user_id: str) -> CommandResult | None:
|
|
80
|
+
"""Run a command. Returns None if the text isn't a known command."""
|
|
81
|
+
if not self.is_command(text):
|
|
82
|
+
return None
|
|
83
|
+
cmd, arg = self.parse(text)
|
|
84
|
+
method = getattr(self, f"cmd_{cmd.replace('-', '_')}", None)
|
|
85
|
+
if method is None:
|
|
86
|
+
return None
|
|
87
|
+
try:
|
|
88
|
+
return await method(arg, user_id)
|
|
89
|
+
except Exception as e: # noqa: BLE001
|
|
90
|
+
logger.exception("Command /%s failed", cmd)
|
|
91
|
+
return CommandResult(f"Errore eseguendo /{cmd}: {e}", is_error=True)
|
|
92
|
+
|
|
93
|
+
# ── commands ───────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
async def cmd_new(self, arg: str, user_id: str) -> CommandResult:
|
|
96
|
+
self.queue.reset_session(user_id)
|
|
97
|
+
return CommandResult("🆕 Nuova sessione avviata. Contesto precedente archiviato.")
|
|
98
|
+
|
|
99
|
+
async def cmd_reset(self, arg: str, user_id: str) -> CommandResult:
|
|
100
|
+
return await self.cmd_new(arg, user_id)
|
|
101
|
+
|
|
102
|
+
async def cmd_stop(self, arg: str, user_id: str) -> CommandResult:
|
|
103
|
+
stopped = self.queue.stop_current(user_id)
|
|
104
|
+
if stopped:
|
|
105
|
+
return CommandResult("⏹ Operazione in corso cancellata.")
|
|
106
|
+
return CommandResult("Nessuna operazione in corso.")
|
|
107
|
+
|
|
108
|
+
async def cmd_status(self, arg: str, user_id: str) -> CommandResult:
|
|
109
|
+
busy = self.queue.is_busy(user_id)
|
|
110
|
+
depth = self.queue.queue_depth(user_id)
|
|
111
|
+
sid = self.queue.get_session_id(user_id)
|
|
112
|
+
state = "🟢 busy" if busy else "⚪ idle"
|
|
113
|
+
lines = [
|
|
114
|
+
"Stato:",
|
|
115
|
+
f"• Agent: {self.agent.name}",
|
|
116
|
+
f"• Stato: {state}",
|
|
117
|
+
f"• In coda: {depth}",
|
|
118
|
+
f"• Sessione: …{sid[-8:]}",
|
|
119
|
+
]
|
|
120
|
+
return CommandResult("\n".join(lines))
|
|
121
|
+
|
|
122
|
+
async def cmd_queue(self, arg: str, user_id: str) -> CommandResult:
|
|
123
|
+
if arg.strip().lower() in {"clear", "clean", "reset"}:
|
|
124
|
+
n = self.queue.clear_queue(user_id)
|
|
125
|
+
return CommandResult(f"🧹 Coda svuotata ({n} messaggi rimossi).")
|
|
126
|
+
depth = self.queue.queue_depth(user_id)
|
|
127
|
+
busy = self.queue.is_busy(user_id)
|
|
128
|
+
if depth == 0 and not busy:
|
|
129
|
+
return CommandResult("Nessun messaggio in coda.")
|
|
130
|
+
tail = " (operazione in corso)" if busy else ""
|
|
131
|
+
return CommandResult(f"📋 In coda: {depth} messaggi{tail}")
|
|
132
|
+
|
|
133
|
+
async def cmd_help(self, arg: str, user_id: str) -> CommandResult:
|
|
134
|
+
return CommandResult(HELP_TEXT)
|
|
135
|
+
|
|
136
|
+
async def cmd_usage(self, arg: str, user_id: str) -> CommandResult:
|
|
137
|
+
from openagent.models.claude_cli import ClaudeCLI
|
|
138
|
+
if not isinstance(self.agent.model, ClaudeCLI):
|
|
139
|
+
return CommandResult(
|
|
140
|
+
"ℹ️ /usage funziona solo con il backend claude-cli.",
|
|
141
|
+
is_error=True,
|
|
142
|
+
)
|
|
143
|
+
npx = shutil.which("npx")
|
|
144
|
+
if not npx:
|
|
145
|
+
return CommandResult(
|
|
146
|
+
"❌ npx non trovato sul PATH (serve Node.js per ccusage).",
|
|
147
|
+
is_error=True,
|
|
148
|
+
)
|
|
149
|
+
try:
|
|
150
|
+
proc = await asyncio.create_subprocess_exec(
|
|
151
|
+
npx, "-y", "ccusage@latest",
|
|
152
|
+
stdout=asyncio.subprocess.PIPE,
|
|
153
|
+
stderr=asyncio.subprocess.PIPE,
|
|
154
|
+
)
|
|
155
|
+
try:
|
|
156
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=45)
|
|
157
|
+
except asyncio.TimeoutError:
|
|
158
|
+
proc.kill()
|
|
159
|
+
return CommandResult("❌ ccusage timeout (45s).", is_error=True)
|
|
160
|
+
except Exception as e: # noqa: BLE001
|
|
161
|
+
return CommandResult(f"❌ Impossibile lanciare ccusage: {e}", is_error=True)
|
|
162
|
+
|
|
163
|
+
output = stdout.decode(errors="replace").strip()
|
|
164
|
+
if not output:
|
|
165
|
+
output = stderr.decode(errors="replace").strip()
|
|
166
|
+
if not output:
|
|
167
|
+
return CommandResult("❌ ccusage non ha restituito output.", is_error=True)
|
|
168
|
+
# Keep it under 3900 chars to stay under Discord/Telegram limits when
|
|
169
|
+
# wrapped in a code fence.
|
|
170
|
+
if len(output) > 3800:
|
|
171
|
+
output = output[:3800] + "\n… (troncato)"
|
|
172
|
+
return CommandResult(f"```\n{output}\n```")
|