omega-code 0.4.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.
Files changed (73) hide show
  1. omega/__init__.py +0 -0
  2. omega/__main__.py +589 -0
  3. omega/artifacts.py +151 -0
  4. omega/checkpoint.py +246 -0
  5. omega/compact.py +106 -0
  6. omega/config.py +285 -0
  7. omega/eval/__init__.py +3 -0
  8. omega/eval/cli.py +127 -0
  9. omega/eval/examples/plan-version-flag.yaml +11 -0
  10. omega/eval/examples/relative-age-negative-delta.yaml +14 -0
  11. omega/eval/examples/version-flag.yaml +10 -0
  12. omega/eval/manifest.py +129 -0
  13. omega/eval/prices.py +29 -0
  14. omega/eval/report.py +135 -0
  15. omega/eval/runner.py +199 -0
  16. omega/eval/tasks.py +97 -0
  17. omega/events.py +145 -0
  18. omega/export.py +80 -0
  19. omega/gitlog.py +229 -0
  20. omega/hooks.py +63 -0
  21. omega/instructions.py +103 -0
  22. omega/integrations.py +284 -0
  23. omega/keys.py +173 -0
  24. omega/llm.py +442 -0
  25. omega/loop.py +510 -0
  26. omega/mcp.py +490 -0
  27. omega/memory/__init__.py +5 -0
  28. omega/memory/consolidate.py +103 -0
  29. omega/memory/curate.py +69 -0
  30. omega/memory/store.py +321 -0
  31. omega/memory/tools.py +175 -0
  32. omega/migrate.py +40 -0
  33. omega/onboarding.py +242 -0
  34. omega/permissions.py +137 -0
  35. omega/secrets.py +173 -0
  36. omega/server/__init__.py +7 -0
  37. omega/server/__main__.py +18 -0
  38. omega/server/app.py +71 -0
  39. omega/server/auth.py +73 -0
  40. omega/server/manager.py +287 -0
  41. omega/server/models.py +123 -0
  42. omega/server/tasks_api.py +311 -0
  43. omega/server/terminals.py +245 -0
  44. omega/server/worker.py +186 -0
  45. omega/session.py +209 -0
  46. omega/setup.html +281 -0
  47. omega/setup_server.py +452 -0
  48. omega/skills.py +158 -0
  49. omega/subagent.py +98 -0
  50. omega/tasks.py +195 -0
  51. omega/tools.py +590 -0
  52. omega/trace.py +156 -0
  53. omega/trajectory.py +146 -0
  54. omega/ui/__init__.py +0 -0
  55. omega/ui/composer.py +140 -0
  56. omega/ui/format.py +708 -0
  57. omega/ui/plain.py +141 -0
  58. omega/ui/tui/__init__.py +9 -0
  59. omega/ui/tui/app.py +958 -0
  60. omega/ui/tui/history.py +50 -0
  61. omega/ui/tui/modals.py +292 -0
  62. omega/ui/tui/onboarding.py +367 -0
  63. omega/ui/tui/prefs.py +25 -0
  64. omega/ui/tui/sidebar.py +510 -0
  65. omega/ui/tui/status.py +115 -0
  66. omega/ui/tui/theme.py +91 -0
  67. omega/ui/tui/transcript.py +783 -0
  68. omega/verify.py +133 -0
  69. omega_code-0.4.0.dist-info/METADATA +479 -0
  70. omega_code-0.4.0.dist-info/RECORD +73 -0
  71. omega_code-0.4.0.dist-info/WHEEL +4 -0
  72. omega_code-0.4.0.dist-info/entry_points.txt +2 -0
  73. omega_code-0.4.0.dist-info/licenses/LICENSE +21 -0
