xqo 3.0.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.
- xqo/__init__.py +2 -0
- xqo/__main__.py +558 -0
- xqo/agent.py +950 -0
- xqo/api.py +46 -0
- xqo/artifacts.py +594 -0
- xqo/attachments.py +145 -0
- xqo/attention.py +241 -0
- xqo/avatars.py +91 -0
- xqo/backup.py +279 -0
- xqo/branding.py +37 -0
- xqo/bus.py +817 -0
- xqo/chat.py +420 -0
- xqo/client.py +35 -0
- xqo/compaction.py +114 -0
- xqo/config.py +291 -0
- xqo/default_agents.json +67 -0
- xqo/default_departments/engineering.json +54 -0
- xqo/default_departments/marketing.json +32 -0
- xqo/default_departments/research.json +30 -0
- xqo/departments.py +237 -0
- xqo/fences.py +238 -0
- xqo/folders.py +88 -0
- xqo/gitstore.py +165 -0
- xqo/grants.py +137 -0
- xqo/lifecycle.py +265 -0
- xqo/limits.py +133 -0
- xqo/llm.py +258 -0
- xqo/mcp_hub.py +218 -0
- xqo/memory.py +77 -0
- xqo/migrations.py +100 -0
- xqo/models.py +90 -0
- xqo/netgrants.py +82 -0
- xqo/offer_judge.py +316 -0
- xqo/patching.py +139 -0
- xqo/plugin.py +580 -0
- xqo/plugins/__init__.py +29 -0
- xqo/plugins/office/__init__.py +159 -0
- xqo/plugins/office/static/office/citizens/dexter_actions.png +0 -0
- xqo/plugins/office/static/office/citizens/dexter_idle.png +0 -0
- xqo/plugins/office/static/office/citizens/dexter_stand.png +0 -0
- xqo/plugins/office/static/office/citizens/dexter_walk.png +0 -0
- xqo/plugins/office/static/office/citizens/morty_actions.png +0 -0
- xqo/plugins/office/static/office/citizens/morty_idle.png +0 -0
- xqo/plugins/office/static/office/citizens/morty_stand.png +0 -0
- xqo/plugins/office/static/office/citizens/morty_walk.png +0 -0
- xqo/plugins/office/static/office/citizens/nova_actions.png +0 -0
- xqo/plugins/office/static/office/citizens/nova_idle.png +0 -0
- xqo/plugins/office/static/office/citizens/nova_stand.png +0 -0
- xqo/plugins/office/static/office/citizens/nova_walk.png +0 -0
- xqo/plugins/office/static/office/citizens/rio_actions.png +0 -0
- xqo/plugins/office/static/office/citizens/rio_idle.png +0 -0
- xqo/plugins/office/static/office/citizens/rio_stand.png +0 -0
- xqo/plugins/office/static/office/citizens/rio_walk.png +0 -0
- xqo/plugins/office/static/office/props/area_rug_lounge.png +0 -0
- xqo/plugins/office/static/office/props/bean_bag_chair_dark.png +0 -0
- xqo/plugins/office/static/office/props/bean_bag_chair_light.png +0 -0
- xqo/plugins/office/static/office/props/bookshelf_packed.png +0 -0
- xqo/plugins/office/static/office/props/coffee_bar_counter.png +0 -0
- xqo/plugins/office/static/office/props/ergonomic_chair.png +0 -0
- xqo/plugins/office/static/office/props/espresso_machine.png +0 -0
- xqo/plugins/office/static/office/props/large_window.png +0 -0
- xqo/plugins/office/static/office/props/low_coffee_table.png +0 -0
- xqo/plugins/office/static/office/props/mini_fridge.png +0 -0
- xqo/plugins/office/static/office/props/tall_potted_plant.png +0 -0
- xqo/plugins/office/static/office/props/wooden_desk_single.png +0 -0
- xqo/plugins/office/static/office/props/wooden_framed_whiteboard.png +0 -0
- xqo/plugins/office/static/office/tiles/concrete_accent.png +0 -0
- xqo/plugins/office/static/office/tiles/exposed_brick_wall.png +0 -0
- xqo/plugins/office/static/office/tiles/main_floor.png +0 -0
- xqo/plugins/office/static/office.html +149 -0
- xqo/plugins/office/static/office.js +508 -0
- xqo/plugins/office/static/office2d.js +305 -0
- xqo/plugins/office/static/office3d/README.txt +10 -0
- xqo/plugins/office/static/office3d/catalog.json +459 -0
- xqo/plugins/office/static/office3d/characters/LICENSE-kenney-mini-characters.txt +28 -0
- xqo/plugins/office/static/office3d/characters/Textures/colormap.png +0 -0
- xqo/plugins/office/static/office3d/characters/character-female-a.glb +0 -0
- xqo/plugins/office/static/office3d/characters/character-female-d.glb +0 -0
- xqo/plugins/office/static/office3d/characters/character-male-a.glb +0 -0
- xqo/plugins/office/static/office3d/characters/character-male-c.glb +0 -0
- xqo/plugins/office/static/office3d/furniture/LICENSE-kenney-furniture-kit.txt +23 -0
- xqo/plugins/office/static/office3d/furniture/chairDesk.glb +0 -0
- xqo/plugins/office/static/office3d/furniture/computerKeyboard.glb +0 -0
- xqo/plugins/office/static/office3d/furniture/computerMouse.glb +0 -0
- xqo/plugins/office/static/office3d/furniture/computerScreen.glb +0 -0
- xqo/plugins/office/static/office3d/furniture/desk.glb +0 -0
- xqo/plugins/office/static/office3d/furniture/pottedPlant.glb +0 -0
- xqo/plugins/office/static/office3d.js +889 -0
- xqo/plugins/office/static/officeedit.js +356 -0
- xqo/plugins/office/static/officeidle.js +89 -0
- xqo/plugins/office/static/officelayout.js +161 -0
- xqo/plugins/office/static/officepath.js +141 -0
- xqo/plugins/office/static/vendor/miniverse/LICENSE +21 -0
- xqo/plugins/office/static/vendor/miniverse/README.txt +3 -0
- xqo/plugins/office/static/vendor/miniverse/miniverse.js +2354 -0
- xqo/plugins/office/static/vendor/three/LICENSE +21 -0
- xqo/plugins/office/static/vendor/three/README.txt +3 -0
- xqo/plugins/office/static/vendor/three/addons/controls/OrbitControls.js +1972 -0
- xqo/plugins/office/static/vendor/three/addons/loaders/GLTFLoader.js +4925 -0
- xqo/plugins/office/static/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- xqo/plugins/office/static/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- xqo/plugins/office/static/vendor/three/three.core.js +60586 -0
- xqo/plugins/office/static/vendor/three/three.module.js +19719 -0
- xqo/plugins/office/view.py +486 -0
- xqo/providers.py +293 -0
- xqo/sandbox.py +666 -0
- xqo/sdk_backend.py +646 -0
- xqo/sdk_check.py +158 -0
- xqo/skills.py +182 -0
- xqo/static/app.css +244 -0
- xqo/static/index.html +114 -0
- xqo/static/js/agents.js +83 -0
- xqo/static/js/artifacts.js +185 -0
- xqo/static/js/attention.js +101 -0
- xqo/static/js/compose.js +145 -0
- xqo/static/js/core.js +91 -0
- xqo/static/js/dialogs.js +86 -0
- xqo/static/js/graph.js +113 -0
- xqo/static/js/main.js +80 -0
- xqo/static/js/timeline.js +117 -0
- xqo/static/render.js +236 -0
- xqo/static/usage.js +32 -0
- xqo/static/vendor/VERSIONS.txt +9 -0
- xqo/static/vendor/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Main-Bold.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Main-Italic.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Main-Regular.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Math-Italic.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Script-Regular.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- xqo/static/vendor/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- xqo/static/vendor/highlight.min.js +1213 -0
- xqo/static/vendor/hljs-github-dark.min.css +10 -0
- xqo/static/vendor/katex.min.css +1 -0
- xqo/static/vendor/katex.min.js +1 -0
- xqo/static/vendor/markdown-it-footnote.min.js +2 -0
- xqo/static/vendor/markdown-it-task-lists.min.js +2 -0
- xqo/static/vendor/markdown-it.min.js +2 -0
- xqo/static/vendor/mermaid.min.js +2314 -0
- xqo/static/vendor/purify.min.js +3 -0
- xqo/store.py +228 -0
- xqo/testing.py +61 -0
- xqo/usage.py +115 -0
- xqo/web.py +711 -0
- xqo-3.0.0.dist-info/METADATA +130 -0
- xqo-3.0.0.dist-info/RECORD +162 -0
- xqo-3.0.0.dist-info/WHEEL +5 -0
- xqo-3.0.0.dist-info/entry_points.txt +2 -0
- xqo-3.0.0.dist-info/licenses/LICENSE +21 -0
- xqo-3.0.0.dist-info/top_level.txt +1 -0
xqo/__init__.py
ADDED
xqo/__main__.py
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import plugin as plugin_mod
|
|
12
|
+
from .client import api as _api
|
|
13
|
+
from .config import PACKAGED_CONFIG, build, load_config
|
|
14
|
+
from .departments import PACKAGED_DEPARTMENTS
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def _run_cli(bus, prompt: str, agent: str | None, files: list[dict] | None = None):
|
|
18
|
+
"""Run one task; answer attention requests on the terminal when it is interactive."""
|
|
19
|
+
await bus.mcp.start()
|
|
20
|
+
q = bus.subscribe()
|
|
21
|
+
job = asyncio.create_task(bus.submit(prompt, agent, files))
|
|
22
|
+
interactive = sys.stdin.isatty()
|
|
23
|
+
try:
|
|
24
|
+
while not (job.done() and q.empty()):
|
|
25
|
+
try:
|
|
26
|
+
ev = await asyncio.wait_for(q.get(), 0.5)
|
|
27
|
+
except asyncio.TimeoutError:
|
|
28
|
+
continue
|
|
29
|
+
print(f"{ev['ts'][11:23]} {ev['kind']:<19} {ev['sender']:>10} -> {ev['recipient'] or '':<10} "
|
|
30
|
+
f"{ev['content'][:90]!r}")
|
|
31
|
+
if ev["kind"] == "attention_requested":
|
|
32
|
+
m = ev["meta"]
|
|
33
|
+
print(f"\n\a*** ATTENTION ({m['reason']}): {m['question']}\n options: {m['options'] or '-'}"
|
|
34
|
+
f" auto-confirms {m['default']!r} in {int(m['timeout'])}s")
|
|
35
|
+
if interactive:
|
|
36
|
+
asyncio.create_task(_ask_terminal(bus, m["id"]))
|
|
37
|
+
return await job
|
|
38
|
+
finally:
|
|
39
|
+
bus.unsubscribe(q)
|
|
40
|
+
await bus.mcp.stop()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _ask_terminal(bus, att_id: str):
|
|
44
|
+
answer = await asyncio.to_thread(input, " your answer> ")
|
|
45
|
+
try:
|
|
46
|
+
bus.attention.resolve(att_id, answer.strip() or bus.attention.items[att_id].default)
|
|
47
|
+
except (KeyError, ValueError):
|
|
48
|
+
print(" (already resolved)")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _manage(a, make_bus) -> None:
|
|
52
|
+
"""cancel / delete / restore: through the running server, or (delete/restore) on the data dir directly."""
|
|
53
|
+
try:
|
|
54
|
+
if a.cmd == "cancel":
|
|
55
|
+
for tid in a.ids:
|
|
56
|
+
t = _api(a.url, "POST", f"/api/tasks/{tid}/cancel", {"reason": a.reason})
|
|
57
|
+
print(f"{t['name']}: {t['status']}")
|
|
58
|
+
elif a.cmd == "delete":
|
|
59
|
+
for r in _api(a.url, "POST", "/api/tasks/delete", {"ids": a.ids, "permanent": a.permanent}):
|
|
60
|
+
print(_deleted(r))
|
|
61
|
+
else:
|
|
62
|
+
for tid in a.ids:
|
|
63
|
+
print(f"{_api(a.url, 'POST', f'/api/trash/{tid}/restore')['name']}: restored")
|
|
64
|
+
return
|
|
65
|
+
except RuntimeError as e:
|
|
66
|
+
sys.exit(str(e))
|
|
67
|
+
except ConnectionError:
|
|
68
|
+
if a.cmd == "cancel":
|
|
69
|
+
sys.exit(f"no server answering at {a.url}: a task can only be cancelled in the process running it")
|
|
70
|
+
bus = make_bus() # no server: work on the data dir
|
|
71
|
+
failed = False
|
|
72
|
+
for tid in a.ids:
|
|
73
|
+
try:
|
|
74
|
+
if a.cmd == "delete":
|
|
75
|
+
print(_deleted({"id": tid, "ok": True, **bus.lifecycle.delete(tid, "cli", a.permanent)}))
|
|
76
|
+
else:
|
|
77
|
+
print(f"{bus.lifecycle.restore(tid, 'cli')['name']}: restored")
|
|
78
|
+
except (KeyError, ValueError) as e:
|
|
79
|
+
failed = True
|
|
80
|
+
print(f"{tid}: {e.args[0] if e.args else e}")
|
|
81
|
+
if failed:
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _archive(a, make_bus) -> None:
|
|
86
|
+
"""archive / unarchive: through the running server, or on the data dir directly."""
|
|
87
|
+
on = a.cmd == "archive"
|
|
88
|
+
try:
|
|
89
|
+
res = _api(a.url, "POST", "/api/tasks/archive", {"ids": a.ids, "archived": on})
|
|
90
|
+
except RuntimeError as e:
|
|
91
|
+
sys.exit(str(e))
|
|
92
|
+
except ConnectionError:
|
|
93
|
+
bus = make_bus() # no server: work on the data dir
|
|
94
|
+
res = []
|
|
95
|
+
for tid in a.ids:
|
|
96
|
+
try:
|
|
97
|
+
res.append({"ok": True, **(bus.lifecycle.archive(tid, "cli") if on else bus.lifecycle.unarchive(tid, "cli"))})
|
|
98
|
+
except (KeyError, ValueError) as e:
|
|
99
|
+
res.append({"id": tid, "ok": False, "error": e.args[0] if e.args else str(e)})
|
|
100
|
+
for r in res:
|
|
101
|
+
print(f"{r.get('name', r['id'])}: {'archived' if r.get('archived') else 'unarchived'}" if r["ok"]
|
|
102
|
+
else f"{r['id']}: {r['error']}")
|
|
103
|
+
if not all(r["ok"] for r in res):
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _talk(a) -> None:
|
|
108
|
+
"""ask / steer / pause / resume go through the running server."""
|
|
109
|
+
try:
|
|
110
|
+
if a.cmd in ("ask", "steer"):
|
|
111
|
+
r = _api(a.url, "POST", f"/api/tasks/{a.task_id}/chat",
|
|
112
|
+
{"text": a.text, "kind": a.cmd, "to": a.to, "urgent": a.urgent})
|
|
113
|
+
how = ("reopened the task" if r.get("reopened") else
|
|
114
|
+
f"answered {r['answered_attention']}" if r.get("answered_attention") else
|
|
115
|
+
f"queued for {', '.join(r.get('delivered_to') or [])}")
|
|
116
|
+
print(f"{a.cmd} sent ({r['id']}): {how}. Replies show in the dashboard.")
|
|
117
|
+
else:
|
|
118
|
+
t = _api(a.url, "POST", f"/api/tasks/{a.task_id}/{a.cmd}", {} if a.cmd == "pause" else None)
|
|
119
|
+
print(f"{t['name']}: {t['status']}" + (" (reopened)" if t.get("reopened") else ""))
|
|
120
|
+
except RuntimeError as e:
|
|
121
|
+
sys.exit(str(e))
|
|
122
|
+
except ConnectionError:
|
|
123
|
+
sys.exit(f"no server answering at {a.url}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def _serve_all(servers) -> None:
|
|
127
|
+
"""Dashboard and peer listener in one process; when one stops, the other stops too."""
|
|
128
|
+
jobs = [asyncio.create_task(s.serve()) for s in servers]
|
|
129
|
+
await asyncio.wait(jobs, return_when=asyncio.FIRST_COMPLETED)
|
|
130
|
+
for s in servers:
|
|
131
|
+
s.should_exit = True
|
|
132
|
+
await asyncio.gather(*jobs, return_exceptions=True)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
SERVER_KEYS = {"host", "port", "data_dir", "departments", "mock", "url"}
|
|
136
|
+
INIT_SERVER = {"host": "127.0.0.1", "port": 8000, "data_dir": ".", "departments": "all"}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _server_settings(a) -> None:
|
|
140
|
+
"""Fill unset options from the config file's "server" block. Order: command-line flag, environment variable,
|
|
141
|
+
"server" in the config file, built-in default. Relative paths in the file are relative to the file's folder."""
|
|
142
|
+
cfg_path = Path(a.config)
|
|
143
|
+
server = {}
|
|
144
|
+
if cfg_path.is_file():
|
|
145
|
+
try:
|
|
146
|
+
server = load_config(cfg_path).get("server") or {}
|
|
147
|
+
except ValueError as e:
|
|
148
|
+
sys.exit(f"config error: {cfg_path}: {e}")
|
|
149
|
+
bad = set(server) - SERVER_KEYS
|
|
150
|
+
if bad:
|
|
151
|
+
sys.exit(f"config error: unknown \"server\" setting(s) {', '.join(sorted(bad))}; "
|
|
152
|
+
f"known: {', '.join(sorted(SERVER_KEYS))}")
|
|
153
|
+
base = cfg_path.resolve().parent if cfg_path.is_file() else Path.cwd()
|
|
154
|
+
|
|
155
|
+
def pick(flag, env: str, key: str, default):
|
|
156
|
+
if flag is not None:
|
|
157
|
+
return flag
|
|
158
|
+
if os.getenv(env) is not None:
|
|
159
|
+
return os.environ[env]
|
|
160
|
+
return server.get(key, default)
|
|
161
|
+
|
|
162
|
+
if a.data_dir is None:
|
|
163
|
+
a.data_dir = os.getenv("XQO_DATA") or (str(base / str(server["data_dir"])) if server.get("data_dir") else ".")
|
|
164
|
+
if a.departments is None and os.getenv("XQO_DEPARTMENTS") is None and "departments" in server:
|
|
165
|
+
d = server["departments"]
|
|
166
|
+
a.departments = ",".join(d) if isinstance(d, list) else str(d)
|
|
167
|
+
a.mock = bool(a.mock or server.get("mock"))
|
|
168
|
+
host = pick(getattr(a, "host", None), "XQO_HOST", "host", "127.0.0.1")
|
|
169
|
+
try:
|
|
170
|
+
port = int(pick(getattr(a, "port", None), "XQO_PORT", "port", 8000))
|
|
171
|
+
except ValueError:
|
|
172
|
+
sys.exit("config error: the port must be a number")
|
|
173
|
+
if hasattr(a, "host"):
|
|
174
|
+
a.host, a.port = host, port
|
|
175
|
+
if hasattr(a, "url") and a.url is None:
|
|
176
|
+
local = "127.0.0.1" if host in ("0.0.0.0", "::", "") else host
|
|
177
|
+
a.url = os.getenv("XQO_URL") or server.get("url") or f"http://{local}:{port}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _deleted(r: dict) -> str:
|
|
181
|
+
if not r.get("ok"):
|
|
182
|
+
return f"{r['id']}: {r.get('error')}"
|
|
183
|
+
how = "deleted permanently" if r.get("permanent") else "moved to the trash (undo: xqo restore)"
|
|
184
|
+
kept = f"; kept {len(r['branches'])} branch(es) forked from it" if r.get("branches") else ""
|
|
185
|
+
return f"{r.get('name', r['id'])}: {how}{kept}"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _ops(config: str) -> dict:
|
|
189
|
+
"""The "backup" block of agents.json, read without building the instance."""
|
|
190
|
+
try:
|
|
191
|
+
cfg = json.loads(Path(config).read_text(encoding="utf-8")) if Path(config).is_file() else {}
|
|
192
|
+
except ValueError:
|
|
193
|
+
cfg = {}
|
|
194
|
+
b = cfg.get("backup") or {}
|
|
195
|
+
d = b.get("dir")
|
|
196
|
+
if d and not Path(d).expanduser().is_absolute():
|
|
197
|
+
d = str(Path(config).parent / d)
|
|
198
|
+
return {"dir": d, "keep": b.get("keep")}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _backup(a, make_bus, data: Path) -> None:
|
|
202
|
+
"""backup [create] / list / verify / restore."""
|
|
203
|
+
from . import backup
|
|
204
|
+
ops = _ops(a.config)
|
|
205
|
+
folder = Path(getattr(a, "out", None) or ops["dir"] or data / "backups")
|
|
206
|
+
try:
|
|
207
|
+
if a.backup_cmd == "verify":
|
|
208
|
+
m = backup.verify(a.archive)
|
|
209
|
+
print(f"ok: {len(m['files'])} file(s), scope {m['scope']}, made by {m.get('version')} on {m['created']}")
|
|
210
|
+
return
|
|
211
|
+
if a.backup_cmd == "restore":
|
|
212
|
+
r = backup.restore(a.archive, a.to, dry_run=a.dry_run)
|
|
213
|
+
print(("would restore" if a.dry_run else "restored") + f" {r['files']} file(s) ({r['scope']}) into {r['to']}")
|
|
214
|
+
if r["external"]:
|
|
215
|
+
print("files that lived outside the config folder are under _external/: " + ", ".join(r["external"]))
|
|
216
|
+
if r["running_tasks"]:
|
|
217
|
+
print(f"{len(r['running_tasks'])} task(s) were running at backup time: they come back as interrupted")
|
|
218
|
+
if not a.dry_run:
|
|
219
|
+
print("channel sign-ins are not in backups: sign in again (see CHANNELS.md); set the environment "
|
|
220
|
+
f"variables, then check it: xqo --config {Path(a.to) / 'agents.json'} "
|
|
221
|
+
f"--data-dir {a.to} check")
|
|
222
|
+
try: # master config: the restored data sits next to agents.json, so server.data_dir must be "."
|
|
223
|
+
dd = (load_config(Path(a.to) / "agents.json").get("server") or {}).get("data_dir")
|
|
224
|
+
except (OSError, ValueError):
|
|
225
|
+
dd = None
|
|
226
|
+
if dd not in (None, ".", "./"):
|
|
227
|
+
print(f"note: agents.json has \"server\": {{\"data_dir\": {dd!r}}}; the restored data is in "
|
|
228
|
+
f"{a.to}: set it to \".\" or pass --data-dir {a.to}")
|
|
229
|
+
return
|
|
230
|
+
if a.backup_cmd == "list":
|
|
231
|
+
for p in backup.archives(folder):
|
|
232
|
+
print(f"{p.name} {p.stat().st_size / 1e6:.1f} MB")
|
|
233
|
+
return
|
|
234
|
+
except (OSError, ValueError, KeyError) as e:
|
|
235
|
+
sys.exit(f"backup: {e}")
|
|
236
|
+
keep = a.keep or ops["keep"]
|
|
237
|
+
if not a.offline:
|
|
238
|
+
try:
|
|
239
|
+
r = _api(a.url, "POST", "/api/admin/backup", {"scope": a.scope, "keep": keep})
|
|
240
|
+
return _print_backup(r)
|
|
241
|
+
except RuntimeError as e:
|
|
242
|
+
sys.exit(str(e))
|
|
243
|
+
except ConnectionError:
|
|
244
|
+
print(f"no server answering at {a.url}: writing the archive directly")
|
|
245
|
+
try:
|
|
246
|
+
r = backup.create(make_bus(), a.scope, folder, data, keep)
|
|
247
|
+
except (OSError, ValueError) as e:
|
|
248
|
+
sys.exit(f"backup: {e}")
|
|
249
|
+
_print_backup(r)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _print_backup(r: dict) -> None:
|
|
253
|
+
print(f"{r['path']} ({r['files']} file(s), {r['bytes'] / 1e6:.1f} MB, scope {r['scope']})")
|
|
254
|
+
if r.get("running_tasks"):
|
|
255
|
+
print(f"note: {len(r['running_tasks'])} task(s) were running while it was written")
|
|
256
|
+
for x in r.get("skipped") or []:
|
|
257
|
+
print(f"skipped: {x}")
|
|
258
|
+
if r.get("pruned"):
|
|
259
|
+
print(f"removed {len(r['pruned'])} older archive(s)")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _unset_env(node, out: set) -> None:
|
|
263
|
+
"""Names in *_env settings (keys, tokens) whose environment variable is not set."""
|
|
264
|
+
if isinstance(node, dict):
|
|
265
|
+
for k, v in node.items():
|
|
266
|
+
if k.startswith("_"): # comments and examples ("_mcp_examples")
|
|
267
|
+
continue
|
|
268
|
+
if k.endswith("_env") and isinstance(v, str) and v and not os.getenv(v):
|
|
269
|
+
out.add(v)
|
|
270
|
+
else:
|
|
271
|
+
_unset_env(v, out)
|
|
272
|
+
elif isinstance(node, list):
|
|
273
|
+
for v in node:
|
|
274
|
+
_unset_env(v, out)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _check(a, data: Path) -> None:
|
|
278
|
+
"""Load the configuration with this code, without starting anything or touching the data."""
|
|
279
|
+
import tempfile
|
|
280
|
+
|
|
281
|
+
from . import __version__
|
|
282
|
+
from .config import load_config
|
|
283
|
+
from .migrations import DATA_VERSION, pending, read_data_version
|
|
284
|
+
errors, warnings = [], []
|
|
285
|
+
print(f"xqo {__version__} (data version {DATA_VERSION})")
|
|
286
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
287
|
+
try:
|
|
288
|
+
bus = build(a.config, Path(tmp) / "artifacts", str(Path(tmp) / "events.jsonl"), mock=True,
|
|
289
|
+
departments=a.departments, departments_dir=a.departments_dir, run_migrations=False,
|
|
290
|
+
strict=True) # a plugin error is an error here, not a warning
|
|
291
|
+
except (ValueError, KeyError, OSError) as e:
|
|
292
|
+
bus = None
|
|
293
|
+
errors.append(f"config: {e}")
|
|
294
|
+
if bus:
|
|
295
|
+
print(f"config: {a.config}: {len(bus.agents)} agent(s), {len(bus.departments)} department(s), "
|
|
296
|
+
f"{len(bus.skills.packs)} skill pack(s), "
|
|
297
|
+
f"{len(bus.mcp.servers)} local + {len(bus.mcp_remote)} remote MCP server(s)")
|
|
298
|
+
warnings += list(getattr(bus, "config_warnings", []))
|
|
299
|
+
print(f"storage: {bus.store.describe()}")
|
|
300
|
+
print("plugins: " + ", ".join(f"{n} ({st.state})" for n, st in sorted(bus.plugins.states.items())))
|
|
301
|
+
if a.mcp and bus.mcp.servers:
|
|
302
|
+
async def probe():
|
|
303
|
+
await bus.mcp.start()
|
|
304
|
+
st = bus.mcp.status()
|
|
305
|
+
await bus.mcp.stop()
|
|
306
|
+
return st
|
|
307
|
+
for st in asyncio.run(probe()):
|
|
308
|
+
if st["error"]:
|
|
309
|
+
errors.append(f"MCP {st['name']}: {st['error']}")
|
|
310
|
+
else:
|
|
311
|
+
print(f"MCP {st['name']}: {len(st['tools'])} tool(s)")
|
|
312
|
+
missing: set = set()
|
|
313
|
+
try:
|
|
314
|
+
_unset_env(load_config(a.config), missing)
|
|
315
|
+
for p in Path(a.departments_dir or Path(a.config).parent / "departments").glob("*.json"):
|
|
316
|
+
_unset_env(json.loads(p.read_text(encoding="utf-8")), missing)
|
|
317
|
+
except (OSError, ValueError):
|
|
318
|
+
pass
|
|
319
|
+
warnings += [f"environment variable {v} is not set" for v in sorted(missing)]
|
|
320
|
+
root = data / "artifacts"
|
|
321
|
+
have = read_data_version(root) if root.is_dir() else None
|
|
322
|
+
if have is None:
|
|
323
|
+
print(f"data: {root} does not exist yet (a fresh instance)")
|
|
324
|
+
elif have > DATA_VERSION:
|
|
325
|
+
errors.append(f"data: {root} is version {have}, newer than this code ({DATA_VERSION}): "
|
|
326
|
+
"run the newer version, or restore a backup made before the upgrade")
|
|
327
|
+
else:
|
|
328
|
+
todo = pending(root)
|
|
329
|
+
print(f"data: {root} is version {have}" + (
|
|
330
|
+
f"; at start-up these migrations run: {', '.join(f'v{v} {w}' for v, w in todo)}" if todo else ", up to date"))
|
|
331
|
+
for w in warnings:
|
|
332
|
+
print(f"warning: {w}")
|
|
333
|
+
for e in errors:
|
|
334
|
+
print(f"error: {e}")
|
|
335
|
+
print("check failed" if errors else "check passed")
|
|
336
|
+
if errors:
|
|
337
|
+
sys.exit(1)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _drain(a) -> None:
|
|
341
|
+
"""Stop taking new work, wait for running tasks, optionally stop the server (before an upgrade)."""
|
|
342
|
+
import time
|
|
343
|
+
try:
|
|
344
|
+
if a.off:
|
|
345
|
+
_api(a.url, "POST", "/api/admin/drain", {"on": False})
|
|
346
|
+
print("taking new tasks again")
|
|
347
|
+
return
|
|
348
|
+
st = _api(a.url, "POST", "/api/admin/drain", {"on": True})
|
|
349
|
+
deadline = time.monotonic() + a.timeout
|
|
350
|
+
shown = None
|
|
351
|
+
while st["running_tasks"] and time.monotonic() < deadline:
|
|
352
|
+
names = ", ".join(t["name"] for t in st["running_tasks"])
|
|
353
|
+
if names != shown:
|
|
354
|
+
print(f"waiting for {len(st['running_tasks'])} running task(s): {names}")
|
|
355
|
+
shown = names
|
|
356
|
+
time.sleep(2)
|
|
357
|
+
st = _api(a.url, "GET", "/api/health")
|
|
358
|
+
if st["running_tasks"]:
|
|
359
|
+
print(f"{len(st['running_tasks'])} task(s) still running after {int(a.timeout)}s: after a restart they "
|
|
360
|
+
"come back as interrupted and can be resumed")
|
|
361
|
+
else:
|
|
362
|
+
print("drained: no task is running")
|
|
363
|
+
if a.stop:
|
|
364
|
+
_api(a.url, "POST", "/api/admin/stop")
|
|
365
|
+
print("server stopping")
|
|
366
|
+
elif not st["running_tasks"]:
|
|
367
|
+
print("safe to stop and upgrade now (or: xqo drain --stop)")
|
|
368
|
+
if st["running_tasks"] and not a.stop:
|
|
369
|
+
sys.exit(2)
|
|
370
|
+
except RuntimeError as e:
|
|
371
|
+
sys.exit(str(e))
|
|
372
|
+
except ConnectionError:
|
|
373
|
+
sys.exit(f"no server answering at {a.url}")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def main() -> None:
|
|
377
|
+
from . import __version__
|
|
378
|
+
p = argparse.ArgumentParser(prog="xqo", description="xqo: plugin-based Claude agents HQ")
|
|
379
|
+
p.add_argument("--version", action="version", version=f"xqo {__version__}")
|
|
380
|
+
p.add_argument("--config", default=os.getenv("XQO_CONFIG", "agents.json"),
|
|
381
|
+
help="agent roster (falls back to the packaged default)")
|
|
382
|
+
p.add_argument("--data-dir", default=None,
|
|
383
|
+
help="where artifacts/ and logs/ are written (XQO_DATA, else server.data_dir in the config, else .)")
|
|
384
|
+
p.add_argument("--mock", action="store_true", help="use scripted offline LLM (no API key)")
|
|
385
|
+
p.add_argument("--departments", default=None,
|
|
386
|
+
help="departments to load, e.g. engineering,marketing ('none' = HQ only; default: all, "
|
|
387
|
+
"or XQO_DEPARTMENTS)")
|
|
388
|
+
p.add_argument("--departments-dir", default=None,
|
|
389
|
+
help="folder with one JSON file per department (default: departments/ next to agents.json)")
|
|
390
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
391
|
+
s = sub.add_parser("serve", help="run the monitoring website + xqo")
|
|
392
|
+
s.add_argument("--host", default=None, help="XQO_HOST, else server.host in the config, else 127.0.0.1")
|
|
393
|
+
s.add_argument("--port", type=int, default=None, help="XQO_PORT, else server.port in the config, else 8000")
|
|
394
|
+
s.add_argument("--edit", action="store_true", help="allow changing the office layout (the office page's Edit mode); or set \"office\": {\"edit\": true} in agents.json")
|
|
395
|
+
r = sub.add_parser("run", help="run one task from the command line")
|
|
396
|
+
r.add_argument("prompt")
|
|
397
|
+
r.add_argument("--agent")
|
|
398
|
+
r.add_argument("--attach", action="append", default=[], metavar="FILE",
|
|
399
|
+
help="attach an image, PDF or text file (repeatable)")
|
|
400
|
+
url = None # XQO_URL, else server.url / host:port from the config file (see _server_settings)
|
|
401
|
+
c = sub.add_parser("cancel", help="cancel running task(s) on the server")
|
|
402
|
+
c.add_argument("ids", nargs="+", metavar="TASK_ID")
|
|
403
|
+
c.add_argument("--reason", default="")
|
|
404
|
+
c.add_argument("--url", default=url, help="server address (XQO_URL)")
|
|
405
|
+
d = sub.add_parser("delete", help="delete finished task(s): to the trash, or --permanent")
|
|
406
|
+
d.add_argument("ids", nargs="+", metavar="TASK_ID")
|
|
407
|
+
d.add_argument("--permanent", action="store_true", help="skip the trash")
|
|
408
|
+
d.add_argument("--url", default=url, help="server address (XQO_URL); without a server the data dir is used")
|
|
409
|
+
u = sub.add_parser("restore", help="bring deleted task(s) back from the trash")
|
|
410
|
+
u.add_argument("ids", nargs="+", metavar="TASK_ID")
|
|
411
|
+
u.add_argument("--url", default=url, help="server address (XQO_URL); without a server the data dir is used")
|
|
412
|
+
for kind, what in (("archive", "archive finished task(s): hidden from the task list, nothing is removed"),
|
|
413
|
+
("unarchive", "bring archived task(s) back to the task list")):
|
|
414
|
+
k = sub.add_parser(kind, help=what)
|
|
415
|
+
k.add_argument("ids", nargs="+", metavar="TASK_ID")
|
|
416
|
+
k.add_argument("--url", default=url, help="server address (XQO_URL); without a server the data dir is used")
|
|
417
|
+
for kind, what in (("ask", "ask a running (or finished) task's agents a question"),
|
|
418
|
+
("steer", "change a running (or finished) task's instructions")):
|
|
419
|
+
k = sub.add_parser(kind, help=what)
|
|
420
|
+
k.add_argument("task_id")
|
|
421
|
+
k.add_argument("text")
|
|
422
|
+
k.add_argument("--to", help="agent that should receive it (default: the task's entry agent)")
|
|
423
|
+
k.add_argument("--urgent", action="store_true", help="jump the queue; an urgent ask is answered at once")
|
|
424
|
+
k.add_argument("--url", default=url, help="server address (XQO_URL)")
|
|
425
|
+
for kind, what in (("pause", "hold a running task"), ("resume", "continue a paused or finished task")):
|
|
426
|
+
k = sub.add_parser(kind, help=what)
|
|
427
|
+
k.add_argument("task_id")
|
|
428
|
+
k.add_argument("--url", default=url, help="server address (XQO_URL)")
|
|
429
|
+
bk = sub.add_parser("backup", help="back up the configuration and data: create (default), list, verify, restore")
|
|
430
|
+
bk.add_argument("--url", default=url, help="server address (XQO_URL); without a server the data dir is used")
|
|
431
|
+
bk.add_argument("--scope", choices=["full", "state", "config"], default="full",
|
|
432
|
+
help="full: everything; state: config + runtime settings (schedules, office, channel and feed "
|
|
433
|
+
"state), no task data; config: the configuration files only")
|
|
434
|
+
bk.add_argument("--keep", type=int, help="keep only the newest N archives of this scope (backup.keep)")
|
|
435
|
+
bk.add_argument("--out", help="folder for the archive when no server runs (default: backup.dir, else <data>/backups)")
|
|
436
|
+
bk.add_argument("--offline", action="store_true", help="write it directly, without asking the server to pause")
|
|
437
|
+
bsub = bk.add_subparsers(dest="backup_cmd")
|
|
438
|
+
bsub.add_parser("create", help="write an archive (the default)")
|
|
439
|
+
bsub.add_parser("list", help="archives in the backup folder")
|
|
440
|
+
bv = bsub.add_parser("verify", help="check an archive against its manifest")
|
|
441
|
+
bv.add_argument("archive")
|
|
442
|
+
br = bsub.add_parser("restore", help="unpack an archive into a new, empty folder")
|
|
443
|
+
br.add_argument("archive")
|
|
444
|
+
br.add_argument("--to", required=True, help="the new instance folder (config files and data dir)")
|
|
445
|
+
br.add_argument("--dry-run", action="store_true", help="verify and show what would be restored")
|
|
446
|
+
ck = sub.add_parser("check", help="load the configuration with this version without starting it "
|
|
447
|
+
"(before an upgrade: run it with the new version)")
|
|
448
|
+
ck.add_argument("--mcp", action="store_true", help="also start the local MCP servers once and list their tools")
|
|
449
|
+
dr = sub.add_parser("drain", help="before an upgrade: stop taking new work and wait for running tasks")
|
|
450
|
+
dr.add_argument("--timeout", type=float, default=600, help="seconds to wait for running tasks (default 600)")
|
|
451
|
+
dr.add_argument("--stop", action="store_true", help="then stop the server")
|
|
452
|
+
dr.add_argument("--off", action="store_true", help="cancel a drain: take new work again")
|
|
453
|
+
dr.add_argument("--url", default=url, help="server address (XQO_URL)")
|
|
454
|
+
i = sub.add_parser("init", help="write a starter agents.json to edit")
|
|
455
|
+
i.add_argument("path", nargs="?", default="agents.json")
|
|
456
|
+
plugin_cmds = {} # commands added by plugins (schedule, channels, feed, peers, agency, …)
|
|
457
|
+
for cls in plugin_mod.load_classes().values(): # a plugin that can't load just adds no command
|
|
458
|
+
try:
|
|
459
|
+
for name in cls.add_cli(sub, url) or []:
|
|
460
|
+
plugin_cmds[name] = cls
|
|
461
|
+
except Exception as e:
|
|
462
|
+
print(f"warning: plugin {cls.name}: its commands are unavailable: {e}", file=sys.stderr)
|
|
463
|
+
argv = sys.argv[1:]
|
|
464
|
+
if argv and argv[0].endswith(".json") and not argv[0].startswith("-"):
|
|
465
|
+
# `xqo hq.json [command ...]`: the file is the master config; no command = serve
|
|
466
|
+
argv = ["--config", argv[0], *(argv[1:] or ["serve"])]
|
|
467
|
+
a = p.parse_args(argv)
|
|
468
|
+
_server_settings(a)
|
|
469
|
+
|
|
470
|
+
if a.cmd in plugin_cmds:
|
|
471
|
+
plugin_cmds[a.cmd].run_cli(a)
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
if a.cmd == "init":
|
|
475
|
+
dst = Path(a.path)
|
|
476
|
+
if dst.exists():
|
|
477
|
+
sys.exit(f"{dst} already exists")
|
|
478
|
+
cfg = json.loads(PACKAGED_CONFIG.read_text(encoding="utf-8"))
|
|
479
|
+
dst.write_text(json.dumps({"server": INIT_SERVER, **cfg}, indent=2) + "\n", encoding="utf-8")
|
|
480
|
+
ddir = dst.parent / "departments"
|
|
481
|
+
if not ddir.exists():
|
|
482
|
+
shutil.copytree(PACKAGED_DEPARTMENTS, ddir)
|
|
483
|
+
print(f"wrote {ddir}/ ({', '.join(p.stem for p in sorted(ddir.glob('*.json')))})")
|
|
484
|
+
print(f"wrote {dst}")
|
|
485
|
+
return
|
|
486
|
+
|
|
487
|
+
data = Path(a.data_dir)
|
|
488
|
+
if a.cmd == "drain":
|
|
489
|
+
_drain(a)
|
|
490
|
+
return
|
|
491
|
+
if a.cmd == "check":
|
|
492
|
+
_check(a, data)
|
|
493
|
+
return
|
|
494
|
+
if a.cmd == "backup":
|
|
495
|
+
_backup(a, lambda: build(a.config, data / "artifacts", str(data / "logs" / "events.jsonl"), mock=True,
|
|
496
|
+
departments=a.departments, departments_dir=a.departments_dir,
|
|
497
|
+
run_migrations=False), data)
|
|
498
|
+
return
|
|
499
|
+
if a.cmd in ("ask", "steer", "pause", "resume"):
|
|
500
|
+
_talk(a)
|
|
501
|
+
return
|
|
502
|
+
if a.cmd in ("archive", "unarchive"):
|
|
503
|
+
_archive(a, lambda: build(a.config, data / "artifacts", str(data / "logs" / "events.jsonl"), mock=True,
|
|
504
|
+
departments=a.departments, departments_dir=a.departments_dir))
|
|
505
|
+
return
|
|
506
|
+
if a.cmd in ("cancel", "delete", "restore"):
|
|
507
|
+
_manage(a, lambda: build(a.config, data / "artifacts", str(data / "logs" / "events.jsonl"), mock=True,
|
|
508
|
+
departments=a.departments, departments_dir=a.departments_dir))
|
|
509
|
+
return
|
|
510
|
+
try:
|
|
511
|
+
bus = build(a.config, data / "artifacts", str(data / "logs" / "events.jsonl"), mock=a.mock or None,
|
|
512
|
+
departments=a.departments, departments_dir=a.departments_dir)
|
|
513
|
+
except ValueError as e:
|
|
514
|
+
sys.exit(f"config error: {e}")
|
|
515
|
+
names = ", ".join(f"{d.name} (head {d.head})" for d in bus.departments.values()) or "none"
|
|
516
|
+
print(f"Departments: {names}")
|
|
517
|
+
for m in bus.migrations_applied:
|
|
518
|
+
print(f"Data migrated: {m}")
|
|
519
|
+
for w in getattr(bus, "config_warnings", []):
|
|
520
|
+
print(f"warning: {w}", file=sys.stderr)
|
|
521
|
+
ops = _ops(a.config)
|
|
522
|
+
bus.data_dir, bus.backup_dir = data, ops["dir"]
|
|
523
|
+
if a.cmd == "serve":
|
|
524
|
+
import uvicorn
|
|
525
|
+
|
|
526
|
+
from .web import create_app
|
|
527
|
+
|
|
528
|
+
bus.office_edit = a.edit or bool(getattr(getattr(bus, "office", None), "edit", False)) # --edit or "office": {"edit": true}
|
|
529
|
+
print(f"Monitor: http://{a.host}:{a.port}" + (" (office editing on)" if bus.office_edit else ""))
|
|
530
|
+
side = []
|
|
531
|
+
for p in bus.plugins.active():
|
|
532
|
+
try:
|
|
533
|
+
for line in p.banner():
|
|
534
|
+
print(line)
|
|
535
|
+
side += p.side_servers()
|
|
536
|
+
except Exception as e:
|
|
537
|
+
print(f"warning: plugin {p.name}: {type(e).__name__}: {e}", file=sys.stderr)
|
|
538
|
+
print("Plugins: " + ", ".join(f"{n} ({st.state})" for n, st in sorted(bus.plugins.states.items())
|
|
539
|
+
if st.state in ("active", "failed")))
|
|
540
|
+
servers = [uvicorn.Server(uvicorn.Config(create_app(bus), host=a.host, port=a.port, log_level="warning"))]
|
|
541
|
+
servers += [uvicorn.Server(uvicorn.Config(app, host=h, port=pt, log_level="warning")) for app, h, pt in side]
|
|
542
|
+
if len(servers) > 1:
|
|
543
|
+
asyncio.run(_serve_all(servers))
|
|
544
|
+
else:
|
|
545
|
+
servers[0].run()
|
|
546
|
+
else:
|
|
547
|
+
from .attachments import validate
|
|
548
|
+
try:
|
|
549
|
+
files = validate([(Path(f).name, None, Path(f).read_bytes()) for f in a.attach])
|
|
550
|
+
except (OSError, ValueError) as e:
|
|
551
|
+
sys.exit(f"attachment: {e}")
|
|
552
|
+
task = asyncio.run(_run_cli(bus, a.prompt, a.agent, files))
|
|
553
|
+
print(f"\n{task.name}\n[{task.status}] {task.result}")
|
|
554
|
+
print(f"\nArtifacts: {len(bus.artifacts.list(task.id))} saved under {bus.artifacts.root}")
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
if __name__ == "__main__":
|
|
558
|
+
main()
|