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/__main__.py
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
"""python -m pascal -- run the Pascal loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from textwrap import dedent
|
|
13
|
+
|
|
14
|
+
from pascal.config import load_config
|
|
15
|
+
from pascal.receipts import Ledger
|
|
16
|
+
from pascal.loop import run_loop
|
|
17
|
+
from pascal.state import PascalStore
|
|
18
|
+
|
|
19
|
+
_MAIN_EPILOG = dedent(
|
|
20
|
+
"""\
|
|
21
|
+
Examples:
|
|
22
|
+
pascal "Review today's inbox and draft replies"
|
|
23
|
+
pascal --status
|
|
24
|
+
pascal --notify "Build finished"
|
|
25
|
+
pascal init
|
|
26
|
+
pascal --init
|
|
27
|
+
"""
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_INIT_EPILOG = dedent(
|
|
31
|
+
"""\
|
|
32
|
+
Examples:
|
|
33
|
+
pascal init
|
|
34
|
+
pascal --init
|
|
35
|
+
"""
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_DEFAULT_CONFIG_TOML = dedent(
|
|
39
|
+
"""\
|
|
40
|
+
[pascal]
|
|
41
|
+
# model = "gpt-5.4-mini"
|
|
42
|
+
# provider = "openai" # openai | anthropic | codex
|
|
43
|
+
# db_path = "~/.pascal/state.db"
|
|
44
|
+
# max_effect = "E2" # E0(read) E1(analyze) E2(write) E3(push) E4(merge) E5(delete)
|
|
45
|
+
# max_tool_rounds = 10
|
|
46
|
+
"""
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_CONFIG_KEYS = {
|
|
50
|
+
"model": "LLM model name (e.g. gpt-4o, claude-sonnet-4-20250514, o3)",
|
|
51
|
+
"provider": "LLM provider: openai | anthropic | codex",
|
|
52
|
+
"base_url": "API base URL override",
|
|
53
|
+
"db_path": "SQLite database path",
|
|
54
|
+
"max_effect": "Max effect level: E0-E5",
|
|
55
|
+
"max_tool_rounds": "Max tool call rounds per turn",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_PROVIDERS = ("openai", "anthropic", "codex")
|
|
59
|
+
_EFFECTS = ("E0", "E1", "E2", "E3", "E4", "E5")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _print_action(action: dict) -> None:
|
|
63
|
+
a = action.get("action", "?")
|
|
64
|
+
reason = action.get("reason", "")
|
|
65
|
+
result = action.get("result", {})
|
|
66
|
+
status = result.get("status", "") if isinstance(result, dict) else ""
|
|
67
|
+
error = result.get("error", "") if isinstance(result, dict) else ""
|
|
68
|
+
output = result.get("output", "") if isinstance(result, dict) else ""
|
|
69
|
+
|
|
70
|
+
icons = {
|
|
71
|
+
"execute": ">", "think": "~", "pick_task": "+", "complete_task": "v",
|
|
72
|
+
"fail_task": "x", "delegate": "->", "plan": "P", "wait": ".", "escalate": "?",
|
|
73
|
+
"handle_notification": "!", "memorize": "M", "create_subtask": "++",
|
|
74
|
+
"pause_task": "||", "add_rule": "R+", "remove_rule": "R-",
|
|
75
|
+
"set_context": "C", "dismiss_notification": "x",
|
|
76
|
+
}
|
|
77
|
+
symbol = icons.get(a, " ")
|
|
78
|
+
|
|
79
|
+
line = f" [{symbol}] {a}"
|
|
80
|
+
if reason:
|
|
81
|
+
line += f": {reason[:80]}"
|
|
82
|
+
if status and status != "ok":
|
|
83
|
+
line += f" ({status})"
|
|
84
|
+
if error:
|
|
85
|
+
line += f" -- {error[:100]}"
|
|
86
|
+
elif output and a == "execute":
|
|
87
|
+
preview = output.strip().split("\n")[0][:80]
|
|
88
|
+
if preview:
|
|
89
|
+
line += f" -> {preview}"
|
|
90
|
+
print(line)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _resolve_max_tool_rounds(config, args, *, daemon_mode: bool) -> int:
|
|
94
|
+
"""Resolve max_tool_rounds: CLI > config > mode default. max_inner_turns is deprecated alias."""
|
|
95
|
+
# Explicit max_tool_rounds takes priority
|
|
96
|
+
cli_rounds = getattr(args, "max_tool_rounds", None) if args else None
|
|
97
|
+
if cli_rounds is not None:
|
|
98
|
+
return max(1, int(cli_rounds))
|
|
99
|
+
config_rounds = getattr(config, "max_tool_rounds", None)
|
|
100
|
+
if config_rounds is not None:
|
|
101
|
+
return max(1, int(config_rounds))
|
|
102
|
+
# Deprecated alias: max_inner_turns
|
|
103
|
+
cli_turns = getattr(args, "max_inner_turns", None) if args else None
|
|
104
|
+
if cli_turns is not None:
|
|
105
|
+
return max(1, int(cli_turns))
|
|
106
|
+
config_turns = getattr(config, "max_inner_turns", None)
|
|
107
|
+
if config_turns is not None:
|
|
108
|
+
return max(1, int(config_turns))
|
|
109
|
+
return 3 if daemon_mode else 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _run(args, config) -> None:
|
|
113
|
+
store = PascalStore(config.db_path)
|
|
114
|
+
|
|
115
|
+
# Seed initial task if provided
|
|
116
|
+
if args.task:
|
|
117
|
+
promised_to = json.loads(args.promised_to) if getattr(args, "promised_to", None) else None
|
|
118
|
+
proof_required = json.loads(args.proof_required) if getattr(args, "proof_required", None) else None
|
|
119
|
+
task_id = store.add_task(
|
|
120
|
+
args.task, source="cli",
|
|
121
|
+
promised_to=promised_to,
|
|
122
|
+
due_at=getattr(args, "due", None),
|
|
123
|
+
proof_required=proof_required,
|
|
124
|
+
)
|
|
125
|
+
print(f"Task created: {task_id}")
|
|
126
|
+
|
|
127
|
+
# Seed mission if provided
|
|
128
|
+
if args.mission:
|
|
129
|
+
store.set_context("mission", args.mission)
|
|
130
|
+
|
|
131
|
+
# Create LLM provider
|
|
132
|
+
from pascal.config import create_llm
|
|
133
|
+
llm = create_llm(config)
|
|
134
|
+
|
|
135
|
+
# Audit ledger (hash-chained)
|
|
136
|
+
from pathlib import Path
|
|
137
|
+
ledger_path = Path(config.db_path).parent / "audit.jsonl"
|
|
138
|
+
ledger = Ledger(str(ledger_path))
|
|
139
|
+
|
|
140
|
+
# MCP servers (if configured)
|
|
141
|
+
from pascal.mcp import MCPManager
|
|
142
|
+
mcp_manager = MCPManager()
|
|
143
|
+
mcp_configs = _load_mcp_configs(config)
|
|
144
|
+
if mcp_configs:
|
|
145
|
+
await mcp_manager.connect_all(mcp_configs)
|
|
146
|
+
|
|
147
|
+
print(f"Pascal loop starting (model: {config.model})")
|
|
148
|
+
print(f"DB: {config.db_path}")
|
|
149
|
+
print(f"Ledger: {ledger_path}")
|
|
150
|
+
if mcp_manager.connected_servers:
|
|
151
|
+
print(f"MCP: {', '.join(mcp_manager.connected_servers)}")
|
|
152
|
+
print()
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
actions = await run_loop(
|
|
156
|
+
store, llm,
|
|
157
|
+
verifier_llm=llm,
|
|
158
|
+
ledger=ledger,
|
|
159
|
+
mcp_manager=mcp_manager if mcp_manager.connected_servers else None,
|
|
160
|
+
max_effect=getattr(args, "max_effect", None) or config.max_effect,
|
|
161
|
+
max_iterations=args.max_iterations,
|
|
162
|
+
max_tool_rounds=_resolve_max_tool_rounds(config, args, daemon_mode=False),
|
|
163
|
+
on_action=_print_action,
|
|
164
|
+
)
|
|
165
|
+
finally:
|
|
166
|
+
await mcp_manager.disconnect_all()
|
|
167
|
+
|
|
168
|
+
print(f"\nLoop ended after {len(actions)} action(s).")
|
|
169
|
+
store.close()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def _resume_run(args, config) -> None:
|
|
173
|
+
store = PascalStore(config.db_path)
|
|
174
|
+
task_id = args.resume
|
|
175
|
+
task = store.connection.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
|
|
176
|
+
if task is None:
|
|
177
|
+
print(f"Task {task_id} not found", file=sys.stderr)
|
|
178
|
+
sys.exit(1)
|
|
179
|
+
if task["status"] in ("done", "failed"):
|
|
180
|
+
print(f"Task {task_id} is {task['status']}, cannot resume", file=sys.stderr)
|
|
181
|
+
sys.exit(1)
|
|
182
|
+
# Reactivate if paused/blocked
|
|
183
|
+
if task["status"] in ("paused", "blocked"):
|
|
184
|
+
store.activate_task(task_id)
|
|
185
|
+
print(f"Task {task_id} reactivated from {task['status']}")
|
|
186
|
+
|
|
187
|
+
from pascal.config import create_llm
|
|
188
|
+
llm = create_llm(config)
|
|
189
|
+
|
|
190
|
+
cp = store.get_latest_checkpoint(task_id)
|
|
191
|
+
if cp:
|
|
192
|
+
print(f"Resuming from checkpoint (iteration {cp['snapshot'].get('iteration', '?')})")
|
|
193
|
+
print(f"Pascal resuming task: {task['goal']}")
|
|
194
|
+
|
|
195
|
+
# Audit ledger + verifier (same as main path)
|
|
196
|
+
from pathlib import Path as _Path
|
|
197
|
+
ledger_path = _Path(config.db_path).parent / "audit.jsonl"
|
|
198
|
+
ledger = Ledger(str(ledger_path))
|
|
199
|
+
|
|
200
|
+
actions = await run_loop(
|
|
201
|
+
store, llm,
|
|
202
|
+
verifier_llm=llm,
|
|
203
|
+
ledger=ledger,
|
|
204
|
+
max_effect=getattr(args, "max_effect", None),
|
|
205
|
+
max_iterations=args.max_iterations,
|
|
206
|
+
max_tool_rounds=_resolve_max_tool_rounds(config, args, daemon_mode=False),
|
|
207
|
+
on_action=_print_action,
|
|
208
|
+
)
|
|
209
|
+
print(f"\nLoop ended after {len(actions)} action(s).")
|
|
210
|
+
store.close()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _resume(args, config) -> None:
|
|
214
|
+
asyncio.run(_resume_run(args, config))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _notify(args, config) -> None:
|
|
218
|
+
"""Push a notification into Pascal's desk."""
|
|
219
|
+
store = PascalStore(config.db_path)
|
|
220
|
+
notif_id = store.push_notification(
|
|
221
|
+
source=args.notify_source or "cli",
|
|
222
|
+
message=args.notify,
|
|
223
|
+
priority=args.notify_priority or "normal",
|
|
224
|
+
)
|
|
225
|
+
print(f"Notification pushed: {notif_id}")
|
|
226
|
+
store.close()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _status(config) -> None:
|
|
230
|
+
"""Print Pascal's current desk state."""
|
|
231
|
+
import sys
|
|
232
|
+
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
|
|
233
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
234
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
235
|
+
from pascal.desk import Desk
|
|
236
|
+
store = PascalStore(config.db_path)
|
|
237
|
+
desk = Desk(store)
|
|
238
|
+
print(desk.render())
|
|
239
|
+
store.close()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _add_rule(args, config) -> None:
|
|
243
|
+
store = PascalStore(config.db_path)
|
|
244
|
+
mutable = not args.immutable
|
|
245
|
+
rule_id = store.add_rule(args.add_rule, mutable=mutable, added_by="human")
|
|
246
|
+
lock = " [immutable]" if not mutable else ""
|
|
247
|
+
print(f"Rule added{lock}: {rule_id}")
|
|
248
|
+
store.close()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _load_mcp_configs(config) -> list:
|
|
252
|
+
"""Load MCP server configs from ~/.pascal/mcp.json if it exists."""
|
|
253
|
+
from pascal.mcp import MCPServerConfig
|
|
254
|
+
import json as _json
|
|
255
|
+
mcp_path = Path(config.db_path).expanduser().parent / "mcp.json"
|
|
256
|
+
if not mcp_path.exists():
|
|
257
|
+
return []
|
|
258
|
+
try:
|
|
259
|
+
servers = _json.loads(mcp_path.read_text(encoding="utf-8"))
|
|
260
|
+
return [
|
|
261
|
+
MCPServerConfig(
|
|
262
|
+
name=s["name"], command=s["command"],
|
|
263
|
+
args=s.get("args", []), env=s.get("env"),
|
|
264
|
+
)
|
|
265
|
+
for s in servers
|
|
266
|
+
]
|
|
267
|
+
except Exception as e:
|
|
268
|
+
print(f"Warning: failed to load MCP config: {e}")
|
|
269
|
+
return []
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
273
|
+
parser = argparse.ArgumentParser(
|
|
274
|
+
prog="pascal",
|
|
275
|
+
description="Pascal CLI for running tasks, checking status, and managing your local workspace.",
|
|
276
|
+
epilog=_MAIN_EPILOG,
|
|
277
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
278
|
+
)
|
|
279
|
+
parser.add_argument("--init", action="store_true", help="Create ~/.pascal, pascal.toml, and skills/, then exit")
|
|
280
|
+
parser.add_argument("task", nargs="?", default=None, help="Task to add and execute")
|
|
281
|
+
parser.add_argument("--mission", help="Set the agent's mission")
|
|
282
|
+
parser.add_argument("--status", action="store_true", help="Print current desk state")
|
|
283
|
+
parser.add_argument("--notify", help="Push a notification")
|
|
284
|
+
parser.add_argument("--notify-source", default="cli", help="Notification source")
|
|
285
|
+
parser.add_argument("--notify-priority", choices=["urgent", "normal", "low"], default="normal")
|
|
286
|
+
parser.add_argument("--add-rule", help="Add a behavior rule")
|
|
287
|
+
parser.add_argument("--immutable", action="store_true", help="Make the rule immutable")
|
|
288
|
+
parser.add_argument("--max-iterations", type=int, default=20, help="Max loop iterations")
|
|
289
|
+
parser.add_argument(
|
|
290
|
+
"--max-inner-turns",
|
|
291
|
+
type=int,
|
|
292
|
+
default=None,
|
|
293
|
+
help="Max inner reasoning turns (default: 1 one-shot, 3 daemon; env/TOML supported)",
|
|
294
|
+
)
|
|
295
|
+
parser.add_argument(
|
|
296
|
+
"--max-tool-rounds",
|
|
297
|
+
type=int,
|
|
298
|
+
default=None,
|
|
299
|
+
help="Max tool call rounds per turn (default: None; env/TOML supported)",
|
|
300
|
+
)
|
|
301
|
+
parser.add_argument("--max-effect", choices=["E0", "E1", "E2", "E3", "E4", "E5"],
|
|
302
|
+
help="Max effect level (default: E2, env: PASCAL_MAX_EFFECT)")
|
|
303
|
+
parser.add_argument("--promised-to", help='JSON array of stakeholders')
|
|
304
|
+
parser.add_argument("--due", help="Deadline in ISO 8601")
|
|
305
|
+
parser.add_argument("--proof-required", help='JSON array of required evidence types')
|
|
306
|
+
parser.add_argument("--tick", action="store_true", help="Run scheduler tick")
|
|
307
|
+
parser.add_argument("--resume", metavar="TASK_ID", help="Resume a paused/blocked task from checkpoint")
|
|
308
|
+
parser.add_argument("--daemon", action="store_true", help="Run as always-on daemon with Telegram")
|
|
309
|
+
parser.add_argument("--model", help="LLM model name")
|
|
310
|
+
parser.add_argument("--provider", choices=["anthropic", "openai", "codex"], help="LLM provider")
|
|
311
|
+
parser.add_argument("--base-url", help="API base URL override")
|
|
312
|
+
parser.add_argument("--verbose", "-v", action="store_true")
|
|
313
|
+
return parser
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _build_init_parser() -> argparse.ArgumentParser:
|
|
317
|
+
return argparse.ArgumentParser(
|
|
318
|
+
prog="pascal init",
|
|
319
|
+
description="Create Pascal's home directory and starter configuration.",
|
|
320
|
+
epilog=_INIT_EPILOG,
|
|
321
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _init_pascal_home() -> None:
|
|
326
|
+
pascal_home = Path.home() / ".pascal"
|
|
327
|
+
config_path = pascal_home / "pascal.toml"
|
|
328
|
+
skills_dir = pascal_home / "skills"
|
|
329
|
+
created: list[Path] = []
|
|
330
|
+
existing: list[Path] = []
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
if pascal_home.exists():
|
|
334
|
+
if not pascal_home.is_dir():
|
|
335
|
+
raise NotADirectoryError(f"{pascal_home} exists and is not a directory")
|
|
336
|
+
existing.append(pascal_home)
|
|
337
|
+
else:
|
|
338
|
+
pascal_home.mkdir(parents=True, exist_ok=True)
|
|
339
|
+
created.append(pascal_home)
|
|
340
|
+
|
|
341
|
+
if config_path.exists():
|
|
342
|
+
if not config_path.is_file():
|
|
343
|
+
raise IsADirectoryError(f"{config_path} exists and is not a file")
|
|
344
|
+
existing.append(config_path)
|
|
345
|
+
else:
|
|
346
|
+
config_path.write_text(_DEFAULT_CONFIG_TOML, encoding="utf-8")
|
|
347
|
+
created.append(config_path)
|
|
348
|
+
|
|
349
|
+
if skills_dir.exists():
|
|
350
|
+
if not skills_dir.is_dir():
|
|
351
|
+
raise NotADirectoryError(f"{skills_dir} exists and is not a directory")
|
|
352
|
+
existing.append(skills_dir)
|
|
353
|
+
else:
|
|
354
|
+
skills_dir.mkdir(parents=True, exist_ok=True)
|
|
355
|
+
created.append(skills_dir)
|
|
356
|
+
except OSError as exc:
|
|
357
|
+
print(f"Pascal initialization failed: {exc}", file=sys.stderr)
|
|
358
|
+
sys.exit(1)
|
|
359
|
+
|
|
360
|
+
print("Pascal initialization complete.")
|
|
361
|
+
if created:
|
|
362
|
+
print("Created:")
|
|
363
|
+
for path in created:
|
|
364
|
+
print(f" - {path}")
|
|
365
|
+
if existing:
|
|
366
|
+
print("Already existed:")
|
|
367
|
+
for path in existing:
|
|
368
|
+
print(f" - {path}")
|
|
369
|
+
print()
|
|
370
|
+
print("Next steps:")
|
|
371
|
+
print(f" - Edit {config_path} to choose your model and provider.")
|
|
372
|
+
print(f" - Add custom skills to {skills_dir}.")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _read_toml_config() -> tuple[Path, dict]:
|
|
376
|
+
"""Read the pascal.toml config. Returns (path, pascal_section)."""
|
|
377
|
+
config_path = Path.home() / ".pascal" / "pascal.toml"
|
|
378
|
+
if not config_path.exists():
|
|
379
|
+
return config_path, {}
|
|
380
|
+
import tomllib
|
|
381
|
+
with config_path.open("rb") as f:
|
|
382
|
+
data = tomllib.load(f)
|
|
383
|
+
return config_path, data.get("pascal", {})
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _write_toml_config(path: Path, section: dict) -> None:
|
|
387
|
+
"""Write the pascal section to pascal.toml, preserving [pascal] header."""
|
|
388
|
+
lines = ["[pascal]"]
|
|
389
|
+
for key, value in sorted(section.items()):
|
|
390
|
+
if isinstance(value, str):
|
|
391
|
+
lines.append(f'{key} = "{value}"')
|
|
392
|
+
elif isinstance(value, int):
|
|
393
|
+
lines.append(f"{key} = {value}")
|
|
394
|
+
else:
|
|
395
|
+
lines.append(f'{key} = "{value}"')
|
|
396
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
397
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _config_show() -> None:
|
|
401
|
+
"""Show current resolved configuration."""
|
|
402
|
+
config = load_config()
|
|
403
|
+
config_path, toml_data = _read_toml_config()
|
|
404
|
+
print(f"Config file: {config_path}" + (" (exists)" if config_path.exists() else " (not found)"))
|
|
405
|
+
print()
|
|
406
|
+
fields = {
|
|
407
|
+
"model": config.model,
|
|
408
|
+
"provider": config.provider,
|
|
409
|
+
"base_url": config.base_url or "(default)",
|
|
410
|
+
"db_path": config.db_path,
|
|
411
|
+
"max_effect": config.max_effect,
|
|
412
|
+
"max_tool_rounds": config.max_tool_rounds or "(default)",
|
|
413
|
+
}
|
|
414
|
+
import os
|
|
415
|
+
for key, value in fields.items():
|
|
416
|
+
source = ""
|
|
417
|
+
env_key = f"PASCAL_{key.upper()}"
|
|
418
|
+
if os.environ.get(env_key):
|
|
419
|
+
source = f" (from ${env_key})"
|
|
420
|
+
elif key in toml_data:
|
|
421
|
+
source = " (from pascal.toml)"
|
|
422
|
+
else:
|
|
423
|
+
source = " (default)"
|
|
424
|
+
print(f" {key:20s} = {value}{source}")
|
|
425
|
+
|
|
426
|
+
# API key status
|
|
427
|
+
print()
|
|
428
|
+
for name, env in [("OpenAI", "OPENAI_API_KEY"), ("Anthropic", "ANTHROPIC_API_KEY"), ("Codex", "~/.codex/auth.json")]:
|
|
429
|
+
if env.startswith("~"):
|
|
430
|
+
found = (Path.home() / env.replace("~/", "")).exists()
|
|
431
|
+
else:
|
|
432
|
+
found = bool(os.environ.get(env))
|
|
433
|
+
status = "configured" if found else "not set"
|
|
434
|
+
print(f" {name:20s} : {status}")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _config_set(key: str, value: str) -> None:
|
|
438
|
+
"""Set a config value in ~/.pascal/pascal.toml."""
|
|
439
|
+
if key not in _CONFIG_KEYS:
|
|
440
|
+
print(f"Unknown config key: {key}")
|
|
441
|
+
print(f"Available keys: {', '.join(sorted(_CONFIG_KEYS))}")
|
|
442
|
+
sys.exit(1)
|
|
443
|
+
if key == "provider" and value not in _PROVIDERS:
|
|
444
|
+
print(f"Invalid provider: {value}. Choose from: {', '.join(_PROVIDERS)}")
|
|
445
|
+
sys.exit(1)
|
|
446
|
+
if key == "max_effect" and value not in _EFFECTS:
|
|
447
|
+
print(f"Invalid effect level: {value}. Choose from: {', '.join(_EFFECTS)}")
|
|
448
|
+
sys.exit(1)
|
|
449
|
+
|
|
450
|
+
config_path, section = _read_toml_config()
|
|
451
|
+
if key == "max_tool_rounds":
|
|
452
|
+
section[key] = int(value)
|
|
453
|
+
else:
|
|
454
|
+
section[key] = value
|
|
455
|
+
_write_toml_config(config_path, section)
|
|
456
|
+
print(f"Set {key} = {value} in {config_path}")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _config_get(key: str) -> None:
|
|
460
|
+
"""Get a specific config value."""
|
|
461
|
+
config = load_config()
|
|
462
|
+
value = getattr(config, key, None)
|
|
463
|
+
if value is None:
|
|
464
|
+
print(f"Unknown key: {key}")
|
|
465
|
+
sys.exit(1)
|
|
466
|
+
print(value)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _setup_interactive() -> None:
|
|
470
|
+
"""Interactive guided setup."""
|
|
471
|
+
import os
|
|
472
|
+
print("Pascal Setup")
|
|
473
|
+
print("=" * 40)
|
|
474
|
+
print()
|
|
475
|
+
|
|
476
|
+
# Ensure ~/.pascal exists
|
|
477
|
+
_init_pascal_home()
|
|
478
|
+
print()
|
|
479
|
+
|
|
480
|
+
config_path, section = _read_toml_config()
|
|
481
|
+
|
|
482
|
+
# Provider
|
|
483
|
+
current_provider = section.get("provider", "openai")
|
|
484
|
+
print(f"LLM Provider ({', '.join(_PROVIDERS)})")
|
|
485
|
+
print(f" Current: {current_provider}")
|
|
486
|
+
provider = input(f" New value [{current_provider}]: ").strip() or current_provider
|
|
487
|
+
if provider not in _PROVIDERS:
|
|
488
|
+
print(f" Invalid. Using {current_provider}.")
|
|
489
|
+
provider = current_provider
|
|
490
|
+
section["provider"] = provider
|
|
491
|
+
|
|
492
|
+
# Model
|
|
493
|
+
defaults = {"openai": "gpt-5.4-mini", "anthropic": "claude-sonnet-4-20250514", "codex": "o3"}
|
|
494
|
+
current_model = section.get("model", defaults.get(provider, "gpt-5.4-mini"))
|
|
495
|
+
print("\nModel name")
|
|
496
|
+
print(f" Current: {current_model}")
|
|
497
|
+
model = input(f" New value [{current_model}]: ").strip() or current_model
|
|
498
|
+
section["model"] = model
|
|
499
|
+
|
|
500
|
+
# Max effect
|
|
501
|
+
current_effect = section.get("max_effect", "E2")
|
|
502
|
+
print("\nMax effect level (E0=read E1=analyze E2=write E3=push E4=merge E5=delete)")
|
|
503
|
+
print(f" Current: {current_effect}")
|
|
504
|
+
effect = input(f" New value [{current_effect}]: ").strip() or current_effect
|
|
505
|
+
if effect in _EFFECTS:
|
|
506
|
+
section["max_effect"] = effect
|
|
507
|
+
else:
|
|
508
|
+
print(f" Invalid. Keeping {current_effect}.")
|
|
509
|
+
|
|
510
|
+
# API key check
|
|
511
|
+
if provider == "openai":
|
|
512
|
+
if not os.environ.get("OPENAI_API_KEY"):
|
|
513
|
+
print("\nOpenAI API key not found in environment.")
|
|
514
|
+
key = input(" Enter OPENAI_API_KEY (or press Enter to skip): ").strip()
|
|
515
|
+
if key:
|
|
516
|
+
_save_env_var("OPENAI_API_KEY", key)
|
|
517
|
+
elif provider == "anthropic":
|
|
518
|
+
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|
519
|
+
print("\nAnthropic API key not found in environment.")
|
|
520
|
+
key = input(" Enter ANTHROPIC_API_KEY (or press Enter to skip): ").strip()
|
|
521
|
+
if key:
|
|
522
|
+
_save_env_var("ANTHROPIC_API_KEY", key)
|
|
523
|
+
elif provider == "codex":
|
|
524
|
+
auth = Path.home() / ".codex" / "auth.json"
|
|
525
|
+
if not auth.exists():
|
|
526
|
+
print(f"\nCodex auth not found at {auth}")
|
|
527
|
+
print(" Run: codex auth login")
|
|
528
|
+
|
|
529
|
+
_write_toml_config(config_path, section)
|
|
530
|
+
print(f"\nSaved to {config_path}")
|
|
531
|
+
print()
|
|
532
|
+
print("Ready! Try:")
|
|
533
|
+
print(' pascal "Summarize the files in this directory"')
|
|
534
|
+
print(' pascal --status')
|
|
535
|
+
print(' pascal config')
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _save_env_var(key: str, value: str) -> None:
|
|
539
|
+
"""Save an env var to ~/.pascal/.env and load into current process."""
|
|
540
|
+
env_path = Path.home() / ".pascal" / ".env"
|
|
541
|
+
env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
542
|
+
# Read existing, replace if key exists, else append
|
|
543
|
+
lines = []
|
|
544
|
+
replaced = False
|
|
545
|
+
if env_path.exists():
|
|
546
|
+
for line in env_path.read_text(encoding="utf-8").splitlines():
|
|
547
|
+
if line.strip().startswith(f"{key}="):
|
|
548
|
+
lines.append(f"{key}={value}")
|
|
549
|
+
replaced = True
|
|
550
|
+
else:
|
|
551
|
+
lines.append(line)
|
|
552
|
+
if not replaced:
|
|
553
|
+
lines.append(f"{key}={value}")
|
|
554
|
+
env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
555
|
+
os.environ[key] = value # load into current process immediately
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _quick_auth_setup() -> bool:
|
|
559
|
+
"""Inline auth setup when no provider is detected. Returns True if configured."""
|
|
560
|
+
import shutil
|
|
561
|
+
import subprocess
|
|
562
|
+
|
|
563
|
+
print("No LLM provider configured. Quick setup:\n")
|
|
564
|
+
print(" [1] Codex (free with ChatGPT Pro subscription)")
|
|
565
|
+
print(" [2] OpenAI (API key)")
|
|
566
|
+
print(" [3] Anthropic (API key)")
|
|
567
|
+
print(" [0] Cancel\n")
|
|
568
|
+
|
|
569
|
+
try:
|
|
570
|
+
choice = input("Choose [1-3]: ").strip()
|
|
571
|
+
except (KeyboardInterrupt, EOFError):
|
|
572
|
+
return False
|
|
573
|
+
|
|
574
|
+
config_path, section = _read_toml_config()
|
|
575
|
+
|
|
576
|
+
if choice == "1":
|
|
577
|
+
# Codex OAuth
|
|
578
|
+
auth_path = Path.home() / ".codex" / "auth.json"
|
|
579
|
+
if auth_path.exists():
|
|
580
|
+
print("Codex auth already exists.")
|
|
581
|
+
elif shutil.which("codex"):
|
|
582
|
+
print("Opening Codex login...")
|
|
583
|
+
try:
|
|
584
|
+
subprocess.run(["codex", "auth", "login"], timeout=120)
|
|
585
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
|
586
|
+
print(f" Login failed: {e}")
|
|
587
|
+
print(" Try manually: codex auth login")
|
|
588
|
+
return False
|
|
589
|
+
if not auth_path.exists():
|
|
590
|
+
print(" Auth file not created. Try: codex auth login")
|
|
591
|
+
return False
|
|
592
|
+
else:
|
|
593
|
+
print(" Codex CLI not found. Install it first:")
|
|
594
|
+
print(" npm install -g @anthropic-ai/codex")
|
|
595
|
+
print(" Then: codex auth login")
|
|
596
|
+
return False
|
|
597
|
+
section["provider"] = "codex"
|
|
598
|
+
section["model"] = "gpt-5.4-mini"
|
|
599
|
+
_write_toml_config(config_path, section)
|
|
600
|
+
print("Done! Provider: codex\n")
|
|
601
|
+
return True
|
|
602
|
+
|
|
603
|
+
elif choice == "2":
|
|
604
|
+
# OpenAI API key
|
|
605
|
+
try:
|
|
606
|
+
key = input("OPENAI_API_KEY: ").strip()
|
|
607
|
+
except (KeyboardInterrupt, EOFError):
|
|
608
|
+
return False
|
|
609
|
+
if not key:
|
|
610
|
+
print(" No key provided.")
|
|
611
|
+
return False
|
|
612
|
+
_save_env_var("OPENAI_API_KEY", key)
|
|
613
|
+
section["provider"] = "openai"
|
|
614
|
+
section["model"] = "gpt-5.4-mini"
|
|
615
|
+
_write_toml_config(config_path, section)
|
|
616
|
+
print("Done! Provider: openai\n")
|
|
617
|
+
return True
|
|
618
|
+
|
|
619
|
+
elif choice == "3":
|
|
620
|
+
# Anthropic API key
|
|
621
|
+
try:
|
|
622
|
+
key = input("ANTHROPIC_API_KEY: ").strip()
|
|
623
|
+
except (KeyboardInterrupt, EOFError):
|
|
624
|
+
return False
|
|
625
|
+
if not key:
|
|
626
|
+
print(" No key provided.")
|
|
627
|
+
return False
|
|
628
|
+
_save_env_var("ANTHROPIC_API_KEY", key)
|
|
629
|
+
section["provider"] = "anthropic"
|
|
630
|
+
section["model"] = "claude-sonnet-4-20250514"
|
|
631
|
+
_write_toml_config(config_path, section)
|
|
632
|
+
print("Done! Provider: anthropic\n")
|
|
633
|
+
return True
|
|
634
|
+
|
|
635
|
+
return False
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _auto_detect_provider() -> tuple[str, str]:
|
|
639
|
+
"""Auto-detect the best available provider. Returns (provider, model)."""
|
|
640
|
+
import os
|
|
641
|
+
# Priority: Codex (free with Pro) > OpenAI > Anthropic
|
|
642
|
+
if (Path.home() / ".codex" / "auth.json").exists():
|
|
643
|
+
return "codex", "gpt-5.4-mini"
|
|
644
|
+
if os.environ.get("OPENAI_API_KEY"):
|
|
645
|
+
return "openai", "gpt-5.4-mini"
|
|
646
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
647
|
+
return "anthropic", "claude-sonnet-4-20250514"
|
|
648
|
+
return "", ""
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _ensure_ready() -> bool:
|
|
652
|
+
"""Ensure Pascal is initialized. Auto-init on first run. Returns True if ready."""
|
|
653
|
+
pascal_home = Path.home() / ".pascal"
|
|
654
|
+
config_path = pascal_home / "pascal.toml"
|
|
655
|
+
|
|
656
|
+
# First run: auto-init
|
|
657
|
+
if not pascal_home.exists():
|
|
658
|
+
print("First run detected. Setting up Pascal...\n")
|
|
659
|
+
_init_pascal_home()
|
|
660
|
+
print()
|
|
661
|
+
|
|
662
|
+
# Auto-detect provider if not configured
|
|
663
|
+
_, section = _read_toml_config()
|
|
664
|
+
if not section.get("provider"):
|
|
665
|
+
provider, model = _auto_detect_provider()
|
|
666
|
+
if provider:
|
|
667
|
+
section["provider"] = provider
|
|
668
|
+
section["model"] = model
|
|
669
|
+
_write_toml_config(config_path, section)
|
|
670
|
+
print(f"Auto-detected: provider={provider}, model={model}\n")
|
|
671
|
+
else:
|
|
672
|
+
# Interactive provider setup
|
|
673
|
+
result = _quick_auth_setup()
|
|
674
|
+
if not result:
|
|
675
|
+
return False
|
|
676
|
+
return True
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _print_banner() -> None:
|
|
680
|
+
"""Print compact banner with current config."""
|
|
681
|
+
from pascal import __version__
|
|
682
|
+
config = load_config()
|
|
683
|
+
print(f"Pascal v{__version__} | {config.provider}/{config.model} | max:{config.max_effect}")
|
|
684
|
+
print("Type a task, or 'help' for commands. Ctrl+C to exit.\n")
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
async def _run_one_task(task: str, config) -> None:
|
|
688
|
+
"""Run a single task through the loop. Shared by REPL and one-shot mode."""
|
|
689
|
+
store = PascalStore(config.db_path)
|
|
690
|
+
store.add_task(task, source="cli")
|
|
691
|
+
|
|
692
|
+
from pascal.config import create_llm
|
|
693
|
+
llm = create_llm(config)
|
|
694
|
+
|
|
695
|
+
ledger_path = Path(config.db_path).expanduser().parent / "audit.jsonl"
|
|
696
|
+
ledger = Ledger(str(ledger_path))
|
|
697
|
+
|
|
698
|
+
from pascal.mcp import MCPManager
|
|
699
|
+
mcp_manager = MCPManager()
|
|
700
|
+
mcp_configs = _load_mcp_configs(config)
|
|
701
|
+
if mcp_configs:
|
|
702
|
+
try:
|
|
703
|
+
await mcp_manager.connect_all(mcp_configs)
|
|
704
|
+
except Exception:
|
|
705
|
+
pass # MCP is optional
|
|
706
|
+
|
|
707
|
+
try:
|
|
708
|
+
await run_loop(
|
|
709
|
+
store, llm,
|
|
710
|
+
verifier_llm=llm,
|
|
711
|
+
ledger=ledger,
|
|
712
|
+
mcp_manager=mcp_manager if mcp_manager.connected_servers else None,
|
|
713
|
+
max_effect=config.max_effect,
|
|
714
|
+
max_iterations=20,
|
|
715
|
+
max_tool_rounds=_resolve_max_tool_rounds(config, None, daemon_mode=False),
|
|
716
|
+
on_action=_print_action,
|
|
717
|
+
)
|
|
718
|
+
finally:
|
|
719
|
+
try:
|
|
720
|
+
await mcp_manager.disconnect_all()
|
|
721
|
+
except Exception:
|
|
722
|
+
pass
|
|
723
|
+
store.close()
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _interactive() -> None:
|
|
727
|
+
"""Interactive REPL mode -- type tasks, see Pascal work, repeat."""
|
|
728
|
+
if not _ensure_ready():
|
|
729
|
+
return
|
|
730
|
+
|
|
731
|
+
_print_banner()
|
|
732
|
+
config = load_config()
|
|
733
|
+
|
|
734
|
+
# Show pending work if any
|
|
735
|
+
store = PascalStore(config.db_path)
|
|
736
|
+
active = store.get_active_task()
|
|
737
|
+
pending = store.get_pending_tasks()
|
|
738
|
+
notifs = store.get_pending_notifications()
|
|
739
|
+
if active:
|
|
740
|
+
print(f" Active: {active['goal'][:70]}")
|
|
741
|
+
if pending:
|
|
742
|
+
print(f" Queued: {len(pending)} task(s)")
|
|
743
|
+
if notifs:
|
|
744
|
+
print(f" Notifications: {len(notifs)}")
|
|
745
|
+
if active or pending or notifs:
|
|
746
|
+
print()
|
|
747
|
+
store.close()
|
|
748
|
+
|
|
749
|
+
while True:
|
|
750
|
+
try:
|
|
751
|
+
task = input("> ").strip()
|
|
752
|
+
except (KeyboardInterrupt, EOFError):
|
|
753
|
+
print("\nBye.")
|
|
754
|
+
break
|
|
755
|
+
|
|
756
|
+
if not task:
|
|
757
|
+
continue
|
|
758
|
+
if task.lower() in ("exit", "quit", "q"):
|
|
759
|
+
print("Bye.")
|
|
760
|
+
break
|
|
761
|
+
if task.lower() == "help":
|
|
762
|
+
print(" Type any task to run it. Examples:")
|
|
763
|
+
print(' Summarize the files in this directory')
|
|
764
|
+
print(' Read README.md and explain the architecture')
|
|
765
|
+
print()
|
|
766
|
+
print(" Commands:")
|
|
767
|
+
print(" help Show this help")
|
|
768
|
+
print(" status Show desk state")
|
|
769
|
+
print(" config Show current settings")
|
|
770
|
+
print(" exit Quit")
|
|
771
|
+
print()
|
|
772
|
+
continue
|
|
773
|
+
if task.lower() == "status":
|
|
774
|
+
_status(config)
|
|
775
|
+
print()
|
|
776
|
+
continue
|
|
777
|
+
if task.lower() == "config":
|
|
778
|
+
_config_show()
|
|
779
|
+
print()
|
|
780
|
+
continue
|
|
781
|
+
|
|
782
|
+
print()
|
|
783
|
+
try:
|
|
784
|
+
asyncio.run(_run_one_task(task, config))
|
|
785
|
+
except KeyboardInterrupt:
|
|
786
|
+
print("\n (interrupted)")
|
|
787
|
+
except Exception as exc:
|
|
788
|
+
print(f" Error: {exc}")
|
|
789
|
+
print()
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def main() -> None:
|
|
793
|
+
argv = sys.argv[1:]
|
|
794
|
+
|
|
795
|
+
# Subcommands: init, setup, config
|
|
796
|
+
if argv and argv[0] == "init":
|
|
797
|
+
init_parser = _build_init_parser()
|
|
798
|
+
init_parser.parse_args(argv[1:])
|
|
799
|
+
_init_pascal_home()
|
|
800
|
+
return
|
|
801
|
+
|
|
802
|
+
if argv and argv[0] == "setup":
|
|
803
|
+
_setup_interactive()
|
|
804
|
+
return
|
|
805
|
+
|
|
806
|
+
if argv and argv[0] == "config":
|
|
807
|
+
rest = argv[1:]
|
|
808
|
+
if not rest:
|
|
809
|
+
_config_show()
|
|
810
|
+
elif rest[0] == "set" and len(rest) >= 3:
|
|
811
|
+
_config_set(rest[1], rest[2])
|
|
812
|
+
elif rest[0] == "get" and len(rest) >= 2:
|
|
813
|
+
_config_get(rest[1])
|
|
814
|
+
else:
|
|
815
|
+
print("Usage:")
|
|
816
|
+
print(" pascal config Show all settings")
|
|
817
|
+
print(" pascal config set <key> <value> Set a value")
|
|
818
|
+
print(" pascal config get <key> Get a value")
|
|
819
|
+
print(f"\nKeys: {', '.join(sorted(_CONFIG_KEYS))}")
|
|
820
|
+
return
|
|
821
|
+
|
|
822
|
+
parser = _build_parser()
|
|
823
|
+
args = parser.parse_args(argv)
|
|
824
|
+
|
|
825
|
+
if args.init:
|
|
826
|
+
_init_pascal_home()
|
|
827
|
+
return
|
|
828
|
+
|
|
829
|
+
# No task + no flags → interactive REPL
|
|
830
|
+
has_action = (
|
|
831
|
+
args.task or args.status or args.notify or args.add_rule
|
|
832
|
+
or args.daemon or args.tick or getattr(args, "resume", None)
|
|
833
|
+
or args.mission
|
|
834
|
+
)
|
|
835
|
+
if not has_action:
|
|
836
|
+
_interactive()
|
|
837
|
+
return
|
|
838
|
+
|
|
839
|
+
# Ensure initialized before any real work
|
|
840
|
+
if not _ensure_ready():
|
|
841
|
+
return
|
|
842
|
+
|
|
843
|
+
config = load_config(
|
|
844
|
+
**({"model": args.model} if args.model else {}),
|
|
845
|
+
**({"provider": args.provider} if args.provider else {}),
|
|
846
|
+
**({"base_url": args.base_url} if args.base_url else {}),
|
|
847
|
+
**({"max_inner_turns": args.max_inner_turns} if args.max_inner_turns is not None else {}),
|
|
848
|
+
**({"max_tool_rounds": args.max_tool_rounds} if getattr(args, "max_tool_rounds", None) is not None else {}),
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
if args.verbose:
|
|
852
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
853
|
+
else:
|
|
854
|
+
# Suppress noisy MCP/connection warnings in normal mode
|
|
855
|
+
logging.basicConfig(level=logging.ERROR)
|
|
856
|
+
|
|
857
|
+
if args.daemon:
|
|
858
|
+
from pascal.daemon import run_daemon
|
|
859
|
+
asyncio.run(run_daemon(config, args))
|
|
860
|
+
return
|
|
861
|
+
elif getattr(args, "resume", None):
|
|
862
|
+
_resume(args, config)
|
|
863
|
+
elif args.tick:
|
|
864
|
+
from pascal.scheduler import Scheduler
|
|
865
|
+
store = PascalStore(config.db_path)
|
|
866
|
+
result = Scheduler(store, emit=print).tick()
|
|
867
|
+
print(json.dumps(result, indent=2))
|
|
868
|
+
store.close()
|
|
869
|
+
elif args.status:
|
|
870
|
+
_status(config)
|
|
871
|
+
elif args.notify:
|
|
872
|
+
_notify(args, config)
|
|
873
|
+
elif args.add_rule:
|
|
874
|
+
_add_rule(args, config)
|
|
875
|
+
else:
|
|
876
|
+
asyncio.run(_run(args, config))
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
if __name__ == "__main__":
|
|
880
|
+
main()
|