pascal-agent 0.3.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.
pascal/capability.py ADDED
@@ -0,0 +1,218 @@
1
+ """Capability tracking for Pascal's domain-level trust map."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, ClassVar, Literal
9
+
10
+
11
+ DEFAULT_DOMAINS: dict[str, float] = {
12
+ "email": 0.5,
13
+ "scheduling": 0.5,
14
+ "file_ops": 0.5,
15
+ "communication": 0.5,
16
+ "analysis": 0.5,
17
+ "planning": 0.5,
18
+ }
19
+
20
+ _CAPABILITY_SCHEMA = """
21
+ CREATE TABLE IF NOT EXISTS capability (
22
+ key TEXT PRIMARY KEY,
23
+ value TEXT NOT NULL,
24
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
25
+ );
26
+ """
27
+
28
+ _CAPABILITY_KEY = "trust_map"
29
+ _SUCCESS_DELTA = 0.02
30
+ _JUDGMENT_FAILURE_DELTA = 0.10
31
+ _NEUTRAL_TRUST = 0.5
32
+ _HIGH_ERROR_STREAK = 3
33
+ _VERY_HIGH_ERROR_STREAK = 5
34
+ _HIGH_ERROR_PENALTY = 0.15
35
+ _VERY_HIGH_ERROR_PENALTY = 0.25
36
+
37
+ Decision = Literal["go", "caution", "block"]
38
+
39
+
40
+ def infer_domain(action_type: str | None, tool_name: str | None) -> str:
41
+ """Infer a capability domain from an action and tool name."""
42
+ haystack = f"{action_type or ''} {tool_name or ''}".lower()
43
+
44
+ if _contains_any(haystack, ("email", "gmail", "mail", "inbox")):
45
+ return "email"
46
+ if _contains_any(haystack, ("schedule", "calendar", "meeting", "reminder", "cron")):
47
+ return "scheduling"
48
+ if _contains_any(haystack, ("file", "filesystem", "directory", "path", "read", "write", "edit")):
49
+ return "file_ops"
50
+ if _contains_any(haystack, ("telegram", "message", "chat", "notify", "slack", "discord", "sms")):
51
+ return "communication"
52
+ if _contains_any(haystack, ("plan", "todo", "task", "roadmap", "outline", "planner")):
53
+ return "planning"
54
+ return "analysis"
55
+
56
+
57
+ @dataclass
58
+ class TrustMap:
59
+ """Domain-specific trust scores with asymmetric learning."""
60
+
61
+ domains: dict[str, float] = field(default_factory=lambda: dict(DEFAULT_DOMAINS))
62
+
63
+ DOMAIN_NAMES: ClassVar[tuple[str, ...]] = tuple(DEFAULT_DOMAINS)
64
+
65
+ def __post_init__(self) -> None:
66
+ merged_domains = dict(DEFAULT_DOMAINS)
67
+ merged_domains.update(self.domains)
68
+ self.domains = {name: _clamp_score(score) for name, score in merged_domains.items()}
69
+
70
+ @property
71
+ def overall(self) -> float:
72
+ """Average domain score for backward compatibility."""
73
+ return sum(self.domains.values()) / len(self.domains)
74
+
75
+ def update_on_success(self, domain: str) -> float:
76
+ """Increase trust slowly after a successful action."""
77
+ key = _require_domain(domain)
78
+ self.domains[key] = _clamp_score(self.domains[key] + _SUCCESS_DELTA)
79
+ return self.domains[key]
80
+
81
+ def update_on_failure(self, domain: str, is_judgment_error: bool) -> float:
82
+ """Penalize only Pascal's judgment mistakes, not tool or env failures."""
83
+ key = _require_domain(domain)
84
+ if is_judgment_error:
85
+ self.domains[key] = _clamp_score(self.domains[key] - _JUDGMENT_FAILURE_DELTA)
86
+ return self.domains[key]
87
+
88
+ def to_dict(self) -> dict[str, Any]:
89
+ return {"domains": dict(self.domains)}
90
+
91
+ @classmethod
92
+ def from_dict(cls, data: dict[str, Any] | None) -> "TrustMap":
93
+ if not isinstance(data, dict):
94
+ return cls()
95
+ domains = data.get("domains")
96
+ return cls(
97
+ domains=domains if isinstance(domains, dict) else dict(DEFAULT_DOMAINS),
98
+ )
99
+
100
+ def save(self, conn: sqlite3.Connection) -> None:
101
+ """Persist the trust map as JSON in the capability table."""
102
+ _ensure_capability_table(conn)
103
+ payload = json.dumps(self.to_dict(), ensure_ascii=False, sort_keys=True)
104
+ conn.execute(
105
+ """INSERT INTO capability (key, value, updated_at)
106
+ VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
107
+ ON CONFLICT(key) DO UPDATE SET
108
+ value = excluded.value,
109
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')""",
110
+ (_CAPABILITY_KEY, payload),
111
+ )
112
+ conn.commit()
113
+
114
+ @classmethod
115
+ def load(cls, conn: sqlite3.Connection) -> "TrustMap":
116
+ """Load the persisted trust map or return defaults."""
117
+ _ensure_capability_table(conn)
118
+ row = conn.execute(
119
+ "SELECT value FROM capability WHERE key = ?",
120
+ (_CAPABILITY_KEY,),
121
+ ).fetchone()
122
+ if row is None:
123
+ return cls()
124
+ try:
125
+ return cls.from_dict(json.loads(row[0]))
126
+ except (json.JSONDecodeError, TypeError):
127
+ return cls()
128
+
129
+
130
+ def compute_threshold(
131
+ effect_level: str,
132
+ *,
133
+ trust_map: TrustMap | None = None,
134
+ domain: str | None = None,
135
+ recent_error_streak: int = 0,
136
+ ) -> Decision:
137
+ """Return a simple action gate from effect level, trust, and recent failures.
138
+
139
+ This heuristic intentionally ignores any LLM self-reported confidence.
140
+ """
141
+ level = _normalize_effect_level(effect_level)
142
+ if level == "E0":
143
+ return "go"
144
+ if level == "E5":
145
+ return "block"
146
+
147
+ readiness_score = _readiness_score(
148
+ trust_map=trust_map,
149
+ domain=domain,
150
+ recent_error_streak=recent_error_streak,
151
+ )
152
+
153
+ if level == "E1":
154
+ return "go" if readiness_score >= 0.3 else "caution"
155
+ if level == "E2":
156
+ return "go" if readiness_score >= 0.4 else "caution"
157
+ if level == "E3":
158
+ return "go" if readiness_score >= 0.6 else "caution"
159
+ if readiness_score < 0.4:
160
+ return "block"
161
+ return "caution"
162
+
163
+
164
+ def _ensure_capability_table(conn: sqlite3.Connection) -> None:
165
+ conn.execute(_CAPABILITY_SCHEMA)
166
+ conn.commit()
167
+
168
+
169
+ def _normalize_effect_level(effect_level: str) -> str:
170
+ if effect_level in {"E0", "E1", "E2", "E3", "E4", "E5"}:
171
+ return effect_level
172
+ return "E2"
173
+
174
+
175
+ def _readiness_score(
176
+ *,
177
+ trust_map: TrustMap | None,
178
+ domain: str | None,
179
+ recent_error_streak: int,
180
+ ) -> float:
181
+ trust_score = _domain_trust(trust_map, domain)
182
+ penalty = _error_streak_penalty(recent_error_streak)
183
+ return _clamp_score(trust_score - penalty)
184
+
185
+
186
+ def _domain_trust(trust_map: TrustMap | None, domain: str | None) -> float:
187
+ if trust_map is None:
188
+ return _NEUTRAL_TRUST
189
+ if domain is not None and domain in trust_map.domains:
190
+ return trust_map.domains[domain]
191
+ return trust_map.overall
192
+
193
+
194
+ def _error_streak_penalty(recent_error_streak: int) -> float:
195
+ streak = max(0, int(recent_error_streak))
196
+ if streak >= _VERY_HIGH_ERROR_STREAK:
197
+ return _VERY_HIGH_ERROR_PENALTY
198
+ if streak >= _HIGH_ERROR_STREAK:
199
+ return _HIGH_ERROR_PENALTY
200
+ return 0.0
201
+
202
+
203
+ def _require_domain(domain: str) -> str:
204
+ if domain not in DEFAULT_DOMAINS:
205
+ raise KeyError(f"Unknown capability domain: {domain}")
206
+ return domain
207
+
208
+
209
+ def _contains_any(text: str, needles: tuple[str, ...]) -> bool:
210
+ return any(needle in text for needle in needles)
211
+
212
+
213
+ def _clamp_score(value: Any) -> float:
214
+ try:
215
+ number = float(value)
216
+ except (TypeError, ValueError):
217
+ number = 0.0
218
+ return round(min(1.0, max(0.0, number)), 6)
File without changes
@@ -0,0 +1,108 @@
1
+ """Telegram channel adapter -- aiogram 3.x, DM-only, single user."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def load_telegram_config() -> dict[str, Any]:
13
+ """Load bot_token and owner_chat_id from ~/.pascal/telegram.json."""
14
+ config_path = Path.home() / ".pascal" / "telegram.json"
15
+ if not config_path.exists():
16
+ raise FileNotFoundError(
17
+ f"Telegram config not found at {config_path}. "
18
+ 'Create it with: {"bot_token": "...", "owner_chat_id": 12345}'
19
+ )
20
+ return json.loads(config_path.read_text(encoding="utf-8"))
21
+
22
+
23
+ def create_telegram_bot(store, wake_event, config: dict[str, Any]):
24
+ """Create aiogram Bot + Dispatcher. Returns (bot, start_coro, send_approval, owner_chat_id).
25
+
26
+ start_coro is an async function that runs the long poll loop.
27
+ send_approval is an async function for approval request buttons.
28
+ """
29
+ from aiogram import Bot, Dispatcher, Router, F
30
+ from aiogram.types import (
31
+ Message, CallbackQuery,
32
+ InlineKeyboardMarkup, InlineKeyboardButton,
33
+ )
34
+
35
+ bot_token = config["bot_token"]
36
+ owner_chat_id = int(config["owner_chat_id"])
37
+
38
+ bot = Bot(token=bot_token)
39
+ dp = Dispatcher()
40
+ router = Router()
41
+ dp.include_router(router)
42
+
43
+ @router.message()
44
+ async def on_message(message: Message):
45
+ if message.chat.id != owner_chat_id:
46
+ return
47
+ if not message.text:
48
+ return
49
+ # Immediate ack -- user knows we received it
50
+ try:
51
+ from aiogram.types import ReactionTypeEmoji
52
+ await message.react([ReactionTypeEmoji(emoji="👀")])
53
+ except Exception:
54
+ pass
55
+ store.push_conversation_turn("telegram", "user", message.text)
56
+ store.push_notification(
57
+ source="telegram",
58
+ message=message.text,
59
+ priority="normal",
60
+ metadata={
61
+ "chat_id": str(message.chat.id),
62
+ "message_id": str(message.message_id),
63
+ "user": message.from_user.full_name if message.from_user else "unknown",
64
+ },
65
+ )
66
+ wake_event.set()
67
+ logger.info("Telegram message -> notification: %s", message.text[:80])
68
+
69
+ @router.callback_query(F.data.startswith("approve:") | F.data.startswith("deny:"))
70
+ async def on_approval(callback: CallbackQuery):
71
+ if callback.message and callback.message.chat.id != owner_chat_id:
72
+ return
73
+ parts = (callback.data or "").split(":", 1)
74
+ if len(parts) != 2:
75
+ return
76
+ action_type, task_id = parts
77
+ approved = action_type == "approve"
78
+ store.push_notification(
79
+ source="approval",
80
+ message=f"{'approved' if approved else 'denied'}: {task_id}",
81
+ priority="urgent",
82
+ metadata={"task_id": task_id, "approved": approved},
83
+ )
84
+ await callback.answer("Approved!" if approved else "Denied.")
85
+ try:
86
+ msg = callback.message
87
+ if msg is not None and hasattr(msg, "edit_reply_markup"):
88
+ await msg.edit_reply_markup(reply_markup=None)
89
+ except Exception:
90
+ pass
91
+ wake_event.set()
92
+ logger.info("Approval %s for task %s", action_type, task_id)
93
+
94
+ async def send_approval_request(chat_id: int, task_id: str, question: str):
95
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[[
96
+ InlineKeyboardButton(text="Approve", callback_data=f"approve:{task_id}"),
97
+ InlineKeyboardButton(text="Deny", callback_data=f"deny:{task_id}"),
98
+ ]])
99
+ await bot.send_message(chat_id, f"Approval needed:\n{question}", reply_markup=keyboard)
100
+
101
+ async def start_polling():
102
+ logger.info("Telegram bot starting (owner: %d)", owner_chat_id)
103
+ try:
104
+ await dp.start_polling(bot, handle_signals=False)
105
+ finally:
106
+ await bot.session.close()
107
+
108
+ return bot, start_polling, send_approval_request, owner_chat_id
pascal/clipboard.py ADDED
@@ -0,0 +1,38 @@
1
+ """Clipboard helper -- text-only get/set via pywin32 with pyperclip fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def get_text() -> str:
7
+ """Read text from clipboard."""
8
+ try:
9
+ import win32clipboard
10
+ except ImportError:
11
+ import pyperclip
12
+ return str(pyperclip.paste() or "")
13
+
14
+ win32clipboard.OpenClipboard()
15
+ try:
16
+ if win32clipboard.IsClipboardFormatAvailable(win32clipboard.CF_UNICODETEXT):
17
+ data = win32clipboard.GetClipboardData(win32clipboard.CF_UNICODETEXT)
18
+ return str(data) if data else ""
19
+ return ""
20
+ finally:
21
+ win32clipboard.CloseClipboard()
22
+
23
+
24
+ def set_text(text: str) -> None:
25
+ """Write text to clipboard."""
26
+ try:
27
+ import win32clipboard
28
+ except ImportError:
29
+ import pyperclip
30
+ pyperclip.copy(text)
31
+ return
32
+
33
+ win32clipboard.OpenClipboard()
34
+ try:
35
+ win32clipboard.EmptyClipboard()
36
+ win32clipboard.SetClipboardText(text, win32clipboard.CF_UNICODETEXT)
37
+ finally:
38
+ win32clipboard.CloseClipboard()
pascal/config.py ADDED
@@ -0,0 +1,134 @@
1
+ """pascal/config.py -- PascalConfig 로드 (환경변수 + 기본값)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from pathlib import Path
8
+
9
+ from pascal.types import PascalConfig
10
+
11
+
12
+ def _load_dotenv() -> None:
13
+ """Load ~/.pascal/.env into os.environ (won't overwrite existing vars)."""
14
+ env_path = Path.home() / ".pascal" / ".env"
15
+ if not env_path.is_file():
16
+ return
17
+ try:
18
+ for line in env_path.read_text(encoding="utf-8").splitlines():
19
+ line = line.strip()
20
+ if not line or line.startswith("#") or "=" not in line:
21
+ continue
22
+ key, _, value = line.partition("=")
23
+ key = key.strip()
24
+ value = value.strip().strip("'\"")
25
+ if key and key not in os.environ: # don't overwrite
26
+ os.environ[key] = value
27
+ except OSError:
28
+ pass
29
+
30
+
31
+ # Auto-load on import
32
+ _load_dotenv()
33
+
34
+ _ENV_MAP: dict[str, tuple[str, type]] = {
35
+ "model": ("PASCAL_MODEL", str),
36
+ "provider": ("PASCAL_PROVIDER", str),
37
+ "base_url": ("PASCAL_BASE_URL", str),
38
+ "db_path": ("PASCAL_DB_PATH", str),
39
+ "max_effect": ("PASCAL_MAX_EFFECT", str),
40
+ }
41
+
42
+ _EXTRA_ENV_MAP: dict[str, tuple[str, type]] = {
43
+ "max_inner_turns": ("PASCAL_MAX_INNER_TURNS", int),
44
+ "max_tool_rounds": ("PASCAL_MAX_TOOL_ROUNDS", int),
45
+ }
46
+
47
+
48
+ def _config_paths() -> list[Path]:
49
+ seen: set[Path] = set()
50
+ paths: list[Path] = []
51
+ for path in (Path.cwd() / "pascal.toml", Path.home() / ".pascal" / "pascal.toml"):
52
+ try:
53
+ key = path.resolve()
54
+ except OSError:
55
+ key = path.absolute()
56
+ if key in seen:
57
+ continue
58
+ seen.add(key)
59
+ paths.append(path)
60
+ return paths
61
+
62
+
63
+ def _load_toml_config() -> dict[str, object]:
64
+ for path in _config_paths():
65
+ if not path.is_file():
66
+ continue
67
+ with path.open("rb") as f:
68
+ payload = tomllib.load(f)
69
+ section = payload.get("pascal")
70
+ if isinstance(section, dict):
71
+ return section
72
+ return {}
73
+
74
+
75
+ def _load_typed_values(raw: dict[str, object], mapping: dict[str, tuple[str, type]]) -> dict[str, object]:
76
+ values: dict[str, object] = {}
77
+ for field_name, (_, conv) in mapping.items():
78
+ if field_name not in raw or raw[field_name] is None:
79
+ continue
80
+ values[field_name] = conv(raw[field_name])
81
+ return values
82
+
83
+
84
+ def _resolve_extra_value(
85
+ name: str,
86
+ *,
87
+ toml_values: dict[str, object],
88
+ overrides: dict[str, object],
89
+ ) -> int | None:
90
+ value = toml_values.get(name)
91
+ env_key, conv = _EXTRA_ENV_MAP[name]
92
+ env_val = os.environ.get(env_key)
93
+ if env_val is not None:
94
+ value = conv(env_val)
95
+ if name in overrides and overrides[name] is not None:
96
+ value = overrides[name]
97
+ if value is None:
98
+ return None
99
+ return max(1, int(str(value)))
100
+
101
+
102
+ def load_config(**overrides) -> PascalConfig:
103
+ """PascalConfig를 생성한다. 우선순위: overrides > 환경변수 > pascal.toml > 기본값."""
104
+ toml_values = _load_toml_config()
105
+ kwargs: dict[str, object] = _load_typed_values(toml_values, _ENV_MAP)
106
+ for field_name, (env_key, conv) in _ENV_MAP.items():
107
+ env_val = os.environ.get(env_key)
108
+ if env_val is not None:
109
+ kwargs[field_name] = conv(env_val)
110
+ kwargs.update({k: v for k, v in overrides.items() if k in _ENV_MAP and v is not None})
111
+
112
+ config = PascalConfig(**kwargs) # type: ignore[arg-type] # kwargs built from validated ENV_MAP
113
+ max_inner = _resolve_extra_value("max_inner_turns", toml_values=toml_values, overrides=overrides)
114
+ if max_inner is not None:
115
+ config.max_inner_turns = max_inner
116
+ max_tool = _resolve_extra_value("max_tool_rounds", toml_values=toml_values, overrides=overrides)
117
+ if max_tool is not None:
118
+ config.max_tool_rounds = max_tool
119
+ return config
120
+
121
+
122
+ def create_llm(config: PascalConfig):
123
+ """Create an LLM provider from config. Avoids duplication between __main__ and daemon."""
124
+ if config.provider == "codex":
125
+ from pascal.llm.codex import CodexProvider
126
+ return CodexProvider(model=config.model)
127
+ elif config.provider == "openai":
128
+ from pascal.llm.openai import OpenAIProvider
129
+ return OpenAIProvider(model=config.model, base_url=config.base_url)
130
+ elif config.provider == "anthropic":
131
+ from pascal.llm.anthropic import AnthropicProvider
132
+ return AnthropicProvider(model=config.model, base_url=config.base_url)
133
+ else:
134
+ raise ValueError(f"Unknown provider: {config.provider}")