pcli-agent 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.
Files changed (130) hide show
  1. pcli/__init__.py +1 -0
  2. pcli/__main__.py +4 -0
  3. pcli/agent/__init__.py +0 -0
  4. pcli/agent/activity.py +116 -0
  5. pcli/agent/compaction.py +205 -0
  6. pcli/agent/context_pruning.py +88 -0
  7. pcli/agent/headless.py +209 -0
  8. pcli/agent/loop.py +442 -0
  9. pcli/agent/prompt.py +371 -0
  10. pcli/agent/runtime.py +240 -0
  11. pcli/browser/__init__.py +0 -0
  12. pcli/browser/session.py +135 -0
  13. pcli/cli.py +757 -0
  14. pcli/config/__init__.py +0 -0
  15. pcli/config/paths.py +95 -0
  16. pcli/config/settings.py +435 -0
  17. pcli/cost/__init__.py +0 -0
  18. pcli/cost/context.py +275 -0
  19. pcli/cost/context_detect.py +183 -0
  20. pcli/cost/pricing_table.py +141 -0
  21. pcli/cost/tracker.py +126 -0
  22. pcli/llm/__init__.py +0 -0
  23. pcli/llm/client.py +285 -0
  24. pcli/llm/errors.py +37 -0
  25. pcli/llm/models.py +100 -0
  26. pcli/llm/streaming.py +108 -0
  27. pcli/memory/__init__.py +0 -0
  28. pcli/memory/extraction.py +106 -0
  29. pcli/memory/models.py +103 -0
  30. pcli/memory/store.py +88 -0
  31. pcli/permissions/__init__.py +0 -0
  32. pcli/permissions/guardrails.py +219 -0
  33. pcli/permissions/manager.py +215 -0
  34. pcli/permissions/policy.py +70 -0
  35. pcli/sandbox/__init__.py +0 -0
  36. pcli/sandbox/base.py +50 -0
  37. pcli/sandbox/docker_backend.py +107 -0
  38. pcli/sandbox/limits.py +63 -0
  39. pcli/sandbox/null_backend.py +92 -0
  40. pcli/sandbox/selector.py +75 -0
  41. pcli/sandbox/subprocess_backend.py +376 -0
  42. pcli/scheduler/__init__.py +0 -0
  43. pcli/scheduler/daemon.py +194 -0
  44. pcli/scheduler/models.py +97 -0
  45. pcli/scheduler/runner.py +84 -0
  46. pcli/scheduler/store.py +75 -0
  47. pcli/scheduler/triggers.py +84 -0
  48. pcli/session/__init__.py +0 -0
  49. pcli/session/audit.py +122 -0
  50. pcli/session/directory_check.py +28 -0
  51. pcli/session/export.py +57 -0
  52. pcli/session/importer.py +92 -0
  53. pcli/session/models.py +168 -0
  54. pcli/session/store.py +127 -0
  55. pcli/telegram/__init__.py +0 -0
  56. pcli/telegram/bot.py +266 -0
  57. pcli/telegram/daemon.py +1197 -0
  58. pcli/telegram/permissions.py +131 -0
  59. pcli/telegram/sender.py +58 -0
  60. pcli/tools/__init__.py +0 -0
  61. pcli/tools/_nested_agent.py +204 -0
  62. pcli/tools/agent_tools.py +264 -0
  63. pcli/tools/agent_tools_store.py +69 -0
  64. pcli/tools/artifacts.py +47 -0
  65. pcli/tools/base.py +185 -0
  66. pcli/tools/builtin/__init__.py +0 -0
  67. pcli/tools/builtin/agent_tool_register_tool.py +100 -0
  68. pcli/tools/builtin/artifact_tool.py +212 -0
  69. pcli/tools/builtin/ask_tool.py +77 -0
  70. pcli/tools/builtin/browser_tool.py +253 -0
  71. pcli/tools/builtin/decision_tool.py +73 -0
  72. pcli/tools/builtin/describe_tool.py +389 -0
  73. pcli/tools/builtin/diff_tools.py +225 -0
  74. pcli/tools/builtin/fs_tools.py +371 -0
  75. pcli/tools/builtin/grep_tool.py +88 -0
  76. pcli/tools/builtin/memory_tool.py +108 -0
  77. pcli/tools/builtin/network_tools.py +107 -0
  78. pcli/tools/builtin/pip_tool.py +106 -0
  79. pcli/tools/builtin/shell_tool.py +240 -0
  80. pcli/tools/builtin/subagent_tool.py +146 -0
  81. pcli/tools/builtin/todo_tool.py +122 -0
  82. pcli/tools/builtin/toolbox_register_tool.py +76 -0
  83. pcli/tools/builtin/web_tools.py +322 -0
  84. pcli/tools/pydiscovery/__init__.py +0 -0
  85. pcli/tools/pydiscovery/cache.py +51 -0
  86. pcli/tools/pydiscovery/index.py +48 -0
  87. pcli/tools/pydiscovery/invoke.py +181 -0
  88. pcli/tools/pydiscovery/search.py +117 -0
  89. pcli/tools/registry.py +138 -0
  90. pcli/tools/toolbox/__init__.py +0 -0
  91. pcli/tools/toolbox/introspect.py +48 -0
  92. pcli/tools/toolbox/manager.py +336 -0
  93. pcli/tools/toolbox/plugin_base.py +51 -0
  94. pcli/tools/toolbox/plugins/__init__.py +6 -0
  95. pcli/tools/toolbox/plugins/httpd.py +99 -0
  96. pcli/tools/toolbox/plugins/kafka.py +162 -0
  97. pcli/tools/toolbox/plugins/kubectl.py +211 -0
  98. pcli/tools/toolbox/plugins/sge.py +146 -0
  99. pcli/tools/toolbox/store.py +65 -0
  100. pcli/tools/toolbox/synthesize.py +100 -0
  101. pcli/tui/__init__.py +0 -0
  102. pcli/tui/app.py +37 -0
  103. pcli/tui/screens/__init__.py +0 -0
  104. pcli/tui/screens/ask_question_modal.py +54 -0
  105. pcli/tui/screens/chat.py +2070 -0
  106. pcli/tui/screens/confirm_modal.py +39 -0
  107. pcli/tui/screens/models.py +43 -0
  108. pcli/tui/screens/permission_modal.py +71 -0
  109. pcli/tui/screens/sessions.py +162 -0
  110. pcli/tui/screens/subagent_activity_modal.py +71 -0
  111. pcli/tui/shell_passthrough.py +56 -0
  112. pcli/tui/styles/pcli.tcss +241 -0
  113. pcli/tui/themes.py +84 -0
  114. pcli/tui/widgets/__init__.py +0 -0
  115. pcli/tui/widgets/chat_input.py +240 -0
  116. pcli/tui/widgets/command_suggestions.py +33 -0
  117. pcli/tui/widgets/message_view.py +328 -0
  118. pcli/tui/widgets/paste_input.py +99 -0
  119. pcli/tui/widgets/paste_marker.py +69 -0
  120. pcli/tui/widgets/status_bar.py +133 -0
  121. pcli/tui/widgets/status_pane.py +58 -0
  122. pcli/util/__init__.py +0 -0
  123. pcli/util/ids.py +15 -0
  124. pcli/util/logging.py +18 -0
  125. pcli/util/text.py +10 -0
  126. pcli_agent-0.1.0.dist-info/METADATA +259 -0
  127. pcli_agent-0.1.0.dist-info/RECORD +130 -0
  128. pcli_agent-0.1.0.dist-info/WHEEL +4 -0
  129. pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  130. pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
