kojee-mcp 0.5.13 → 0.5.14

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.
package/dist/cli.js CHANGED
@@ -140,7 +140,7 @@ program.command("init").description(
140
140
  console.error("Not paired. Run `kojee-mcp pair <code> --url <broker>` first, then re-run `init` \u2014 or pass --token/--pair-code, or run `init` in a terminal for the guided wizard.");
141
141
  process.exit(1);
142
142
  }
143
- const { runWizard } = await import("./wizard-L4MYRLJI.js");
143
+ const { runWizard } = await import("./wizard-5ILBK6YD.js");
144
144
  const result = await runWizard({
145
145
  ...opts.runtime !== void 0 ? { runtime: opts.runtime } : {},
146
146
  ...opts.uninstall ? { uninstall: true } : {},
@@ -0,0 +1,21 @@
1
+ """Hermes plugin package entry for kojee-tandem.
2
+
3
+ Inside Hermes, hermes_cli/plugins.py loads this dir as the package
4
+ ``hermes_plugins.kojee_tandem`` and calls ``register(ctx)``.
5
+
6
+ FAIL-SOFT IMPORT: outside that loader (e.g. pytest collecting this dir as a
7
+ Package on a box without Hermes) the relative import below cannot resolve —
8
+ ``gateway.*`` doesn't exist and there is no parent package. We degrade to
9
+ ``register = None`` instead of raising so tooling can walk the tree; the
10
+ Hermes loader itself would surface a real adapter bug as a loud
11
+ "Plugin 'kojee-tandem' has no register() function" + this module's import
12
+ error in the gateway log, not as a silent no-op.
13
+ """
14
+
15
+ try:
16
+ from .adapter import register
17
+ except ImportError as _exc: # pragma: no cover — outside the Hermes loader
18
+ register = None
19
+ _IMPORT_ERROR = _exc
20
+
21
+ __all__ = ["register"]
@@ -0,0 +1,481 @@
1
+ """Kojee Tandem platform adapter for the Hermes agent gateway (v1, sidecar).
2
+
3
+ Makes Tandem a first-class Hermes channel: install this plugin dir as
4
+ ``~/.hermes/plugins/kojee-tandem/`` and Tandem messages wake the agent exactly
5
+ like a Telegram DM — per-tandem chat ids, Hermes-native sessions, allowlists.
6
+
7
+ Architecture (v1 — the kojee-mcp daemon is the sidecar, never forked):
8
+
9
+ Tandem cloud ──SSE──> kojee-mcp daemon ──signed webhook POST──>
10
+ this adapter's loopback HTTP listener ──MessageEvent──> Hermes agent
11
+ agent reply ──send()──> `kojee-mcp send` CLI (0.5.4+, the shipped local
12
+ SEND control surface; same paired ~/.kojee creds + DPoP keystore the
13
+ daemon uses) ──tandem_send──> Tandem cloud
14
+
15
+ Outbound (T10): the reply path shells the shipped ``kojee-mcp send``
16
+ sub-command rather than a tsx helper that dynamically imports source modules,
17
+ so the install no longer requires a kojee-mcp SOURCE checkout — only the
18
+ ``kojee-mcp`` binary (KOJEE_MCP_BIN, with a PATH fallback) and a paired
19
+ ``~/.kojee``.
20
+
21
+ Coded against (pinned upstream source, NousResearch/hermes-agent @ main):
22
+ - gateway/platforms/base.py: BasePlatformAdapter.__init__(config, platform)
23
+ (line 1826), abstract connect (2244) / disconnect (2253) / send (2258) /
24
+ get_chat_info (4709); send_typing default no-op (2534); build_source
25
+ (4669); handle_message (3839); MessageEvent (1413); SendResult (1542).
26
+ - gateway/config.py Platform._missing_ (167): "kojee-tandem" resolves as a
27
+ runtime-registered pseudo-member AFTER register() runs — registration
28
+ precedes adapter construction, so Platform("kojee-tandem") is safe here.
29
+ - hermes_cli/plugins.py PluginContext.register_platform (770) forwarding
30
+ **entry_kwargs to gateway/platform_registry.py PlatformEntry (39).
31
+ - plugins/platforms/irc/ — the bundled-plugin idiom this file mirrors.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import json
38
+ import logging
39
+ import os
40
+ import time
41
+ from pathlib import Path
42
+ from typing import Any, Dict, List, Optional
43
+
44
+ from gateway.platforms.base import (
45
+ BasePlatformAdapter,
46
+ MessageEvent,
47
+ MessageType,
48
+ SendResult,
49
+ )
50
+ from gateway.config import Platform
51
+
52
+ from .kojee_tandem_core import (
53
+ EventDeduper,
54
+ build_send_command,
55
+ is_self_event,
56
+ load_self_session_id,
57
+ normalize_event,
58
+ parse_allowlist,
59
+ parse_send_envelope,
60
+ resolve_kojee_mcp_bin,
61
+ tandem_allowed,
62
+ verify_signature,
63
+ )
64
+
65
+ logger = logging.getLogger(__name__)
66
+
67
+ PLATFORM_NAME = "kojee-tandem"
68
+
69
+ # Loopback listener defaults. 8645 sits next to Hermes's own webhook platform
70
+ # (8644) — same box, same loopback-only posture, never exposed publicly.
71
+ DEFAULT_LISTEN_HOST = "127.0.0.1"
72
+ DEFAULT_LISTEN_PORT = 8645
73
+ DEFAULT_LISTEN_PATH = "/kojee-tandem"
74
+ DEFAULT_SEND_TIMEOUT_S = 45.0
75
+
76
+
77
+ def _env_or_extra(extra: dict, env_name: str, extra_key: str, default: str = "") -> str:
78
+ """env var wins over config.yaml extra (Hermes precedence convention)."""
79
+ return os.getenv(env_name) or str(extra.get(extra_key, "") or "") or default
80
+
81
+
82
+ class KojeeTandemAdapter(BasePlatformAdapter):
83
+ """Tandem as a Hermes platform, driving the kojee-mcp daemon as a sidecar."""
84
+
85
+ def __init__(self, config, **kwargs):
86
+ platform = Platform(PLATFORM_NAME)
87
+ super().__init__(config=config, platform=platform)
88
+
89
+ extra = getattr(config, "extra", {}) or {}
90
+
91
+ self.listen_host = _env_or_extra(
92
+ extra, "KOJEE_TANDEM_LISTEN_HOST", "listen_host", DEFAULT_LISTEN_HOST
93
+ )
94
+ try:
95
+ self.listen_port = int(
96
+ _env_or_extra(
97
+ extra, "KOJEE_TANDEM_LISTEN_PORT", "listen_port", str(DEFAULT_LISTEN_PORT)
98
+ )
99
+ )
100
+ except (TypeError, ValueError):
101
+ self.listen_port = DEFAULT_LISTEN_PORT
102
+ self.listen_path = _env_or_extra(
103
+ extra, "KOJEE_TANDEM_LISTEN_PATH", "listen_path", DEFAULT_LISTEN_PATH
104
+ )
105
+ if not self.listen_path.startswith("/"):
106
+ self.listen_path = "/" + self.listen_path
107
+
108
+ # The HMAC secret MUST equal the daemon's KOJEE_WEBHOOK_SECRET — both
109
+ # ends of one wire. Never logged.
110
+ self.webhook_secret = _env_or_extra(
111
+ extra, "KOJEE_WEBHOOK_SECRET", "webhook_secret"
112
+ )
113
+
114
+ # Outbound (T10): shell the shipped `kojee-mcp send` CLI. The binary is
115
+ # KOJEE_MCP_BIN (absolute — fold B, login-shell PATH may not apply inside
116
+ # the gateway process) with a `kojee-mcp`-on-PATH fallback. No source
117
+ # checkout required anymore.
118
+ # TODO(hermes-installer): wizard writes KOJEE_MCP_BIN at install
119
+ # (src/wizard/installers/hermes.ts); until then the PATH fallback carries it.
120
+ self.kojee_mcp_bin = resolve_kojee_mcp_bin(os.environ)
121
+ self.kojee_dir = Path(
122
+ _env_or_extra(extra, "KOJEE_DIR", "kojee_dir", str(Path.home() / ".kojee"))
123
+ )
124
+
125
+ # Tandem allowlist (which tandems may inject). Empty = all tandems;
126
+ # per-USER authorization stays with Hermes via KOJEE_TANDEM_ALLOWED_USERS.
127
+ self.tandem_allowlist = parse_allowlist(
128
+ _env_or_extra(extra, "KOJEE_TANDEM_ALLOWLIST", "tandem_allowlist")
129
+ )
130
+
131
+ # Self-echo filters: derived session id (paired config) + optional principal.
132
+ self.self_principal = _env_or_extra(
133
+ extra, "KOJEE_TANDEM_SELF_PRINCIPAL", "self_principal"
134
+ ) or None
135
+ self._self_session_id = load_self_session_id(self.kojee_dir)
136
+
137
+ try:
138
+ self.send_timeout_s = float(
139
+ _env_or_extra(
140
+ extra, "KOJEE_TANDEM_SEND_TIMEOUT_S", "send_timeout_s",
141
+ str(DEFAULT_SEND_TIMEOUT_S),
142
+ )
143
+ )
144
+ except (TypeError, ValueError):
145
+ self.send_timeout_s = DEFAULT_SEND_TIMEOUT_S
146
+
147
+ self._deduper = EventDeduper()
148
+ self._runner = None # aiohttp.web.AppRunner
149
+ self._site = None # aiohttp.web.TCPSite
150
+
151
+ @property
152
+ def name(self) -> str:
153
+ return "Kojee Tandem"
154
+
155
+ # ── Connection lifecycle ────────────────────────────────────────────
156
+
157
+ async def connect(self) -> bool:
158
+ """Start the loopback webhook listener the kojee-mcp daemon POSTs to."""
159
+ if not self.webhook_secret:
160
+ logger.error("kojee-tandem: KOJEE_WEBHOOK_SECRET must be set (signed-only inbound)")
161
+ self._set_fatal_error(
162
+ "config_missing",
163
+ "KOJEE_WEBHOOK_SECRET must be set — the adapter never accepts unsigned events",
164
+ retryable=False,
165
+ )
166
+ return False
167
+
168
+ logger.info(
169
+ "kojee-tandem: outbound via `%s send` (set KOJEE_MCP_BIN to an "
170
+ "absolute path if `kojee-mcp` is not on the gateway process PATH)",
171
+ self.kojee_mcp_bin,
172
+ )
173
+
174
+ try:
175
+ from aiohttp import web
176
+ except ImportError:
177
+ logger.error("kojee-tandem: aiohttp not available (pip install aiohttp)")
178
+ self._set_fatal_error(
179
+ "missing_dependency", "aiohttp is required", retryable=False
180
+ )
181
+ return False
182
+
183
+ app = web.Application()
184
+ app.router.add_post(self.listen_path, self._handle_webhook)
185
+ app.router.add_get("/health", self._handle_health)
186
+
187
+ try:
188
+ self._runner = web.AppRunner(app)
189
+ await self._runner.setup()
190
+ self._site = web.TCPSite(self._runner, self.listen_host, self.listen_port)
191
+ await self._site.start()
192
+ except OSError as e:
193
+ logger.error(
194
+ "kojee-tandem: cannot bind %s:%s — %s",
195
+ self.listen_host, self.listen_port, e,
196
+ )
197
+ self._set_fatal_error("bind_failed", str(e), retryable=True)
198
+ return False
199
+
200
+ self._mark_connected()
201
+ logger.info(
202
+ "kojee-tandem: listening on http://%s:%s%s (point the kojee-mcp "
203
+ "daemon's KOJEE_WEBHOOK_URL here)",
204
+ self.listen_host, self.listen_port, self.listen_path,
205
+ )
206
+ return True
207
+
208
+ async def disconnect(self) -> None:
209
+ self._mark_disconnected()
210
+ if self._runner is not None:
211
+ try:
212
+ await self._runner.cleanup()
213
+ except Exception:
214
+ pass
215
+ self._runner = None
216
+ self._site = None
217
+
218
+ # ── Inbound: webhook → MessageEvent ─────────────────────────────────
219
+
220
+ async def _handle_health(self, request):
221
+ from aiohttp import web
222
+ return web.json_response({"ok": True, "platform": PLATFORM_NAME})
223
+
224
+ async def _handle_webhook(self, request):
225
+ """One TandemEvent per POST (webhook-sink contract). Verify HMAC over
226
+ the RAW bytes, normalize, filter, inject. Filtered events are ACKed
227
+ with 200 — a 4xx would make the sink drop-as-permanent or retry-burn,
228
+ and redelivery on daemon restart is NORMAL (at-least-once)."""
229
+ from aiohttp import web
230
+
231
+ raw = await request.read()
232
+ if not verify_signature(request.headers, raw, self.webhook_secret):
233
+ # 401 is correct here: a bad signature is never OUR event.
234
+ # (4xx = permanent drop in the sink — desired for forged traffic.)
235
+ return web.json_response({"error": "invalid signature"}, status=401)
236
+
237
+ try:
238
+ payload = json.loads(raw.decode("utf-8"))
239
+ except (ValueError, UnicodeDecodeError):
240
+ return web.json_response({"error": "invalid json"}, status=400)
241
+
242
+ msg = normalize_event(payload)
243
+ if msg is None:
244
+ return web.json_response({"ok": True, "ignored": "not an injectable message"})
245
+ if self._deduper.seen_before(msg.event_id):
246
+ return web.json_response({"ok": True, "ignored": "duplicate"})
247
+ if not tandem_allowed(msg.chat_id, self.tandem_allowlist):
248
+ logger.debug("kojee-tandem: tandem %s not in allowlist — ignored", msg.chat_id)
249
+ return web.json_response({"ok": True, "ignored": "tandem not allowed"})
250
+ if is_self_event(msg, self._self_session_id, self.self_principal):
251
+ return web.json_response({"ok": True, "ignored": "self echo"})
252
+
253
+ source = self.build_source(
254
+ chat_id=msg.chat_id, # tandem_id = the chat id
255
+ chat_name=f"Tandem {msg.chat_id}",
256
+ chat_type="group", # tandems are multi-member rooms
257
+ user_id=msg.sender_id, # principal — Hermes authz key
258
+ user_name=msg.sender_name, # sender display
259
+ message_id=msg.message_id, # cursor
260
+ )
261
+ event = MessageEvent(
262
+ text=msg.text,
263
+ message_type=MessageType.TEXT,
264
+ source=source,
265
+ raw_message=payload,
266
+ message_id=msg.message_id, # cursor as the message id
267
+ reply_to_message_id=msg.reply_to,
268
+ )
269
+ # handle_message returns fast (spawns background tasks) — base.py:3839.
270
+ await self.handle_message(event)
271
+ return web.json_response({"ok": True})
272
+
273
+ # ── Outbound: send → `kojee-mcp send` CLI → tandem_send ─────────────
274
+
275
+ async def send(
276
+ self,
277
+ chat_id: str,
278
+ content: str,
279
+ reply_to: Optional[str] = None,
280
+ metadata: Optional[Dict[str, Any]] = None,
281
+ ) -> SendResult:
282
+ return await _cli_send(
283
+ kojee_mcp_bin=self.kojee_mcp_bin,
284
+ kojee_dir=self.kojee_dir,
285
+ tandem_id=chat_id,
286
+ body=content,
287
+ reply_to=reply_to,
288
+ timeout_s=self.send_timeout_s,
289
+ )
290
+
291
+ async def send_typing(self, chat_id: str, metadata=None) -> None:
292
+ """v1 no-op: the helper does not expose a Tandem typing call yet."""
293
+ pass
294
+
295
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
296
+ return {
297
+ "name": f"Tandem {chat_id}",
298
+ "type": "group",
299
+ "chat_id": chat_id,
300
+ }
301
+
302
+
303
+ def _send_env(kojee_dir: Path) -> Dict[str, str]:
304
+ """Subprocess env for the `kojee-mcp send` CLI.
305
+
306
+ The CLI resolves paired credentials from ``os.homedir()/.kojee`` with no
307
+ flag/env override on the `send` sub-command. To honor a configured KOJEE_DIR
308
+ of the form ``<base>/.kojee`` we point HOME at ``<base>`` so the CLI's
309
+ ``~/.kojee`` resolves to the configured dir; otherwise the inherited HOME
310
+ (default ``~/.kojee``) applies."""
311
+ env = dict(os.environ)
312
+ if kojee_dir.name == ".kojee":
313
+ env["HOME"] = str(kojee_dir.parent)
314
+ return env
315
+
316
+
317
+ async def _cli_send(
318
+ kojee_mcp_bin: str,
319
+ kojee_dir: Path,
320
+ tandem_id: str,
321
+ body: str,
322
+ reply_to: Optional[str],
323
+ timeout_s: float,
324
+ ) -> SendResult:
325
+ """Shell `kojee-mcp send <tandem_id> --body <text> [--reply-to]`; the CLI
326
+ signs with the same paired ~/.kojee creds + DPoP keystore the daemon uses
327
+ and prints one JSON envelope on stdout. Shared by the live adapter send()
328
+ and the standalone cron sender. No source checkout required (T10)."""
329
+ argv = build_send_command(kojee_mcp_bin, tandem_id, body, reply_to)
330
+ env = _send_env(kojee_dir)
331
+
332
+ try:
333
+ proc = await asyncio.create_subprocess_exec(
334
+ *argv,
335
+ stdin=asyncio.subprocess.DEVNULL,
336
+ stdout=asyncio.subprocess.PIPE,
337
+ stderr=asyncio.subprocess.PIPE,
338
+ env=env,
339
+ )
340
+ stdout_b, stderr_b = await asyncio.wait_for(
341
+ proc.communicate(), timeout=timeout_s
342
+ )
343
+ except asyncio.TimeoutError:
344
+ try:
345
+ proc.kill()
346
+ except ProcessLookupError:
347
+ pass
348
+ return SendResult(success=False, error=f"`kojee-mcp send` timed out after {timeout_s}s")
349
+ except FileNotFoundError:
350
+ return SendResult(
351
+ success=False,
352
+ error=(
353
+ f"`kojee-mcp` binary not found ({kojee_mcp_bin}) — set KOJEE_MCP_BIN "
354
+ "to an absolute path or put kojee-mcp on PATH"
355
+ ),
356
+ )
357
+ except OSError as e:
358
+ return SendResult(success=False, error=f"failed to launch `kojee-mcp send`: {e}")
359
+
360
+ verdict = parse_send_envelope(stdout_b.decode("utf-8", errors="replace"))
361
+ if verdict["ok"]:
362
+ return SendResult(
363
+ success=True,
364
+ message_id=verdict["message_id"] or str(int(time.time() * 1000)),
365
+ )
366
+ stderr_tail = stderr_b.decode("utf-8", errors="replace").strip()[-300:]
367
+ error = verdict["error"] or "tandem_send failed"
368
+ if stderr_tail:
369
+ error = f"{error} | stderr: {stderr_tail}"
370
+ return SendResult(success=False, error=error)
371
+
372
+
373
+ # ── Registration plumbing (mirrors plugins/platforms/irc/adapter.py) ────────
374
+
375
+ def check_requirements() -> bool:
376
+ """Dependencies + minimal config: aiohttp importable and a secret set.
377
+ (Config-yaml-only setups validate via validate_config instead.)"""
378
+ try:
379
+ import aiohttp # noqa: F401
380
+ except ImportError:
381
+ return False
382
+ return bool(os.getenv("KOJEE_WEBHOOK_SECRET"))
383
+
384
+
385
+ def validate_config(config) -> bool:
386
+ extra = getattr(config, "extra", {}) or {}
387
+ secret = os.getenv("KOJEE_WEBHOOK_SECRET") or extra.get("webhook_secret", "")
388
+ return bool(secret)
389
+
390
+
391
+ def is_connected(config) -> bool:
392
+ return validate_config(config)
393
+
394
+
395
+ def _env_enablement() -> Optional[dict]:
396
+ """Seed PlatformConfig.extra from env BEFORE adapter construction so
397
+ env-only setups surface in `hermes gateway status` (registry hook —
398
+ see ADDING_A_PLATFORM.md optional hooks)."""
399
+ secret = os.getenv("KOJEE_WEBHOOK_SECRET", "").strip()
400
+ if not secret:
401
+ return None
402
+ seed: dict = {"webhook_secret": secret}
403
+ for env_name, key in (
404
+ ("KOJEE_TANDEM_LISTEN_HOST", "listen_host"),
405
+ ("KOJEE_TANDEM_LISTEN_PORT", "listen_port"),
406
+ ("KOJEE_TANDEM_LISTEN_PATH", "listen_path"),
407
+ ("KOJEE_DIR", "kojee_dir"),
408
+ ("KOJEE_TANDEM_ALLOWLIST", "tandem_allowlist"),
409
+ ("KOJEE_TANDEM_SELF_PRINCIPAL", "self_principal"),
410
+ ):
411
+ value = os.getenv(env_name, "").strip()
412
+ if value:
413
+ seed[key] = value
414
+ home = os.getenv("KOJEE_TANDEM_HOME_CHANNEL", "").strip()
415
+ if home:
416
+ # Handled by the core hook: becomes a HomeChannel on the PlatformConfig.
417
+ seed["home_channel"] = {"chat_id": home, "name": f"Tandem {home}"}
418
+ return seed
419
+
420
+
421
+ async def _standalone_send(
422
+ pconfig,
423
+ chat_id: str,
424
+ message: str,
425
+ *,
426
+ thread_id: Optional[str] = None,
427
+ media_files: Optional[List[str]] = None,
428
+ force_document: bool = False,
429
+ ) -> Dict[str, Any]:
430
+ """Out-of-process delivery for `deliver=kojee-tandem` cron jobs (the
431
+ standalone_sender_fn registry hook). Reuses the same `kojee-mcp send` CLI —
432
+ no live adapter, no source checkout needed. thread_id/media_files accepted
433
+ for signature parity; Tandem has no native thread/attachment primitive in v1."""
434
+ kojee_mcp_bin = resolve_kojee_mcp_bin(os.environ)
435
+ extra = getattr(pconfig, "extra", {}) or {}
436
+ kojee_dir = Path(os.getenv("KOJEE_DIR") or extra.get("kojee_dir", "") or (Path.home() / ".kojee"))
437
+ result = await _cli_send(
438
+ kojee_mcp_bin=kojee_mcp_bin,
439
+ kojee_dir=kojee_dir,
440
+ tandem_id=chat_id,
441
+ body=message,
442
+ reply_to=None,
443
+ timeout_s=DEFAULT_SEND_TIMEOUT_S,
444
+ )
445
+ if result.success:
446
+ return {"success": True, "message_id": result.message_id}
447
+ return {"error": result.error or "kojee-tandem standalone send failed"}
448
+
449
+
450
+ def register(ctx):
451
+ """Plugin entry point — called by the Hermes plugin system
452
+ (hermes_cli/plugins.py _load_plugin → register(ctx))."""
453
+ ctx.register_platform(
454
+ name=PLATFORM_NAME,
455
+ label="Kojee Tandem",
456
+ adapter_factory=lambda cfg: KojeeTandemAdapter(cfg),
457
+ check_fn=check_requirements,
458
+ validate_config=validate_config,
459
+ is_connected=is_connected,
460
+ required_env=["KOJEE_WEBHOOK_SECRET"],
461
+ install_hint=(
462
+ "pip install aiohttp; install + pair kojee-mcp (`npm i -g kojee-mcp` "
463
+ "or set KOJEE_MCP_BIN to its absolute path); set KOJEE_WEBHOOK_SECRET"
464
+ ),
465
+ env_enablement_fn=_env_enablement,
466
+ cron_deliver_env_var="KOJEE_TANDEM_HOME_CHANNEL",
467
+ standalone_sender_fn=_standalone_send,
468
+ # Hermes-side per-user authorization (gateway _is_user_authorized):
469
+ # user_id is the Tandem principal (e.g. "user:daria@cohen.io").
470
+ allowed_users_env="KOJEE_TANDEM_ALLOWED_USERS",
471
+ allow_all_env="KOJEE_TANDEM_ALLOW_ALL_USERS",
472
+ emoji="🤝",
473
+ pii_safe=False,
474
+ allow_update_command=False,
475
+ platform_hint=(
476
+ "You are in a Kojee Tandem — a shared conversation that can include "
477
+ "humans and other agents. Messages render as plain text (no markdown "
478
+ "guarantees). Be concise. The chat id is the tandem id; replies go "
479
+ "to the whole tandem, not just the sender."
480
+ ),
481
+ )