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.
- pcli/__init__.py +1 -0
- pcli/__main__.py +4 -0
- pcli/agent/__init__.py +0 -0
- pcli/agent/activity.py +116 -0
- pcli/agent/compaction.py +205 -0
- pcli/agent/context_pruning.py +88 -0
- pcli/agent/headless.py +209 -0
- pcli/agent/loop.py +442 -0
- pcli/agent/prompt.py +371 -0
- pcli/agent/runtime.py +240 -0
- pcli/browser/__init__.py +0 -0
- pcli/browser/session.py +135 -0
- pcli/cli.py +757 -0
- pcli/config/__init__.py +0 -0
- pcli/config/paths.py +95 -0
- pcli/config/settings.py +435 -0
- pcli/cost/__init__.py +0 -0
- pcli/cost/context.py +275 -0
- pcli/cost/context_detect.py +183 -0
- pcli/cost/pricing_table.py +141 -0
- pcli/cost/tracker.py +126 -0
- pcli/llm/__init__.py +0 -0
- pcli/llm/client.py +285 -0
- pcli/llm/errors.py +37 -0
- pcli/llm/models.py +100 -0
- pcli/llm/streaming.py +108 -0
- pcli/memory/__init__.py +0 -0
- pcli/memory/extraction.py +106 -0
- pcli/memory/models.py +103 -0
- pcli/memory/store.py +88 -0
- pcli/permissions/__init__.py +0 -0
- pcli/permissions/guardrails.py +219 -0
- pcli/permissions/manager.py +215 -0
- pcli/permissions/policy.py +70 -0
- pcli/sandbox/__init__.py +0 -0
- pcli/sandbox/base.py +50 -0
- pcli/sandbox/docker_backend.py +107 -0
- pcli/sandbox/limits.py +63 -0
- pcli/sandbox/null_backend.py +92 -0
- pcli/sandbox/selector.py +75 -0
- pcli/sandbox/subprocess_backend.py +376 -0
- pcli/scheduler/__init__.py +0 -0
- pcli/scheduler/daemon.py +194 -0
- pcli/scheduler/models.py +97 -0
- pcli/scheduler/runner.py +84 -0
- pcli/scheduler/store.py +75 -0
- pcli/scheduler/triggers.py +84 -0
- pcli/session/__init__.py +0 -0
- pcli/session/audit.py +122 -0
- pcli/session/directory_check.py +28 -0
- pcli/session/export.py +57 -0
- pcli/session/importer.py +92 -0
- pcli/session/models.py +168 -0
- pcli/session/store.py +127 -0
- pcli/telegram/__init__.py +0 -0
- pcli/telegram/bot.py +266 -0
- pcli/telegram/daemon.py +1197 -0
- pcli/telegram/permissions.py +131 -0
- pcli/telegram/sender.py +58 -0
- pcli/tools/__init__.py +0 -0
- pcli/tools/_nested_agent.py +204 -0
- pcli/tools/agent_tools.py +264 -0
- pcli/tools/agent_tools_store.py +69 -0
- pcli/tools/artifacts.py +47 -0
- pcli/tools/base.py +185 -0
- pcli/tools/builtin/__init__.py +0 -0
- pcli/tools/builtin/agent_tool_register_tool.py +100 -0
- pcli/tools/builtin/artifact_tool.py +212 -0
- pcli/tools/builtin/ask_tool.py +77 -0
- pcli/tools/builtin/browser_tool.py +253 -0
- pcli/tools/builtin/decision_tool.py +73 -0
- pcli/tools/builtin/describe_tool.py +389 -0
- pcli/tools/builtin/diff_tools.py +225 -0
- pcli/tools/builtin/fs_tools.py +371 -0
- pcli/tools/builtin/grep_tool.py +88 -0
- pcli/tools/builtin/memory_tool.py +108 -0
- pcli/tools/builtin/network_tools.py +107 -0
- pcli/tools/builtin/pip_tool.py +106 -0
- pcli/tools/builtin/shell_tool.py +240 -0
- pcli/tools/builtin/subagent_tool.py +146 -0
- pcli/tools/builtin/todo_tool.py +122 -0
- pcli/tools/builtin/toolbox_register_tool.py +76 -0
- pcli/tools/builtin/web_tools.py +322 -0
- pcli/tools/pydiscovery/__init__.py +0 -0
- pcli/tools/pydiscovery/cache.py +51 -0
- pcli/tools/pydiscovery/index.py +48 -0
- pcli/tools/pydiscovery/invoke.py +181 -0
- pcli/tools/pydiscovery/search.py +117 -0
- pcli/tools/registry.py +138 -0
- pcli/tools/toolbox/__init__.py +0 -0
- pcli/tools/toolbox/introspect.py +48 -0
- pcli/tools/toolbox/manager.py +336 -0
- pcli/tools/toolbox/plugin_base.py +51 -0
- pcli/tools/toolbox/plugins/__init__.py +6 -0
- pcli/tools/toolbox/plugins/httpd.py +99 -0
- pcli/tools/toolbox/plugins/kafka.py +162 -0
- pcli/tools/toolbox/plugins/kubectl.py +211 -0
- pcli/tools/toolbox/plugins/sge.py +146 -0
- pcli/tools/toolbox/store.py +65 -0
- pcli/tools/toolbox/synthesize.py +100 -0
- pcli/tui/__init__.py +0 -0
- pcli/tui/app.py +37 -0
- pcli/tui/screens/__init__.py +0 -0
- pcli/tui/screens/ask_question_modal.py +54 -0
- pcli/tui/screens/chat.py +2070 -0
- pcli/tui/screens/confirm_modal.py +39 -0
- pcli/tui/screens/models.py +43 -0
- pcli/tui/screens/permission_modal.py +71 -0
- pcli/tui/screens/sessions.py +162 -0
- pcli/tui/screens/subagent_activity_modal.py +71 -0
- pcli/tui/shell_passthrough.py +56 -0
- pcli/tui/styles/pcli.tcss +241 -0
- pcli/tui/themes.py +84 -0
- pcli/tui/widgets/__init__.py +0 -0
- pcli/tui/widgets/chat_input.py +240 -0
- pcli/tui/widgets/command_suggestions.py +33 -0
- pcli/tui/widgets/message_view.py +328 -0
- pcli/tui/widgets/paste_input.py +99 -0
- pcli/tui/widgets/paste_marker.py +69 -0
- pcli/tui/widgets/status_bar.py +133 -0
- pcli/tui/widgets/status_pane.py +58 -0
- pcli/util/__init__.py +0 -0
- pcli/util/ids.py +15 -0
- pcli/util/logging.py +18 -0
- pcli/util/text.py +10 -0
- pcli_agent-0.1.0.dist-info/METADATA +259 -0
- pcli_agent-0.1.0.dist-info/RECORD +130 -0
- pcli_agent-0.1.0.dist-info/WHEEL +4 -0
- pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|