pcli/session/models.py ADDED
@@ -0,0 +1,168 @@
1
+ """Persisted session data model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any, Literal
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from pcli.llm.models import ChatMessage, Role, ToolCall, Usage
11
+ from pcli.session.audit import AuditEntry
12
+ from pcli.util.ids import new_id
13
+
14
+ CURRENT_SCHEMA_VERSION = 1
15
+
16
+
17
+ def _utcnow() -> datetime:
18
+ return datetime.now(UTC)
19
+
20
+
21
+ class Message(BaseModel):
22
+ id: str = Field(default_factory=lambda: new_id("msg_"))
23
+ role: Role
24
+ content: str | None = None
25
+ tool_calls: list[ToolCall] | None = None
26
+ tool_call_id: str | None = None
27
+ name: str | None = None
28
+ created_at: datetime = Field(default_factory=_utcnow)
29
+ pruned_artifact_id: str | None = None
30
+ """Set when this tool-role message's content has been replaced with a
31
+ compact placeholder by agent/context_pruning.py's prune_old_tool_results
32
+ — the original full content was archived first (retrievable via
33
+ fetch_artifact), and this field is the idempotency guard preventing a
34
+ message from being re-archived/re-pruned on a later call."""
35
+
36
+ def to_chat_message(self) -> ChatMessage:
37
+ return ChatMessage(
38
+ role=self.role,
39
+ content=self.content,
40
+ tool_calls=self.tool_calls,
41
+ tool_call_id=self.tool_call_id,
42
+ name=self.name,
43
+ )
44
+
45
+ @classmethod
46
+ def from_chat_message(cls, message: ChatMessage) -> Message:
47
+ return cls(
48
+ role=message.role,
49
+ content=message.content,
50
+ tool_calls=message.tool_calls,
51
+ tool_call_id=message.tool_call_id,
52
+ name=message.name,
53
+ )
54
+
55
+
56
+ class ToolInvocation(BaseModel):
57
+ id: str = Field(default_factory=lambda: new_id("inv_"))
58
+ tool_name: str
59
+ arguments: dict[str, Any] = Field(default_factory=dict)
60
+ result_summary: str = ""
61
+ full_result_ref: str | None = None
62
+ """Blob filename (relative to the session's blobs/ dir) holding the full,
63
+ untruncated result, when it was too large to inline in result_summary."""
64
+ status: Literal["ok", "error", "denied"] = "ok"
65
+ backend: str | None = None
66
+ duration_ms: float | None = None
67
+ permission_decision: str | None = None
68
+ created_at: datetime = Field(default_factory=_utcnow)
69
+
70
+
71
+ class TurnCost(BaseModel):
72
+ turn_index: int
73
+ model: str
74
+ usage: Usage
75
+ cost_usd: float
76
+ estimated: bool = False
77
+ created_at: datetime = Field(default_factory=_utcnow)
78
+ source: Literal["main", "subagent", "compaction", "memory"] = "main"
79
+ """Which conversation this usage actually came from. cost/context.py's
80
+ current_context_usage/looks_like_context_ceiling need this to find the
81
+ last *main*-conversation entry specifically — a subagent's,
82
+ compaction's, or memory extraction's own usage is real spend (still
83
+ counted in SessionCost.total_tokens/session_total_usd) but reflects a
84
+ completely different, unrelated conversation's size, not how full the
85
+ main conversation's context window actually is. Defaults to "main" so a
86
+ session persisted before this field existed still validates and behaves
87
+ exactly as it did before (every entry was implicitly a main-turn entry
88
+ back then)."""
89
+
90
+
91
+ class SessionCost(BaseModel):
92
+ turns: list[TurnCost] = Field(default_factory=list)
93
+ session_total_usd: float = 0.0
94
+ total_tokens: int = 0
95
+
96
+
97
+ class PermissionGrant(BaseModel):
98
+ tool_name: str
99
+ argument_pattern: str | None = None
100
+ scope: Literal["session", "always"] = "session"
101
+ decision: Literal["allow", "deny"] = "allow"
102
+ created_at: datetime = Field(default_factory=_utcnow)
103
+
104
+
105
+ class TodoItem(BaseModel):
106
+ content: str
107
+ status: Literal["pending", "in_progress", "completed"] = "pending"
108
+
109
+
110
+ class Decision(BaseModel):
111
+ """One entry in the session's decision log (see tools/builtin/decision_tool.py).
112
+ Append-only audit trail — unlike TodoItem, an entry is never mutated once
113
+ recorded; a change of mind is a new entry, not an edit of the old one."""
114
+
115
+ decision: str
116
+ rationale: str
117
+ created_at: datetime = Field(default_factory=_utcnow)
118
+
119
+
120
+ class Session(BaseModel):
121
+ id: str = Field(default_factory=lambda: new_id("sess_"))
122
+ schema_version: int = CURRENT_SCHEMA_VERSION
123
+ created_at: datetime = Field(default_factory=_utcnow)
124
+ updated_at: datetime = Field(default_factory=_utcnow)
125
+ title: str | None = None
126
+ model: str = ""
127
+ gateway_base_url: str = ""
128
+ messages: list[Message] = Field(default_factory=list)
129
+ tool_invocations: list[ToolInvocation] = Field(default_factory=list)
130
+ cost: SessionCost = Field(default_factory=SessionCost)
131
+ permission_grants: list[PermissionGrant] = Field(default_factory=list)
132
+ todos: list[TodoItem] = Field(default_factory=list)
133
+ decisions: list[Decision] = Field(default_factory=list)
134
+ audit_log: list[AuditEntry] = Field(default_factory=list)
135
+ """Tamper-evident, hash-chained record of permission decisions and
136
+ tool calls - see session/audit.py. Empty unless Settings.
137
+ audit_mode_enabled was on when this session ran; append-only, same as
138
+ decisions/tool_invocations."""
139
+ metadata: dict[str, Any] = Field(default_factory=dict)
140
+ working_dir: str | None = None
141
+ """Where this session was created (Path.cwd() at the time) - resuming it
142
+ from a different directory means every relative path in its tool-call
143
+ history refers to a different set of files, so callers check this
144
+ against the current cwd before resuming (see session/directory_check.py)
145
+ and warn rather than silently proceeding. None for a session persisted
146
+ before this field existed, or one that was never tied to a real cwd
147
+ (e.g. some test fixtures) - never treated as a mismatch."""
148
+
149
+ def touch(self) -> None:
150
+ self.updated_at = _utcnow()
151
+
152
+ def derive_title(self) -> str:
153
+ if self.title:
154
+ return self.title
155
+ for message in self.messages:
156
+ if message.role == "user" and message.content:
157
+ text = message.content.strip().splitlines()[0]
158
+ return text[:60] + ("..." if len(text) > 60 else "")
159
+ return "New session"
160
+
161
+
162
+ class SessionIndexEntry(BaseModel):
163
+ id: str
164
+ title: str
165
+ updated_at: datetime
166
+ model: str
167
+ message_count: int
168
+ total_cost_usd: float
pcli/session/store.py ADDED
@@ -0,0 +1,127 @@
1
+ """On-disk CRUD for sessions, plus a fast index for listing without loading
2
+ every session file in full."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ from collections.abc import Iterable
9
+ from pathlib import Path
10
+
11
+ from pcli.config.paths import sessions_dir
12
+ from pcli.session.models import Session, SessionIndexEntry
13
+
14
+
15
+ def _atomic_write(path: Path, content: str) -> None:
16
+ path.parent.mkdir(parents=True, exist_ok=True)
17
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
18
+ tmp_path.write_text(content, encoding="utf-8")
19
+ os.replace(tmp_path, path)
20
+
21
+
22
+ class SessionNotFoundError(Exception):
23
+ pass
24
+
25
+
26
+ class SessionStore:
27
+ def __init__(self, base_dir: Path | None = None) -> None:
28
+ self._base_dir = base_dir or sessions_dir()
29
+ self._index_path = self._base_dir / "index.json"
30
+
31
+ def session_dir(self, session_id: str) -> Path:
32
+ return self._base_dir / session_id
33
+
34
+ def blobs_dir(self, session_id: str) -> Path:
35
+ return self.session_dir(session_id) / "blobs"
36
+
37
+ def _session_file(self, session_id: str) -> Path:
38
+ return self.session_dir(session_id) / "session.json"
39
+
40
+ def new_session(
41
+ self, *, model: str = "", gateway_base_url: str = "", working_dir: str | None = None
42
+ ) -> Session:
43
+ session = Session(model=model, gateway_base_url=gateway_base_url, working_dir=working_dir)
44
+ self.save(session)
45
+ return session
46
+
47
+ def save(self, session: Session) -> None:
48
+ session.touch()
49
+ _atomic_write(
50
+ self._session_file(session.id), session.model_dump_json(indent=2)
51
+ )
52
+ self._upsert_index(session)
53
+
54
+ def load(self, session_id: str) -> Session:
55
+ path = self._session_file(session_id)
56
+ if not path.exists():
57
+ raise SessionNotFoundError(session_id)
58
+ return Session.model_validate_json(path.read_text(encoding="utf-8"))
59
+
60
+ def delete(self, session_id: str) -> None:
61
+ import shutil
62
+
63
+ session_dir = self.session_dir(session_id)
64
+ if session_dir.exists():
65
+ shutil.rmtree(session_dir)
66
+ index = self._read_index()
67
+ index.pop(session_id, None)
68
+ self._write_index(index)
69
+
70
+ def list_index(self) -> list[SessionIndexEntry]:
71
+ index = self._read_index()
72
+ entries = [SessionIndexEntry.model_validate(v) for v in index.values()]
73
+ return sorted(entries, key=lambda e: e.updated_at, reverse=True)
74
+
75
+ def prune_empty_sessions(self, *, exclude_session_ids: Iterable[str] = ()) -> int:
76
+ """Deletes every persisted session with zero messages. new_session()
77
+ saves eagerly the moment a fresh session is created (before the
78
+ leading system prompt is even appended in ChatScreen.__init__), so
79
+ closing pcli without ever sending a message leaves a useless,
80
+ never-touched entry cluttering /sessions — this is the only way a
81
+ session ends up with message_count == 0, since messages are only
82
+ ever appended, never removed (even a turn that fails outright still
83
+ persists the system prompt + the user's message first). Returns how
84
+ many were pruned."""
85
+ exclude = set(exclude_session_ids)
86
+ pruned = 0
87
+ for entry in self.list_index():
88
+ if entry.message_count == 0 and entry.id not in exclude:
89
+ self.delete(entry.id)
90
+ pruned += 1
91
+ return pruned
92
+
93
+ def write_blob(self, session_id: str, invocation_id: str, content: str) -> str:
94
+ blob_name = f"{invocation_id}.txt"
95
+ self.write_blob_named(session_id, blob_name, content)
96
+ return blob_name
97
+
98
+ def write_blob_named(self, session_id: str, blob_name: str, content: str) -> None:
99
+ """Writes a blob under an exact filename (no '.txt' appended) — used
100
+ by the importer to restore blobs under their original blob_name."""
101
+ blob_path = self.blobs_dir(session_id) / blob_name
102
+ _atomic_write(blob_path, content)
103
+
104
+ def read_blob(self, session_id: str, blob_name: str) -> str:
105
+ blob_path = self.blobs_dir(session_id) / blob_name
106
+ return blob_path.read_text(encoding="utf-8")
107
+
108
+ def _read_index(self) -> dict[str, dict]:
109
+ if not self._index_path.exists():
110
+ return {}
111
+ return json.loads(self._index_path.read_text(encoding="utf-8"))
112
+
113
+ def _write_index(self, index: dict[str, dict]) -> None:
114
+ _atomic_write(self._index_path, json.dumps(index, indent=2, default=str))
115
+
116
+ def _upsert_index(self, session: Session) -> None:
117
+ index = self._read_index()
118
+ entry = SessionIndexEntry(
119
+ id=session.id,
120
+ title=session.derive_title(),
121
+ updated_at=session.updated_at,
122
+ model=session.model,
123
+ message_count=len(session.messages),
124
+ total_cost_usd=session.cost.session_total_usd,
125
+ )
126
+ index[session.id] = json.loads(entry.model_dump_json())
127
+ self._write_index(index)
File without changes
pcli/telegram/bot.py ADDED
@@ -0,0 +1,266 @@
1
+ """run_telegram_daemon: the actual python-telegram-bot wiring behind `pcli
2
+ telegram` (cli.py) - builds an Application, registers handlers that
3
+ forward to TelegramDaemon's public methods (daemon.py, where the real
4
+ business logic and its test coverage live), and runs until stopped.
5
+
6
+ python-telegram-bot (the optional "telegram" extra - see pyproject.toml)
7
+ is imported lazily inside this function, not at module level, so pcli
8
+ imports and runs fine with the extra not installed - same discipline as
9
+ browser/session.py's Playwright import and telegram/sender.py's own.
10
+
11
+ This module is deliberately thin glue around TelegramDaemon, in the same
12
+ spirit browser/session.py's _ensure_page is thin glue around Playwright's
13
+ own launch call - the business logic it wires up (message queueing,
14
+ authorization, permission correlation) is what's heavily unit tested
15
+ (test_telegram_daemon.py, test_telegram_permissions.py); this function's
16
+ own handler-registration/lifecycle wiring gets lighter, fake-PTB coverage
17
+ (test_telegram_bot.py) rather than an exhaustive one, since faithfully
18
+ reproducing PTB's full Application/Updater lifecycle in a fake would cost
19
+ far more than it would ever catch.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import logging
26
+ from collections.abc import Awaitable, Callable
27
+ from pathlib import Path
28
+
29
+ from pcli.agent.runtime import build_agent_runtime, build_permission_manager
30
+ from pcli.config.settings import Settings
31
+ from pcli.session.store import SessionStore
32
+ from pcli.telegram.daemon import TelegramDaemon
33
+ from pcli.telegram.sender import BotSender
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ async def notify_telegram(settings: Settings, text: str) -> None:
39
+ """A one-off message to the configured chat, independent of the full
40
+ daemon above - what `pcli run --notify-telegram` uses to report a
41
+ scheduled run's result without needing a whole Application/polling
42
+ loop just to send one outgoing message. Callers check
43
+ settings.is_telegram_configured() themselves first (see cli.py) - this
44
+ assumes both fields are already set."""
45
+ from telegram import Bot # type: ignore[import-not-found]
46
+
47
+ async with Bot(settings.telegram_bot_token) as bot:
48
+ await BotSender(bot).send_message(settings.telegram_chat_id, text)
49
+
50
+
51
+ async def run_telegram_daemon(
52
+ settings: Settings,
53
+ cwd: Path,
54
+ *,
55
+ on_ready: Callable[[str], None] = lambda _session_id: None,
56
+ stop_event: asyncio.Event | None = None,
57
+ ) -> None:
58
+ """Runs until `stop_event` is set (or the process receives Ctrl+C,
59
+ propagating as KeyboardInterrupt/CancelledError through the wait
60
+ below) - a fresh, internal Event by default, so a plain `await
61
+ run_telegram_daemon(settings, cwd)` in cli.py blocks forever, exactly
62
+ what a long-running daemon should do. Tests pass an already-set Event
63
+ to make this return promptly instead.
64
+
65
+ `on_ready(session_id)` fires once polling has actually started -
66
+ cli.py uses it to print a confirmation with something to reference
67
+ (`pcli --resume <session_id>` after the daemon stops)."""
68
+ from telegram import Update # type: ignore[import-not-found]
69
+ from telegram.ext import ( # type: ignore[import-not-found]
70
+ ApplicationBuilder,
71
+ CallbackQueryHandler,
72
+ CommandHandler,
73
+ ContextTypes,
74
+ MessageHandler,
75
+ filters,
76
+ )
77
+
78
+ runtime = await build_agent_runtime(settings, cwd, browser_headless=True)
79
+ store = SessionStore()
80
+ permission_manager = build_permission_manager(settings)
81
+
82
+ application = ApplicationBuilder().token(settings.telegram_bot_token).build()
83
+ sender = BotSender(application.bot)
84
+ daemon = TelegramDaemon(
85
+ settings=settings,
86
+ runtime=runtime,
87
+ permission_manager=permission_manager,
88
+ store=store,
89
+ cwd=cwd,
90
+ sender=sender,
91
+ )
92
+
93
+ async def on_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
94
+ chat = update.effective_chat
95
+ message = update.effective_message
96
+ if chat is not None and message is not None and message.text:
97
+ await daemon.handle_text(chat.id, message.text)
98
+
99
+ async def on_new_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
100
+ if update.effective_chat is not None:
101
+ await daemon.handle_new_command(update.effective_chat.id)
102
+
103
+ async def on_rename_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
104
+ chat = update.effective_chat
105
+ if chat is None:
106
+ return
107
+ arg = " ".join(context.args) if context.args else None
108
+ await daemon.handle_rename_command(chat.id, arg)
109
+
110
+ async def on_allowed_roots_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
111
+ chat = update.effective_chat
112
+ if chat is None:
113
+ return
114
+ rest = " ".join(context.args) if context.args else ""
115
+ await daemon.handle_allowed_roots_command(chat.id, rest)
116
+
117
+ async def on_memory_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
118
+ chat = update.effective_chat
119
+ if chat is None:
120
+ return
121
+ rest = " ".join(context.args) if context.args else ""
122
+ await daemon.handle_memory_command(chat.id, rest)
123
+
124
+ async def on_help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
125
+ if update.effective_chat is not None:
126
+ await daemon.handle_help_command(update.effective_chat.id)
127
+
128
+ async def on_toolbox_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
129
+ chat = update.effective_chat
130
+ if chat is None:
131
+ return
132
+ rest = " ".join(context.args) if context.args else ""
133
+ await daemon.handle_toolbox_command(chat.id, rest)
134
+
135
+ async def on_compact_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
136
+ if update.effective_chat is not None:
137
+ await daemon.handle_compact_command(update.effective_chat.id)
138
+
139
+ async def on_models_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
140
+ chat = update.effective_chat
141
+ if chat is None:
142
+ return
143
+ arg = " ".join(context.args) if context.args else None
144
+ await daemon.handle_models_command(chat.id, arg)
145
+
146
+ async def on_sessions_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
147
+ chat = update.effective_chat
148
+ if chat is None:
149
+ return
150
+ rest = " ".join(context.args) if context.args else ""
151
+ await daemon.handle_sessions_command(chat.id, rest)
152
+
153
+ async def on_export_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
154
+ chat = update.effective_chat
155
+ if chat is None:
156
+ return
157
+ arg = " ".join(context.args) if context.args else None
158
+ await daemon.handle_export_command(chat.id, arg)
159
+
160
+ async def on_plan_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
161
+ if update.effective_chat is not None:
162
+ await daemon.handle_plan_command(update.effective_chat.id)
163
+
164
+ async def on_build_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
165
+ if update.effective_chat is not None:
166
+ await daemon.handle_build_command(update.effective_chat.id)
167
+
168
+ async def on_unsupported_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
169
+ chat = update.effective_chat
170
+ message = update.effective_message
171
+ if chat is None or message is None or not message.text:
172
+ return
173
+ command = message.text.split()[0] if message.text.split() else message.text
174
+ await daemon.handle_unsupported_command(chat.id, command)
175
+
176
+ # Telegram bot commands can only contain [A-Za-z0-9_] - no hyphens - so
177
+ # every hyphenated TUI command name below is spelled with underscores
178
+ # here instead (e.g. /context-limit -> /context_limit). A single
179
+ # CommandHandler registered against all these names, routed by this one
180
+ # dict, instead of ten near-identical PTB handler registrations.
181
+ scalar_setting_commands: dict[str, Callable[[int, str | None], Awaitable[None]]] = {
182
+ "timeout": daemon.handle_timeout_command,
183
+ "temperature": daemon.handle_temperature_command,
184
+ "budget": daemon.handle_budget_command,
185
+ "context_limit": daemon.handle_context_limit_command,
186
+ "max_tool_iterations": daemon.handle_max_tool_iterations_command,
187
+ "artifact_threshold": daemon.handle_artifact_threshold_command,
188
+ "max_tool_calls_per_turn": daemon.handle_max_tool_calls_per_turn_command,
189
+ "max_tool_calls_per_minute": daemon.handle_max_tool_calls_per_minute_command,
190
+ "prune_tool_results": daemon.handle_prune_tool_results_command,
191
+ "max_response_tokens": daemon.handle_max_response_tokens_command,
192
+ }
193
+
194
+ async def on_scalar_setting_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
195
+ chat = update.effective_chat
196
+ message = update.effective_message
197
+ if chat is None or message is None or not message.text:
198
+ return
199
+ tokens = message.text.split()
200
+ if not tokens:
201
+ return
202
+ command = tokens[0][1:].partition("@")[0] # drop leading "/" and any "@botname" suffix
203
+ handler = scalar_setting_commands.get(command)
204
+ if handler is None:
205
+ return
206
+ arg = " ".join(context.args) if context.args else None
207
+ await handler(chat.id, arg)
208
+
209
+ async def on_callback_query(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
210
+ query = update.callback_query
211
+ if query is None:
212
+ return
213
+ await query.answer()
214
+ if query.data:
215
+ await daemon.handle_callback(query.data)
216
+ try:
217
+ # Best-effort - clears the buttons so a decision can't be
218
+ # pressed twice. Not fatal if the message is too old/already
219
+ # edited for Telegram to allow this.
220
+ await query.edit_message_reply_markup(reply_markup=None)
221
+ except Exception:
222
+ logger.debug("Could not clear the inline keyboard after a decision", exc_info=True)
223
+
224
+ application.add_handler(CommandHandler("new", on_new_command))
225
+ application.add_handler(CommandHandler("rename", on_rename_command))
226
+ application.add_handler(CommandHandler("allowed_roots", on_allowed_roots_command))
227
+ application.add_handler(CommandHandler("memory", on_memory_command))
228
+ application.add_handler(CommandHandler("help", on_help_command))
229
+ application.add_handler(CommandHandler("toolbox", on_toolbox_command))
230
+ application.add_handler(CommandHandler("compact", on_compact_command))
231
+ application.add_handler(CommandHandler("models", on_models_command))
232
+ application.add_handler(CommandHandler("sessions", on_sessions_command))
233
+ application.add_handler(CommandHandler("export", on_export_command))
234
+ application.add_handler(CommandHandler("plan", on_plan_command))
235
+ application.add_handler(CommandHandler("build", on_build_command))
236
+ application.add_handler(
237
+ CommandHandler(list(scalar_setting_commands), on_scalar_setting_command)
238
+ )
239
+ # Must come before the plain-text handler below and after the specific
240
+ # command handlers above: filters.COMMAND matches any /word-shaped
241
+ # message, not just registered ones, so without this anything not
242
+ # explicitly registered matched no handler at all and was silently
243
+ # dropped by PTB itself - never even reaching TelegramDaemon. See
244
+ # handle_unsupported_command's own docstring.
245
+ application.add_handler(MessageHandler(filters.COMMAND, on_unsupported_command))
246
+ application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, on_text))
247
+ application.add_handler(CallbackQueryHandler(on_callback_query))
248
+
249
+ worker = asyncio.create_task(daemon.run_forever())
250
+ try:
251
+ async with application:
252
+ await application.start()
253
+ await application.updater.start_polling()
254
+ on_ready(daemon.session_id)
255
+ wait_for = stop_event if stop_event is not None else asyncio.Event()
256
+ try:
257
+ await wait_for.wait()
258
+ except (KeyboardInterrupt, asyncio.CancelledError):
259
+ pass
260
+ finally:
261
+ await application.updater.stop()
262
+ await application.stop()
263
+ finally:
264
+ worker.cancel()
265
+ await runtime.client.aclose()
266
+ await runtime.browser_session.close()