napcat-cli 2.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.
- napcat_cli/__init__.py +3 -0
- napcat_cli/cli.py +1834 -0
- napcat_cli/daemon/__init__.py +1 -0
- napcat_cli/daemon/schemas.py +470 -0
- napcat_cli/daemon/watch.py +1994 -0
- napcat_cli/data/SKILL.md +328 -0
- napcat_cli/data/__init__.py +0 -0
- napcat_cli/data/persona.md +155 -0
- napcat_cli/data/references/mounting.md +90 -0
- napcat_cli/data/skills-fs-config.json +12 -0
- napcat_cli/data/skills-fs-fragment.json +473 -0
- napcat_cli/data/skills-fs.d/agents-friend-time.md +7 -0
- napcat_cli/data/skills-fs.d/agents-friend.md +7 -0
- napcat_cli/data/skills-fs.d/agents-friends.md +4 -0
- napcat_cli/data/skills-fs.d/agents-group-time.md +7 -0
- napcat_cli/data/skills-fs.d/agents-group.md +6 -0
- napcat_cli/data/skills-fs.d/agents-groups.md +5 -0
- napcat_cli/data/skills-fs.d/agents-napcat.md +9 -0
- napcat_cli/data/skills-fs.d/persona.md +155 -0
- napcat_cli/data/skills-fs.d/skill-agents.md +42 -0
- napcat_cli/data/skills-fs.d/skill-body.md +102 -0
- napcat_cli/lib/__init__.py +1 -0
- napcat_cli/lib/api.py +258 -0
- napcat_cli/lib/config.py +105 -0
- napcat_cli/lib/events.py +127 -0
- napcat_cli/lib/events_sqlite.py +242 -0
- napcat_cli/lib/message.py +156 -0
- napcat_cli/setup_wizard.py +380 -0
- napcat_cli/tui/__init__.py +1 -0
- napcat_cli/tui/__main__.py +13 -0
- napcat_cli/tui/api.py +158 -0
- napcat_cli/tui/app.py +127 -0
- napcat_cli/tui/chat_list.py +168 -0
- napcat_cli/tui/chat_view.py +456 -0
- napcat_cli/tui/styles.tcss +9 -0
- napcat_cli/wake.py +51 -0
- napcat_cli/wake_backend.py +390 -0
- napcat_cli/wake_orchestrator.py +302 -0
- napcat_cli/wake_presets.py +77 -0
- napcat_cli-2.0.0.dist-info/METADATA +219 -0
- napcat_cli-2.0.0.dist-info/RECORD +43 -0
- napcat_cli-2.0.0.dist-info/WHEEL +4 -0
- napcat_cli-2.0.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Generic, pluggable agent wake backends.
|
|
2
|
+
|
|
3
|
+
Agent-agnostic transports for delivering a wake prompt + reason + context to an
|
|
4
|
+
external agent (Hermes by default, but any HTTP endpoint or shell command works).
|
|
5
|
+
A :class:`Waker` holds an ordered list of backends and tries each until one
|
|
6
|
+
succeeds — this is the "dual transport + auto-fallback" behaviour.
|
|
7
|
+
|
|
8
|
+
Two built-in backends:
|
|
9
|
+
|
|
10
|
+
- :class:`HttpWakeBackend` — generic HTTP POST with Bearer auth and an optional
|
|
11
|
+
``Idempotency-Key`` header. The Hermes API Server preset targets
|
|
12
|
+
``POST /api/sessions/{id}/chat`` (verified working per the Hermes API docs).
|
|
13
|
+
- :class:`CliWakeBackend` — generic shell command rendered via
|
|
14
|
+
:func:`napcat_cli.wake.render_wake_command`. The Hermes preset targets
|
|
15
|
+
``hermes --continue <session> -z <prompt> --yolo --pass-session-id``.
|
|
16
|
+
|
|
17
|
+
Backends use only the standard library (``urllib``, ``subprocess``).
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import subprocess
|
|
23
|
+
import time
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.parse
|
|
26
|
+
import urllib.request
|
|
27
|
+
from abc import ABC, abstractmethod
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
|
|
30
|
+
from .wake import render_wake_command
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Result
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class WakeResult:
|
|
39
|
+
"""Outcome of a single wake attempt."""
|
|
40
|
+
|
|
41
|
+
ok: bool
|
|
42
|
+
transport: str # "http" | "cli" | "none"
|
|
43
|
+
detail: str = ""
|
|
44
|
+
elapsed: float = 0.0
|
|
45
|
+
http_status: int | None = None
|
|
46
|
+
extra: dict = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
def __bool__(self) -> bool: # convenience: `if result:`
|
|
49
|
+
return self.ok
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Backend interface
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
class WakeBackend(ABC):
|
|
57
|
+
"""Abstract wake transport."""
|
|
58
|
+
|
|
59
|
+
name: str = "backend"
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def configured(self) -> bool:
|
|
63
|
+
"""True if this backend has enough config to attempt a wake."""
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def wake(
|
|
67
|
+
self,
|
|
68
|
+
prompt: str,
|
|
69
|
+
reason: str,
|
|
70
|
+
ctx: dict,
|
|
71
|
+
idem_key: str,
|
|
72
|
+
*,
|
|
73
|
+
dry_run: bool = False,
|
|
74
|
+
timeout: float = 30.0,
|
|
75
|
+
) -> WakeResult:
|
|
76
|
+
"""Deliver (or render, when dry_run) a wake. Never raises."""
|
|
77
|
+
|
|
78
|
+
# optional: connectivity probe + session enumeration (http-only by default)
|
|
79
|
+
def probe(self, timeout: float = 3.0) -> bool:
|
|
80
|
+
return self.configured()
|
|
81
|
+
|
|
82
|
+
def list_sessions(self, timeout: float = 5.0) -> list[dict] | None:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Helpers
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def _json_escape(value: str) -> str:
|
|
91
|
+
"""Escape a string for safe interpolation into a JSON string literal."""
|
|
92
|
+
return json.dumps(value, ensure_ascii=False)[1:-1]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def extract_reply(resp: Any, limit: int = 2000) -> str:
|
|
96
|
+
"""Best-effort extract the agent's textual reply from an HTTP response body."""
|
|
97
|
+
if resp is None:
|
|
98
|
+
return ""
|
|
99
|
+
if isinstance(resp, str):
|
|
100
|
+
return resp[:limit]
|
|
101
|
+
if isinstance(resp, dict):
|
|
102
|
+
# try common shapes across agent HTTP APIs
|
|
103
|
+
for key in ("reply", "output", "response", "text", "content", "message", "answer"):
|
|
104
|
+
v = resp.get(key)
|
|
105
|
+
if isinstance(v, str) and v.strip():
|
|
106
|
+
return v[:limit]
|
|
107
|
+
if isinstance(v, list):
|
|
108
|
+
# OpenAI-style content arrays
|
|
109
|
+
parts = []
|
|
110
|
+
for item in v:
|
|
111
|
+
if isinstance(item, dict):
|
|
112
|
+
t = item.get("text") or item.get("content") or ""
|
|
113
|
+
if isinstance(t, str):
|
|
114
|
+
parts.append(t)
|
|
115
|
+
elif isinstance(item, str):
|
|
116
|
+
parts.append(item)
|
|
117
|
+
joined = "".join(parts).strip()
|
|
118
|
+
if joined:
|
|
119
|
+
return joined[:limit]
|
|
120
|
+
# nested message.content
|
|
121
|
+
msg = resp.get("message") or resp.get("data") or {}
|
|
122
|
+
if isinstance(msg, dict):
|
|
123
|
+
c = msg.get("content") or msg.get("text")
|
|
124
|
+
if isinstance(c, str) and c.strip():
|
|
125
|
+
return c[:limit]
|
|
126
|
+
return ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# HTTP backend
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
class HttpWakeBackend(WakeBackend):
|
|
134
|
+
"""Generic HTTP POST wake.
|
|
135
|
+
|
|
136
|
+
Targets an OpenAI-ish / agent endpoint. For the Hermes API Server the path
|
|
137
|
+
template is ``/api/sessions/{session}/chat`` and the body field is ``input``.
|
|
138
|
+
Session resolution: if ``session_id`` is set it is used directly; otherwise
|
|
139
|
+
the name is resolved via ``GET {base}/api/sessions`` (Hermes Sessions API).
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
name = "http"
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
base_url: str,
|
|
147
|
+
key: str,
|
|
148
|
+
*,
|
|
149
|
+
session: str = "",
|
|
150
|
+
session_id: str = "",
|
|
151
|
+
path_template: str = "/api/sessions/{session}/chat",
|
|
152
|
+
body_field: str = "input",
|
|
153
|
+
body_extra: dict | None = None,
|
|
154
|
+
health_path: str = "/health",
|
|
155
|
+
sessions_path: str = "/api/sessions",
|
|
156
|
+
label: str = "http",
|
|
157
|
+
):
|
|
158
|
+
self.base_url = (base_url or "").rstrip("/")
|
|
159
|
+
self.key = key or ""
|
|
160
|
+
self.session = session or ""
|
|
161
|
+
self.session_id = session_id or ""
|
|
162
|
+
self.path_template = path_template
|
|
163
|
+
self.body_field = body_field
|
|
164
|
+
self.body_extra = body_extra or {}
|
|
165
|
+
self.health_path = health_path
|
|
166
|
+
self.sessions_path = sessions_path
|
|
167
|
+
self.label = label
|
|
168
|
+
|
|
169
|
+
# -- configuration -----------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def configured(self) -> bool:
|
|
172
|
+
return bool(self.base_url and self.key)
|
|
173
|
+
|
|
174
|
+
def _request(
|
|
175
|
+
self,
|
|
176
|
+
method: str,
|
|
177
|
+
path: str,
|
|
178
|
+
*,
|
|
179
|
+
body: dict | None = None,
|
|
180
|
+
timeout: float = 5.0,
|
|
181
|
+
idem_key: str = "",
|
|
182
|
+
) -> tuple[int | None, dict | str]:
|
|
183
|
+
"""Issue an authenticated request. Returns (http_status, parsed_body_or_text)."""
|
|
184
|
+
url = f"{self.base_url}{path}"
|
|
185
|
+
data = None
|
|
186
|
+
headers = {
|
|
187
|
+
"Authorization": f"Bearer {self.key}",
|
|
188
|
+
"Content-Type": "application/json",
|
|
189
|
+
}
|
|
190
|
+
if idem_key:
|
|
191
|
+
headers["Idempotency-Key"] = idem_key
|
|
192
|
+
if body is not None:
|
|
193
|
+
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
194
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
195
|
+
try:
|
|
196
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
197
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
198
|
+
status = resp.status
|
|
199
|
+
except urllib.error.HTTPError as e:
|
|
200
|
+
raw = e.read().decode("utf-8", errors="replace")
|
|
201
|
+
status = e.code
|
|
202
|
+
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
203
|
+
return None, f"connection failed: {e.reason if hasattr(e, 'reason') else e}"
|
|
204
|
+
try:
|
|
205
|
+
return status, json.loads(raw)
|
|
206
|
+
except json.JSONDecodeError:
|
|
207
|
+
return status, raw
|
|
208
|
+
|
|
209
|
+
# -- session resolution ------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def _resolve_session(self, timeout: float = 5.0) -> tuple[str, str]:
|
|
212
|
+
"""Return (session_id_for_path, note). Uses explicit id, else lookup by name."""
|
|
213
|
+
if self.session_id:
|
|
214
|
+
return self.session_id, f"explicit id {self.session_id}"
|
|
215
|
+
if not self.session:
|
|
216
|
+
return "", "no session configured"
|
|
217
|
+
status, body = self._request("GET", f"{self.sessions_path}?limit=200", timeout=timeout)
|
|
218
|
+
if status != 200 or not isinstance(body, dict):
|
|
219
|
+
return "", f"session lookup failed (status={status})"
|
|
220
|
+
entries = body.get("sessions") or body.get("data") or body.get("items") or []
|
|
221
|
+
# match by id, title, or name (case-insensitive substring on title)
|
|
222
|
+
target = self.session.lower()
|
|
223
|
+
for s in entries:
|
|
224
|
+
if not isinstance(s, dict):
|
|
225
|
+
continue
|
|
226
|
+
sid = str(s.get("id") or s.get("session_id") or "")
|
|
227
|
+
title = str(s.get("title") or s.get("name") or "")
|
|
228
|
+
if sid == self.session or title.lower() == target or target in title.lower():
|
|
229
|
+
return sid, f"resolved by name '{self.session}' -> {sid}"
|
|
230
|
+
return "", f"session '{self.session}' not found in {len(entries)} sessions"
|
|
231
|
+
|
|
232
|
+
def probe(self, timeout: float = 3.0) -> bool:
|
|
233
|
+
if not self.configured():
|
|
234
|
+
return False
|
|
235
|
+
status, _ = self._request("GET", self.health_path, timeout=timeout)
|
|
236
|
+
return status == 200
|
|
237
|
+
|
|
238
|
+
def list_sessions(self, timeout: float = 5.0) -> list[dict] | None:
|
|
239
|
+
if not self.configured():
|
|
240
|
+
return None
|
|
241
|
+
status, body = self._request("GET", f"{self.sessions_path}?limit=200", timeout=timeout)
|
|
242
|
+
if status != 200 or not isinstance(body, dict):
|
|
243
|
+
return None
|
|
244
|
+
entries = body.get("sessions") or body.get("data") or body.get("items") or []
|
|
245
|
+
out = []
|
|
246
|
+
for s in entries:
|
|
247
|
+
if isinstance(s, dict):
|
|
248
|
+
out.append({
|
|
249
|
+
"id": s.get("id") or s.get("session_id") or "",
|
|
250
|
+
"title": s.get("title") or s.get("name") or "",
|
|
251
|
+
"last_active": s.get("last_active") or s.get("updated_at") or "",
|
|
252
|
+
})
|
|
253
|
+
return out
|
|
254
|
+
|
|
255
|
+
# -- wake --------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def wake(self, prompt, reason, ctx, idem_key, *, dry_run=False, timeout=30.0) -> WakeResult:
|
|
258
|
+
if not self.configured():
|
|
259
|
+
return WakeResult(False, "http", "not configured (base_url+key required)")
|
|
260
|
+
start = time.monotonic()
|
|
261
|
+
sid, note = self._resolve_session() if "{session}" in self.path_template else (self.session_id or "", "static path")
|
|
262
|
+
if "{session}" in self.path_template and not sid:
|
|
263
|
+
return WakeResult(False, "http", f"session resolution failed: {note}",
|
|
264
|
+
elapsed=time.monotonic() - start)
|
|
265
|
+
path = self.path_template.replace("{session}", urllib.parse.quote(sid, safe=""))
|
|
266
|
+
body = {**self.body_extra, self.body_field: prompt}
|
|
267
|
+
if dry_run:
|
|
268
|
+
return WakeResult(True, "http", f"[dry-run] POST {self.base_url}{path} "
|
|
269
|
+
f"body={{{self.body_field}: <prompt>}} idem={idem_key} ({note})",
|
|
270
|
+
elapsed=time.monotonic() - start)
|
|
271
|
+
status, resp = self._request("POST", path, body=body, timeout=timeout, idem_key=idem_key)
|
|
272
|
+
ok = status == 200 and not (isinstance(resp, dict) and resp.get("error"))
|
|
273
|
+
detail = f"POST {path} -> {status}"
|
|
274
|
+
if not ok:
|
|
275
|
+
detail += f" :: {resp if isinstance(resp, str) else json.dumps(resp, ensure_ascii=False)[:300]}"
|
|
276
|
+
return WakeResult(ok, "http", detail, elapsed=time.monotonic() - start,
|
|
277
|
+
http_status=status,
|
|
278
|
+
extra={"session_id": sid, "note": note, "reply": extract_reply(resp)})
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
# CLI backend
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
class CliWakeBackend(WakeBackend):
|
|
286
|
+
"""Generic shell-command wake (rendered, shlex-safe)."""
|
|
287
|
+
|
|
288
|
+
name = "cli"
|
|
289
|
+
|
|
290
|
+
def __init__(self, command_template: str, *, session: str = "", label: str = "cli"):
|
|
291
|
+
self.command_template = command_template or ""
|
|
292
|
+
self.session = session or ""
|
|
293
|
+
self.label = label
|
|
294
|
+
|
|
295
|
+
def configured(self) -> bool:
|
|
296
|
+
return bool(self.command_template)
|
|
297
|
+
|
|
298
|
+
def render(self, prompt: str, reason: str) -> str:
|
|
299
|
+
return render_wake_command(self.command_template, reason=reason, prompt=prompt, session=self.session)
|
|
300
|
+
|
|
301
|
+
def wake(self, prompt, reason, ctx, idem_key, *, dry_run=False, timeout=30.0) -> WakeResult:
|
|
302
|
+
if not self.configured():
|
|
303
|
+
return WakeResult(False, "cli", "not configured (command_template required)")
|
|
304
|
+
cmd = self.render(prompt, reason)
|
|
305
|
+
if dry_run:
|
|
306
|
+
return WakeResult(True, "cli", f"[dry-run] {cmd}")
|
|
307
|
+
start = time.monotonic()
|
|
308
|
+
try:
|
|
309
|
+
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
|
310
|
+
ok = r.returncode == 0
|
|
311
|
+
detail = f"exit={r.returncode}"
|
|
312
|
+
if r.stderr:
|
|
313
|
+
detail += f" err={r.stderr.strip()[:200]}"
|
|
314
|
+
return WakeResult(ok, "cli", detail, elapsed=time.monotonic() - start,
|
|
315
|
+
extra={"reply": r.stdout or "", "stderr": r.stderr or ""})
|
|
316
|
+
except subprocess.TimeoutExpired:
|
|
317
|
+
return WakeResult(False, "cli", f"timeout after {timeout}s", elapsed=time.monotonic() - start)
|
|
318
|
+
except Exception as e:
|
|
319
|
+
return WakeResult(False, "cli", f"failed: {e}", elapsed=time.monotonic() - start)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
# Waker — ordered backends with auto-fallback
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
class Waker:
|
|
327
|
+
"""Tries each configured backend in priority order until one succeeds.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
backends: available backends (any unconfigured ones are dropped).
|
|
331
|
+
primary: "http" | "cli" | "auto". ``auto`` orders http before cli but
|
|
332
|
+
still falls back; an explicit primary puts that transport first.
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
def __init__(self, backends: list[WakeBackend], primary: str = "auto"):
|
|
336
|
+
self.primary = primary
|
|
337
|
+
configured = [b for b in backends if b.configured()]
|
|
338
|
+
by_name = {b.name: b for b in configured}
|
|
339
|
+
|
|
340
|
+
order: list[WakeBackend] = []
|
|
341
|
+
if primary == "http" and "http" in by_name:
|
|
342
|
+
order.append(by_name["http"])
|
|
343
|
+
elif primary == "cli" and "cli" in by_name:
|
|
344
|
+
order.append(by_name["cli"])
|
|
345
|
+
# append the rest in stable [http, cli] order
|
|
346
|
+
for name in ("http", "cli"):
|
|
347
|
+
b = by_name.get(name)
|
|
348
|
+
if b and b not in order:
|
|
349
|
+
order.append(b)
|
|
350
|
+
self.backends = order
|
|
351
|
+
|
|
352
|
+
@property
|
|
353
|
+
def empty(self) -> bool:
|
|
354
|
+
return not self.backends
|
|
355
|
+
|
|
356
|
+
def wake(self, prompt: str, reason: str, ctx: dict | None = None, *, idem_key: str = "", dry_run: bool = False, timeout: float = 120.0) -> WakeResult:
|
|
357
|
+
ctx = ctx or {}
|
|
358
|
+
if not idem_key:
|
|
359
|
+
idem_key = f"napcat-{reason}-{int(time.time())}"
|
|
360
|
+
if self.empty:
|
|
361
|
+
return WakeResult(False, "none", "no wake backend configured (run `napcat setup`)")
|
|
362
|
+
attempts: list[str] = []
|
|
363
|
+
for b in self.backends:
|
|
364
|
+
res = b.wake(prompt, reason, ctx, idem_key, dry_run=dry_run, timeout=timeout)
|
|
365
|
+
attempts.append(f"{b.name}: {res.detail}")
|
|
366
|
+
if dry_run:
|
|
367
|
+
return res # render only the first (primary) backend
|
|
368
|
+
if res.ok:
|
|
369
|
+
return res
|
|
370
|
+
# all failed
|
|
371
|
+
return WakeResult(False, self.backends[-1].name if self.backends else "none",
|
|
372
|
+
"all backends failed :: " + " | ".join(attempts))
|
|
373
|
+
|
|
374
|
+
def test(self) -> list[dict]:
|
|
375
|
+
"""Per-backend connectivity/configuration status."""
|
|
376
|
+
out = []
|
|
377
|
+
for b in self.backends:
|
|
378
|
+
out.append({
|
|
379
|
+
"transport": b.name,
|
|
380
|
+
"configured": b.configured(),
|
|
381
|
+
"reachable": b.probe() if b.configured() else False,
|
|
382
|
+
"label": getattr(b, "label", b.name),
|
|
383
|
+
})
|
|
384
|
+
return out
|
|
385
|
+
|
|
386
|
+
def list_sessions(self) -> list[dict] | None:
|
|
387
|
+
for b in self.backends:
|
|
388
|
+
if b.name == "http":
|
|
389
|
+
return b.list_sessions()
|
|
390
|
+
return None
|