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.
Files changed (38) hide show
  1. package/dist/commands/init-project.d.ts +2 -0
  2. package/dist/commands/init-project.d.ts.map +1 -1
  3. package/dist/commands/init-project.js +16 -0
  4. package/dist/commands/init-project.js.map +1 -1
  5. package/dist/commands/install.js +2 -2
  6. package/dist/commands/status.d.ts +0 -1
  7. package/dist/commands/status.d.ts.map +1 -1
  8. package/dist/commands/status.js +0 -1
  9. package/dist/commands/status.js.map +1 -1
  10. package/dist/commands/up.test.js +2 -2
  11. package/dist/index.js +21 -4
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/claude-plugin.d.ts +17 -0
  14. package/dist/lib/claude-plugin.d.ts.map +1 -0
  15. package/dist/lib/claude-plugin.js +83 -0
  16. package/dist/lib/claude-plugin.js.map +1 -0
  17. package/dist/lib/claude-plugin.test.d.ts +2 -0
  18. package/dist/lib/claude-plugin.test.d.ts.map +1 -0
  19. package/dist/lib/claude-plugin.test.js +48 -0
  20. package/dist/lib/claude-plugin.test.js.map +1 -0
  21. package/dist/lib/hermes-plugin.d.ts +28 -0
  22. package/dist/lib/hermes-plugin.d.ts.map +1 -0
  23. package/dist/lib/hermes-plugin.js +90 -0
  24. package/dist/lib/hermes-plugin.js.map +1 -0
  25. package/dist/lib/hermes-plugin.test.d.ts +2 -0
  26. package/dist/lib/hermes-plugin.test.d.ts.map +1 -0
  27. package/dist/lib/hermes-plugin.test.js +54 -0
  28. package/dist/lib/hermes-plugin.test.js.map +1 -0
  29. package/dist/lib/mcp-bundle.test.js +2 -2
  30. package/package.json +3 -3
  31. package/scripts/vendor-hermes-plugin.mjs +26 -0
  32. package/scripts/verify-publish-tarball.mjs +22 -1
  33. package/vendor/hermes-plugin/robrain/README.md +103 -0
  34. package/vendor/hermes-plugin/robrain/__init__.py +428 -0
  35. package/vendor/hermes-plugin/robrain/client.py +168 -0
  36. package/vendor/hermes-plugin/robrain/plugin.yaml +3 -0
  37. package/vendor/sensing-mcp/dist/index.js +1 -1
  38. 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
+ )
@@ -0,0 +1,3 @@
1
+ name: robrain
2
+ version: 1.0.0
3
+ description: "RoBrain decision memory — self-hosted capture of decisions AND rejected alternatives (structured vetoes), with composite-scored recall so the agent never re-proposes what you already ruled out."
@@ -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.2"
23040
+ version: "2.3.4"
23041
23041
  });
23042
23042
  server.tool(
23043
23043
  "sensing_start_session",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robrain/sensing-mcp",
3
- "version": "2.3.2",
3
+ "version": "2.3.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js"