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.
- omega/__init__.py +0 -0
- omega/__main__.py +589 -0
- omega/artifacts.py +151 -0
- omega/checkpoint.py +246 -0
- omega/compact.py +106 -0
- omega/config.py +285 -0
- omega/eval/__init__.py +3 -0
- omega/eval/cli.py +127 -0
- omega/eval/examples/plan-version-flag.yaml +11 -0
- omega/eval/examples/relative-age-negative-delta.yaml +14 -0
- omega/eval/examples/version-flag.yaml +10 -0
- omega/eval/manifest.py +129 -0
- omega/eval/prices.py +29 -0
- omega/eval/report.py +135 -0
- omega/eval/runner.py +199 -0
- omega/eval/tasks.py +97 -0
- omega/events.py +145 -0
- omega/export.py +80 -0
- omega/gitlog.py +229 -0
- omega/hooks.py +63 -0
- omega/instructions.py +103 -0
- omega/integrations.py +284 -0
- omega/keys.py +173 -0
- omega/llm.py +442 -0
- omega/loop.py +510 -0
- omega/mcp.py +490 -0
- omega/memory/__init__.py +5 -0
- omega/memory/consolidate.py +103 -0
- omega/memory/curate.py +69 -0
- omega/memory/store.py +321 -0
- omega/memory/tools.py +175 -0
- omega/migrate.py +40 -0
- omega/onboarding.py +242 -0
- omega/permissions.py +137 -0
- omega/secrets.py +173 -0
- omega/server/__init__.py +7 -0
- omega/server/__main__.py +18 -0
- omega/server/app.py +71 -0
- omega/server/auth.py +73 -0
- omega/server/manager.py +287 -0
- omega/server/models.py +123 -0
- omega/server/tasks_api.py +311 -0
- omega/server/terminals.py +245 -0
- omega/server/worker.py +186 -0
- omega/session.py +209 -0
- omega/setup.html +281 -0
- omega/setup_server.py +452 -0
- omega/skills.py +158 -0
- omega/subagent.py +98 -0
- omega/tasks.py +195 -0
- omega/tools.py +590 -0
- omega/trace.py +156 -0
- omega/trajectory.py +146 -0
- omega/ui/__init__.py +0 -0
- omega/ui/composer.py +140 -0
- omega/ui/format.py +708 -0
- omega/ui/plain.py +141 -0
- omega/ui/tui/__init__.py +9 -0
- omega/ui/tui/app.py +958 -0
- omega/ui/tui/history.py +50 -0
- omega/ui/tui/modals.py +292 -0
- omega/ui/tui/onboarding.py +367 -0
- omega/ui/tui/prefs.py +25 -0
- omega/ui/tui/sidebar.py +510 -0
- omega/ui/tui/status.py +115 -0
- omega/ui/tui/theme.py +91 -0
- omega/ui/tui/transcript.py +783 -0
- omega/verify.py +133 -0
- omega_code-0.4.0.dist-info/METADATA +479 -0
- omega_code-0.4.0.dist-info/RECORD +73 -0
- omega_code-0.4.0.dist-info/WHEEL +4 -0
- omega_code-0.4.0.dist-info/entry_points.txt +2 -0
- omega_code-0.4.0.dist-info/licenses/LICENSE +21 -0
omega/mcp.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from mcp import ClientSession, StdioServerParameters
|
|
13
|
+
from mcp.client.stdio import stdio_client
|
|
14
|
+
from mcp.types import TextContent
|
|
15
|
+
from mcp.types import Tool as McpTool
|
|
16
|
+
|
|
17
|
+
from . import config, tools
|
|
18
|
+
|
|
19
|
+
SERVERS: dict[str, "Server"] = {}
|
|
20
|
+
LAST_USED: dict[str, float] = {}
|
|
21
|
+
# Names this process has registered into tools.REGISTRY, per server -- lets
|
|
22
|
+
# disconnect()/remove() clean those entries back out.
|
|
23
|
+
_REGISTERED: dict[str, list[str]] = {}
|
|
24
|
+
|
|
25
|
+
# Pinned: an unpinned `npx -y mcp-remote` executes whatever npm serves as
|
|
26
|
+
# latest, on every single run.
|
|
27
|
+
MCP_REMOTE_VERSION = "0.8.1"
|
|
28
|
+
CALL_TIMEOUT = 120
|
|
29
|
+
|
|
30
|
+
# How long an explicit `connect()` (a user sitting at a browser, doing OAuth)
|
|
31
|
+
# waits before giving up; lazy background loading uses a much shorter budget
|
|
32
|
+
# since nobody is there to click "authorize".
|
|
33
|
+
AUTH_TIMEOUT = 90.0
|
|
34
|
+
LAZY_TIMEOUT = 20.0
|
|
35
|
+
POLL_INTERVAL = 1.0
|
|
36
|
+
AUTH_URL_RE = re.compile(r"https?://\S+")
|
|
37
|
+
|
|
38
|
+
# MCP tools whose names indicate a read; these become available to plan mode
|
|
39
|
+
# and subagents instead of every MCP tool being treated as mutating.
|
|
40
|
+
READ_PREFIXES = ("get_", "list_", "search", "read_", "fetch", "query", "resolve")
|
|
41
|
+
|
|
42
|
+
DESC_LIMIT = 2000 # descriptions drive tool SELECTION -- keep them
|
|
43
|
+
PARAMS_LIMIT = 1500 # inputSchema is 78% of the bytes and mostly noise
|
|
44
|
+
ENUM_LIMIT = 12
|
|
45
|
+
NOISE_KEYS = {"examples", "example", "default", "$comment", "$schema", "$id",
|
|
46
|
+
"additionalProperties", "readOnly", "writeOnly", "deprecated"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def prune_schema(node: Any, depth: int = 0) -> Any:
|
|
50
|
+
"""Strip JSON Schema down to what a model needs to CALL a tool.
|
|
51
|
+
|
|
52
|
+
Measured on linear+notion+posthog: descriptions were 59.5k chars, parameters
|
|
53
|
+
143k. omega previously capped descriptions and passed parameters untouched --
|
|
54
|
+
exactly backwards, since descriptions are what tool selection depends on.
|
|
55
|
+
"""
|
|
56
|
+
if isinstance(node, list):
|
|
57
|
+
return [prune_schema(x, depth + 1) for x in node[:ENUM_LIMIT]]
|
|
58
|
+
if not isinstance(node, dict):
|
|
59
|
+
return node
|
|
60
|
+
|
|
61
|
+
out: dict[str, Any] = {}
|
|
62
|
+
for k, v in node.items():
|
|
63
|
+
if k in NOISE_KEYS:
|
|
64
|
+
continue
|
|
65
|
+
if k == "enum" and isinstance(v, list):
|
|
66
|
+
out[k] = v[:ENUM_LIMIT] if len(v) <= ENUM_LIMIT else v[:ENUM_LIMIT]
|
|
67
|
+
continue
|
|
68
|
+
if k == "description" and isinstance(v, str):
|
|
69
|
+
out[k] = v[:300]
|
|
70
|
+
continue
|
|
71
|
+
if k in ("anyOf", "oneOf", "allOf") and isinstance(v, list):
|
|
72
|
+
# deep unions explode; the first branch is enough to call the tool
|
|
73
|
+
if depth >= 2:
|
|
74
|
+
continue
|
|
75
|
+
out[k] = [prune_schema(x, depth + 1) for x in v[:3]]
|
|
76
|
+
continue
|
|
77
|
+
if depth >= 6:
|
|
78
|
+
continue
|
|
79
|
+
out[k] = prune_schema(v, depth + 1)
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def fit_params(schema: dict[str, Any]) -> dict[str, Any]:
|
|
84
|
+
"""Prune, then drop optional property descriptions until it fits."""
|
|
85
|
+
pruned: dict[str, Any] = prune_schema(schema or {"type": "object", "properties": {}})
|
|
86
|
+
if len(json.dumps(pruned)) <= PARAMS_LIMIT:
|
|
87
|
+
return pruned
|
|
88
|
+
required = set(pruned.get("required") or [])
|
|
89
|
+
for name, prop in (pruned.get("properties") or {}).items():
|
|
90
|
+
if name not in required and isinstance(prop, dict):
|
|
91
|
+
prop.pop("description", None)
|
|
92
|
+
if len(json.dumps(pruned)) <= PARAMS_LIMIT:
|
|
93
|
+
return pruned
|
|
94
|
+
props = pruned.get("properties") or {}
|
|
95
|
+
kept = {k: v for k, v in props.items() if k in required}
|
|
96
|
+
for k, v in props.items():
|
|
97
|
+
if k in kept:
|
|
98
|
+
continue
|
|
99
|
+
if len(json.dumps({**kept, k: v})) > PARAMS_LIMIT:
|
|
100
|
+
break
|
|
101
|
+
kept[k] = v
|
|
102
|
+
pruned["properties"] = kept
|
|
103
|
+
return pruned
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def discover(paths: list[Path] | None = None, include_omega: bool = True) -> dict[str, dict[str, Any]]:
|
|
107
|
+
"""omega's own mcp block wins; Claude Code's config and installed plugins
|
|
108
|
+
(which ship their own .mcp.json) are imported under it. `include_omega=False`
|
|
109
|
+
returns Claude Code's servers only -- used to show what's importable
|
|
110
|
+
without mixing in what omega already manages."""
|
|
111
|
+
if paths is None:
|
|
112
|
+
paths = [Path.home() / ".claude.json", Path.home() / ".claude" / "settings.json"]
|
|
113
|
+
plugins = Path.home() / ".claude" / "plugins"
|
|
114
|
+
if plugins.exists():
|
|
115
|
+
for depth in range(1, 5):
|
|
116
|
+
paths += sorted(plugins.glob("/".join(["*"] * depth) + "/.mcp.json"))
|
|
117
|
+
found: dict[str, dict[str, Any]] = {}
|
|
118
|
+
|
|
119
|
+
def walk(o: Any) -> None:
|
|
120
|
+
if isinstance(o, dict):
|
|
121
|
+
for k, v in o.items():
|
|
122
|
+
if k == "mcpServers" and isinstance(v, dict):
|
|
123
|
+
found.update(v)
|
|
124
|
+
else:
|
|
125
|
+
walk(v)
|
|
126
|
+
elif isinstance(o, list):
|
|
127
|
+
for x in o:
|
|
128
|
+
walk(x)
|
|
129
|
+
|
|
130
|
+
for p in paths:
|
|
131
|
+
if p.exists():
|
|
132
|
+
try:
|
|
133
|
+
walk(json.loads(p.read_text()))
|
|
134
|
+
except json.JSONDecodeError:
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
if not include_omega:
|
|
138
|
+
return found
|
|
139
|
+
omega_cfg = Path(os.environ.get("OMEGA_CONFIG", Path.home() / ".omega" / "config.json"))
|
|
140
|
+
if omega_cfg.exists():
|
|
141
|
+
try:
|
|
142
|
+
found.update(json.loads(omega_cfg.read_text()).get("mcp", {}))
|
|
143
|
+
except json.JSONDecodeError:
|
|
144
|
+
pass
|
|
145
|
+
return found
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class ServerStatus:
|
|
150
|
+
name: str
|
|
151
|
+
enabled: bool
|
|
152
|
+
state: Literal["connected", "configured", "needs_auth", "error", "disabled"]
|
|
153
|
+
tools: int
|
|
154
|
+
error: str | None
|
|
155
|
+
last_used: float | None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def as_stdio(cfg: dict[str, Any]) -> dict[str, Any]:
|
|
159
|
+
"""Any remote MCP server can be reached by proxying it through mcp-remote,
|
|
160
|
+
which owns the OAuth dance and caches tokens in ~/.mcp-auth."""
|
|
161
|
+
if "command" in cfg:
|
|
162
|
+
return cfg
|
|
163
|
+
url = cfg.get("url") or cfg.get("serverUrl")
|
|
164
|
+
if not url:
|
|
165
|
+
return cfg
|
|
166
|
+
args = ["-y", f"mcp-remote@{MCP_REMOTE_VERSION}", url]
|
|
167
|
+
for k, v in (cfg.get("headers") or {}).items():
|
|
168
|
+
args += ["--header", f"{k}:{v}"]
|
|
169
|
+
return {"command": "npx", "args": args}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Server:
|
|
173
|
+
"""Owns one MCP connection. anyio requires the context managers be entered
|
|
174
|
+
and exited in the same task, so the whole lifecycle lives in _run."""
|
|
175
|
+
|
|
176
|
+
def __init__(self, name: str, cfg: dict[str, Any]):
|
|
177
|
+
self.name, self.cfg = name, cfg
|
|
178
|
+
self.session: ClientSession | None = None
|
|
179
|
+
self.tools: list[McpTool] = []
|
|
180
|
+
self.error: str | None = None
|
|
181
|
+
# Set once a poller (see connect()) spots an authorize-me URL in the
|
|
182
|
+
# child's stderr -- distinct from `error`, since the task is still
|
|
183
|
+
# alive and waiting, not dead.
|
|
184
|
+
self.auth_url: str | None = None
|
|
185
|
+
self.ready = asyncio.Event()
|
|
186
|
+
self._stop = asyncio.Event()
|
|
187
|
+
self._task: asyncio.Task[None] | None = None
|
|
188
|
+
self._errlog_path: Path | None = None
|
|
189
|
+
|
|
190
|
+
async def _run(self) -> None:
|
|
191
|
+
# NamedTemporaryFile's wrapper type isn't a TextIO as far as mypy is
|
|
192
|
+
# concerned; fdopen over its fd is, and gives the same file.
|
|
193
|
+
fd, path = tempfile.mkstemp(prefix="omega-mcp-", suffix=".log")
|
|
194
|
+
self._errlog_path = Path(path)
|
|
195
|
+
errlog = os.fdopen(fd, "w")
|
|
196
|
+
try:
|
|
197
|
+
command = self.cfg["command"]
|
|
198
|
+
if not shutil.which(command):
|
|
199
|
+
raise FileNotFoundError(f"{command!r} not on PATH")
|
|
200
|
+
params = StdioServerParameters(
|
|
201
|
+
command=command, args=self.cfg.get("args", []),
|
|
202
|
+
env={**os.environ, **self.cfg.get("env", {})})
|
|
203
|
+
async with stdio_client(params, errlog=errlog) as (read, write):
|
|
204
|
+
async with ClientSession(read, write) as session:
|
|
205
|
+
await session.initialize()
|
|
206
|
+
self.session = session
|
|
207
|
+
self.tools = (await session.list_tools()).tools
|
|
208
|
+
self.ready.set()
|
|
209
|
+
await self._stop.wait()
|
|
210
|
+
except Exception as e:
|
|
211
|
+
self.error = f"{type(e).__name__}: {e}"[:120]
|
|
212
|
+
finally:
|
|
213
|
+
self.ready.set()
|
|
214
|
+
errlog.close()
|
|
215
|
+
try:
|
|
216
|
+
self._errlog_path.unlink(missing_ok=True)
|
|
217
|
+
except OSError:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
def peek_auth_url(self) -> str | None:
|
|
221
|
+
"""Best-effort read of the child's stderr so far, looking for an
|
|
222
|
+
"authorize this client" link. Opens the path fresh each time rather
|
|
223
|
+
than reusing the write handle, whose fd is shared with the child
|
|
224
|
+
process and must not have its offset disturbed."""
|
|
225
|
+
if self._errlog_path is None or not self._errlog_path.exists():
|
|
226
|
+
return None
|
|
227
|
+
try:
|
|
228
|
+
content = self._errlog_path.read_text(errors="replace")
|
|
229
|
+
except OSError:
|
|
230
|
+
return None
|
|
231
|
+
m = AUTH_URL_RE.search(content)
|
|
232
|
+
return m.group(0) if m else None
|
|
233
|
+
|
|
234
|
+
async def start(self, timeout: float = 60) -> None:
|
|
235
|
+
self._task = asyncio.create_task(self._run())
|
|
236
|
+
await asyncio.wait_for(self.ready.wait(), timeout)
|
|
237
|
+
if self.error:
|
|
238
|
+
raise RuntimeError(self.error)
|
|
239
|
+
|
|
240
|
+
async def stop(self) -> None:
|
|
241
|
+
self._stop.set()
|
|
242
|
+
if not self._task:
|
|
243
|
+
return
|
|
244
|
+
try:
|
|
245
|
+
# No shield: shielding guarantees this timeout can never cancel the
|
|
246
|
+
# task it is waiting on, so a hung server leaks its subprocess.
|
|
247
|
+
await asyncio.wait_for(self._task, 10)
|
|
248
|
+
except (TimeoutError, asyncio.CancelledError):
|
|
249
|
+
self._task.cancel()
|
|
250
|
+
await asyncio.gather(self._task, return_exceptions=True)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _register(server: Server, tool: McpTool) -> str:
|
|
254
|
+
name = f"mcp__{server.name}__{tool.name}"
|
|
255
|
+
if len(name) > 64:
|
|
256
|
+
# Truncating alone silently collapses two distinct tools into one key.
|
|
257
|
+
import hashlib
|
|
258
|
+
name = name[:57] + "_" + hashlib.sha1(name.encode()).hexdigest()[:6]
|
|
259
|
+
|
|
260
|
+
reads = tool.name.lower().startswith(READ_PREFIXES)
|
|
261
|
+
|
|
262
|
+
async def call(**kwargs: Any) -> str:
|
|
263
|
+
session = server.session
|
|
264
|
+
assert session is not None, "call() invoked before the server finished starting"
|
|
265
|
+
LAST_USED[server.name] = time.time()
|
|
266
|
+
try:
|
|
267
|
+
result = await asyncio.wait_for(
|
|
268
|
+
session.call_tool(tool.name, kwargs), CALL_TIMEOUT)
|
|
269
|
+
except TimeoutError:
|
|
270
|
+
return f"error: MCP server {server.name!r} timed out after {CALL_TIMEOUT}s"
|
|
271
|
+
parts = [c.text for c in result.content if isinstance(c, TextContent) and c.text]
|
|
272
|
+
body = tools.truncate("\n".join(parts) or "(no content)")
|
|
273
|
+
# Remote content is writable by anyone in that workspace: mark the turn
|
|
274
|
+
# so bash drops to ASK for the rest of it.
|
|
275
|
+
tools.set_tainted(True)
|
|
276
|
+
body = (f"<untrusted source=\"mcp:{server.name}/{tool.name}\">\n{body}\n"
|
|
277
|
+
f"</untrusted>")
|
|
278
|
+
if getattr(result, "isError", False):
|
|
279
|
+
return f"error: {body}"
|
|
280
|
+
return body
|
|
281
|
+
|
|
282
|
+
if reads:
|
|
283
|
+
tools.READ_ONLY.add(name)
|
|
284
|
+
tools.REGISTRY[name] = tools.ToolEntry(
|
|
285
|
+
fn=call, locks_path=None, mutates=not reads, deferred=True,
|
|
286
|
+
schema={"type": "function", "function": {
|
|
287
|
+
"name": name,
|
|
288
|
+
"description": (tool.description or "")[:DESC_LIMIT],
|
|
289
|
+
"parameters": fit_params(tool.inputSchema),
|
|
290
|
+
}},
|
|
291
|
+
)
|
|
292
|
+
_REGISTERED.setdefault(server.name, []).append(name)
|
|
293
|
+
return name
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _unregister(name: str) -> None:
|
|
297
|
+
for tool_name in _REGISTERED.pop(name, []):
|
|
298
|
+
tools.REGISTRY.pop(tool_name, None)
|
|
299
|
+
tools.READ_ONLY.discard(tool_name)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def load(only: set[str] | None = None, timeout: float = 60) -> dict[str, str]:
|
|
303
|
+
"""Eager path: connect everything now, in a loop (`omega --mcp`). Lazy
|
|
304
|
+
loading (ensure_loaded, below) is what a normal turn uses instead."""
|
|
305
|
+
report: dict[str, str] = {}
|
|
306
|
+
for name, cfg in discover().items():
|
|
307
|
+
if only and name not in only:
|
|
308
|
+
continue
|
|
309
|
+
if not cfg.get("enabled", True):
|
|
310
|
+
report[name] = "disabled"
|
|
311
|
+
continue
|
|
312
|
+
if "command" not in cfg and not (cfg.get("url") or cfg.get("serverUrl")):
|
|
313
|
+
report[name] = "skipped: no command or url"
|
|
314
|
+
continue
|
|
315
|
+
server = Server(name, as_stdio(cfg))
|
|
316
|
+
try:
|
|
317
|
+
await server.start(timeout)
|
|
318
|
+
for t in server.tools:
|
|
319
|
+
_register(server, t)
|
|
320
|
+
SERVERS[name] = server
|
|
321
|
+
report[name] = f"{len(server.tools)} tools"
|
|
322
|
+
except Exception as e:
|
|
323
|
+
# A failed start still leaves the _run task and its npx/node
|
|
324
|
+
# children alive; the common failure is a hung server.
|
|
325
|
+
try:
|
|
326
|
+
await server.stop()
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
report[name] = f"failed: {type(e).__name__}: {e}"[:110]
|
|
330
|
+
return report
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def shutdown() -> None:
|
|
334
|
+
for s in SERVERS.values():
|
|
335
|
+
try:
|
|
336
|
+
await s.stop()
|
|
337
|
+
except Exception:
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _clean_spec(spec: dict[str, Any]) -> dict[str, Any]:
|
|
342
|
+
return {k: v for k, v in spec.items() if k not in ("enabled", "catalog")}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _write_mcp(data: dict[str, dict[str, Any]]) -> None:
|
|
346
|
+
"""Same write pattern as permissions.remember: merge into the config the
|
|
347
|
+
onboarding/setup flows already own, touch nothing but the "mcp" key."""
|
|
348
|
+
raw = config._json_or_default()
|
|
349
|
+
raw["mcp"] = data
|
|
350
|
+
config.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
351
|
+
config.CONFIG_PATH.write_text(json.dumps(raw, indent=2) + "\n")
|
|
352
|
+
config.CONFIG_PATH.chmod(0o600)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def status() -> dict[str, ServerStatus]:
|
|
356
|
+
"""One row per server omega itself has configured (config.mcp_config()) --
|
|
357
|
+
not what discover() would merge in from Claude Code, which the caller
|
|
358
|
+
surfaces separately as "importable"."""
|
|
359
|
+
out: dict[str, ServerStatus] = {}
|
|
360
|
+
for name, cfg in config.mcp_config().items():
|
|
361
|
+
enabled = bool(cfg.get("enabled", True))
|
|
362
|
+
last_used = LAST_USED.get(name)
|
|
363
|
+
if not enabled:
|
|
364
|
+
out[name] = ServerStatus(name, False, "disabled", 0, None, last_used)
|
|
365
|
+
continue
|
|
366
|
+
server = SERVERS.get(name)
|
|
367
|
+
if server is None:
|
|
368
|
+
out[name] = ServerStatus(name, True, "configured", 0, None, last_used)
|
|
369
|
+
elif server.error:
|
|
370
|
+
out[name] = ServerStatus(name, True, "error", 0, server.error, last_used)
|
|
371
|
+
elif server.ready.is_set():
|
|
372
|
+
out[name] = ServerStatus(name, True, "connected", len(server.tools), None, last_used)
|
|
373
|
+
elif server.auth_url:
|
|
374
|
+
out[name] = ServerStatus(name, True, "needs_auth", 0, server.auth_url, last_used)
|
|
375
|
+
else:
|
|
376
|
+
out[name] = ServerStatus(name, True, "configured", 0, None, last_used)
|
|
377
|
+
return out
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
async def connect(name: str, timeout: float | None = None) -> ServerStatus:
|
|
381
|
+
"""Connect one server. For a remote oauth server this is what triggers
|
|
382
|
+
mcp-remote's browser flow; if it hasn't finished within `timeout` we
|
|
383
|
+
check the child's stderr for an authorize-me URL and report needs_auth
|
|
384
|
+
instead of failing outright -- the caller re-runs connect() once the
|
|
385
|
+
user has clicked through it."""
|
|
386
|
+
timeout = AUTH_TIMEOUT if timeout is None else timeout
|
|
387
|
+
all_cfg = config.mcp_config()
|
|
388
|
+
cfg = all_cfg.get(name)
|
|
389
|
+
if cfg is None:
|
|
390
|
+
return ServerStatus(name, False, "error", 0, f"no such server {name!r}", None)
|
|
391
|
+
if not cfg.get("enabled", True):
|
|
392
|
+
return ServerStatus(name, False, "disabled", 0, None, LAST_USED.get(name))
|
|
393
|
+
|
|
394
|
+
server = SERVERS.get(name)
|
|
395
|
+
if server is None or server.error:
|
|
396
|
+
if server is not None:
|
|
397
|
+
await server.stop()
|
|
398
|
+
server = Server(name, as_stdio(_clean_spec(cfg)))
|
|
399
|
+
SERVERS[name] = server
|
|
400
|
+
server._task = asyncio.create_task(server._run())
|
|
401
|
+
|
|
402
|
+
loop = asyncio.get_event_loop()
|
|
403
|
+
deadline = loop.time() + timeout
|
|
404
|
+
while not server.ready.is_set() and loop.time() < deadline:
|
|
405
|
+
remaining = max(deadline - loop.time(), 0.01)
|
|
406
|
+
try:
|
|
407
|
+
await asyncio.wait_for(server.ready.wait(), min(POLL_INTERVAL, remaining))
|
|
408
|
+
except TimeoutError:
|
|
409
|
+
url = server.peek_auth_url()
|
|
410
|
+
if url:
|
|
411
|
+
server.auth_url = url
|
|
412
|
+
|
|
413
|
+
if not server.ready.is_set():
|
|
414
|
+
if server.auth_url:
|
|
415
|
+
return ServerStatus(name, True, "needs_auth", 0, server.auth_url, LAST_USED.get(name))
|
|
416
|
+
server.error = f"timed out connecting after {timeout:.0f}s"
|
|
417
|
+
return ServerStatus(name, True, "error", 0, server.error, LAST_USED.get(name))
|
|
418
|
+
|
|
419
|
+
if server.error:
|
|
420
|
+
return ServerStatus(name, True, "error", 0, server.error, LAST_USED.get(name))
|
|
421
|
+
|
|
422
|
+
server.auth_url = None
|
|
423
|
+
if name not in _REGISTERED:
|
|
424
|
+
for t in server.tools:
|
|
425
|
+
_register(server, t)
|
|
426
|
+
return ServerStatus(name, True, "connected", len(server.tools), None, LAST_USED.get(name))
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
async def disconnect(name: str) -> None:
|
|
430
|
+
server = SERVERS.pop(name, None)
|
|
431
|
+
if server is not None:
|
|
432
|
+
await server.stop()
|
|
433
|
+
_unregister(name)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
async def enable(name: str, value: bool) -> None:
|
|
437
|
+
data = config.mcp_config()
|
|
438
|
+
if name not in data:
|
|
439
|
+
raise KeyError(f"no such server {name!r}")
|
|
440
|
+
data[name]["enabled"] = value
|
|
441
|
+
_write_mcp(data)
|
|
442
|
+
if not value:
|
|
443
|
+
await disconnect(name)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def add(name: str, spec: dict[str, Any]) -> None:
|
|
447
|
+
data = config.mcp_config()
|
|
448
|
+
entry = dict(spec)
|
|
449
|
+
entry.setdefault("enabled", True)
|
|
450
|
+
data[name] = entry
|
|
451
|
+
_write_mcp(data)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
async def remove(name: str) -> None:
|
|
455
|
+
await disconnect(name)
|
|
456
|
+
data = config.mcp_config()
|
|
457
|
+
if name in data:
|
|
458
|
+
del data[name]
|
|
459
|
+
_write_mcp(data)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
async def ensure_loaded(timeout: float = LAZY_TIMEOUT) -> None:
|
|
463
|
+
"""Called by find_tools/call_tool on first use each process: connects
|
|
464
|
+
every enabled server that hasn't been attempted yet, in parallel.
|
|
465
|
+
Failures are recorded as `error`/`needs_auth` state, never raised --
|
|
466
|
+
a broken integration must not break every other tool call."""
|
|
467
|
+
pending = [name for name, cfg in config.mcp_config().items()
|
|
468
|
+
if cfg.get("enabled", True) and name not in SERVERS]
|
|
469
|
+
if not pending:
|
|
470
|
+
return
|
|
471
|
+
await asyncio.gather(*(connect(n, timeout=timeout) for n in pending),
|
|
472
|
+
return_exceptions=True)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def summary_line() -> str:
|
|
476
|
+
"""For the system prompt: `linear (23 tools), notion (14 tools), slack
|
|
477
|
+
(needs auth)`. Disabled servers are omitted."""
|
|
478
|
+
parts = []
|
|
479
|
+
for name, st in sorted(status().items()):
|
|
480
|
+
if st.state == "disabled":
|
|
481
|
+
continue
|
|
482
|
+
if st.state == "connected":
|
|
483
|
+
parts.append(f"{name} ({st.tools} tools)")
|
|
484
|
+
elif st.state == "needs_auth":
|
|
485
|
+
parts.append(f"{name} (needs auth)")
|
|
486
|
+
elif st.state == "error":
|
|
487
|
+
parts.append(f"{name} (error)")
|
|
488
|
+
else:
|
|
489
|
+
parts.append(f"{name} (not connected)")
|
|
490
|
+
return ", ".join(parts)
|
omega/memory/__init__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
from .. import llm
|
|
6
|
+
from ..config import Config
|
|
7
|
+
from ..llm import Turn
|
|
8
|
+
from . import store
|
|
9
|
+
from .store import Node
|
|
10
|
+
|
|
11
|
+
SYSTEM = """You maintain a personal knowledge graph for a coding agent. Given a
|
|
12
|
+
list of memory nodes, find near-duplicates to merge, direct contradictions, and
|
|
13
|
+
any importance/volatility corrections.
|
|
14
|
+
|
|
15
|
+
Respond with STRICT JSON only -- no prose, no code fences:
|
|
16
|
+
{"merge": [{"keep": "<id>", "drop": "<id>", "merged_body": "<str>"}],
|
|
17
|
+
"contradict": [["<id>", "<id>"]],
|
|
18
|
+
"retag": [{"id": "<id>", "volatility": "<str>", "importance": <float>}]}
|
|
19
|
+
|
|
20
|
+
merge.keep/drop must be ids taken from the input. retag entries may omit
|
|
21
|
+
either field. Return empty lists where nothing applies."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _render(nodes: list[Node]) -> str:
|
|
25
|
+
return "\n\n".join(
|
|
26
|
+
f"[{n['id']}] type={n['type']} confidence={n['confidence']} "
|
|
27
|
+
f"volatility={n['volatility']} importance={n['importance']}\n"
|
|
28
|
+
f"{n['title']}\n{n['body']}"
|
|
29
|
+
for n in nodes)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse(text: str) -> dict[str, Any]:
|
|
33
|
+
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip())
|
|
34
|
+
return dict(json.loads(text))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def run(cfg: Config, scope: str, min_new: int = 5, force: bool = False) -> str:
|
|
38
|
+
if not force and store.since_consolidation(scope) < min_new:
|
|
39
|
+
return ""
|
|
40
|
+
|
|
41
|
+
nodes = store.recent(scope, limit=60)
|
|
42
|
+
if not nodes:
|
|
43
|
+
store.reset_consolidation(scope)
|
|
44
|
+
return "memory: nothing to consolidate"
|
|
45
|
+
|
|
46
|
+
role = cfg.role("memory") if "memory" in cfg.roles else cfg.role("compact")
|
|
47
|
+
text = ""
|
|
48
|
+
async for kind, payload in llm.stream(
|
|
49
|
+
role, [{"role": "system", "content": SYSTEM},
|
|
50
|
+
{"role": "user", "content": _render(nodes)}]):
|
|
51
|
+
if kind == "done":
|
|
52
|
+
text = cast(Turn, payload).text
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
data = _parse(text)
|
|
56
|
+
except (json.JSONDecodeError, TypeError, AttributeError):
|
|
57
|
+
return "consolidation skipped: unparseable model output"
|
|
58
|
+
|
|
59
|
+
by_id = {n["id"]: n for n in nodes}
|
|
60
|
+
merged = contradicted = retagged = 0
|
|
61
|
+
|
|
62
|
+
for m in (data.get("merge") or []):
|
|
63
|
+
keep, drop = by_id.get(m.get("keep")), by_id.get(m.get("drop"))
|
|
64
|
+
if keep is None or drop is None or keep["id"] == drop["id"]:
|
|
65
|
+
continue
|
|
66
|
+
new_id = store.write_node(
|
|
67
|
+
scope, keep["type"], keep["title"], m.get("merged_body") or keep["body"],
|
|
68
|
+
confidence=keep["confidence"], volatility=keep["volatility"],
|
|
69
|
+
sensitivity=keep["sensitivity"], importance=keep["importance"])
|
|
70
|
+
store.add_edge(scope, new_id, keep["id"], "supersedes")
|
|
71
|
+
store.add_edge(scope, new_id, drop["id"], "supersedes")
|
|
72
|
+
store.mark_superseded(scope, keep["id"], new_id)
|
|
73
|
+
store.mark_superseded(scope, drop["id"], new_id)
|
|
74
|
+
merged += 1
|
|
75
|
+
|
|
76
|
+
for pair in (data.get("contradict") or []):
|
|
77
|
+
if len(pair) != 2 or pair[0] not in by_id or pair[1] not in by_id:
|
|
78
|
+
continue
|
|
79
|
+
store.add_edge(scope, pair[0], pair[1], "contradicts")
|
|
80
|
+
contradicted += 1
|
|
81
|
+
|
|
82
|
+
for r in (data.get("retag") or []):
|
|
83
|
+
node_id = r.get("id")
|
|
84
|
+
if node_id not in by_id:
|
|
85
|
+
continue
|
|
86
|
+
fields: dict[str, str | float] = {}
|
|
87
|
+
if r.get("volatility") in store.VOLATILITIES:
|
|
88
|
+
fields["volatility"] = r["volatility"]
|
|
89
|
+
if "importance" in r:
|
|
90
|
+
try:
|
|
91
|
+
fields["importance"] = float(r["importance"])
|
|
92
|
+
except (TypeError, ValueError):
|
|
93
|
+
pass
|
|
94
|
+
if fields:
|
|
95
|
+
store.retag(scope, node_id, volatility=fields.get("volatility"),
|
|
96
|
+
importance=fields.get("importance"))
|
|
97
|
+
retagged += 1
|
|
98
|
+
|
|
99
|
+
store.reset_consolidation(scope)
|
|
100
|
+
if not (merged or contradicted or retagged):
|
|
101
|
+
return "memory: nothing to consolidate"
|
|
102
|
+
plural = "s" if contradicted != 1 else ""
|
|
103
|
+
return f"memory: merged {merged}, flagged {contradicted} contradiction{plural}, retagged {retagged}"
|
omega/memory/curate.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from .. import compact
|
|
4
|
+
from . import store
|
|
5
|
+
from .store import Node
|
|
6
|
+
|
|
7
|
+
HALF_LIFE_DAYS = 30
|
|
8
|
+
|
|
9
|
+
Sections = dict[str, list[str]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _recency_decay(updated: float) -> float:
|
|
13
|
+
age_days = max(0.0, (time.time() - updated) / 86400)
|
|
14
|
+
return float(0.5 ** (age_days / HALF_LIFE_DAYS))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _eligible(node: Node) -> bool:
|
|
18
|
+
if node.get("superseded_by"):
|
|
19
|
+
return False
|
|
20
|
+
if node["sensitivity"] == "sensitive":
|
|
21
|
+
return False
|
|
22
|
+
if node["sensitivity"] == "personal":
|
|
23
|
+
return bool(node["importance"] >= 0.7 and node["volatility"] == "stable")
|
|
24
|
+
if node["volatility"] == "volatile":
|
|
25
|
+
return False
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _score(node: Node, weight: float) -> float:
|
|
30
|
+
return float(node["importance"] * _recency_decay(node["updated"])
|
|
31
|
+
* (1 + 0.1 * node["access_count"]) * weight)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _render_line(node: Node) -> str:
|
|
35
|
+
body = " ".join(node["body"].split())
|
|
36
|
+
if len(body) > 200:
|
|
37
|
+
body = body[:200] + "…"
|
|
38
|
+
return f"- **{node['title']}** ({node['type']}, conf {node['confidence']}): {body}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _render(sections: Sections) -> str:
|
|
42
|
+
parts = [f"## {name}\n" + "\n".join(lines)
|
|
43
|
+
for name in ("Project", "Global") if (lines := sections[name])]
|
|
44
|
+
return "\n\n".join(parts)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def preamble(cwd: str | None = None, budget_tokens: int = 500) -> str:
|
|
48
|
+
scored: list[tuple[str, float, Node]] = []
|
|
49
|
+
# Reading must never create a project db as a side effect -- that would
|
|
50
|
+
# bootstrap .gitignore for a directory the user never asked omega to touch.
|
|
51
|
+
if store.db_exists("project", cwd):
|
|
52
|
+
for node in store.all_nodes("project", cwd=cwd):
|
|
53
|
+
if _eligible(node):
|
|
54
|
+
scored.append(("Project", _score(node, 1.5), node))
|
|
55
|
+
if store.db_exists("global"):
|
|
56
|
+
for node in store.all_nodes("global"):
|
|
57
|
+
if _eligible(node):
|
|
58
|
+
scored.append(("Global", _score(node, 1.0), node))
|
|
59
|
+
scored.sort(key=lambda x: -x[1])
|
|
60
|
+
|
|
61
|
+
sections: Sections = {"Project": [], "Global": []}
|
|
62
|
+
rendered = ""
|
|
63
|
+
for section, _, node in scored:
|
|
64
|
+
candidate = {**sections, section: sections[section] + [_render_line(node)]}
|
|
65
|
+
text = _render(candidate)
|
|
66
|
+
if compact.estimate_tokens([{"role": "user", "content": text}]) > budget_tokens:
|
|
67
|
+
break
|
|
68
|
+
sections, rendered = candidate, text
|
|
69
|
+
return rendered.strip()
|