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/__init__.py +3 -0
- pascal/__main__.py +880 -0
- pascal/actions.py +1066 -0
- pascal/capability.py +218 -0
- pascal/channels/__init__.py +0 -0
- pascal/channels/telegram.py +108 -0
- pascal/clipboard.py +38 -0
- pascal/config.py +134 -0
- pascal/daemon.py +211 -0
- pascal/desk.py +633 -0
- pascal/effect.py +155 -0
- pascal/eval/__init__.py +1 -0
- pascal/eval/smoke.py +213 -0
- pascal/llm/__init__.py +1 -0
- pascal/llm/anthropic.py +225 -0
- pascal/llm/codex.py +331 -0
- pascal/llm/openai.py +224 -0
- pascal/loop.py +1037 -0
- pascal/mcp.py +206 -0
- pascal/prompt.py +141 -0
- pascal/receipts.py +147 -0
- pascal/sandbox.py +287 -0
- pascal/scheduler.py +243 -0
- pascal/schemas.py +183 -0
- pascal/state.py +790 -0
- pascal/tools.py +672 -0
- pascal/trust.py +150 -0
- pascal/types.py +337 -0
- pascal/uia.py +316 -0
- pascal_agent-0.3.0.dist-info/METADATA +262 -0
- pascal_agent-0.3.0.dist-info/RECORD +33 -0
- pascal_agent-0.3.0.dist-info/WHEEL +4 -0
- pascal_agent-0.3.0.dist-info/entry_points.txt +2 -0
pascal/uia.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Windows UI Automation adapter -- access native app controls via Accessibility API.
|
|
2
|
+
|
|
3
|
+
Uses pywinauto with UIA backend. Falls back gracefully on non-Windows platforms.
|
|
4
|
+
Ref system: each snapshot assigns short IDs (e1, e2, ...) to controls so the LLM
|
|
5
|
+
can refer to them in subsequent actions without needing coordinates.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class UIElement:
|
|
20
|
+
"""Lightweight representation of a UIA control for LLM consumption."""
|
|
21
|
+
ref: str
|
|
22
|
+
control_type: str
|
|
23
|
+
name: str
|
|
24
|
+
value: str
|
|
25
|
+
is_enabled: bool
|
|
26
|
+
is_focused: bool
|
|
27
|
+
|
|
28
|
+
def to_text(self, indent: int = 0) -> str:
|
|
29
|
+
prefix = " " * indent
|
|
30
|
+
parts = [f"{prefix}[{self.ref}] {self.control_type}"]
|
|
31
|
+
if self.name:
|
|
32
|
+
parts.append(f"'{self.name}'")
|
|
33
|
+
if self.value:
|
|
34
|
+
display_val = self.value[:100] + ("..." if len(self.value) > 100 else "")
|
|
35
|
+
parts.append(f'value="{display_val}"')
|
|
36
|
+
flags = []
|
|
37
|
+
if self.is_focused:
|
|
38
|
+
flags.append("focused")
|
|
39
|
+
if not self.is_enabled:
|
|
40
|
+
flags.append("disabled")
|
|
41
|
+
if flags:
|
|
42
|
+
parts.append(f"[{', '.join(flags)}]")
|
|
43
|
+
return " ".join(parts)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_INTERACTIVE_TYPES = frozenset({
|
|
47
|
+
"Button", "Edit", "MenuItem", "ListItem", "ComboBox",
|
|
48
|
+
"CheckBox", "RadioButton", "Hyperlink", "Slider", "Spinner",
|
|
49
|
+
"Tab", "TabItem", "TreeItem", "DataItem",
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class UIAutomationAdapter:
|
|
54
|
+
"""Windows UI Automation API wrapper using pywinauto.
|
|
55
|
+
|
|
56
|
+
Public methods (7): snapshot, find, click, type_text, get_text, focus_window, wait_for.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
self._ref_map: dict[str, Any] = {}
|
|
61
|
+
self._ref_counter: int = 0
|
|
62
|
+
self._desktop = None
|
|
63
|
+
|
|
64
|
+
def _get_desktop(self):
|
|
65
|
+
if self._desktop is None:
|
|
66
|
+
from pywinauto import Desktop
|
|
67
|
+
self._desktop = Desktop(backend="uia")
|
|
68
|
+
return self._desktop
|
|
69
|
+
|
|
70
|
+
# -- Exploration -----------------------------------------------
|
|
71
|
+
|
|
72
|
+
def snapshot(
|
|
73
|
+
self,
|
|
74
|
+
window_title: str | None = None,
|
|
75
|
+
*,
|
|
76
|
+
max_depth: int = 3,
|
|
77
|
+
interactive_only: bool = False,
|
|
78
|
+
) -> list[UIElement]:
|
|
79
|
+
"""Take an accessibility tree snapshot of a window."""
|
|
80
|
+
self.clear_refs()
|
|
81
|
+
win = self._find_window(window_title=window_title)
|
|
82
|
+
if win is None:
|
|
83
|
+
return []
|
|
84
|
+
elements: list[UIElement] = []
|
|
85
|
+
self._walk(win, elements, depth=0, max_depth=max_depth,
|
|
86
|
+
interactive_only=interactive_only)
|
|
87
|
+
return elements
|
|
88
|
+
|
|
89
|
+
def find(
|
|
90
|
+
self,
|
|
91
|
+
*,
|
|
92
|
+
name: str | None = None,
|
|
93
|
+
control_type: str | None = None,
|
|
94
|
+
window_title: str | None = None,
|
|
95
|
+
) -> list[UIElement]:
|
|
96
|
+
"""Search for controls matching criteria (AND combination)."""
|
|
97
|
+
win = self._find_window(window_title=window_title)
|
|
98
|
+
if win is None:
|
|
99
|
+
if window_title:
|
|
100
|
+
return []
|
|
101
|
+
desktop = self._get_desktop()
|
|
102
|
+
results: list[UIElement] = []
|
|
103
|
+
for w in desktop.windows():
|
|
104
|
+
try:
|
|
105
|
+
if not w.is_visible():
|
|
106
|
+
continue
|
|
107
|
+
results.extend(self._search_window(w, name=name, control_type=control_type))
|
|
108
|
+
except Exception:
|
|
109
|
+
continue
|
|
110
|
+
return results
|
|
111
|
+
return self._search_window(win, name=name, control_type=control_type)
|
|
112
|
+
|
|
113
|
+
# -- Actions ---------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def click(self, ref: str) -> dict:
|
|
116
|
+
"""Click a control by ref ID."""
|
|
117
|
+
wrapper = self._resolve_ref(ref)
|
|
118
|
+
try:
|
|
119
|
+
wrapper.click_input()
|
|
120
|
+
return {"ok": True, "action": "click", "ref": ref}
|
|
121
|
+
except Exception as e:
|
|
122
|
+
return {"ok": False, "error": str(e), "ref": ref}
|
|
123
|
+
|
|
124
|
+
def type_text(self, ref: str, text: str, *, clear_first: bool = False) -> dict:
|
|
125
|
+
"""Type text into an Edit control."""
|
|
126
|
+
wrapper = self._resolve_ref(ref)
|
|
127
|
+
try:
|
|
128
|
+
if clear_first:
|
|
129
|
+
wrapper.set_edit_text("")
|
|
130
|
+
wrapper.type_keys(text, with_spaces=True, pause=0.02)
|
|
131
|
+
return {"ok": True, "action": "type_text", "ref": ref, "chars": len(text)}
|
|
132
|
+
except Exception as e:
|
|
133
|
+
return {"ok": False, "error": str(e), "ref": ref}
|
|
134
|
+
|
|
135
|
+
def get_text(self, ref: str) -> str:
|
|
136
|
+
"""Get the current text value of a control."""
|
|
137
|
+
wrapper = self._resolve_ref(ref)
|
|
138
|
+
try:
|
|
139
|
+
for method in ("window_text", "get_value", "texts"):
|
|
140
|
+
try:
|
|
141
|
+
fn = getattr(wrapper, method, None)
|
|
142
|
+
if fn is None:
|
|
143
|
+
continue
|
|
144
|
+
result = fn()
|
|
145
|
+
if isinstance(result, list):
|
|
146
|
+
return "\n".join(str(t) for t in result if t)
|
|
147
|
+
if result:
|
|
148
|
+
return str(result)
|
|
149
|
+
except Exception:
|
|
150
|
+
continue
|
|
151
|
+
return ""
|
|
152
|
+
except Exception:
|
|
153
|
+
return ""
|
|
154
|
+
|
|
155
|
+
# -- Window management -----------------------------------------
|
|
156
|
+
|
|
157
|
+
def focus_window(self, window_title: str | None = None) -> dict:
|
|
158
|
+
"""Bring a window to the foreground."""
|
|
159
|
+
win = self._find_window(window_title=window_title)
|
|
160
|
+
if win is None:
|
|
161
|
+
return {"ok": False, "error": f"Window not found: {window_title}"}
|
|
162
|
+
try:
|
|
163
|
+
win.set_focus()
|
|
164
|
+
return {"ok": True, "action": "focus_window", "title": win.window_text()}
|
|
165
|
+
except Exception as e:
|
|
166
|
+
return {"ok": False, "error": str(e)}
|
|
167
|
+
|
|
168
|
+
def wait_for(
|
|
169
|
+
self,
|
|
170
|
+
*,
|
|
171
|
+
name: str | None = None,
|
|
172
|
+
control_type: str | None = None,
|
|
173
|
+
window_title: str | None = None,
|
|
174
|
+
timeout: float = 10.0,
|
|
175
|
+
) -> UIElement | None:
|
|
176
|
+
"""Wait for a control to appear. For dialog popup handling."""
|
|
177
|
+
deadline = time.monotonic() + timeout
|
|
178
|
+
while time.monotonic() < deadline:
|
|
179
|
+
results = self.find(name=name, control_type=control_type, window_title=window_title)
|
|
180
|
+
if results:
|
|
181
|
+
return results[0]
|
|
182
|
+
time.sleep(0.3)
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
# -- Internal --------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def _assign_ref(self, wrapper) -> str:
|
|
188
|
+
self._ref_counter += 1
|
|
189
|
+
ref = f"e{self._ref_counter}"
|
|
190
|
+
self._ref_map[ref] = wrapper
|
|
191
|
+
return ref
|
|
192
|
+
|
|
193
|
+
def _resolve_ref(self, ref: str):
|
|
194
|
+
if ref not in self._ref_map:
|
|
195
|
+
raise KeyError(f"Unknown ref: {ref}. Run snapshot first.")
|
|
196
|
+
wrapper = self._ref_map[ref]
|
|
197
|
+
try:
|
|
198
|
+
wrapper.is_enabled()
|
|
199
|
+
except Exception:
|
|
200
|
+
raise KeyError(f"Stale ref: {ref}. Run snapshot again.")
|
|
201
|
+
return wrapper
|
|
202
|
+
|
|
203
|
+
def clear_refs(self) -> None:
|
|
204
|
+
self._ref_map.clear()
|
|
205
|
+
self._ref_counter = 0
|
|
206
|
+
|
|
207
|
+
def _find_window(self, window_title: str | None = None):
|
|
208
|
+
desktop = self._get_desktop()
|
|
209
|
+
if window_title:
|
|
210
|
+
title_lower = window_title.lower()
|
|
211
|
+
for win in desktop.windows():
|
|
212
|
+
try:
|
|
213
|
+
wt = win.window_text() or ""
|
|
214
|
+
if title_lower in wt.lower():
|
|
215
|
+
return win
|
|
216
|
+
except Exception:
|
|
217
|
+
continue
|
|
218
|
+
return None
|
|
219
|
+
# No filter: return the foreground window
|
|
220
|
+
import ctypes
|
|
221
|
+
fg_hwnd = ctypes.windll.user32.GetForegroundWindow()
|
|
222
|
+
if fg_hwnd:
|
|
223
|
+
for win in desktop.windows():
|
|
224
|
+
try:
|
|
225
|
+
if win.handle == fg_hwnd:
|
|
226
|
+
return win
|
|
227
|
+
except Exception:
|
|
228
|
+
continue
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
def _walk(self, wrapper, elements: list[UIElement], depth: int, max_depth: int, interactive_only: bool) -> None:
|
|
232
|
+
if depth > max_depth:
|
|
233
|
+
return
|
|
234
|
+
try:
|
|
235
|
+
ctrl_type = wrapper.element_info.control_type or "Unknown"
|
|
236
|
+
if interactive_only and ctrl_type not in _INTERACTIVE_TYPES and depth > 0:
|
|
237
|
+
for child in wrapper.children():
|
|
238
|
+
self._walk(child, elements, depth + 1, max_depth, interactive_only)
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
name = ""
|
|
242
|
+
try:
|
|
243
|
+
name = wrapper.window_text() or ""
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
value = ""
|
|
248
|
+
try:
|
|
249
|
+
if ctrl_type in ("Edit", "ComboBox", "Spinner"):
|
|
250
|
+
for method in ("get_value", "window_text"):
|
|
251
|
+
fn = getattr(wrapper, method, None)
|
|
252
|
+
if fn:
|
|
253
|
+
v = fn()
|
|
254
|
+
if v:
|
|
255
|
+
value = str(v)
|
|
256
|
+
break
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
ref = self._assign_ref(wrapper)
|
|
261
|
+
elements.append(UIElement(
|
|
262
|
+
ref=ref, control_type=ctrl_type, name=name, value=value,
|
|
263
|
+
is_enabled=wrapper.is_enabled() if hasattr(wrapper, "is_enabled") else True,
|
|
264
|
+
is_focused=wrapper.has_focus() if hasattr(wrapper, "has_focus") else False,
|
|
265
|
+
))
|
|
266
|
+
|
|
267
|
+
for child in wrapper.children():
|
|
268
|
+
self._walk(child, elements, depth + 1, max_depth, interactive_only)
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logger.debug("UIA walk error at depth %d: %s", depth, e)
|
|
271
|
+
|
|
272
|
+
def _search_window(self, win, *, name: str | None = None, control_type: str | None = None) -> list[UIElement]:
|
|
273
|
+
results: list[UIElement] = []
|
|
274
|
+
criteria: dict[str, Any] = {}
|
|
275
|
+
if control_type:
|
|
276
|
+
criteria["control_type"] = control_type
|
|
277
|
+
if name:
|
|
278
|
+
import re
|
|
279
|
+
criteria["title_re"] = f".*{re.escape(name)}.*"
|
|
280
|
+
try:
|
|
281
|
+
found = win.descendants(**criteria) if criteria else win.descendants()
|
|
282
|
+
for ctrl in found[:50]:
|
|
283
|
+
try:
|
|
284
|
+
ct = ctrl.element_info.control_type or "Unknown"
|
|
285
|
+
cn = ""
|
|
286
|
+
try:
|
|
287
|
+
cn = ctrl.window_text() or ""
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
value = ""
|
|
291
|
+
try:
|
|
292
|
+
if ct in ("Edit", "ComboBox"):
|
|
293
|
+
v = ctrl.get_value() if hasattr(ctrl, "get_value") else ""
|
|
294
|
+
value = str(v) if v else ""
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
ref = self._assign_ref(ctrl)
|
|
298
|
+
results.append(UIElement(
|
|
299
|
+
ref=ref, control_type=ct, name=cn, value=value,
|
|
300
|
+
is_enabled=ctrl.is_enabled(), is_focused=False,
|
|
301
|
+
))
|
|
302
|
+
except Exception:
|
|
303
|
+
continue
|
|
304
|
+
except Exception as e:
|
|
305
|
+
logger.debug("UIA search error: %s", e)
|
|
306
|
+
return results
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def render_snapshot(elements: list[UIElement], window_title: str = "") -> str:
|
|
310
|
+
lines = []
|
|
311
|
+
if window_title:
|
|
312
|
+
lines.append(f'Window: "{window_title}"')
|
|
313
|
+
lines.append("─" * 40)
|
|
314
|
+
for elem in elements:
|
|
315
|
+
lines.append(elem.to_text())
|
|
316
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pascal-agent
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Pascal — autonomous AI employee runtime with 4-layer safety
|
|
5
|
+
Project-URL: Homepage, https://gitlab.com/laum0621/pascal
|
|
6
|
+
Project-URL: Repository, https://gitlab.com/laum0621/pascal
|
|
7
|
+
Project-URL: Issues, https://gitlab.com/laum0621/pascal/-/issues
|
|
8
|
+
Author: laum0621
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: agent,ai,autonomous,employee,llm,tool-use
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: httpx>=0.27
|
|
21
|
+
Requires-Dist: openai>=1.0
|
|
22
|
+
Provides-Extra: all
|
|
23
|
+
Requires-Dist: aiogram>=3.0; extra == 'all'
|
|
24
|
+
Requires-Dist: anthropic>=0.30; extra == 'all'
|
|
25
|
+
Requires-Dist: mcp; extra == 'all'
|
|
26
|
+
Requires-Dist: pillow; extra == 'all'
|
|
27
|
+
Requires-Dist: pyautogui; extra == 'all'
|
|
28
|
+
Requires-Dist: pyperclip; extra == 'all'
|
|
29
|
+
Requires-Dist: pytest; extra == 'all'
|
|
30
|
+
Requires-Dist: pytest-asyncio; extra == 'all'
|
|
31
|
+
Requires-Dist: pywinauto>=0.6.8; extra == 'all'
|
|
32
|
+
Provides-Extra: anthropic
|
|
33
|
+
Requires-Dist: anthropic>=0.30; extra == 'anthropic'
|
|
34
|
+
Provides-Extra: browser
|
|
35
|
+
Requires-Dist: playwright; extra == 'browser'
|
|
36
|
+
Provides-Extra: clipboard
|
|
37
|
+
Requires-Dist: pyperclip; extra == 'clipboard'
|
|
38
|
+
Provides-Extra: desktop
|
|
39
|
+
Requires-Dist: pillow; extra == 'desktop'
|
|
40
|
+
Requires-Dist: pyautogui; extra == 'desktop'
|
|
41
|
+
Provides-Extra: dev
|
|
42
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
43
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
44
|
+
Provides-Extra: mcp
|
|
45
|
+
Requires-Dist: mcp; extra == 'mcp'
|
|
46
|
+
Provides-Extra: ocr
|
|
47
|
+
Requires-Dist: pytesseract; extra == 'ocr'
|
|
48
|
+
Provides-Extra: telegram
|
|
49
|
+
Requires-Dist: aiogram>=3.0; extra == 'telegram'
|
|
50
|
+
Provides-Extra: uia
|
|
51
|
+
Requires-Dist: pywinauto>=0.6.8; extra == 'uia'
|
|
52
|
+
Description-Content-Type: text/markdown
|
|
53
|
+
|
|
54
|
+
# Pascal — Autonomous AI Employee
|
|
55
|
+
|
|
56
|
+
An autonomous AI agent that works like a real employee: receives tasks, plans, executes, and reports back.
|
|
57
|
+
|
|
58
|
+
## Getting Started
|
|
59
|
+
|
|
60
|
+
### Install
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install pascal-agent
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Or with all optional features:
|
|
67
|
+
```bash
|
|
68
|
+
pip install "pascal-agent[all]"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Run
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pascal
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
That's it. On first run, Pascal auto-detects your LLM provider or walks you through setup.
|
|
78
|
+
|
|
79
|
+
### Manual setup (optional)
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Interactive setup
|
|
83
|
+
pascal setup
|
|
84
|
+
|
|
85
|
+
# Or set individually
|
|
86
|
+
pascal config set provider openai
|
|
87
|
+
pascal config set model gpt-5.4-mini
|
|
88
|
+
pascal config
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Provider setup:**
|
|
92
|
+
|
|
93
|
+
| Provider | Auth | How |
|
|
94
|
+
|----------|------|-----|
|
|
95
|
+
| OpenAI | API key | `export OPENAI_API_KEY=sk-...` |
|
|
96
|
+
| Anthropic | API key | `export ANTHROPIC_API_KEY=sk-ant-...` |
|
|
97
|
+
| Codex | ChatGPT Pro OAuth | `codex auth login` (free with Pro subscription) |
|
|
98
|
+
|
|
99
|
+
## Usage
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# Interactive mode (default)
|
|
103
|
+
pascal
|
|
104
|
+
> Summarize the files in this directory
|
|
105
|
+
> Read README.md and explain the architecture
|
|
106
|
+
> exit
|
|
107
|
+
|
|
108
|
+
# One-shot task
|
|
109
|
+
pascal "Write a Python script that downloads weather data"
|
|
110
|
+
|
|
111
|
+
# Set a mission (persistent context)
|
|
112
|
+
pascal --mission "You are a data analyst for the marketing team"
|
|
113
|
+
|
|
114
|
+
# Check current state
|
|
115
|
+
pascal --status
|
|
116
|
+
|
|
117
|
+
# Always-on daemon with Telegram
|
|
118
|
+
pascal --daemon
|
|
119
|
+
|
|
120
|
+
# Resume a paused task
|
|
121
|
+
pascal --resume task_abc123
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Configuration
|
|
125
|
+
|
|
126
|
+
### Config file (`~/.pascal/pascal.toml`)
|
|
127
|
+
|
|
128
|
+
```toml
|
|
129
|
+
[pascal]
|
|
130
|
+
model = "gpt-5.4-mini"
|
|
131
|
+
provider = "openai" # openai | anthropic | codex
|
|
132
|
+
db_path = "~/.pascal/state.db"
|
|
133
|
+
max_effect = "E2" # E0=read E1=analyze E2=write E3=push E4=merge E5=delete
|
|
134
|
+
max_tool_rounds = 10 # max tool calls per LLM turn
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### CLI config commands
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
pascal config # Show all settings
|
|
141
|
+
pascal config set model gpt-5.4-mini # Set a value
|
|
142
|
+
pascal config get provider # Get a value
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Environment variables (override config file)
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
PASCAL_MODEL=gpt-5.4-mini
|
|
149
|
+
PASCAL_PROVIDER=openai
|
|
150
|
+
PASCAL_MAX_EFFECT=E2
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
API keys can be set as environment variables or saved to `~/.pascal/.env` (auto-loaded).
|
|
154
|
+
|
|
155
|
+
### Optional integrations
|
|
156
|
+
|
|
157
|
+
**Telegram bot** (`~/.pascal/telegram.json`):
|
|
158
|
+
```json
|
|
159
|
+
{"bot_token": "123:ABC...", "owner_chat_id": 12345}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**MCP tool servers** (`~/.pascal/mcp.json`):
|
|
163
|
+
```json
|
|
164
|
+
[{"name": "chrome", "command": "npx", "args": ["chrome-devtools-mcp@latest"]}]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**Custom skills** (`~/.pascal/skills/my-skill.md`):
|
|
168
|
+
```yaml
|
|
169
|
+
---
|
|
170
|
+
name: my-skill
|
|
171
|
+
description: What this skill does
|
|
172
|
+
---
|
|
173
|
+
Instructions for Pascal when this skill is activated...
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## How It Works
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
pascal "Do something"
|
|
180
|
+
|
|
|
181
|
+
v
|
|
182
|
+
+---------------------------+
|
|
183
|
+
| Desk compiles state | SQLite -> text prompt
|
|
184
|
+
| LLM decides next action | 22 action types via function calling
|
|
185
|
+
| Execute through safety | Effect Ladder + Trust Scanner + Sandbox
|
|
186
|
+
| Record to audit ledger | Hash-chained, append-only
|
|
187
|
+
| Repeat |
|
|
188
|
+
+---------------------------+
|
|
189
|
+
|
|
|
190
|
+
v
|
|
191
|
+
Task complete / wait / escalate
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**22 actions**: think, execute, plan, delegate, pick_task, create_task, create_subtask, complete_task, fail_task, pause_task, block_task, handle_notification, dismiss_notification, add_todo, complete_todo, memorize, add_rule, remove_rule, set_context, wait, escalate
|
|
195
|
+
|
|
196
|
+
**3 LLM providers**: OpenAI, Anthropic Claude, Codex (ChatGPT Pro)
|
|
197
|
+
|
|
198
|
+
**4-layer safety**: Effect Ladder (E0-E5) | Trust Scanner | Sandbox (Docker/Restricted) | TrustMap + Audit Ledger
|
|
199
|
+
|
|
200
|
+
## Daemon Mode
|
|
201
|
+
|
|
202
|
+
Always-on operation with Telegram integration:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
# Setup Telegram first
|
|
206
|
+
echo '{"bot_token": "YOUR_TOKEN", "owner_chat_id": YOUR_ID}' > ~/.pascal/telegram.json
|
|
207
|
+
|
|
208
|
+
# Start daemon
|
|
209
|
+
pascal --daemon
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Features:
|
|
213
|
+
- Telegram DM for tasks and approvals
|
|
214
|
+
- Adaptive heartbeat (5min active, 30min idle)
|
|
215
|
+
- Auto-restart on crash
|
|
216
|
+
- STOP/PAUSE control (`~/.pascal/STOP` or `~/.pascal/PAUSE` file)
|
|
217
|
+
|
|
218
|
+
## Development
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
git clone https://gitlab.com/laum0621/pascal.git
|
|
222
|
+
cd pascal
|
|
223
|
+
pip install -e ".[dev]"
|
|
224
|
+
|
|
225
|
+
# Tests (245 pass)
|
|
226
|
+
pytest
|
|
227
|
+
|
|
228
|
+
# Lint (0 errors)
|
|
229
|
+
ruff check src/ tests/
|
|
230
|
+
|
|
231
|
+
# Type check (0 errors)
|
|
232
|
+
mypy src/pascal/
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Project Structure
|
|
236
|
+
|
|
237
|
+
```
|
|
238
|
+
src/pascal/
|
|
239
|
+
loop.py ........... Core tool-use loop (LoopRunner)
|
|
240
|
+
actions.py ........ 22 action handlers (ActionContext)
|
|
241
|
+
state.py .......... SQLite persistence (9 tables, FTS5)
|
|
242
|
+
desk.py ........... State -> LLM prompt compiler
|
|
243
|
+
tools.py .......... Built-in tools (file, desktop, UIA, clipboard)
|
|
244
|
+
effect.py ......... Effect Ladder (E0-E5, hard regex rules)
|
|
245
|
+
trust.py .......... Input scanner (injection, credentials, destructive)
|
|
246
|
+
capability.py ..... Domain trust map (asymmetric learning)
|
|
247
|
+
sandbox.py ........ Docker + Restricted sandbox
|
|
248
|
+
receipts.py ....... Hash-chained audit ledger
|
|
249
|
+
scheduler.py ...... Cron tick + self-evolution
|
|
250
|
+
daemon.py ......... Always-on mode (Telegram + loop + scheduler)
|
|
251
|
+
config.py ......... Config loader (CLI > env > TOML > defaults)
|
|
252
|
+
schemas.py ........ Tool JSON schemas for LLM function calling
|
|
253
|
+
prompt.py ......... System prompt
|
|
254
|
+
types.py .......... Shared DTOs
|
|
255
|
+
llm/ .............. OpenAI, Anthropic, Codex providers
|
|
256
|
+
channels/ ......... Telegram adapter
|
|
257
|
+
uia.py ............ Windows UI Automation
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
pascal/__init__.py,sha256=qoVW956gL56Q-xB9SincPJv7E-SJbFLOWCJZOD1P6xI,99
|
|
2
|
+
pascal/__main__.py,sha256=_MvAcOD5REu2hGesPP1f-RozuJ0ZFUncO-d5ZIWfOd8,31261
|
|
3
|
+
pascal/actions.py,sha256=KWaP24ibmu_gJrnEoYU7yiZ8zr_gWXifkNHyVuLApmk,45575
|
|
4
|
+
pascal/capability.py,sha256=2K0sqrKwX0TB8BYC-jGZSfffaWhOEfvyunQOJOLAkAI,6976
|
|
5
|
+
pascal/clipboard.py,sha256=8X_uzQv51crOgzaWUzbDi86YSK3Q4HC34oW6EaztIa8,1055
|
|
6
|
+
pascal/config.py,sha256=UpaMpxuBvfugeyYrpOXfCJ2Vl_BFEJh9EWDvn_XwoN4,4555
|
|
7
|
+
pascal/daemon.py,sha256=OsTLb2tFX72M6JM5gN8mwnjPFGNAgyFJVSgOTsL4Ids,8245
|
|
8
|
+
pascal/desk.py,sha256=phQgVRlEaLMyKzftdzLGlySL8F7UrnHRu7dm73iX0w0,28530
|
|
9
|
+
pascal/effect.py,sha256=1hRDksjfFGpYkaWpKGOC4VDNXYky-wPPsEZhlTYC00Q,6827
|
|
10
|
+
pascal/loop.py,sha256=mZaWsCTro3QRwBTpp3zwDxq_V1PJyEWuQvEywWW8puQ,42991
|
|
11
|
+
pascal/mcp.py,sha256=e9v0HhJdS5hK0fN1eUlQ0mM4V0AI8463QMiBsBNomT8,7726
|
|
12
|
+
pascal/prompt.py,sha256=X-pAaXqZ-qRqy0unt0BjUNF3be1zR701-_Nenb5o_H4,8204
|
|
13
|
+
pascal/receipts.py,sha256=7Ctuszf59jY6pYXhvzht9ymPdCbsk9R_pe7TOiBLvoc,5386
|
|
14
|
+
pascal/sandbox.py,sha256=wF5HtUj84Lw87n6jYJ6Me53f4HhGdzPwfOkiajRW8jc,11593
|
|
15
|
+
pascal/scheduler.py,sha256=soVTVWuRt0-0ogAh9Fkv-vwYUmDesZYcnsgzfCR7HYM,9372
|
|
16
|
+
pascal/schemas.py,sha256=GuSFNeMNK31YJA-sdPU37Ipp1uzfJCuV79IU3AINVz0,6895
|
|
17
|
+
pascal/state.py,sha256=9fi5dXZX0dXfmw4JvpwPaAhTKzhHC8VzON3Qcbjc5fQ,33988
|
|
18
|
+
pascal/tools.py,sha256=iOSSNyI0AvyZ5WFqdMpa79DOij2YKK9UFkl82OhFZYE,25937
|
|
19
|
+
pascal/trust.py,sha256=AXbIO1b2w-LLImf7AIcAdtULbfMxNKcOFJDbba3cMIA,6566
|
|
20
|
+
pascal/types.py,sha256=RA5k_oX07WdvNr4fEHMF5TALW5X0GvlpScoADOU_bVk,11253
|
|
21
|
+
pascal/uia.py,sha256=JIrjhPO94xMW1PdxAi-Qve6nBT7qMWNyfu5p8foz7WQ,11317
|
|
22
|
+
pascal/channels/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
|
+
pascal/channels/telegram.py,sha256=YVKqcmfXpwbOKQGLd805ZetDL2N9AsyRdUPKjhSBH10,4176
|
|
24
|
+
pascal/eval/__init__.py,sha256=BE0F0R1Uu2ISh4aBs28nvdlGOhir1jaXBut1N2A4zRM,38
|
|
25
|
+
pascal/eval/smoke.py,sha256=A10fFDU3SeIJllO4CfSMROSZ0xpbyEEVDORLuTvi6ck,8163
|
|
26
|
+
pascal/llm/__init__.py,sha256=aYJ4l4q9lT-Zp2sRcw023vt3kGXt8Wl4dQWq9kR74iA,30
|
|
27
|
+
pascal/llm/anthropic.py,sha256=EZxQ6ebs0x9TQ5s2vTGOAGXHFeGc8zM9kUeDMan8xBo,8129
|
|
28
|
+
pascal/llm/codex.py,sha256=zwAmjqhyLL6Iiz8Rn3OSo3i37yrkhBYP1Dc_LJqzJHg,13473
|
|
29
|
+
pascal/llm/openai.py,sha256=hmMCqq6m0YyBm8A6oKGBJQR1S5kZgYtqJS6kbXOzAHg,8038
|
|
30
|
+
pascal_agent-0.3.0.dist-info/METADATA,sha256=0yYpiWrTUeeKKIS6VZOKZG_p5g5aGLoR5D7jwo2W4ec,7188
|
|
31
|
+
pascal_agent-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
32
|
+
pascal_agent-0.3.0.dist-info/entry_points.txt,sha256=EfiOsglBJQ51v56-BCFIQ7LB0JKpuDCS3sRIjLm0zX8,48
|
|
33
|
+
pascal_agent-0.3.0.dist-info/RECORD,,
|