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.
Files changed (43) hide show
  1. napcat_cli/__init__.py +3 -0
  2. napcat_cli/cli.py +1834 -0
  3. napcat_cli/daemon/__init__.py +1 -0
  4. napcat_cli/daemon/schemas.py +470 -0
  5. napcat_cli/daemon/watch.py +1994 -0
  6. napcat_cli/data/SKILL.md +328 -0
  7. napcat_cli/data/__init__.py +0 -0
  8. napcat_cli/data/persona.md +155 -0
  9. napcat_cli/data/references/mounting.md +90 -0
  10. napcat_cli/data/skills-fs-config.json +12 -0
  11. napcat_cli/data/skills-fs-fragment.json +473 -0
  12. napcat_cli/data/skills-fs.d/agents-friend-time.md +7 -0
  13. napcat_cli/data/skills-fs.d/agents-friend.md +7 -0
  14. napcat_cli/data/skills-fs.d/agents-friends.md +4 -0
  15. napcat_cli/data/skills-fs.d/agents-group-time.md +7 -0
  16. napcat_cli/data/skills-fs.d/agents-group.md +6 -0
  17. napcat_cli/data/skills-fs.d/agents-groups.md +5 -0
  18. napcat_cli/data/skills-fs.d/agents-napcat.md +9 -0
  19. napcat_cli/data/skills-fs.d/persona.md +155 -0
  20. napcat_cli/data/skills-fs.d/skill-agents.md +42 -0
  21. napcat_cli/data/skills-fs.d/skill-body.md +102 -0
  22. napcat_cli/lib/__init__.py +1 -0
  23. napcat_cli/lib/api.py +258 -0
  24. napcat_cli/lib/config.py +105 -0
  25. napcat_cli/lib/events.py +127 -0
  26. napcat_cli/lib/events_sqlite.py +242 -0
  27. napcat_cli/lib/message.py +156 -0
  28. napcat_cli/setup_wizard.py +380 -0
  29. napcat_cli/tui/__init__.py +1 -0
  30. napcat_cli/tui/__main__.py +13 -0
  31. napcat_cli/tui/api.py +158 -0
  32. napcat_cli/tui/app.py +127 -0
  33. napcat_cli/tui/chat_list.py +168 -0
  34. napcat_cli/tui/chat_view.py +456 -0
  35. napcat_cli/tui/styles.tcss +9 -0
  36. napcat_cli/wake.py +51 -0
  37. napcat_cli/wake_backend.py +390 -0
  38. napcat_cli/wake_orchestrator.py +302 -0
  39. napcat_cli/wake_presets.py +77 -0
  40. napcat_cli-2.0.0.dist-info/METADATA +219 -0
  41. napcat_cli-2.0.0.dist-info/RECORD +43 -0
  42. napcat_cli-2.0.0.dist-info/WHEEL +4 -0
  43. 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