robrain 2.3.2 → 2.3.4
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/commands/init-project.d.ts +2 -0
- package/dist/commands/init-project.d.ts.map +1 -1
- package/dist/commands/init-project.js +16 -0
- package/dist/commands/init-project.js.map +1 -1
- package/dist/commands/install.js +2 -2
- package/dist/commands/status.d.ts +0 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +0 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/up.test.js +2 -2
- package/dist/index.js +21 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/claude-plugin.d.ts +17 -0
- package/dist/lib/claude-plugin.d.ts.map +1 -0
- package/dist/lib/claude-plugin.js +83 -0
- package/dist/lib/claude-plugin.js.map +1 -0
- package/dist/lib/claude-plugin.test.d.ts +2 -0
- package/dist/lib/claude-plugin.test.d.ts.map +1 -0
- package/dist/lib/claude-plugin.test.js +48 -0
- package/dist/lib/claude-plugin.test.js.map +1 -0
- package/dist/lib/hermes-plugin.d.ts +28 -0
- package/dist/lib/hermes-plugin.d.ts.map +1 -0
- package/dist/lib/hermes-plugin.js +90 -0
- package/dist/lib/hermes-plugin.js.map +1 -0
- package/dist/lib/hermes-plugin.test.d.ts +2 -0
- package/dist/lib/hermes-plugin.test.d.ts.map +1 -0
- package/dist/lib/hermes-plugin.test.js +54 -0
- package/dist/lib/hermes-plugin.test.js.map +1 -0
- package/dist/lib/mcp-bundle.test.js +2 -2
- package/package.json +3 -3
- package/scripts/vendor-hermes-plugin.mjs +26 -0
- package/scripts/verify-publish-tarball.mjs +22 -1
- package/vendor/hermes-plugin/robrain/README.md +103 -0
- package/vendor/hermes-plugin/robrain/__init__.py +428 -0
- package/vendor/hermes-plugin/robrain/client.py +168 -0
- package/vendor/hermes-plugin/robrain/plugin.yaml +3 -0
- package/vendor/sensing-mcp/dist/index.js +1 -1
- package/vendor/sensing-mcp/package.json +1 -1
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""RoBrain memory plugin — decision memory with structured rejected alternatives.
|
|
2
|
+
|
|
3
|
+
RoBrain (https://github.com/adelinamart/robrain) is self-hosted decision
|
|
4
|
+
memory for agents: it passively captures what got decided in a session AND
|
|
5
|
+
the alternatives that were explicitly ruled out, storing each veto as a
|
|
6
|
+
structured ``rejected[]`` field (option + reason) in the user's own
|
|
7
|
+
Postgres. The point is preventing an agent from re-proposing an approach
|
|
8
|
+
the user already rejected — the veto arrives in context as an explicit
|
|
9
|
+
warning, with the original reason.
|
|
10
|
+
|
|
11
|
+
Integration shape:
|
|
12
|
+
|
|
13
|
+
system prompt ← the project's always-on summary (session-start recall)
|
|
14
|
+
prefetch ← composite-scored semantic retrieval; rejected
|
|
15
|
+
alternatives rendered as REJECTED warnings
|
|
16
|
+
sync_turn ← raw turns shipped to Perception, which runs its own
|
|
17
|
+
server-side decision extraction (no local LLM needed)
|
|
18
|
+
robrain_search ← on-demand tool for decision + veto lookup
|
|
19
|
+
|
|
20
|
+
Config chain:
|
|
21
|
+
1. $HERMES_HOME/robrain/config.json (base_url, project_id, scope)
|
|
22
|
+
2. Environment: PERCEPTION_API_KEY (secret), ROBRAIN_BASE_URL,
|
|
23
|
+
ROBRAIN_PROJECT_ID
|
|
24
|
+
|
|
25
|
+
Fail-open by design: if the Perception API is down, recall degrades to
|
|
26
|
+
nothing and writes are dropped with a warning — the agent never blocks.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import queue
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
from datetime import datetime, timezone
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Dict, List, Optional
|
|
40
|
+
|
|
41
|
+
from agent.memory_manager import sanitize_context
|
|
42
|
+
from agent.memory_provider import MemoryProvider
|
|
43
|
+
from tools.registry import tool_error
|
|
44
|
+
|
|
45
|
+
from .client import RoBrainClient, RoBrainClientError
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
_CONFIG_DIRNAME = "robrain"
|
|
50
|
+
_CONFIG_FILENAME = "config.json"
|
|
51
|
+
|
|
52
|
+
# Turns shorter than this carry no decision signal — don't ship them.
|
|
53
|
+
_MIN_TURN_CHARS = 20
|
|
54
|
+
# Cap what we send per turn; Perception enforces its own MAX_TURN_TEXT too.
|
|
55
|
+
_MAX_TURN_CHARS = 20_000
|
|
56
|
+
# Shutdown flush window — one POST /signals rides a server-side LLM
|
|
57
|
+
# extraction, so the last turn of a short session needs real time to land.
|
|
58
|
+
_SHUTDOWN_DRAIN_S = 25.0
|
|
59
|
+
|
|
60
|
+
SEARCH_SCHEMA = {
|
|
61
|
+
"name": "robrain_search",
|
|
62
|
+
"description": (
|
|
63
|
+
"Search the team's RoBrain decision memory. Returns prior decisions "
|
|
64
|
+
"ranked by relevance, each with its rationale and — critically — the "
|
|
65
|
+
"alternatives that were explicitly REJECTED and why. Use before "
|
|
66
|
+
"proposing a tool, library, architecture, or approach, to avoid "
|
|
67
|
+
"re-suggesting something the user already ruled out."
|
|
68
|
+
),
|
|
69
|
+
"parameters": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {
|
|
72
|
+
"query": {
|
|
73
|
+
"type": "string",
|
|
74
|
+
"description": "What you are about to decide or propose (natural language).",
|
|
75
|
+
},
|
|
76
|
+
"limit": {
|
|
77
|
+
"type": "integer",
|
|
78
|
+
"description": "Max decisions to return (default 5, max 20).",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
"required": ["query"],
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _utc_now_iso() -> str:
|
|
87
|
+
return datetime.now(timezone.utc).isoformat()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _format_decisions(rows: List[Dict[str, Any]]) -> str:
|
|
91
|
+
"""Render decisions the way RoBrain's own inject does — vetoes as warnings."""
|
|
92
|
+
lines: List[str] = []
|
|
93
|
+
for row in rows:
|
|
94
|
+
decision = str(row.get("decision") or "").strip()
|
|
95
|
+
if not decision:
|
|
96
|
+
continue
|
|
97
|
+
rationale = str(row.get("rationale") or "").strip()
|
|
98
|
+
lines.append(f"- {decision}" + (f" — {rationale}" if rationale else ""))
|
|
99
|
+
for veto in row.get("rejected") or []:
|
|
100
|
+
option = str(veto.get("option") or "").strip()
|
|
101
|
+
reason = str(veto.get("reason") or "").strip()
|
|
102
|
+
if option:
|
|
103
|
+
lines.append(f" - REJECTED: {option}" + (f" — {reason}" if reason else ""))
|
|
104
|
+
return "\n".join(lines)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class RoBrainProvider(MemoryProvider):
|
|
108
|
+
"""MemoryProvider backed by a self-hosted RoBrain Perception API."""
|
|
109
|
+
|
|
110
|
+
def __init__(self) -> None:
|
|
111
|
+
self._client: Any = None
|
|
112
|
+
self._config: Dict[str, Any] = {}
|
|
113
|
+
self._hermes_home: str = ""
|
|
114
|
+
self._session_id: str = ""
|
|
115
|
+
self._writes_enabled = True
|
|
116
|
+
self._degraded = False
|
|
117
|
+
|
|
118
|
+
self._summary: str = ""
|
|
119
|
+
self._summary_lock = threading.Lock()
|
|
120
|
+
|
|
121
|
+
self._prefetch_cache: Dict[str, str] = {}
|
|
122
|
+
self._prefetch_lock = threading.Lock()
|
|
123
|
+
|
|
124
|
+
self._sequence = 0
|
|
125
|
+
self._sequence_lock = threading.Lock()
|
|
126
|
+
|
|
127
|
+
self._write_queue: "queue.Queue[Optional[Dict[str, Any]]]" = queue.Queue(maxsize=200)
|
|
128
|
+
self._writer: Optional[threading.Thread] = None
|
|
129
|
+
self._writer_busy = False
|
|
130
|
+
|
|
131
|
+
# -- config ---------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def name(self) -> str:
|
|
135
|
+
return "robrain"
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _config_path(hermes_home: str) -> Path:
|
|
139
|
+
return Path(hermes_home) / _CONFIG_DIRNAME / _CONFIG_FILENAME
|
|
140
|
+
|
|
141
|
+
def _load_config(self, hermes_home: str) -> Dict[str, Any]:
|
|
142
|
+
config: Dict[str, Any] = {}
|
|
143
|
+
path = self._config_path(hermes_home) if hermes_home else None
|
|
144
|
+
if path and path.is_file():
|
|
145
|
+
try:
|
|
146
|
+
config = json.loads(path.read_text(encoding="utf-8")) or {}
|
|
147
|
+
except Exception:
|
|
148
|
+
logger.warning("robrain: unreadable config at %s — using env/defaults", path)
|
|
149
|
+
config.setdefault("base_url", os.environ.get("ROBRAIN_BASE_URL", ""))
|
|
150
|
+
config.setdefault("project_id", os.environ.get("ROBRAIN_PROJECT_ID", ""))
|
|
151
|
+
config.setdefault("scope", "team")
|
|
152
|
+
config["api_key"] = os.environ.get("PERCEPTION_API_KEY", "")
|
|
153
|
+
return config
|
|
154
|
+
|
|
155
|
+
def is_available(self) -> bool:
|
|
156
|
+
"""Configured = the config file exists or the API key env var is set.
|
|
157
|
+
|
|
158
|
+
No network calls here (contract) — reachability is probed in
|
|
159
|
+
initialize(), which fails open.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
from hermes_constants import get_hermes_home
|
|
163
|
+
if self._config_path(str(get_hermes_home())).is_file():
|
|
164
|
+
return True
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
return bool(os.environ.get("PERCEPTION_API_KEY"))
|
|
168
|
+
|
|
169
|
+
# -- lifecycle -------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def initialize(self, session_id: str, **kwargs) -> None:
|
|
172
|
+
self._session_id = session_id
|
|
173
|
+
self._hermes_home = str(kwargs.get("hermes_home") or "")
|
|
174
|
+
# Cron/subagent/flush contexts must not write into the decision corpus.
|
|
175
|
+
self._writes_enabled = kwargs.get("agent_context", "primary") == "primary"
|
|
176
|
+
|
|
177
|
+
self._config = self._load_config(self._hermes_home)
|
|
178
|
+
self._client = RoBrainClient(
|
|
179
|
+
base_url=self._config.get("base_url") or "",
|
|
180
|
+
api_key=self._config.get("api_key") or "",
|
|
181
|
+
project_id=self._config.get("project_id") or "",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
self._writer = threading.Thread(
|
|
185
|
+
target=self._writer_loop, name="robrain-writer", daemon=True,
|
|
186
|
+
)
|
|
187
|
+
self._writer.start()
|
|
188
|
+
|
|
189
|
+
threading.Thread(
|
|
190
|
+
target=self._fetch_summary, name="robrain-summary", daemon=True,
|
|
191
|
+
).start()
|
|
192
|
+
|
|
193
|
+
def _fetch_summary(self) -> None:
|
|
194
|
+
try:
|
|
195
|
+
payload = self._client.get_summary()
|
|
196
|
+
except RoBrainClientError as exc:
|
|
197
|
+
self._degraded = True
|
|
198
|
+
logger.warning("robrain: Perception unreachable at startup (fail-open): %s", exc)
|
|
199
|
+
return
|
|
200
|
+
summary = str(payload.get("always_on_summary") or "").strip()
|
|
201
|
+
with self._summary_lock:
|
|
202
|
+
self._summary = summary
|
|
203
|
+
self._degraded = False
|
|
204
|
+
|
|
205
|
+
def shutdown(self) -> None:
|
|
206
|
+
"""Flush pending turn captures, then stop the writer.
|
|
207
|
+
|
|
208
|
+
A single POST /signals waits on Perception's server-side LLM
|
|
209
|
+
extraction (typically 5–20s), and short-lived sessions (``hermes
|
|
210
|
+
-z``) reach shutdown with the final turn still in flight — a
|
|
211
|
+
too-short drain here silently loses exactly the turn that made
|
|
212
|
+
the session worth remembering. Bounded by _SHUTDOWN_DRAIN_S so a
|
|
213
|
+
wedged server still can't block exit; the writer is a daemon
|
|
214
|
+
thread, so anything past the deadline dies with the interpreter.
|
|
215
|
+
"""
|
|
216
|
+
deadline = time.monotonic() + _SHUTDOWN_DRAIN_S
|
|
217
|
+
while not self._write_queue.empty() or self._writer_busy:
|
|
218
|
+
if time.monotonic() >= deadline:
|
|
219
|
+
logger.warning(
|
|
220
|
+
"robrain: shutdown drain timed out with %d turn(s) unflushed",
|
|
221
|
+
self._write_queue.qsize(),
|
|
222
|
+
)
|
|
223
|
+
break
|
|
224
|
+
time.sleep(0.2)
|
|
225
|
+
try:
|
|
226
|
+
self._write_queue.put_nowait(None)
|
|
227
|
+
except queue.Full:
|
|
228
|
+
pass
|
|
229
|
+
if self._writer is not None:
|
|
230
|
+
self._writer.join(timeout=max(0.0, deadline - time.monotonic()) + 1.0)
|
|
231
|
+
|
|
232
|
+
# -- recall ----------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def system_prompt_block(self) -> str:
|
|
235
|
+
with self._summary_lock:
|
|
236
|
+
summary = self._summary
|
|
237
|
+
lines = [
|
|
238
|
+
"RoBrain decision memory is active. Prior team decisions — including "
|
|
239
|
+
"explicitly REJECTED alternatives — are injected as context. Never "
|
|
240
|
+
"re-propose a REJECTED option without flagging the recorded reason "
|
|
241
|
+
"and asking the user to confirm the reversal. Use robrain_search "
|
|
242
|
+
"before committing to a tool, library, or architectural approach.",
|
|
243
|
+
]
|
|
244
|
+
if summary:
|
|
245
|
+
lines.append("")
|
|
246
|
+
lines.append("Project decisions (always-on summary):")
|
|
247
|
+
lines.append(summary)
|
|
248
|
+
return "\n".join(lines)
|
|
249
|
+
|
|
250
|
+
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
|
251
|
+
query = (query or "").strip()
|
|
252
|
+
if len(query) < 12 or self._client is None:
|
|
253
|
+
return
|
|
254
|
+
threading.Thread(
|
|
255
|
+
target=self._background_search,
|
|
256
|
+
args=(query, session_id or self._session_id),
|
|
257
|
+
name="robrain-prefetch",
|
|
258
|
+
daemon=True,
|
|
259
|
+
).start()
|
|
260
|
+
|
|
261
|
+
def _background_search(self, query: str, session_id: str) -> None:
|
|
262
|
+
try:
|
|
263
|
+
rows = self._client.search_decisions(query, limit=5)
|
|
264
|
+
except RoBrainClientError as exc:
|
|
265
|
+
logger.debug("robrain: prefetch search failed (fail-open): %s", exc)
|
|
266
|
+
return
|
|
267
|
+
formatted = _format_decisions(rows)
|
|
268
|
+
with self._prefetch_lock:
|
|
269
|
+
self._prefetch_cache[session_id] = (
|
|
270
|
+
f"Relevant prior decisions (RoBrain):\n{formatted}" if formatted else ""
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
|
274
|
+
with self._prefetch_lock:
|
|
275
|
+
return self._prefetch_cache.get(session_id or self._session_id, "")
|
|
276
|
+
|
|
277
|
+
# -- capture -----------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def sync_turn(
|
|
280
|
+
self,
|
|
281
|
+
user_content: str,
|
|
282
|
+
assistant_content: str,
|
|
283
|
+
*,
|
|
284
|
+
session_id: str = "",
|
|
285
|
+
messages: Optional[List[Dict[str, Any]]] = None,
|
|
286
|
+
) -> None:
|
|
287
|
+
if not self._writes_enabled or self._client is None:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
# Strip previously injected memory context so recall never re-enters
|
|
291
|
+
# the corpus as a fresh "decision" (feedback loop).
|
|
292
|
+
user_clean = sanitize_context(user_content or "").strip()[:_MAX_TURN_CHARS]
|
|
293
|
+
assistant_clean = sanitize_context(assistant_content or "").strip()[:_MAX_TURN_CHARS]
|
|
294
|
+
if len(user_clean) + len(assistant_clean) < _MIN_TURN_CHARS:
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
with self._sequence_lock:
|
|
298
|
+
self._sequence += 1
|
|
299
|
+
sequence = self._sequence
|
|
300
|
+
|
|
301
|
+
item = {
|
|
302
|
+
"session_id": session_id or self._session_id,
|
|
303
|
+
"sequence": sequence,
|
|
304
|
+
"user_message": user_clean,
|
|
305
|
+
"assistant_message": assistant_clean,
|
|
306
|
+
"timestamp": _utc_now_iso(),
|
|
307
|
+
"scope": str(self._config.get("scope") or "team"),
|
|
308
|
+
}
|
|
309
|
+
try:
|
|
310
|
+
self._write_queue.put_nowait(item)
|
|
311
|
+
except queue.Full:
|
|
312
|
+
logger.warning("robrain: write queue full — dropping turn %s", sequence)
|
|
313
|
+
|
|
314
|
+
def _writer_loop(self) -> None:
|
|
315
|
+
while True:
|
|
316
|
+
item = self._write_queue.get()
|
|
317
|
+
if item is None:
|
|
318
|
+
return
|
|
319
|
+
self._writer_busy = True
|
|
320
|
+
try:
|
|
321
|
+
self._client.post_turn_signal(**item)
|
|
322
|
+
except RoBrainClientError as exc:
|
|
323
|
+
logger.debug("robrain: turn capture failed (fail-open): %s", exc)
|
|
324
|
+
finally:
|
|
325
|
+
self._writer_busy = False
|
|
326
|
+
|
|
327
|
+
# -- session lifecycle -------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
def on_session_switch(
|
|
330
|
+
self,
|
|
331
|
+
new_session_id: str,
|
|
332
|
+
*,
|
|
333
|
+
parent_session_id: str = "",
|
|
334
|
+
reset: bool = False,
|
|
335
|
+
rewound: bool = False,
|
|
336
|
+
**kwargs,
|
|
337
|
+
) -> None:
|
|
338
|
+
self._session_id = new_session_id
|
|
339
|
+
if reset:
|
|
340
|
+
with self._sequence_lock:
|
|
341
|
+
self._sequence = 0
|
|
342
|
+
with self._prefetch_lock:
|
|
343
|
+
self._prefetch_cache.clear()
|
|
344
|
+
|
|
345
|
+
# -- tools ---------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
|
348
|
+
return [SEARCH_SCHEMA]
|
|
349
|
+
|
|
350
|
+
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
|
|
351
|
+
if tool_name != "robrain_search":
|
|
352
|
+
return tool_error(f"Unknown tool: {tool_name}")
|
|
353
|
+
if self._client is None:
|
|
354
|
+
return tool_error("RoBrain provider is not initialized")
|
|
355
|
+
|
|
356
|
+
query = str(args.get("query") or "").strip()
|
|
357
|
+
if not query:
|
|
358
|
+
return tool_error("query is required")
|
|
359
|
+
limit = args.get("limit") or 5
|
|
360
|
+
try:
|
|
361
|
+
limit = max(1, min(int(limit), 20))
|
|
362
|
+
except (TypeError, ValueError):
|
|
363
|
+
limit = 5
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
rows = self._client.search_decisions(query, limit=limit)
|
|
367
|
+
except RoBrainClientError as exc:
|
|
368
|
+
return tool_error(f"RoBrain search failed: {exc}")
|
|
369
|
+
|
|
370
|
+
decisions = [
|
|
371
|
+
{
|
|
372
|
+
"decision": row.get("decision"),
|
|
373
|
+
"rationale": row.get("rationale"),
|
|
374
|
+
"rejected": row.get("rejected") or [],
|
|
375
|
+
"score": row.get("planning_score"),
|
|
376
|
+
"created_at": row.get("created_at"),
|
|
377
|
+
"reviewed": bool(row.get("reviewed_at")),
|
|
378
|
+
}
|
|
379
|
+
for row in rows
|
|
380
|
+
]
|
|
381
|
+
return json.dumps({"decisions": decisions, "count": len(decisions)})
|
|
382
|
+
|
|
383
|
+
# -- setup ------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
def get_config_schema(self) -> List[Dict[str, Any]]:
|
|
386
|
+
return [
|
|
387
|
+
{
|
|
388
|
+
"key": "base_url",
|
|
389
|
+
"description": "Perception API URL of your RoBrain deployment",
|
|
390
|
+
"default": "http://localhost:3001",
|
|
391
|
+
"required": True,
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
"key": "project_id",
|
|
395
|
+
"description": "RoBrain project id (see `npx robrain review` or the projects table)",
|
|
396
|
+
"default": "default",
|
|
397
|
+
"required": True,
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
"key": "scope",
|
|
401
|
+
"description": "Scope recorded on captured decisions",
|
|
402
|
+
"default": "team",
|
|
403
|
+
"choices": ["user", "local", "team", "global"],
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
"key": "api_key",
|
|
407
|
+
"description": "PERCEPTION_API_KEY from your RoBrain .env",
|
|
408
|
+
"secret": True,
|
|
409
|
+
"required": True,
|
|
410
|
+
"env_var": "PERCEPTION_API_KEY",
|
|
411
|
+
"url": "https://github.com/adelinamart/robrain#security",
|
|
412
|
+
},
|
|
413
|
+
]
|
|
414
|
+
|
|
415
|
+
def save_config(self, values: Dict[str, Any], hermes_home: str) -> None:
|
|
416
|
+
path = self._config_path(hermes_home)
|
|
417
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
418
|
+
config = {
|
|
419
|
+
"base_url": values.get("base_url") or "http://localhost:3001",
|
|
420
|
+
"project_id": values.get("project_id") or "default",
|
|
421
|
+
"scope": values.get("scope") or "team",
|
|
422
|
+
}
|
|
423
|
+
path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def register_memory_provider() -> MemoryProvider:
|
|
427
|
+
"""Entry point used by plugins/memory discovery."""
|
|
428
|
+
return RoBrainProvider()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""HTTP client for RoBrain's self-hosted Perception API.
|
|
2
|
+
|
|
3
|
+
RoBrain (https://github.com/adelinamart/robrain) is a self-hosted decision
|
|
4
|
+
memory: it records what a team decided AND the alternatives it explicitly
|
|
5
|
+
rejected (a structured ``rejected[]`` field per decision), stores both in
|
|
6
|
+
the user's own Postgres, and serves them back ranked by a 5-signal
|
|
7
|
+
composite score. Nothing leaves the user's machine.
|
|
8
|
+
|
|
9
|
+
This client covers the three Perception endpoints the Hermes provider
|
|
10
|
+
needs:
|
|
11
|
+
|
|
12
|
+
GET /projects/:id/summary — the always-on summary (session-start recall)
|
|
13
|
+
GET /decisions?query=... — composite-scored semantic retrieval
|
|
14
|
+
POST /signals — raw-turn capture; Perception extracts the
|
|
15
|
+
decision server-side (needs_classification)
|
|
16
|
+
|
|
17
|
+
Auth is a static bearer token (``PERCEPTION_API_KEY`` from the RoBrain
|
|
18
|
+
deployment's .env). All calls carry short timeouts and raise
|
|
19
|
+
``RoBrainClientError`` — callers are expected to fail open: memory being
|
|
20
|
+
down should degrade recall, never block the agent.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from typing import Any, Dict, List, Optional
|
|
27
|
+
|
|
28
|
+
import requests
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
DEFAULT_BASE_URL = "http://localhost:3001"
|
|
33
|
+
DEFAULT_PROJECT_ID = "default"
|
|
34
|
+
|
|
35
|
+
_READ_TIMEOUT = 6 # summary / search — user-visible latency path
|
|
36
|
+
_WRITE_TIMEOUT = 45 # /signals runs LLM extraction + embedding server-side
|
|
37
|
+
# before responding; writes happen on a background
|
|
38
|
+
# thread, so a generous timeout costs no turn latency
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RoBrainClientError(Exception):
|
|
42
|
+
"""Raised for any transport or non-2xx failure against Perception."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RoBrainClient:
|
|
46
|
+
"""Thin, dependency-light wrapper over the Perception HTTP API."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
51
|
+
api_key: str = "",
|
|
52
|
+
project_id: str = DEFAULT_PROJECT_ID,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
55
|
+
self.api_key = api_key
|
|
56
|
+
self.project_id = project_id or DEFAULT_PROJECT_ID
|
|
57
|
+
self._session = requests.Session()
|
|
58
|
+
|
|
59
|
+
# -- internals ----------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def _headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
62
|
+
headers = {"Content-Type": "application/json"}
|
|
63
|
+
if self.api_key:
|
|
64
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
65
|
+
if extra:
|
|
66
|
+
headers.update(extra)
|
|
67
|
+
return headers
|
|
68
|
+
|
|
69
|
+
def _request(self, method: str, path: str, *, timeout: float, **kwargs) -> Any:
|
|
70
|
+
url = f"{self.base_url}{path}"
|
|
71
|
+
try:
|
|
72
|
+
resp = self._session.request(
|
|
73
|
+
method, url, headers=self._headers(kwargs.pop("extra_headers", None)),
|
|
74
|
+
timeout=timeout, **kwargs,
|
|
75
|
+
)
|
|
76
|
+
except requests.RequestException as exc:
|
|
77
|
+
raise RoBrainClientError(f"{method} {path}: {exc}") from exc
|
|
78
|
+
if resp.status_code >= 400:
|
|
79
|
+
raise RoBrainClientError(
|
|
80
|
+
f"{method} {path}: HTTP {resp.status_code} — {resp.text[:200]}"
|
|
81
|
+
)
|
|
82
|
+
try:
|
|
83
|
+
return resp.json()
|
|
84
|
+
except ValueError as exc:
|
|
85
|
+
raise RoBrainClientError(f"{method} {path}: non-JSON response") from exc
|
|
86
|
+
|
|
87
|
+
# -- API surface ---------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def health(self) -> bool:
|
|
90
|
+
"""True when Perception answers /health (unauthenticated by design)."""
|
|
91
|
+
try:
|
|
92
|
+
self._request("GET", "/health", timeout=3)
|
|
93
|
+
return True
|
|
94
|
+
except RoBrainClientError:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
def get_summary(self) -> Dict[str, Any]:
|
|
98
|
+
"""Fetch the project's always-on summary.
|
|
99
|
+
|
|
100
|
+
Returns ``{"always_on_summary": str | None, "mission": str | None}``.
|
|
101
|
+
404 (project not registered) raises like any other error — the
|
|
102
|
+
provider treats it as "no summary yet", not a hard failure.
|
|
103
|
+
"""
|
|
104
|
+
return self._request(
|
|
105
|
+
"GET", f"/projects/{self.project_id}/summary", timeout=_READ_TIMEOUT,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def search_decisions(
|
|
109
|
+
self,
|
|
110
|
+
query: str,
|
|
111
|
+
*,
|
|
112
|
+
limit: int = 5,
|
|
113
|
+
boost_files: Optional[List[str]] = None,
|
|
114
|
+
) -> List[Dict[str, Any]]:
|
|
115
|
+
"""Composite-scored semantic retrieval over the decision corpus.
|
|
116
|
+
|
|
117
|
+
Each row carries ``decision``, ``rationale``, ``rejected`` (list of
|
|
118
|
+
``{option, reason}``), ``planning_score``, lifecycle fields, etc.
|
|
119
|
+
"""
|
|
120
|
+
params: Dict[str, str] = {
|
|
121
|
+
"project_id": self.project_id,
|
|
122
|
+
"query": query,
|
|
123
|
+
"limit": str(limit),
|
|
124
|
+
}
|
|
125
|
+
if boost_files:
|
|
126
|
+
params["boost_files"] = ",".join(boost_files)
|
|
127
|
+
payload = self._request("GET", "/decisions", timeout=_READ_TIMEOUT, params=params)
|
|
128
|
+
rows = payload.get("decisions") if isinstance(payload, dict) else payload
|
|
129
|
+
return rows if isinstance(rows, list) else []
|
|
130
|
+
|
|
131
|
+
def post_turn_signal(
|
|
132
|
+
self,
|
|
133
|
+
*,
|
|
134
|
+
session_id: str,
|
|
135
|
+
sequence: int,
|
|
136
|
+
user_message: str,
|
|
137
|
+
assistant_message: str,
|
|
138
|
+
timestamp: str,
|
|
139
|
+
scope: str = "team",
|
|
140
|
+
) -> Dict[str, Any]:
|
|
141
|
+
"""Ship a raw turn; Perception extracts the decision server-side.
|
|
142
|
+
|
|
143
|
+
``needs_classification: true`` bypasses the confidence gate and makes
|
|
144
|
+
Perception run its own extraction (the flush-on-close path), so this
|
|
145
|
+
client needs no LLM of its own. Perception also re-redacts secrets
|
|
146
|
+
before anything is embedded or stored.
|
|
147
|
+
"""
|
|
148
|
+
body = {
|
|
149
|
+
"signal": {
|
|
150
|
+
"turn": {
|
|
151
|
+
"session_id": session_id,
|
|
152
|
+
"sequence": sequence,
|
|
153
|
+
"user_message": user_message,
|
|
154
|
+
"claude_reply": assistant_message,
|
|
155
|
+
"files_touched": [],
|
|
156
|
+
"timestamp": timestamp,
|
|
157
|
+
},
|
|
158
|
+
"decision_type": "unclassified",
|
|
159
|
+
"confidence": 0,
|
|
160
|
+
"files_affected": [],
|
|
161
|
+
"scope": scope,
|
|
162
|
+
"needs_classification": True,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return self._request(
|
|
166
|
+
"POST", "/signals", timeout=_WRITE_TIMEOUT, json=body,
|
|
167
|
+
extra_headers={"X-Project-Id": self.project_id},
|
|
168
|
+
)
|
|
@@ -23037,7 +23037,7 @@ function resolveSessionId(raw) {
|
|
|
23037
23037
|
function buildServer() {
|
|
23038
23038
|
const server = new McpServer({
|
|
23039
23039
|
name: "sensing-mcp",
|
|
23040
|
-
version: "2.3.
|
|
23040
|
+
version: "2.3.4"
|
|
23041
23041
|
});
|
|
23042
23042
|
server.tool(
|
|
23043
23043
|
"sensing_start_session",
|