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/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)
@@ -0,0 +1,5 @@
1
+ from . import consolidate, tools
2
+ from .curate import preamble
3
+
4
+ # Importing `tools` registers remember/recall/supersede/link with the registry.
5
+ __all__ = ["consolidate", "preamble", "tools"]
@@ -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()