omega/setup_server.py ADDED
@@ -0,0 +1,452 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import re
5
+ import secrets
6
+ import shlex
7
+ import threading
8
+ import webbrowser
9
+ from collections.abc import Callable
10
+ from functools import partial
11
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.parse import ParseResult, parse_qs, urlparse
15
+
16
+ import httpx
17
+
18
+ from . import config, events, integrations, mcp
19
+
20
+ Body = dict[str, Any]
21
+
22
+ UI = Path(__file__).parent / "setup.html"
23
+ TOKEN = secrets.token_urlsafe(16)
24
+
25
+
26
+ def _cfg() -> Body:
27
+ p = config.CONFIG_PATH
28
+ if p.exists():
29
+ return dict(json.loads(config._strip_jsonc(p.read_text())))
30
+ return config.DEFAULTS
31
+
32
+
33
+ ALLOWED_TOP = {"providers", "models", "roles", "mcp"}
34
+
35
+
36
+ def _validate(data: Body) -> Body:
37
+ if not isinstance(data, dict):
38
+ raise ValueError("config must be an object")
39
+ unknown = set(data) - ALLOWED_TOP
40
+ if unknown:
41
+ raise ValueError(f"unexpected keys: {sorted(unknown)}")
42
+ for name, prov in (data.get("providers") or {}).items():
43
+ if prov.get("type") == "anthropic":
44
+ continue # no baseUrl needed -- the SDK talks to api.anthropic.com directly
45
+ url = prov.get("baseUrl", "")
46
+ if not re.match(r"^https://[A-Za-z0-9.\-]+(:\d+)?(/[\w./\-]*)?$", url):
47
+ raise ValueError(f"provider {name!r}: baseUrl must be a plain https URL")
48
+ return data
49
+
50
+
51
+ def _save(data: Body) -> Body:
52
+ _validate(data)
53
+ p = config.CONFIG_PATH
54
+ p.parent.mkdir(parents=True, exist_ok=True)
55
+ p.write_text(json.dumps(data, indent=2) + "\n")
56
+ p.chmod(0o600)
57
+ return {"ok": True, "path": str(p)}
58
+
59
+
60
+ def _provider(body: Body) -> tuple[str, str]:
61
+ cfg = _cfg()
62
+ name = body.get("provider") or next(iter(cfg["providers"]))
63
+ p = cfg["providers"][name]
64
+ key = body.get("apiKey") or p.get("apiKey") or ""
65
+ if not key and p.get("apiKeyEnv"):
66
+ import os
67
+ key = os.environ.get(p["apiKeyEnv"], "")
68
+ return (p.get("baseUrl") or "").rstrip("/"), key
69
+
70
+
71
+ def _provider_type(body: Body) -> str:
72
+ cfg = _cfg()
73
+ name = body.get("provider") or next(iter(cfg["providers"]))
74
+ return str(cfg["providers"][name].get("type", "openai"))
75
+
76
+
77
+ def _anthropic_catalog() -> list[str]:
78
+ return sorted({m["model"] for m in config.DEFAULTS["models"].values()
79
+ if m["model"].startswith("claude")})
80
+
81
+
82
+ async def _probe_anthropic(model: str, key: str, prompt: str) -> Body:
83
+ """The latency/reachability probe, routed through llm.stream instead of a
84
+ raw httpx POST -- an anthropic provider speaks the Messages API, not
85
+ OpenAI's /chat/completions."""
86
+ import time
87
+
88
+ from . import llm
89
+ from .config import Provider, Role
90
+ if not key:
91
+ return {"model": model, "ok": False, "error": "no API key set"}
92
+ role = Role(model=model, provider=Provider(name="setup-probe", type="anthropic", api_key_literal=key),
93
+ context=200000)
94
+ t0 = time.perf_counter()
95
+ try:
96
+ async for kind, _payload in llm.stream(role, [{"role": "user", "content": prompt}]):
97
+ if kind == "done":
98
+ break
99
+ return {"model": model, "ok": True, "ms": round((time.perf_counter() - t0) * 1000)}
100
+ except Exception as e:
101
+ return {"model": model, "ok": False, "ms": round((time.perf_counter() - t0) * 1000),
102
+ "error": f"{type(e).__name__}: {e}"[:120]}
103
+
104
+
105
+ def api_models(body: Body) -> Body:
106
+ if _provider_type(body) == "anthropic":
107
+ return {"models": _anthropic_catalog()}
108
+ base, key = _provider(body)
109
+ if not key:
110
+ return {"error": "no API key set"}
111
+ r = httpx.get(f"{base}/models", headers={"Authorization": f"Bearer {key}"}, timeout=30)
112
+ if r.status_code != 200:
113
+ return {"error": f"HTTP {r.status_code}: {r.text[:200]}"}
114
+ return {"models": sorted(m["id"] for m in r.json().get("data", []))}
115
+
116
+
117
+ def api_test_model(body: Body) -> Body:
118
+ import time
119
+ model = body["model"]
120
+ if _provider_type(body) == "anthropic":
121
+ _base, key = _provider(body)
122
+ return asyncio.run(_probe_anthropic(model, key, "Reply with the single word: ok"))
123
+ base, key = _provider(body)
124
+ t0 = time.perf_counter()
125
+ try:
126
+ r = httpx.post(f"{base}/chat/completions",
127
+ headers={"Authorization": f"Bearer {key}"},
128
+ json={"model": model, "max_tokens": 40, "messages":
129
+ [{"role": "user", "content": "Reply with the single word: ok"}],
130
+ "tools": [{"type": "function", "function": {
131
+ "name": "noop", "description": "no-op",
132
+ "parameters": {"type": "object", "properties": {}}}}]},
133
+ timeout=60)
134
+ except Exception as e:
135
+ return {"model": model, "ok": False, "error": f"{type(e).__name__}"}
136
+ dt = (time.perf_counter() - t0) * 1000
137
+ if r.status_code != 200:
138
+ return {"model": model, "ok": False, "ms": round(dt),
139
+ "error": f"HTTP {r.status_code}: {r.text[:120]}"}
140
+ return {"model": model, "ok": True, "ms": round(dt)}
141
+
142
+
143
+ def api_mcp_discover(body: Body) -> Body:
144
+ out = []
145
+ for name, cfg in mcp.discover().items():
146
+ out.append({"name": name, "stdio": "command" in cfg,
147
+ "detail": cfg.get("command", cfg.get("url", ""))})
148
+ return {"servers": sorted(out, key=lambda s: (not s["stdio"], s["name"]))}
149
+
150
+
151
+ def api_mcp_test(body: Body) -> Body:
152
+ name, spec = body["name"], body["config"]
153
+ async def go() -> Body:
154
+ s = mcp.Server(name, spec)
155
+ try:
156
+ await s.start(timeout=120)
157
+ return {"ok": True, "tools": [t.name for t in s.tools]}
158
+ except Exception as e:
159
+ return {"ok": False, "error": str(e)[:200]}
160
+ finally:
161
+ await s.stop()
162
+ return asyncio.run(go())
163
+
164
+
165
+ def _status_json(st: "mcp.ServerStatus") -> Body:
166
+ return {"name": st.name, "enabled": st.enabled, "state": st.state,
167
+ "tools": st.tools, "error": st.error, "lastUsed": st.last_used}
168
+
169
+
170
+ def api_connections_list(_b: Body) -> Body:
171
+ configured = mcp.status()
172
+ raw = config.mcp_config()
173
+ known = set(configured)
174
+ claude = {n: c for n, c in integrations.imported_from_claude_code().items() if n not in known}
175
+
176
+ def row(name: str, st: "mcp.ServerStatus") -> Body:
177
+ key = raw.get(name, {}).get("catalog")
178
+ cat = integrations.CATALOG.get(key) if key else None
179
+ out = _status_json(st)
180
+ out["auth"] = cat.auth if cat else None
181
+ out["category"] = cat.category if cat else None
182
+ return out
183
+
184
+ configured_rows = [row(n, st) for n, st in sorted(configured.items())]
185
+ catalog_rows = [{"key": i.key, "name": i.name, "category": i.category, "blurb": i.blurb,
186
+ "auth": i.auth, "transport": i.transport, "verified": i.verified,
187
+ "env": list(i.env), "docs": i.docs}
188
+ for i in sorted(integrations.CATALOG.values(), key=lambda x: x.key)
189
+ if i.key not in known]
190
+ claude_rows = [{"name": n, **c} for n, c in sorted(claude.items())]
191
+ return {"configured": configured_rows, "catalog": catalog_rows, "claudeCode": claude_rows}
192
+
193
+
194
+ def api_connections_add(body: Body) -> Body:
195
+ name = body.get("name")
196
+ if not name:
197
+ return {"error": "name required"}
198
+
199
+ if body.get("source") == "claude-code":
200
+ # Never round-trip a real secret through the browser: read the
201
+ # unredacted spec server-side and write it straight into omega's config.
202
+ raw = mcp.discover(include_omega=False)
203
+ found = raw.get(name)
204
+ if found is None:
205
+ return {"error": f"{name!r} not found in Claude Code's config"}
206
+ mcp.add(name, dict(found))
207
+ return {"ok": True}
208
+
209
+ key = body.get("key")
210
+ catalog = integrations.CATALOG.get(key) if key else integrations.CATALOG.get(name)
211
+ spec: Body = {}
212
+ if catalog is not None:
213
+ spec["catalog"] = catalog.key
214
+ if catalog.transport == "remote" and catalog.url:
215
+ spec["url"] = catalog.url
216
+ elif catalog.command:
217
+ cmdline = [c.replace("<cwd>", os.getcwd()) for c in catalog.command]
218
+ spec["command"], spec["args"] = cmdline[0], cmdline[1:]
219
+
220
+ if body.get("url"):
221
+ spec["url"] = body["url"]
222
+ spec.pop("command", None)
223
+ spec.pop("args", None)
224
+ if body.get("command"):
225
+ parts = shlex.split(body["command"])
226
+ spec["command"], spec["args"] = parts[0], parts[1:]
227
+ spec.pop("url", None)
228
+ if body.get("env"):
229
+ spec["env"] = body["env"]
230
+
231
+ if not spec.get("command") and not spec.get("url"):
232
+ return {"error": "give a url, command, or known catalog key"}
233
+ mcp.add(name, spec)
234
+ return {"ok": True}
235
+
236
+
237
+ def api_connections_connect(body: Body) -> Body:
238
+ st = asyncio.run(mcp.connect(body["name"], timeout=body.get("timeout")))
239
+ return _status_json(st)
240
+
241
+
242
+ def api_connections_toggle(body: Body) -> Body:
243
+ try:
244
+ asyncio.run(mcp.enable(body["name"], bool(body.get("enabled"))))
245
+ except KeyError as e:
246
+ return {"error": str(e)}
247
+ return {"ok": True}
248
+
249
+
250
+ def api_connections_test(body: Body) -> Body:
251
+ async def go() -> "mcp.ServerStatus":
252
+ st = await mcp.connect(body["name"], timeout=body.get("timeout", 30))
253
+ await mcp.disconnect(body["name"])
254
+ return st
255
+ return _status_json(asyncio.run(go()))
256
+
257
+
258
+ def api_connections_remove(body: Body) -> Body:
259
+ asyncio.run(mcp.remove(body["name"]))
260
+ return {"ok": True}
261
+
262
+
263
+ BENCH_CANDIDATES = ["glm-5.3-flash", "kimi-k3", "gemini-2.5-flash-lite",
264
+ "gemini-3.5-flash-lite", "claude-haiku-4-5", "gpt-4.1-mini",
265
+ "deepseek-v4-flash", "kimi-k3-fast"]
266
+
267
+
268
+ def api_benchmark(body: Body) -> Body:
269
+ """Measure time-to-first-token and tool-calling for candidate models."""
270
+ import time
271
+ if _provider_type(body) == "anthropic":
272
+ _base, key = _provider(body)
273
+ models = body.get("models") or _anthropic_catalog()
274
+
275
+ async def go_anthropic() -> list[Body]:
276
+ return [await _probe_anthropic(m, key, "Say ok.") for m in models]
277
+
278
+ rows = asyncio.run(go_anthropic())
279
+ rows.sort(key=lambda r: (not r["ok"], r.get("ms", 9e9)))
280
+ return {"results": rows}
281
+
282
+ base, key = _provider(body)
283
+ models = body.get("models") or BENCH_CANDIDATES
284
+ catalog = api_models(body).get("models", [])
285
+ if catalog:
286
+ models = [m for m in models if m in catalog]
287
+
288
+ async def one(client: httpx.AsyncClient, model: str) -> Body:
289
+ t0 = time.perf_counter()
290
+ try:
291
+ r = await client.post(f"{base}/chat/completions",
292
+ headers={"Authorization": f"Bearer {key}"},
293
+ json={"model": model, "max_tokens": 30, "messages":
294
+ [{"role": "user", "content": "Say ok."}],
295
+ "tools": [{"type": "function", "function": {"name": "noop",
296
+ "description": "no-op",
297
+ "parameters": {"type": "object", "properties": {}}}}]},
298
+ timeout=45)
299
+ ms = round((time.perf_counter() - t0) * 1000)
300
+ if r.status_code != 200:
301
+ return {"model": model, "ok": False, "error": f"HTTP {r.status_code}"}
302
+ return {"model": model, "ok": True, "ms": ms}
303
+ except Exception as e:
304
+ return {"model": model, "ok": False, "error": type(e).__name__}
305
+
306
+ async def go() -> list[Body]:
307
+ # Sequential on purpose: concurrent requests contend and scramble the
308
+ # ranking, which is the one thing this measurement exists to produce.
309
+ async with httpx.AsyncClient() as c:
310
+ return [await one(c, m) for m in models]
311
+
312
+ rows = asyncio.run(go())
313
+ rows.sort(key=lambda r: (not r["ok"], r.get("ms", 9e9)))
314
+ return {"results": rows}
315
+
316
+
317
+ PROBE_PROMPT = ("Run 'pwd' and 'date' with bash, then say in one short "
318
+ "sentence where you are and what day it is.")
319
+
320
+
321
+ def api_agent(body: Body) -> Body:
322
+ """Run one real omega turn so onboarding ends on proof it works."""
323
+ from . import loop
324
+ calls: list[str] = []
325
+ def emit(ev: events.Event) -> None:
326
+ if isinstance(ev, events.ToolStart):
327
+ calls.append(ev.name)
328
+ async def go() -> str:
329
+ cfg = config.load()
330
+ history: list[Body] = [{"role": "user", "content": PROBE_PROMPT}]
331
+ text = await loop.run_agent(cfg, "main",
332
+ loop.BUILD_SYSTEM, history,
333
+ emit=emit)
334
+ return text
335
+ try:
336
+ return {"ok": True, "text": asyncio.run(go()), "tools": calls}
337
+ except Exception as e:
338
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"[:250]}
339
+
340
+
341
+ def _redacted_cfg(_b: Body | None = None) -> Body:
342
+ import copy
343
+ c = copy.deepcopy(_cfg())
344
+ for prov in (c.get("providers") or {}).values():
345
+ if prov.get("apiKey"):
346
+ prov["apiKey"] = ""
347
+ prov["hasKey"] = True
348
+ prov["keyMask"] = "••••••••"
349
+ # A role may be written as a catalog alias ({"alias": "opus"}); the wizard
350
+ # only understands the inline {model, provider, context} shape, so resolve
351
+ # every role to that shape for the page regardless of how it's stored.
352
+ try:
353
+ resolved = config.load()
354
+ c["roles"] = {name: {"model": r.model, "provider": r.provider.name,
355
+ "context": r.context, "alias": r.alias}
356
+ for name, r in resolved.roles.items()}
357
+ except SystemExit:
358
+ pass
359
+ return c
360
+
361
+
362
+ ROUTES: dict[str, Callable[[Body], Body]] = {
363
+ "/api/config": _redacted_cfg,
364
+ "/api/save": _save,
365
+ "/api/models": api_models,
366
+ "/api/test-model": api_test_model,
367
+ "/api/mcp/discover": api_mcp_discover,
368
+ "/api/mcp/test": api_mcp_test,
369
+ "/api/connections/list": api_connections_list,
370
+ "/api/connections/add": api_connections_add,
371
+ "/api/connections/connect": api_connections_connect,
372
+ "/api/connections/toggle": api_connections_toggle,
373
+ "/api/connections/test": api_connections_test,
374
+ "/api/connections/remove": api_connections_remove,
375
+ "/api/benchmark": api_benchmark,
376
+ "/api/agent": api_agent,
377
+ }
378
+
379
+
380
+ class Handler(BaseHTTPRequestHandler):
381
+ def log_message(self, *a: Any) -> None:
382
+ pass
383
+
384
+ def _authed(self, url: ParseResult) -> bool:
385
+ supplied = (self.headers.get("X-Omega-Token")
386
+ or parse_qs(url.query).get("t", [""])[0])
387
+ return secrets.compare_digest(supplied, TOKEN)
388
+
389
+ def _same_origin(self) -> bool:
390
+ """Blocks DNS rebinding and cross-origin CSRF. Without this, a page you
391
+ visit can drive these endpoints even without reading the response."""
392
+ host = (self.headers.get("Host") or "").split(",")[0].strip()
393
+ if host.rsplit(":", 1)[0] not in ("127.0.0.1", "localhost", "[::1]"):
394
+ return False
395
+ origin = self.headers.get("Origin")
396
+ if origin and origin not in (f"http://{host}",):
397
+ return False
398
+ if (self.headers.get("Sec-Fetch-Site") or "same-origin") not in (
399
+ "same-origin", "none"):
400
+ return False
401
+ return True
402
+
403
+ def _send(self, code: int, body: bytes | Body, ctype: str = "application/json") -> None:
404
+ raw = body if isinstance(body, bytes) else json.dumps(body).encode()
405
+ self.send_response(code)
406
+ self.send_header("Content-Type", ctype)
407
+ self.send_header("Content-Length", str(len(raw)))
408
+ self.end_headers()
409
+ self.wfile.write(raw)
410
+
411
+ def do_GET(self) -> None:
412
+ url = urlparse(self.path)
413
+ if not self._same_origin():
414
+ return self._send(403, {"error": "bad origin"})
415
+ if not self._authed(url):
416
+ return self._send(403, {"error": "bad token"})
417
+ if url.path == "/":
418
+ return self._send(200, UI.read_bytes(), "text/html; charset=utf-8")
419
+ # API is POST-only: GET dispatch let <img src="/api/agent?t=..."> run
420
+ # a full agent turn with bash from any page you happened to visit.
421
+ if url.path in ROUTES:
422
+ return self._send(405, {"error": "use POST"})
423
+ self._send(404, {"error": "not found"})
424
+
425
+ def do_POST(self) -> None:
426
+ url = urlparse(self.path)
427
+ if not self._same_origin():
428
+ return self._send(403, {"error": "bad origin"})
429
+ if not self._authed(url):
430
+ return self._send(403, {"error": "bad token"})
431
+ n = int(self.headers.get("Content-Length", 0))
432
+ body = json.loads(self.rfile.read(n) or b"{}")
433
+ fn = ROUTES.get(url.path)
434
+ if not fn:
435
+ return self._send(404, {"error": "not found"})
436
+ try:
437
+ self._send(200, fn(body))
438
+ except Exception as e:
439
+ self._send(200, {"error": f"{type(e).__name__}: {e}"[:300]})
440
+
441
+
442
+ def serve(port: int = 0, open_browser: bool = True) -> None:
443
+ httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
444
+ port = httpd.server_address[1]
445
+ url = f"http://127.0.0.1:{port}/?t={TOKEN}"
446
+ print(f"omega setup → {url}\nctrl-c to stop")
447
+ if open_browser:
448
+ threading.Timer(0.4, partial(webbrowser.open, url)).start()
449
+ try:
450
+ httpd.serve_forever()
451
+ except KeyboardInterrupt:
452
+ print()
omega/skills.py ADDED
@@ -0,0 +1,158 @@
1
+ """Skill discovery and the `skill` tool.
2
+
3
+ A skill is a markdown checklist (YAML frontmatter + body) the model loads on
4
+ demand and follows in the SAME conversation -- not a subagent. Spawning a
5
+ subagent would hide the steps from the transcript and lose the model's
6
+ accumulated context on this task; a skill is meant to steer the very loop
7
+ that's already running, the way a human would open a runbook mid-task.
8
+
9
+ Discovery mirrors Claude Code's `SKILL.md` format so a user's existing
10
+ ``~/.claude/skills/*`` work here unchanged, plus two omega-specific
11
+ locations:
12
+
13
+ - ``.omega/skills/*/SKILL.md`` -- project, highest precedence
14
+ - ``~/.omega/skills/*/SKILL.md`` -- global
15
+ - ``~/.claude/skills/*/SKILL.md`` -- Claude Code's own skills, lowest precedence
16
+
17
+ Plugin subdirectories (Claude Code's ``plugin:skill`` namespacing) are not
18
+ walked -- only the top-level ``*/SKILL.md`` layout.
19
+ """
20
+ import os
21
+ import re
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ import yaml
26
+
27
+ from . import tools
28
+
29
+ INDEX_MAX = 40
30
+ DESC_MAX = 90
31
+ BODY_MAX = 24_000
32
+ SKILL_TOOL = "skill"
33
+
34
+ _PROJECT_SUBDIR = ".omega/skills"
35
+ _GLOBAL_DIR = Path.home() / ".omega" / "skills"
36
+ _CLAUDE_DIR = Path.home() / ".claude" / "skills"
37
+
38
+ _FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?\n)---\s*\n?", re.DOTALL)
39
+ _MD_LINK_RE = re.compile(r"(\]\()([^)\s]+)(\))")
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Skill:
44
+ name: str
45
+ description: str
46
+ path: Path
47
+ source: str # "project" | "global" | "claude"
48
+
49
+
50
+ def _parse(path: Path, source: str) -> Skill | None:
51
+ try:
52
+ text = path.read_text(errors="replace")
53
+ except OSError:
54
+ return None
55
+ name, description = path.parent.name, ""
56
+ m = _FRONTMATTER_RE.match(text)
57
+ if m:
58
+ try:
59
+ meta = yaml.safe_load(m.group(1))
60
+ except yaml.YAMLError:
61
+ meta = None
62
+ if isinstance(meta, dict):
63
+ name = str(meta.get("name") or name)
64
+ description = str(meta.get("description") or "")
65
+ return Skill(name=name, description=description, path=path, source=source)
66
+
67
+
68
+ def _discover(root: Path, source: str) -> dict[str, Skill]:
69
+ if not root.is_dir():
70
+ return {}
71
+ found: dict[str, Skill] = {}
72
+ for skill_md in sorted(root.glob("*/SKILL.md")):
73
+ parsed = _parse(skill_md, source)
74
+ if parsed is not None:
75
+ found[parsed.name] = parsed
76
+ return found
77
+
78
+
79
+ def catalog(cwd: str | Path | None = None) -> list[Skill]:
80
+ """Project overrides global overrides claude, keyed by skill name."""
81
+ cwd_path = Path(cwd) if cwd else Path(os.getcwd())
82
+ merged: dict[str, Skill] = {}
83
+ merged.update(_discover(_CLAUDE_DIR, "claude"))
84
+ merged.update(_discover(_GLOBAL_DIR, "global"))
85
+ merged.update(_discover(cwd_path / _PROJECT_SUBDIR, "project"))
86
+ return sorted(merged.values(), key=lambda s: s.name)
87
+
88
+
89
+ def _truncate_desc(description: str, limit: int = DESC_MAX) -> str:
90
+ description = " ".join(description.split())
91
+ if len(description) <= limit:
92
+ return description
93
+ return description[: limit - 1].rstrip() + "…"
94
+
95
+
96
+ def render_index(cwd: str | Path | None = None) -> str:
97
+ entries = catalog(cwd)[:INDEX_MAX]
98
+ if not entries:
99
+ return ""
100
+ lines = [f"{s.name} — {_truncate_desc(s.description)}" for s in entries]
101
+ return ("## Skills\n" + "\n".join(lines) +
102
+ "\n\nCall `skill(name)` to load one; follow it as a checklist "
103
+ "in this same conversation.")
104
+
105
+
106
+ def system_block(cwd: str | Path | None = None) -> str:
107
+ return render_index(cwd)
108
+
109
+
110
+ def find(name: str, cwd: str | Path | None = None) -> Skill | None:
111
+ for s in catalog(cwd):
112
+ if s.name == name:
113
+ return s
114
+ return None
115
+
116
+
117
+ def _rewrite_refs(body: str, skill_dir: Path) -> str:
118
+ """A relative link only resolves from the skill's own directory; the
119
+ `read` tool has no notion of that, so rewrite it to an absolute path
120
+ before the model ever sees it."""
121
+ def repl(m: re.Match[str]) -> str:
122
+ prefix, ref, suffix = m.group(1), m.group(2), m.group(3)
123
+ if ref.startswith(("/", "http://", "https://", "#", "mailto:")):
124
+ return m.group(0)
125
+ resolved = (skill_dir / ref).resolve()
126
+ return f"{prefix}{resolved}{suffix}" if resolved.exists() else m.group(0)
127
+
128
+ return _MD_LINK_RE.sub(repl, body)
129
+
130
+
131
+ def load_body(name: str, cwd: str | Path | None = None) -> str | None:
132
+ found = find(name, cwd)
133
+ if found is None:
134
+ return None
135
+ text = found.path.read_text(errors="replace")
136
+ body = _FRONTMATTER_RE.sub("", text, count=1).strip()
137
+ body = _rewrite_refs(body, found.path.parent)
138
+ if len(body) > BODY_MAX:
139
+ dropped = len(body) - BODY_MAX
140
+ body = f"{body[:BODY_MAX]}\n...[truncated {dropped} chars]"
141
+ return f'<skill name="{found.name}">\n{body}\n</skill>'
142
+
143
+
144
+ S = {"type": "string"}
145
+
146
+
147
+ @tools.tool(
148
+ SKILL_TOOL,
149
+ "Load a skill by name -- a pre-written checklist for a sub-workflow "
150
+ "(from the catalog in the system prompt). Returns its instructions to "
151
+ "follow in this same conversation; it does not spawn another agent.",
152
+ {"name": S}, ["name"])
153
+ def _skill(name: str) -> str:
154
+ body = load_body(name)
155
+ if body is not None:
156
+ return body
157
+ available = ", ".join(s.name for s in catalog()) or "(none found)"
158
+ return f"error: no skill named {name!r}. Available: {available}"