loopentx 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.
loopentx/cli/main.py ADDED
@@ -0,0 +1,398 @@
1
+ """Loopentx CLI — deploy, inspect, trust, runs, escalations, memory, worker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import importlib
7
+ import importlib.util
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import click
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+ from rich.panel import Panel
16
+ from rich import box
17
+
18
+ console = Console()
19
+
20
+
21
+ # ── Helpers ───────────────────────────────────────────────────────────────────
22
+
23
+ def _load_config(config_path: str) -> None:
24
+ path = Path(config_path)
25
+ if not path.exists():
26
+ return
27
+ spec = importlib.util.spec_from_file_location("loopentx_config", path)
28
+ if spec and spec.loader:
29
+ mod = importlib.util.module_from_spec(spec)
30
+ spec.loader.exec_module(mod) # type: ignore[attr-defined]
31
+
32
+
33
+ def _load_app(app: str) -> object:
34
+ try:
35
+ return importlib.import_module(app)
36
+ except Exception as e:
37
+ console.print(f"[red]Failed to load app module '{app}': {e}[/red]")
38
+ sys.exit(1)
39
+
40
+
41
+ # ── Root ──────────────────────────────────────────────────────────────────────
42
+
43
+ @click.group()
44
+ @click.version_option()
45
+ def cli() -> None:
46
+ """Loopentx — write the loop once. Step back."""
47
+
48
+
49
+ # ── Worker ────────────────────────────────────────────────────────────────────
50
+
51
+ @cli.group()
52
+ def worker() -> None:
53
+ """Manage the Loopentx worker."""
54
+
55
+
56
+ @worker.command("start")
57
+ @click.option("--app", "-a", required=True, help="Module path (e.g. myapp.loops)")
58
+ @click.option("--config", "-c", default="loopentx_config.py")
59
+ def worker_start(app: str, config: str) -> None:
60
+ """Start the worker and begin processing loops and events."""
61
+ console.print(Panel.fit("[bold green]Starting Loopentx Worker[/bold green]"))
62
+ _load_config(config)
63
+ module = _load_app(app)
64
+
65
+ from loopentx.core.orchestrator import Orchestrator
66
+ orch = Orchestrator()
67
+
68
+ for name in dir(module):
69
+ obj = getattr(module, name)
70
+ kind = getattr(obj, "_loopentx_kind", None)
71
+ if kind in ("skill", "loop"):
72
+ orch.register(obj)
73
+
74
+ console.print(f"[green]Registered {len(orch._skills)} skill(s), "
75
+ f"{len(orch._loops)} loop(s)[/green]")
76
+
77
+ try:
78
+ asyncio.run(orch.start())
79
+ except KeyboardInterrupt:
80
+ console.print("\n[yellow]Worker stopped.[/yellow]")
81
+
82
+
83
+ # ── Deploy ────────────────────────────────────────────────────────────────────
84
+
85
+ @cli.group()
86
+ def deploy() -> None:
87
+ """Deploy loops and skills."""
88
+
89
+
90
+ @deploy.command("skill")
91
+ @click.argument("skill_name")
92
+ @click.option("--app", "-a", required=True)
93
+ @click.option("--config", "-c", default="loopentx_config.py")
94
+ def deploy_skill(skill_name: str, app: str, config: str) -> None:
95
+ """Register a skill with the orchestrator."""
96
+ async def _run() -> None:
97
+ _load_config(config)
98
+ module = _load_app(app)
99
+ fn = getattr(module, skill_name, None)
100
+ if fn is None or not hasattr(fn, "_loopentx_skill"):
101
+ console.print(f"[red]Skill '{skill_name}' not found in {app}[/red]")
102
+ sys.exit(1)
103
+
104
+ from loopentx.core.config import get_config
105
+ from loopentx.core.models import SkillRegistration
106
+ cfg = get_config()
107
+ pc = fn._loopentx_skill.policy_context
108
+ reg = pc.to_registration(skill_name) if pc else SkillRegistration(
109
+ name=skill_name, kind="skill", is_active=True)
110
+ await cfg.backend.save_skill_registration(reg)
111
+
112
+ console.print(f"[green]✓ Deployed:[/green] {skill_name}")
113
+ if reg.is_shadow:
114
+ console.print(f" [yellow]Shadow mode:[/yellow] {reg.shadow_cycles} cycles required")
115
+ elif reg.require_approval:
116
+ console.print(f" [yellow]Awaiting approval[/yellow]")
117
+ else:
118
+ console.print(f" [green]Status:[/green] Active")
119
+ asyncio.run(_run())
120
+
121
+
122
+ @deploy.command("loop")
123
+ @click.argument("loop_name")
124
+ @click.option("--app", "-a", required=True)
125
+ @click.option("--config", "-c", default="loopentx_config.py")
126
+ def deploy_loop(loop_name: str, app: str, config: str) -> None:
127
+ """Register a loop with the orchestrator."""
128
+ async def _run() -> None:
129
+ _load_config(config)
130
+ module = _load_app(app)
131
+ fn = getattr(module, loop_name, None)
132
+ if fn is None or not hasattr(fn, "_loopentx_loop"):
133
+ console.print(f"[red]Loop '{loop_name}' not found in {app}[/red]")
134
+ sys.exit(1)
135
+ from loopentx.core.config import get_config
136
+ from loopentx.core.models import SkillRegistration
137
+ ld = fn._loopentx_loop
138
+ cfg = get_config()
139
+ reg = SkillRegistration(
140
+ name=loop_name, kind="loop", is_active=True,
141
+ cron=ld.cron, description=ld.description,
142
+ )
143
+ await cfg.backend.save_skill_registration(reg)
144
+ console.print(f"[green]✓ Deployed loop:[/green] {loop_name}")
145
+ asyncio.run(_run())
146
+
147
+
148
+ # ── Inspect ───────────────────────────────────────────────────────────────────
149
+
150
+ @cli.group()
151
+ def inspect() -> None:
152
+ """Inspect loops, skills, and runs."""
153
+
154
+
155
+ @inspect.command("skill")
156
+ @click.argument("skill_name")
157
+ @click.option("--config", "-c", default="loopentx_config.py")
158
+ def inspect_skill(skill_name: str, config: str) -> None:
159
+ """Show policy, trust score, and recent runs for a skill."""
160
+ async def _run() -> None:
161
+ _load_config(config)
162
+ from loopentx.core.config import get_config
163
+ from loopentx.trust.scorer import TrustScorer
164
+ cfg = get_config()
165
+ reg = await cfg.backend.get_skill_registration(skill_name)
166
+ trust = await cfg.backend.get_trust_record(skill_name)
167
+ runs = await cfg.backend.get_runs(skill_name=skill_name, limit=10)
168
+
169
+ if not reg:
170
+ console.print(f"[red]Skill '{skill_name}' not registered.[/red]")
171
+ return
172
+
173
+ console.print(Panel(
174
+ f"[bold]Status:[/bold] {'🟢 Active' if reg.is_active else '🟡 Inactive'}\n"
175
+ f"[bold]Kind:[/bold] {reg.kind}\n"
176
+ f"[bold]Blast radius:[/bold] {reg.blast_radius.value}\n"
177
+ f"[bold]Can read:[/bold] {', '.join(reg.can_read) or 'none'}\n"
178
+ f"[bold]Can write:[/bold] {', '.join(reg.can_write) or 'none'}\n"
179
+ f"[bold]Shadow cycles remaining:[/bold] {reg.shadow_cycles_remaining}",
180
+ title=f"[bold cyan]{skill_name}[/bold cyan]",
181
+ ))
182
+
183
+ if trust:
184
+ scorer = TrustScorer()
185
+ console.print(Panel(scorer.explain(trust), title="Trust"))
186
+ else:
187
+ console.print("[dim]No trust data yet.[/dim]")
188
+
189
+ if runs:
190
+ table = Table(box=box.SIMPLE, title="Recent runs")
191
+ table.add_column("Run ID"); table.add_column("Status")
192
+ table.add_column("Duration"); table.add_column("Shadow")
193
+ for run in runs:
194
+ c = {"completed": "green", "failed": "red"}.get(run.status.value, "white")
195
+ table.add_row(
196
+ run.id[:10] + "…", f"[{c}]{run.status.value}[/{c}]",
197
+ f"{run.duration_ms}ms" if run.duration_ms else "-",
198
+ "yes" if run.is_shadow else "no",
199
+ )
200
+ console.print(table)
201
+ asyncio.run(_run())
202
+
203
+
204
+ # ── Runs ──────────────────────────────────────────────────────────────────────
205
+
206
+ @cli.group()
207
+ def runs() -> None:
208
+ """Query run history."""
209
+
210
+
211
+ @runs.command("list")
212
+ @click.option("--skill", "-s", default=None)
213
+ @click.option("--last", "-l", default=None, help="e.g. 7d, 24h")
214
+ @click.option("--limit", "-n", default=20)
215
+ @click.option("--config", "-c", default="loopentx_config.py")
216
+ def runs_list(skill: Optional[str], last: Optional[str], limit: int, config: str) -> None:
217
+ """List recent runs."""
218
+ async def _run() -> None:
219
+ import time as _time
220
+ _load_config(config)
221
+ from loopentx.core.config import get_config
222
+ cfg = get_config()
223
+ since = None
224
+ if last:
225
+ u = last[-1]
226
+ v = int(last[:-1])
227
+ since = _time.time() - v * {"h": 3600, "d": 86400, "w": 604800}.get(u, 3600)
228
+ run_list = await cfg.backend.get_runs(skill_name=skill, since=since, limit=limit)
229
+ table = Table(box=box.SIMPLE)
230
+ table.add_column("Run ID"); table.add_column("Skill")
231
+ table.add_column("Status"); table.add_column("Trigger"); table.add_column("Iter")
232
+ for r in run_list:
233
+ c = {"completed": "green", "failed": "red"}.get(r.status.value, "white")
234
+ table.add_row(
235
+ r.id[:10] + "…", r.skill_name,
236
+ f"[{c}]{r.status.value}[/{c}]", r.trigger, str(r.iteration),
237
+ )
238
+ console.print(table)
239
+ console.print(f"[dim]{len(run_list)} run(s)[/dim]")
240
+ asyncio.run(_run())
241
+
242
+
243
+ # ── Trust ─────────────────────────────────────────────────────────────────────
244
+
245
+ @cli.group()
246
+ def trust() -> None:
247
+ """Manage skill trust scores and approvals."""
248
+
249
+
250
+ @trust.command("list")
251
+ @click.option("--config", "-c", default="loopentx_config.py")
252
+ def trust_list(config: str) -> None:
253
+ """Show trust scores for all registered skills."""
254
+ async def _run() -> None:
255
+ _load_config(config)
256
+ from loopentx.core.config import get_config
257
+ cfg = get_config()
258
+ skills = await cfg.backend.list_skill_registrations()
259
+ table = Table(box=box.SIMPLE, title="Trust scores")
260
+ table.add_column("Name"); table.add_column("Kind")
261
+ table.add_column("Level"); table.add_column("Score")
262
+ table.add_column("Runs"); table.add_column("Status")
263
+ for reg in skills:
264
+ tr = await cfg.backend.get_trust_record(reg.name)
265
+ score = f"{tr.trust_score:.2f}" if tr else "-"
266
+ level = tr.trust_level.value if tr else "-"
267
+ runs = str(tr.total_runs) if tr else "0"
268
+ status = "🟢 active" if reg.is_active else "🟡 pending"
269
+ table.add_row(reg.name, reg.kind, level, score, runs, status)
270
+ console.print(table)
271
+ asyncio.run(_run())
272
+
273
+
274
+ @trust.command("approve")
275
+ @click.argument("skill_name")
276
+ @click.option("--by", default="human")
277
+ @click.option("--config", "-c", default="loopentx_config.py")
278
+ def trust_approve(skill_name: str, by: str, config: str) -> None:
279
+ """Approve a skill for live execution."""
280
+ async def _run() -> None:
281
+ _load_config(config)
282
+ from loopentx.core.config import get_config
283
+ from loopentx.trust.scorer import TrustScore
284
+ cfg = get_config()
285
+ await cfg.backend.approve_skill(skill_name, approved_by=by)
286
+ await TrustScore.approve(skill_name, approved_by=by)
287
+ console.print(f"[green]✓ Approved:[/green] {skill_name} (by {by})")
288
+ asyncio.run(_run())
289
+
290
+
291
+ @trust.command("reject")
292
+ @click.argument("skill_name")
293
+ @click.option("--by", default="human")
294
+ @click.option("--config", "-c", default="loopentx_config.py")
295
+ def trust_reject(skill_name: str, by: str, config: str) -> None:
296
+ """Reject and pause a skill."""
297
+ async def _run() -> None:
298
+ _load_config(config)
299
+ from loopentx.core.config import get_config
300
+ from loopentx.trust.scorer import TrustScore
301
+ cfg = get_config()
302
+ await cfg.backend.set_skill_active(skill_name, active=False)
303
+ await TrustScore.reject(skill_name, rejected_by=by)
304
+ console.print(f"[red]✗ Rejected:[/red] {skill_name} — skill paused")
305
+ asyncio.run(_run())
306
+
307
+
308
+ # ── Memory ────────────────────────────────────────────────────────────────────
309
+
310
+ @cli.group()
311
+ def memory() -> None:
312
+ """Inspect and manage loop memory."""
313
+
314
+
315
+ @memory.command("show")
316
+ @click.argument("loop_name")
317
+ @click.option("--config", "-c", default="loopentx_config.py")
318
+ def memory_show(loop_name: str, config: str) -> None:
319
+ """Show current memory for a loop."""
320
+ async def _run() -> None:
321
+ _load_config(config)
322
+ from loopentx.core.config import get_config
323
+ cfg = get_config()
324
+ rec = await cfg.backend.get_loop_memory(loop_name)
325
+ if not rec:
326
+ console.print(f"[dim]No memory stored for '{loop_name}'.[/dim]")
327
+ return
328
+ console.print(Panel(
329
+ "\n".join(
330
+ f"[bold]{k}:[/bold] {v.value}" for k, v in rec.entries.items()
331
+ ) or "(empty)",
332
+ title=f"Memory: {loop_name}",
333
+ ))
334
+ for k, v in rec.lists.items():
335
+ console.print(f" [bold]{k}[/bold] ({len(v)} items): {v[-3:]}")
336
+ asyncio.run(_run())
337
+
338
+
339
+ @memory.command("clear")
340
+ @click.argument("loop_name")
341
+ @click.option("--config", "-c", default="loopentx_config.py")
342
+ def memory_clear(loop_name: str, config: str) -> None:
343
+ """Clear all memory for a loop."""
344
+ async def _run() -> None:
345
+ _load_config(config)
346
+ from loopentx.core.config import get_config
347
+ from loopentx.core.models import LoopMemoryRecord
348
+ cfg = get_config()
349
+ await cfg.backend.save_loop_memory(LoopMemoryRecord(loop_name=loop_name))
350
+ console.print(f"[yellow]Cleared memory for '{loop_name}'.[/yellow]")
351
+ asyncio.run(_run())
352
+
353
+
354
+ # ── Escalations ───────────────────────────────────────────────────────────────
355
+
356
+ @cli.group()
357
+ def escalations() -> None:
358
+ """Manage loop escalations."""
359
+
360
+
361
+ @escalations.command("list")
362
+ @click.option("--config", "-c", default="loopentx_config.py")
363
+ def escalations_list(config: str) -> None:
364
+ """List pending escalations awaiting human response."""
365
+ async def _run() -> None:
366
+ _load_config(config)
367
+ from loopentx.core.config import get_config
368
+ cfg = get_config()
369
+ pending = await cfg.backend.list_pending_escalations()
370
+ if not pending:
371
+ console.print("[dim]No pending escalations.[/dim]")
372
+ return
373
+ table = Table(box=box.SIMPLE, title="Pending escalations")
374
+ table.add_column("ID"); table.add_column("Skill")
375
+ table.add_column("Message"); table.add_column("Fallback")
376
+ for e in pending:
377
+ table.add_row(e.id[:10] + "…", e.skill_name, e.message[:60], e.fallback)
378
+ console.print(table)
379
+ asyncio.run(_run())
380
+
381
+
382
+ @escalations.command("respond")
383
+ @click.argument("escalation_id")
384
+ @click.argument("response")
385
+ @click.option("--config", "-c", default="loopentx_config.py")
386
+ def escalations_respond(escalation_id: str, response: str, config: str) -> None:
387
+ """Provide a human response to an escalation."""
388
+ async def _run() -> None:
389
+ _load_config(config)
390
+ from loopentx.core.orchestrator import Orchestrator
391
+ orch = Orchestrator()
392
+ await orch.respond_to_escalation(escalation_id, response)
393
+ console.print(f"[green]✓ Response sent:[/green] {response}")
394
+ asyncio.run(_run())
395
+
396
+
397
+ if __name__ == "__main__":
398
+ cli()
@@ -0,0 +1,28 @@
1
+ """Loopentx core primitives."""
2
+
3
+ from loopentx.core.loop import loop
4
+ from loopentx.core.skill import skill
5
+ from loopentx.core.context import LoopContext
6
+ from loopentx.core.memory import LoopMemory
7
+ from loopentx.core.orchestrator import Orchestrator
8
+ from loopentx.core.config import configure, get_config
9
+ from loopentx.core.events import event, LoopentxEvent
10
+ from loopentx.core.models import (
11
+ RunRecord, StepRecord, SkillRegistration,
12
+ TrustRecord, LoopMemoryRecord,
13
+ )
14
+ from loopentx.core.exceptions import (
15
+ LoopentxError, StepError, SkillError,
16
+ PolicyViolationError, SkillNotApprovedError,
17
+ EscalationTimeoutError,
18
+ )
19
+
20
+ __all__ = [
21
+ "loop", "skill", "event", "configure", "get_config",
22
+ "LoopContext", "LoopMemory", "Orchestrator", "LoopentxEvent",
23
+ "RunRecord", "StepRecord", "SkillRegistration",
24
+ "TrustRecord", "LoopMemoryRecord",
25
+ "LoopentxError", "StepError", "SkillError",
26
+ "PolicyViolationError", "SkillNotApprovedError",
27
+ "EscalationTimeoutError",
28
+ ]
@@ -0,0 +1,67 @@
1
+ """Global configuration for Loopentx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Optional, TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from loopentx.backends.base import BaseBackend
10
+
11
+ _config: Optional["LoopentxConfig"] = None
12
+
13
+
14
+ @dataclass
15
+ class LoopentxConfig:
16
+ backend: "BaseBackend"
17
+ llm_provider: str = "openai"
18
+ llm_model: str = "gpt-4o"
19
+ llm_api_key: Optional[str] = None
20
+ llm_extra: dict[str, Any] = field(default_factory=dict)
21
+ default_shadow_cycles: int = 0
22
+ default_blast_radius: str = "low"
23
+ auto_approve_low_blast: bool = True
24
+ worker_concurrency: int = 10
25
+ worker_poll_interval: float = 1.0
26
+ log_level: str = "INFO"
27
+ store_step_outputs: bool = True
28
+
29
+
30
+ def configure(
31
+ backend: "BaseBackend",
32
+ llm_provider: str = "openai",
33
+ llm_model: str = "gpt-4o",
34
+ llm_api_key: Optional[str] = None,
35
+ **kwargs: Any,
36
+ ) -> LoopentxConfig:
37
+ """Configure Loopentx. Call once at application startup.
38
+
39
+ Example:
40
+ from loopentx import configure
41
+ from loopentx.backends import RedisBackend
42
+
43
+ configure(
44
+ backend=RedisBackend("redis://localhost:6379"),
45
+ llm_provider="anthropic",
46
+ llm_model="claude-sonnet-4-6",
47
+ )
48
+ """
49
+ global _config
50
+ _config = LoopentxConfig(
51
+ backend=backend,
52
+ llm_provider=llm_provider,
53
+ llm_model=llm_model,
54
+ llm_api_key=llm_api_key,
55
+ **kwargs,
56
+ )
57
+ return _config
58
+
59
+
60
+ def get_config() -> LoopentxConfig:
61
+ """Return the current global configuration."""
62
+ if _config is None:
63
+ raise RuntimeError(
64
+ "Loopentx is not configured. "
65
+ "Call loopentx.configure() before using the framework."
66
+ )
67
+ return _config