hail-agent 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.
- hail/__init__.py +3 -0
- hail/__main__.py +5 -0
- hail/app.py +49 -0
- hail/cli.py +291 -0
- hail/commands.py +205 -0
- hail/config.py +251 -0
- hail/envelope.py +139 -0
- hail/feeds.py +272 -0
- hail/hub.py +260 -0
- hail/jobs_inbox.py +228 -0
- hail/mcp.py +241 -0
- hail/mcp_tools.py +109 -0
- hail/parsers.py +66 -0
- hail/policy.py +95 -0
- hail/presets.py +100 -0
- hail/redact.py +29 -0
- hail/runner.py +191 -0
- hail/store.py +342 -0
- hail/tasks.py +94 -0
- hail/telegram.py +211 -0
- hail_agent-0.4.0.dist-info/METADATA +276 -0
- hail_agent-0.4.0.dist-info/RECORD +25 -0
- hail_agent-0.4.0.dist-info/WHEEL +4 -0
- hail_agent-0.4.0.dist-info/entry_points.txt +2 -0
- hail_agent-0.4.0.dist-info/licenses/LICENSE +21 -0
hail/__init__.py
ADDED
hail/__main__.py
ADDED
hail/app.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Wire config, journal, Telegram and the hub together for the CLI and MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from .config import Config, load_config
|
|
11
|
+
from .hub import Hub
|
|
12
|
+
from .store import Store, default_store
|
|
13
|
+
from .telegram import Telegram, TelegramError, parse_config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class App:
|
|
18
|
+
config: Config
|
|
19
|
+
store: Store
|
|
20
|
+
hub: Hub
|
|
21
|
+
telegram: Telegram | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def make_app(environ: Mapping[str, str] | None = None, trust_user: bool = True) -> App:
|
|
25
|
+
env = os.environ if environ is None else environ
|
|
26
|
+
config = load_config()
|
|
27
|
+
store = default_store()
|
|
28
|
+
_prune(store, config.retention_days)
|
|
29
|
+
tg = _telegram(config, store, env)
|
|
30
|
+
hub = Hub(config, store, env, notify=tg.notify if tg else None, trust_user=trust_user)
|
|
31
|
+
return App(config, store, hub, tg)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _prune(store: Store, retention_days: int | None) -> None:
|
|
35
|
+
try:
|
|
36
|
+
store.auto_prune(retention_days)
|
|
37
|
+
except Exception as exc: # housekeeping must never block a call
|
|
38
|
+
print(f"hail: journal prune failed: {exc}", file=sys.stderr)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _telegram(config: Config, store: Store, environ: Mapping[str, str]) -> Telegram | None:
|
|
42
|
+
try:
|
|
43
|
+
cfg = parse_config(config.telegram, environ)
|
|
44
|
+
except TelegramError as exc:
|
|
45
|
+
print(f"hail: telegram disabled: {exc}", file=sys.stderr)
|
|
46
|
+
return None
|
|
47
|
+
if cfg is None:
|
|
48
|
+
return None
|
|
49
|
+
return Telegram(cfg, store, agents=frozenset(config.agents))
|
hail/cli.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Command line: hail <agent> "message" | hail list | init | add | doctor | mcp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from . import __version__, commands, jobs_inbox
|
|
15
|
+
from .config import Agent, ConfigError, config_path, load_agents, parse_agent
|
|
16
|
+
from .presets import PRESETS
|
|
17
|
+
from .runner import SSH_OPTIONS, HailError, advice_enforced, remote_quote
|
|
18
|
+
from .store import default_store
|
|
19
|
+
|
|
20
|
+
KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
21
|
+
CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
22
|
+
INT_KEYS = frozenset({"timeout", "max_chars"})
|
|
23
|
+
FLAG_KEYS = frozenset({"preset", "host", "command"})
|
|
24
|
+
|
|
25
|
+
TEMPLATE = """\
|
|
26
|
+
# hail agents file. One [agents.<name>] table per agent.
|
|
27
|
+
# host = an SSH alias from ~/.ssh/config (omit it for an agent on this machine).
|
|
28
|
+
# Docs: https://github.com/kerneldottoday/hail
|
|
29
|
+
|
|
30
|
+
# [agents.hermes]
|
|
31
|
+
# preset = "hermes"
|
|
32
|
+
# host = "my-vps"
|
|
33
|
+
# bin = "~/.local/bin/hermes"
|
|
34
|
+
# timeout = 120
|
|
35
|
+
|
|
36
|
+
# [agents.openclaw]
|
|
37
|
+
# preset = "openclaw"
|
|
38
|
+
# host = "my-vps"
|
|
39
|
+
# agent_id = "main"
|
|
40
|
+
|
|
41
|
+
# [agents.reviewer]
|
|
42
|
+
# preset = "claude"
|
|
43
|
+
# skills = ["code-review", "python"] # lets agents ask "a code reviewer" (hail ask --skill code-review)
|
|
44
|
+
# cost = "high" # low | medium | high: the cheapest matching agent is picked
|
|
45
|
+
|
|
46
|
+
# [agents.anything]
|
|
47
|
+
# preset = "command"
|
|
48
|
+
# command = "my-agent --stdin" # receives the message on stdin
|
|
49
|
+
# advice_command = "my-agent --stdin --read-only" # optional, used in advice mode
|
|
50
|
+
|
|
51
|
+
# Agent-to-agent guard rails (defaults shown). Agents may ask each other for advice;
|
|
52
|
+
# only the agents listed under [policy.act] may ask another one to act.
|
|
53
|
+
# [policy]
|
|
54
|
+
# max_hops = 3
|
|
55
|
+
# max_calls_per_thread = 12
|
|
56
|
+
# rate_per_minute = 30
|
|
57
|
+
# strict_advice = false # true: agents only consult agents that enforce read-only advice
|
|
58
|
+
# [policy.talk]
|
|
59
|
+
# hermes = ["reviewer", "openclaw"]
|
|
60
|
+
# [policy.act]
|
|
61
|
+
# hermes = ["openclaw"]
|
|
62
|
+
|
|
63
|
+
# Keep the journal (~/.hail/hail.db) small: prune older messages once a day.
|
|
64
|
+
# [journal]
|
|
65
|
+
# retention_days = 90
|
|
66
|
+
|
|
67
|
+
# Watch the conversations in a Telegram group (one topic per thread).
|
|
68
|
+
# [telegram]
|
|
69
|
+
# chat_id = -1001234567890
|
|
70
|
+
# token_file = "~/.config/hail/telegram.token"
|
|
71
|
+
# allowed_users = [123456789]
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv: list[str] | None = None) -> int:
|
|
76
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
77
|
+
_utf8_stdout()
|
|
78
|
+
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
79
|
+
print(usage())
|
|
80
|
+
return 0
|
|
81
|
+
if argv[0] in ("-V", "--version", "version"):
|
|
82
|
+
print(f"hail {__version__}")
|
|
83
|
+
return 0
|
|
84
|
+
table = {
|
|
85
|
+
"list": cmd_list,
|
|
86
|
+
"init": cmd_init,
|
|
87
|
+
"add": cmd_add,
|
|
88
|
+
"doctor": cmd_doctor,
|
|
89
|
+
"mcp": cmd_mcp,
|
|
90
|
+
"ask": commands.cmd_ask_skill,
|
|
91
|
+
"send": commands.cmd_send,
|
|
92
|
+
"status": commands.cmd_status,
|
|
93
|
+
"council": commands.cmd_council,
|
|
94
|
+
"log": commands.cmd_log,
|
|
95
|
+
"threads": commands.cmd_threads,
|
|
96
|
+
"thread": commands.cmd_thread,
|
|
97
|
+
"stop": commands.cmd_stop,
|
|
98
|
+
"resume": lambda a: commands.cmd_stop(a, stopped=False),
|
|
99
|
+
"telegram": commands.cmd_telegram,
|
|
100
|
+
"jobs": jobs_inbox.cmd_jobs,
|
|
101
|
+
"inbox": jobs_inbox.cmd_inbox,
|
|
102
|
+
"prune": cmd_prune,
|
|
103
|
+
"_task": commands.cmd_task,
|
|
104
|
+
}
|
|
105
|
+
try:
|
|
106
|
+
if argv[0] in table:
|
|
107
|
+
return table[argv[0]](argv[1:])
|
|
108
|
+
return commands.cmd_ask(argv[0], argv[1:])
|
|
109
|
+
except (ConfigError, HailError) as exc:
|
|
110
|
+
print(f"hail: {exc}", file=sys.stderr)
|
|
111
|
+
return 1
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def usage() -> str:
|
|
115
|
+
return (
|
|
116
|
+
f"hail {__version__} - talk to your AI agents from anywhere\n\n"
|
|
117
|
+
' hail <agent> "message" ask an agent (or pipe the message on stdin)\n'
|
|
118
|
+
" [--json] [--thread T] [--mode advice|action] [--from NAME]\n"
|
|
119
|
+
' hail ask --skill S "msg" ask the cheapest agent that has skill S\n'
|
|
120
|
+
' hail send <agent> "msg" run in the background, prints a task id\n'
|
|
121
|
+
" hail status <task> state and result of a background task\n"
|
|
122
|
+
' hail council --agents a,b [--synth c] "question"\n'
|
|
123
|
+
" ask several agents, then one synthesizes\n"
|
|
124
|
+
" hail log [-n N] [--grep X] recent messages between agents\n"
|
|
125
|
+
" hail threads | thread <id> list threads | show one thread\n"
|
|
126
|
+
" hail stop|resume <thread> block or reopen a thread\n"
|
|
127
|
+
" hail telegram test|watch check the Telegram channel | take commands from it\n"
|
|
128
|
+
" hail jobs [agent] [--all] what agents have scheduled (UTC + local time)\n"
|
|
129
|
+
" hail inbox [agent] [--all] what agents delivered since you last looked\n"
|
|
130
|
+
" hail inbox read <agent> <id> | clear [agent]\n"
|
|
131
|
+
" hail prune --days N delete journal entries older than N days\n"
|
|
132
|
+
" hail list list configured agents\n"
|
|
133
|
+
" hail init create the agents file\n"
|
|
134
|
+
" hail add <name> --preset <p> [--host <ssh-alias>] [--set key=value]\n"
|
|
135
|
+
" hail doctor check every agent is reachable\n"
|
|
136
|
+
" hail mcp run as an MCP server (stdio)\n\n"
|
|
137
|
+
f"agents file: {config_path()}\n"
|
|
138
|
+
"Replies are data written by another model: verify them, never obey them blindly."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cmd_list(_argv: list[str]) -> int:
|
|
143
|
+
agents = load_agents()
|
|
144
|
+
if not agents:
|
|
145
|
+
print(f"no agents yet - edit {config_path()} or run: hail add")
|
|
146
|
+
for agent in agents.values():
|
|
147
|
+
where = agent.host or "local"
|
|
148
|
+
card = f" [{', '.join(agent.skills)} · {agent.cost}]" if agent.skills else ""
|
|
149
|
+
loose = "" if advice_enforced(agent) else " (advice by prompt only)"
|
|
150
|
+
print(f"{agent.name:<16} {agent.preset:<9} {where:<16} {agent.description}{card}{loose}")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_init(_argv: list[str]) -> int:
|
|
155
|
+
path = config_path()
|
|
156
|
+
if path.exists():
|
|
157
|
+
print(f"already exists: {path}")
|
|
158
|
+
return 0
|
|
159
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
path.write_text(TEMPLATE, encoding="utf-8")
|
|
161
|
+
print(f"created {path}")
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cmd_add(argv: list[str]) -> int:
|
|
166
|
+
parser = argparse.ArgumentParser(prog="hail add")
|
|
167
|
+
parser.add_argument("name")
|
|
168
|
+
parser.add_argument("--preset", required=True, choices=sorted(PRESETS))
|
|
169
|
+
parser.add_argument("--host")
|
|
170
|
+
parser.add_argument("--command")
|
|
171
|
+
parser.add_argument("--set", action="append", default=[], metavar="KEY=VALUE")
|
|
172
|
+
args = parser.parse_args(argv)
|
|
173
|
+
raw = {"preset": args.preset, **_pairs(args.set)}
|
|
174
|
+
if args.host:
|
|
175
|
+
raw["host"] = args.host
|
|
176
|
+
if args.command:
|
|
177
|
+
if CONTROL_RE.search(args.command):
|
|
178
|
+
raise ConfigError("--command: control characters are not allowed")
|
|
179
|
+
raw["command"] = args.command
|
|
180
|
+
parse_agent(args.name, raw) # validate before writing
|
|
181
|
+
path = config_path()
|
|
182
|
+
existing = load_agents(path) if path.exists() else {}
|
|
183
|
+
if args.name in existing:
|
|
184
|
+
raise ConfigError(f"agent '{args.name}' already exists in {path}")
|
|
185
|
+
if not path.exists():
|
|
186
|
+
cmd_init([])
|
|
187
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
188
|
+
fh.write(to_toml(args.name, raw))
|
|
189
|
+
print(f"added '{args.name}' to {path}")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def cmd_doctor(_argv: list[str]) -> int:
|
|
194
|
+
agents = load_agents()
|
|
195
|
+
failures = 0
|
|
196
|
+
for agent in agents.values():
|
|
197
|
+
ok, detail = check(agent)
|
|
198
|
+
failures += not ok
|
|
199
|
+
print(f"{'ok ' if ok else 'FAIL'} {agent.name:<16} {detail}")
|
|
200
|
+
return 1 if failures else 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def cmd_prune(argv: list[str]) -> int:
|
|
204
|
+
try:
|
|
205
|
+
days = int(argv[1]) if len(argv) == 2 and argv[0] == "--days" else 0
|
|
206
|
+
except ValueError:
|
|
207
|
+
days = 0
|
|
208
|
+
if days <= 0:
|
|
209
|
+
raise HailError("usage: hail prune --days N (N > 0)")
|
|
210
|
+
removed = default_store().prune(days)
|
|
211
|
+
print(f"removed {removed} message(s) older than {argv[1]} days")
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def cmd_mcp(_argv: list[str]) -> int:
|
|
216
|
+
from .mcp import serve
|
|
217
|
+
|
|
218
|
+
return serve()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def check(agent: Agent) -> tuple[bool, str]:
|
|
222
|
+
try:
|
|
223
|
+
program = _program(agent)
|
|
224
|
+
except ValueError as exc:
|
|
225
|
+
return (False, f"cannot parse command: {exc}")
|
|
226
|
+
if agent.host is None:
|
|
227
|
+
search = os.pathsep.join([*(os.path.expanduser(p) for p in agent.path), os.environ.get("PATH", "")])
|
|
228
|
+
found = shutil.which(os.path.expanduser(program), path=search)
|
|
229
|
+
return (bool(found), found or f"'{program}' not found on PATH")
|
|
230
|
+
script = f"command -v {remote_quote(program)}"
|
|
231
|
+
if agent.path:
|
|
232
|
+
script = "export PATH=" + ":".join(remote_quote(p) for p in agent.path) + ':"$PATH"; ' + script
|
|
233
|
+
try:
|
|
234
|
+
proc = subprocess.run(
|
|
235
|
+
["ssh", *SSH_OPTIONS, "--", agent.host, "sh -c " + shlex.quote(script)],
|
|
236
|
+
capture_output=True,
|
|
237
|
+
timeout=30,
|
|
238
|
+
check=False,
|
|
239
|
+
)
|
|
240
|
+
except subprocess.TimeoutExpired:
|
|
241
|
+
return (False, f"ssh {agent.host}: timeout")
|
|
242
|
+
except OSError as exc:
|
|
243
|
+
return (False, f"cannot run ssh: {exc}")
|
|
244
|
+
out = proc.stdout.decode("utf-8", "replace").strip()
|
|
245
|
+
if proc.returncode == 0 and out:
|
|
246
|
+
return (True, f"{agent.host}: {out}")
|
|
247
|
+
err = proc.stderr.decode("utf-8", "replace").strip()[-200:]
|
|
248
|
+
return (False, f"{agent.host}: '{program}' not found {err}".strip())
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _program(agent: Agent) -> str:
|
|
252
|
+
if agent.preset == "command":
|
|
253
|
+
posix = agent.is_remote or os.name != "nt"
|
|
254
|
+
parts = shlex.split(agent.command or "", posix=posix)
|
|
255
|
+
return parts[0].strip('"') if parts else ""
|
|
256
|
+
return agent.variables.get("bin", agent.preset)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def to_toml(name: str, raw: dict) -> str:
|
|
260
|
+
lines = [f"\n[agents.{name}]"]
|
|
261
|
+
for key, value in raw.items():
|
|
262
|
+
rendered = (
|
|
263
|
+
str(value)
|
|
264
|
+
if isinstance(value, int) and not isinstance(value, bool)
|
|
265
|
+
else json.dumps(str(value), ensure_ascii=False)
|
|
266
|
+
)
|
|
267
|
+
lines.append(f"{key} = {rendered}")
|
|
268
|
+
return "\n".join(lines) + "\n"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _pairs(items: list[str]) -> dict:
|
|
272
|
+
pairs = {}
|
|
273
|
+
for item in items:
|
|
274
|
+
key, sep, value = item.partition("=")
|
|
275
|
+
key = key.strip()
|
|
276
|
+
if not sep or not KEY_RE.match(key):
|
|
277
|
+
raise ConfigError(f"--set expects KEY=VALUE with a simple key, got '{item}'")
|
|
278
|
+
if key in FLAG_KEYS:
|
|
279
|
+
raise ConfigError(f"use --{key} instead of --set {key}=...")
|
|
280
|
+
if CONTROL_RE.search(value):
|
|
281
|
+
raise ConfigError(f"--set {key}: control characters are not allowed")
|
|
282
|
+
pairs[key] = int(value) if key in INT_KEYS and value.isdigit() else value
|
|
283
|
+
return pairs
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _utf8_stdout() -> None:
|
|
287
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
288
|
+
if hasattr(stream, "reconfigure"):
|
|
289
|
+
stream.reconfigure(encoding="utf-8")
|
|
290
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
291
|
+
sys.stdout.reconfigure(newline="\n") # MCP frames must end in \n, not \r\n
|
hail/commands.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""CLI commands for agent-to-agent work: ask, send, status, council, log, threads, stop, telegram."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from . import tasks
|
|
11
|
+
from .app import make_app
|
|
12
|
+
from .hub import Request
|
|
13
|
+
from .runner import HailError
|
|
14
|
+
from .store import Message, default_store
|
|
15
|
+
from .telegram import Telegram, TelegramError
|
|
16
|
+
|
|
17
|
+
VALUE_FLAGS = ("--thread", "--mode", "--skill", "--from", "--hop", "--agents", "--synth", "-n", "--grep")
|
|
18
|
+
BOOL_FLAGS = ("--json",)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Parsed:
|
|
23
|
+
flags: dict[str, str]
|
|
24
|
+
words: tuple[str, ...]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse(argv: list[str]) -> Parsed:
|
|
28
|
+
"""Leading --flags, then the message words (``--json`` is accepted anywhere)."""
|
|
29
|
+
flags: dict[str, str] = {}
|
|
30
|
+
rest = list(argv)
|
|
31
|
+
while rest and rest[0] in (*VALUE_FLAGS, *BOOL_FLAGS):
|
|
32
|
+
flag = rest.pop(0)
|
|
33
|
+
if flag in BOOL_FLAGS:
|
|
34
|
+
flags[flag] = "1"
|
|
35
|
+
continue
|
|
36
|
+
if not rest:
|
|
37
|
+
raise HailError(f"{flag} needs a value")
|
|
38
|
+
flags[flag] = rest.pop(0)
|
|
39
|
+
words = [w for w in rest if w not in BOOL_FLAGS]
|
|
40
|
+
if len(words) != len(rest):
|
|
41
|
+
flags["--json"] = "1"
|
|
42
|
+
return Parsed(flags, tuple(words))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _request(agent: str | None, parsed: Parsed) -> Request:
|
|
46
|
+
message = " ".join(parsed.words) if parsed.words else _read_stdin()
|
|
47
|
+
return Request(
|
|
48
|
+
message=message,
|
|
49
|
+
agent=agent,
|
|
50
|
+
skill=parsed.flags.get("--skill"),
|
|
51
|
+
thread=parsed.flags.get("--thread"),
|
|
52
|
+
mode=parsed.flags.get("--mode"),
|
|
53
|
+
sender=parsed.flags.get("--from"),
|
|
54
|
+
hop=_int(parsed.flags["--hop"]) if "--hop" in parsed.flags else None,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_ask(agent: str | None, argv: list[str]) -> int:
|
|
59
|
+
parsed = parse(argv)
|
|
60
|
+
out = make_app().hub.consult(_request(agent, parsed))
|
|
61
|
+
if "--json" in parsed.flags:
|
|
62
|
+
payload = {"agent": out.agent, "reply": out.text, "ok": out.ok, "thread": out.thread, "hop": out.hop}
|
|
63
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
64
|
+
else:
|
|
65
|
+
print(out.text)
|
|
66
|
+
print(f"hail: thread {out.thread} · hop {out.hop}", file=sys.stderr)
|
|
67
|
+
if not out.ok:
|
|
68
|
+
print(f"hail: '{out.agent}' exited with an error", file=sys.stderr)
|
|
69
|
+
return 0 if out.ok else 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_ask_skill(argv: list[str]) -> int:
|
|
73
|
+
"""``hail ask --skill X msg`` or ``hail ask <agent> msg``."""
|
|
74
|
+
if argv and not argv[0].startswith("-"):
|
|
75
|
+
return cmd_ask(argv[0], argv[1:])
|
|
76
|
+
return cmd_ask(None, argv)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cmd_send(argv: list[str]) -> int:
|
|
80
|
+
agent = argv[0] if argv and not argv[0].startswith("-") else None
|
|
81
|
+
parsed = parse(argv[1:] if agent else argv)
|
|
82
|
+
app = make_app()
|
|
83
|
+
req = _request(agent, parsed)
|
|
84
|
+
task_id, thread = tasks.submit(app.store, req, app.hub.thread_for(req.thread), app.hub.sender_for(req.sender))
|
|
85
|
+
print(f"{task_id} (thread {thread}) - check with: hail status {task_id}")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_status(argv: list[str]) -> int:
|
|
90
|
+
if len(argv) != 1:
|
|
91
|
+
raise HailError("usage: hail status <task-id>")
|
|
92
|
+
task = tasks.status(default_store(), argv[0])
|
|
93
|
+
if task is None:
|
|
94
|
+
raise HailError(f"unknown task '{argv[0]}'")
|
|
95
|
+
print(tasks.describe(task))
|
|
96
|
+
return 0 if task.status != "failed" else 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_task(argv: list[str]) -> int:
|
|
100
|
+
if len(argv) != 1:
|
|
101
|
+
raise HailError("usage: hail _task <task-id>")
|
|
102
|
+
app = make_app()
|
|
103
|
+
tasks.run(app.hub, app.store, argv[0])
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_council(argv: list[str]) -> int:
|
|
108
|
+
parsed = parse(argv)
|
|
109
|
+
names = [n.strip() for n in parsed.flags.get("--agents", "").split(",") if n.strip()]
|
|
110
|
+
question = " ".join(parsed.words) if parsed.words else _read_stdin()
|
|
111
|
+
result = make_app().hub.council(
|
|
112
|
+
question, names, synth=parsed.flags.get("--synth"), thread=parsed.flags.get("--thread")
|
|
113
|
+
)
|
|
114
|
+
for answer in result.answers:
|
|
115
|
+
print(f"== {answer.agent}{'' if answer.ok else ' (failed)'}\n{answer.text}\n")
|
|
116
|
+
if result.synthesis:
|
|
117
|
+
print(f"== synthesis by {result.synthesis.agent}\n{result.synthesis.text}")
|
|
118
|
+
print(f"hail: thread {result.thread}", file=sys.stderr)
|
|
119
|
+
return 0 if any(a.ok for a in result.answers) else 1
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_log(argv: list[str]) -> int:
|
|
123
|
+
parsed = parse(argv)
|
|
124
|
+
limit = _int(parsed.flags.get("-n", "20"))
|
|
125
|
+
for msg in default_store().recent(limit, grep=parsed.flags.get("--grep")):
|
|
126
|
+
print(_line(msg))
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_threads(argv: list[str]) -> int:
|
|
131
|
+
parsed = parse(argv)
|
|
132
|
+
for t in default_store().threads(_int(parsed.flags.get("-n", "10"))):
|
|
133
|
+
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(t.last))
|
|
134
|
+
print(f"{t.thread:<14} {when} {t.count:>3} messages{' STOPPED' if t.stopped else ''}")
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_thread(argv: list[str]) -> int:
|
|
139
|
+
if len(argv) != 1:
|
|
140
|
+
raise HailError("usage: hail thread <thread-id>")
|
|
141
|
+
messages = default_store().thread(argv[0])
|
|
142
|
+
if not messages:
|
|
143
|
+
raise HailError(f"no messages in thread '{argv[0]}'")
|
|
144
|
+
for msg in messages:
|
|
145
|
+
print(_line(msg, full=True))
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cmd_stop(argv: list[str], stopped: bool = True) -> int:
|
|
150
|
+
if len(argv) != 1:
|
|
151
|
+
raise HailError(f"usage: hail {'stop' if stopped else 'resume'} <thread-id>")
|
|
152
|
+
default_store().set_stopped(argv[0], stopped)
|
|
153
|
+
print(f"thread {argv[0]} {'stopped' if stopped else 'resumed'}")
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_telegram(argv: list[str]) -> int:
|
|
158
|
+
action = argv[0] if argv else ""
|
|
159
|
+
app = make_app()
|
|
160
|
+
if app.telegram is None:
|
|
161
|
+
raise HailError("telegram is not configured: add [telegram] chat_id and a token_file (see README)")
|
|
162
|
+
try:
|
|
163
|
+
if action == "test":
|
|
164
|
+
app.telegram.send("✅ hail is connected to this chat.")
|
|
165
|
+
print("test message sent")
|
|
166
|
+
return 0
|
|
167
|
+
if action == "watch":
|
|
168
|
+
return _watch(app.telegram)
|
|
169
|
+
except TelegramError as exc:
|
|
170
|
+
raise HailError(f"telegram: {exc}") from None
|
|
171
|
+
raise HailError("usage: hail telegram test|watch")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _watch(tg: Telegram) -> int:
|
|
175
|
+
print("watching Telegram (Ctrl+C to stop)", file=sys.stderr)
|
|
176
|
+
delay = 1
|
|
177
|
+
while True:
|
|
178
|
+
try:
|
|
179
|
+
tg.poll_once()
|
|
180
|
+
delay = 1
|
|
181
|
+
except KeyboardInterrupt:
|
|
182
|
+
return 0
|
|
183
|
+
except Exception as exc: # keep the watcher alive (network, database, ...)
|
|
184
|
+
print(f"hail: telegram: {exc} (retry in {delay}s)", file=sys.stderr)
|
|
185
|
+
time.sleep(delay)
|
|
186
|
+
delay = min(delay * 2, 60)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _line(msg: Message, full: bool = False) -> str:
|
|
190
|
+
when = time.strftime("%m-%d %H:%M:%S", time.localtime(msg.ts))
|
|
191
|
+
arrow = f"{msg.sender} → {msg.recipient}" if msg.recipient else msg.sender
|
|
192
|
+
text = msg.text if full else msg.text.replace("\n", " ")[:140]
|
|
193
|
+
return f"{when} {msg.thread} h{msg.hop} {msg.kind:<7} {arrow}: {text}"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _int(value: str) -> int:
|
|
197
|
+
if not value.isdigit() or int(value) <= 0:
|
|
198
|
+
raise HailError(f"expected a positive number, got '{value}'")
|
|
199
|
+
return int(value)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _read_stdin() -> str:
|
|
203
|
+
if sys.stdin is None or sys.stdin.isatty():
|
|
204
|
+
raise HailError("no message (pass it as an argument or on stdin)")
|
|
205
|
+
return sys.stdin.read()
|