@signetai/connector-hermes-agent 0.140.1

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.
@@ -0,0 +1,571 @@
1
+ """Signet daemon HTTP client.
2
+
3
+ Communicates with the Signet daemon on localhost:3850 (default) for
4
+ memory operations: search, store, hooks, and session lifecycle.
5
+
6
+ Configuration resolution:
7
+ 1. SIGNET_HOST + SIGNET_PORT env vars
8
+ 2. SIGNET_DAEMON_URL env var (full URL override)
9
+ 3. Default: http://localhost:3850
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import os
17
+ import ipaddress
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
+ from typing import Any, Dict, List, Optional
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _DEFAULT_HOST = "localhost"
26
+ _DEFAULT_PORT = 3850
27
+ _TIMEOUT_SECS = 5
28
+ _LONG_TIMEOUT_SECS = 15
29
+ _RECALL_TIMEOUT_SECS = 30
30
+ _TRUSTED_ORIGINS_ENV = "SIGNET_TRUSTED_DAEMON_ORIGINS"
31
+
32
+
33
+ def _sanitize(value: str) -> str:
34
+ """Strip leading/trailing whitespace and embedded newlines from env values."""
35
+ return value.strip().replace("\r", "").replace("\n", "")
36
+
37
+
38
+ def _normalize_base_url(raw: str, source: str) -> str:
39
+ """Normalize a daemon URL to an origin string."""
40
+ parsed = urllib.parse.urlparse(raw)
41
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
42
+ raise ValueError(f"{source} must be an http(s) URL")
43
+ if parsed.username or parsed.password:
44
+ raise ValueError(f"{source} must not include username or password")
45
+ if parsed.query or parsed.fragment:
46
+ raise ValueError(f"{source} must not include query strings or fragments")
47
+ if parsed.path not in ("", "/"):
48
+ raise ValueError(f"{source} must point at the daemon origin, not a path")
49
+ return urllib.parse.urlunparse((parsed.scheme, parsed.netloc, "", "", "", ""))
50
+
51
+
52
+ def _resolve_base_url() -> str:
53
+ """Resolve the Signet daemon base URL."""
54
+ explicit = _sanitize(os.environ.get("SIGNET_DAEMON_URL", ""))
55
+ if explicit:
56
+ return _normalize_base_url(explicit, "SIGNET_DAEMON_URL")
57
+ host = _sanitize(os.environ.get("SIGNET_HOST", _DEFAULT_HOST))
58
+ port = _sanitize(os.environ.get("SIGNET_PORT", str(_DEFAULT_PORT)))
59
+ return _normalize_base_url(f"http://{host}:{port}", "SIGNET_HOST/SIGNET_PORT")
60
+
61
+
62
+ def _is_loopback_host(host: str) -> bool:
63
+ """Return true for localhost and loopback IP literals."""
64
+ if host.lower() == "localhost":
65
+ return True
66
+ try:
67
+ return ipaddress.ip_address(host).is_loopback
68
+ except ValueError:
69
+ return False
70
+
71
+
72
+ def _trusted_daemon_origins() -> List[str]:
73
+ """Read the exact remote daemon origins trusted to receive SIGNET_TOKEN."""
74
+ raw = _sanitize(os.environ.get(_TRUSTED_ORIGINS_ENV, ""))
75
+ origins: List[str] = []
76
+ for part in raw.split(","):
77
+ candidate = part.strip()
78
+ if not candidate:
79
+ continue
80
+ try:
81
+ origins.append(_normalize_base_url(candidate, _TRUSTED_ORIGINS_ENV))
82
+ except ValueError:
83
+ continue
84
+ return origins
85
+
86
+
87
+ def _should_send_auth_token(base_url: str) -> bool:
88
+ """Only send bearer tokens to loopback or explicitly trusted daemon origins."""
89
+ parsed = urllib.parse.urlparse(base_url)
90
+ host = parsed.hostname or ""
91
+ return _is_loopback_host(host) or base_url in _trusted_daemon_origins()
92
+
93
+
94
+ def _read_json_response(resp) -> Dict[str, Any]:
95
+ """Read a daemon response, treating empty successful bodies as an empty object."""
96
+ body = resp.read()
97
+ if not body:
98
+ return {}
99
+ return json.loads(body.decode("utf-8"))
100
+
101
+
102
+ def _safe_score(value: Any) -> float:
103
+ """Coerce daemon result scores without failing recall on malformed rows."""
104
+ try:
105
+ return float(value or 0.0)
106
+ except (TypeError, ValueError):
107
+ return 0.0
108
+
109
+
110
+ class SignetClient:
111
+ """HTTP client for the Signet daemon API."""
112
+
113
+ def __init__(self, agent_id: str = "", harness: str = "hermes-agent"):
114
+ self._base_url = _resolve_base_url()
115
+ self._agent_id = agent_id
116
+ self._harness = harness
117
+
118
+ @property
119
+ def base_url(self) -> str:
120
+ return self._base_url
121
+
122
+ def _headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
123
+ h: Dict[str, str] = {
124
+ "Content-Type": "application/json",
125
+ "x-signet-runtime-path": "plugin",
126
+ "x-signet-agent-id": self._agent_id,
127
+ "x-signet-actor": "hermes-memory-plugin",
128
+ }
129
+ # Include auth token only for loopback or explicitly trusted origins.
130
+ token = _sanitize(os.environ.get("SIGNET_API_KEY", "")) or _sanitize(os.environ.get("SIGNET_TOKEN", ""))
131
+ if token and _should_send_auth_token(self._base_url):
132
+ h["Authorization"] = f"Bearer {token}"
133
+ if extra:
134
+ h.update(extra)
135
+ return h
136
+
137
+ def _post(
138
+ self,
139
+ path: str,
140
+ body: Dict[str, Any],
141
+ *,
142
+ timeout: float = _TIMEOUT_SECS,
143
+ extra_headers: Optional[Dict[str, str]] = None,
144
+ ) -> Optional[Dict[str, Any]]:
145
+ """POST JSON to the daemon. Returns parsed response or None on failure."""
146
+ url = f"{self._base_url}{path}"
147
+ data = json.dumps(body).encode("utf-8")
148
+ headers = self._headers(extra_headers)
149
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
150
+ try:
151
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
152
+ return _read_json_response(resp)
153
+ except urllib.error.HTTPError as e:
154
+ body_text = ""
155
+ try:
156
+ body_text = e.read().decode("utf-8", errors="replace")[:200]
157
+ except Exception as read_err:
158
+ logger.debug("Signet POST %s: failed to read error body: %s", path, read_err)
159
+ logger.debug("Signet POST %s returned %d: %s", path, e.code, body_text)
160
+ return None
161
+ except (urllib.error.URLError, OSError, TimeoutError, ValueError) as e:
162
+ logger.debug("Signet POST %s failed: %s", path, e)
163
+ return None
164
+
165
+ def _get(
166
+ self,
167
+ path: str,
168
+ *,
169
+ timeout: float = _TIMEOUT_SECS,
170
+ ) -> Optional[Dict[str, Any]]:
171
+ """GET from the daemon. Returns parsed response or None on failure."""
172
+ url = f"{self._base_url}{path}"
173
+ req = urllib.request.Request(url, headers=self._headers(), method="GET")
174
+ try:
175
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
176
+ return _read_json_response(resp)
177
+ except (urllib.error.HTTPError, urllib.error.URLError, OSError, TimeoutError, ValueError) as e:
178
+ logger.debug("Signet GET %s failed: %s", path, e)
179
+ return None
180
+
181
+ def _patch(
182
+ self,
183
+ path: str,
184
+ body: Dict[str, Any],
185
+ *,
186
+ timeout: float = _TIMEOUT_SECS,
187
+ ) -> Optional[Dict[str, Any]]:
188
+ """PATCH JSON to the daemon. Returns parsed response or None on failure."""
189
+ url = f"{self._base_url}{path}"
190
+ data = json.dumps(body).encode("utf-8")
191
+ req = urllib.request.Request(url, data=data, headers=self._headers(), method="PATCH")
192
+ try:
193
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
194
+ return _read_json_response(resp)
195
+ except (urllib.error.HTTPError, urllib.error.URLError, OSError, TimeoutError, ValueError) as e:
196
+ logger.debug("Signet PATCH %s failed: %s", path, e)
197
+ return None
198
+
199
+ def _delete(
200
+ self,
201
+ path: str,
202
+ *,
203
+ timeout: float = _TIMEOUT_SECS,
204
+ ) -> Optional[Dict[str, Any]]:
205
+ """DELETE from the daemon. Returns parsed response or None on failure."""
206
+ url = f"{self._base_url}{path}"
207
+ req = urllib.request.Request(url, headers=self._headers(), method="DELETE")
208
+ try:
209
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
210
+ return _read_json_response(resp)
211
+ except (urllib.error.HTTPError, urllib.error.URLError, OSError, TimeoutError, ValueError) as e:
212
+ logger.debug("Signet DELETE %s failed: %s", path, e)
213
+ return None
214
+
215
+ # -- Health ---------------------------------------------------------------
216
+
217
+ def is_available(self) -> bool:
218
+ """Check if the Signet daemon is reachable. No credentials needed."""
219
+ result = self._get("/health", timeout=2)
220
+ return result is not None
221
+
222
+ # -- Hooks ----------------------------------------------------------------
223
+
224
+ def session_start(
225
+ self,
226
+ session_key: str,
227
+ *,
228
+ project: str = "",
229
+ ) -> Optional[Dict[str, Any]]:
230
+ """Call session-start hook. Returns identity + memories + inject text."""
231
+ return self._post(
232
+ "/api/hooks/session-start",
233
+ {
234
+ "harness": self._harness,
235
+ "sessionKey": session_key,
236
+ "project": project,
237
+ "agentId": self._agent_id,
238
+ },
239
+ timeout=_LONG_TIMEOUT_SECS,
240
+ )
241
+
242
+ def user_prompt_submit(
243
+ self,
244
+ session_key: str,
245
+ user_message: str,
246
+ *,
247
+ last_assistant_message: str = "",
248
+ project: str = "",
249
+ ) -> Optional[Dict[str, Any]]:
250
+ """Call user-prompt-submit hook. Returns recall inject text."""
251
+ return self._post(
252
+ "/api/hooks/user-prompt-submit",
253
+ {
254
+ "harness": self._harness,
255
+ "sessionKey": session_key,
256
+ "userMessage": user_message,
257
+ "lastAssistantMessage": last_assistant_message,
258
+ "agentId": self._agent_id,
259
+ "project": project,
260
+ },
261
+ timeout=_RECALL_TIMEOUT_SECS,
262
+ )
263
+
264
+ def session_end(
265
+ self,
266
+ session_key: str,
267
+ transcript: str,
268
+ *,
269
+ project: str = "",
270
+ reason: str = "",
271
+ ) -> Optional[Dict[str, Any]]:
272
+ """Call session-end hook. Triggers memory extraction from transcript."""
273
+ body: Dict[str, Any] = {
274
+ "harness": self._harness,
275
+ "sessionKey": session_key,
276
+ "transcript": transcript,
277
+ "agentId": self._agent_id,
278
+ "cwd": project,
279
+ }
280
+ if reason:
281
+ body["reason"] = reason
282
+ return self._post(
283
+ "/api/hooks/session-end",
284
+ body,
285
+ timeout=_LONG_TIMEOUT_SECS,
286
+ )
287
+
288
+ def pre_compaction(
289
+ self,
290
+ session_key: str,
291
+ *,
292
+ session_context: str = "",
293
+ message_count: int = 0,
294
+ ) -> Optional[Dict[str, Any]]:
295
+ """Call pre-compaction hook. Returns summary prompt and guidelines."""
296
+ body: Dict[str, Any] = {
297
+ "harness": self._harness,
298
+ "sessionKey": session_key,
299
+ }
300
+ if session_context:
301
+ body["sessionContext"] = session_context
302
+ if message_count > 0:
303
+ body["messageCount"] = message_count
304
+ return self._post("/api/hooks/pre-compaction", body)
305
+
306
+ def compaction_complete(
307
+ self,
308
+ session_key: str,
309
+ summary: str,
310
+ *,
311
+ project: str = "",
312
+ ) -> Optional[Dict[str, Any]]:
313
+ """Call compaction-complete hook. Saves summary as session memory."""
314
+ return self._post(
315
+ "/api/hooks/compaction-complete",
316
+ {
317
+ "harness": self._harness,
318
+ "sessionKey": session_key,
319
+ "summary": summary,
320
+ "agentId": self._agent_id,
321
+ "project": project,
322
+ },
323
+ timeout=_LONG_TIMEOUT_SECS,
324
+ )
325
+
326
+ def checkpoint_extract(
327
+ self,
328
+ session_key: str,
329
+ transcript: str,
330
+ *,
331
+ project: str = "",
332
+ ) -> Optional[Dict[str, Any]]:
333
+ """Call checkpoint-extract for long-running sessions.
334
+
335
+ Extracts only the delta since last extraction. Does not
336
+ release the session claim.
337
+ """
338
+ return self._post(
339
+ "/api/hooks/session-checkpoint-extract",
340
+ {
341
+ "harness": self._harness,
342
+ "sessionKey": session_key,
343
+ "transcript": transcript,
344
+ "agentId": self._agent_id,
345
+ "project": project,
346
+ },
347
+ timeout=_LONG_TIMEOUT_SECS,
348
+ )
349
+
350
+ # -- Memory API -----------------------------------------------------------
351
+
352
+ def remember(
353
+ self,
354
+ content: str,
355
+ *,
356
+ importance: float = 0.5,
357
+ tags: Optional[List[str]] = None,
358
+ memory_type: str = "",
359
+ pinned: Optional[bool] = None,
360
+ project: str = "",
361
+ source_type: str = "",
362
+ source_id: str = "",
363
+ hints: Optional[List[str]] = None,
364
+ transcript: str = "",
365
+ structured: Optional[Dict[str, Any]] = None,
366
+ who: str = "hermes-agent",
367
+ ) -> Optional[Dict[str, Any]]:
368
+ """Store a memory via the daemon API."""
369
+ body: Dict[str, Any] = {
370
+ "content": content,
371
+ "importance": importance,
372
+ "who": who,
373
+ }
374
+ if self._agent_id:
375
+ body["agentId"] = self._agent_id
376
+ if memory_type:
377
+ body["type"] = memory_type
378
+ if tags:
379
+ body["tags"] = tags
380
+ if pinned is not None:
381
+ body["pinned"] = pinned
382
+ if project:
383
+ body["project"] = project
384
+ if source_type:
385
+ body["sourceType"] = source_type
386
+ if source_id:
387
+ body["sourceId"] = source_id
388
+ if hints:
389
+ body["hints"] = hints
390
+ if transcript:
391
+ body["transcript"] = transcript
392
+ if structured:
393
+ body["structured"] = structured
394
+ return self._post("/api/memory/remember", body, timeout=_LONG_TIMEOUT_SECS)
395
+
396
+ def recall(
397
+ self,
398
+ query: str,
399
+ *,
400
+ limit: int = 10,
401
+ project: str = "",
402
+ memory_type: str = "",
403
+ tags: str = "",
404
+ who: str = "",
405
+ pinned: Optional[bool] = None,
406
+ importance_min: Optional[float] = None,
407
+ since: str = "",
408
+ until: str = "",
409
+ keyword_query: str = "",
410
+ score_min: Optional[float] = None,
411
+ aggregate: bool = False,
412
+ aggregate_budget: str = "",
413
+ save_aggregate: Optional[bool] = None,
414
+ agent_scoped: bool = False,
415
+ ) -> Optional[Dict[str, Any]]:
416
+ """Search memories via hybrid recall."""
417
+ body: Dict[str, Any] = {
418
+ "query": query,
419
+ "limit": limit,
420
+ }
421
+ if project:
422
+ body["project"] = project
423
+ if memory_type:
424
+ body["type"] = memory_type
425
+ if tags:
426
+ body["tags"] = tags
427
+ if who:
428
+ body["who"] = who
429
+ if pinned is not None:
430
+ body["pinned"] = pinned
431
+ if importance_min is not None:
432
+ body["importance_min"] = importance_min
433
+ if since:
434
+ body["since"] = since
435
+ if until:
436
+ body["until"] = until
437
+ if keyword_query:
438
+ body["keywordQuery"] = keyword_query
439
+ if aggregate:
440
+ body["aggregate"] = True
441
+ if aggregate_budget in ("small", "medium", "large"):
442
+ body["aggregateBudget"] = aggregate_budget
443
+ if save_aggregate is not None:
444
+ body["saveAggregate"] = save_aggregate
445
+ if agent_scoped and self._agent_id:
446
+ body["agentId"] = self._agent_id
447
+
448
+ result = self._post("/api/memory/recall", body, timeout=_RECALL_TIMEOUT_SECS)
449
+ if (
450
+ result
451
+ and score_min is not None
452
+ and isinstance(result.get("results"), list)
453
+ ):
454
+ kept = [
455
+ row for row in result["results"]
456
+ if not isinstance(row, dict) or _safe_score(row.get("score")) >= score_min
457
+ ]
458
+ result = dict(result)
459
+ result["results"] = kept
460
+ meta = result.get("meta")
461
+ if isinstance(meta, dict):
462
+ result["meta"] = {**meta, "totalReturned": len(kept), "noHits": len(kept) == 0}
463
+ return result
464
+
465
+ def session_search(
466
+ self,
467
+ query: str,
468
+ *,
469
+ session_key: str = "",
470
+ current_session_key: str = "",
471
+ agent_id: str = "",
472
+ project: str = "",
473
+ limit: int = 10,
474
+ ) -> Optional[Dict[str, Any]]:
475
+ """Search active or completed session transcripts."""
476
+ body: Dict[str, Any] = {
477
+ "query": query,
478
+ "limit": limit,
479
+ }
480
+ if session_key:
481
+ body["sessionKey"] = session_key
482
+ if current_session_key:
483
+ body["currentSessionKey"] = current_session_key
484
+ resolved_agent_id = agent_id or self._agent_id
485
+ if resolved_agent_id:
486
+ body["agentId"] = resolved_agent_id
487
+ if project:
488
+ body["project"] = project
489
+ return self._post("/api/sessions/search", body, timeout=_RECALL_TIMEOUT_SECS)
490
+
491
+ def get_memory(self, memory_id: str) -> Optional[Dict[str, Any]]:
492
+ """Retrieve a single memory by ID."""
493
+ return self._get(f"/api/memory/{urllib.parse.quote(memory_id)}")
494
+
495
+ def list_memories(
496
+ self,
497
+ *,
498
+ limit: int = 100,
499
+ offset: int = 0,
500
+ memory_type: str = "",
501
+ ) -> Optional[Dict[str, Any]]:
502
+ """List memories with optional filters."""
503
+ params = f"?limit={limit}&offset={offset}"
504
+ if memory_type:
505
+ params += f"&type={urllib.parse.quote(memory_type)}"
506
+ return self._get(f"/api/memories{params}")
507
+
508
+ def modify_memory(
509
+ self,
510
+ memory_id: str,
511
+ *,
512
+ content: str = "",
513
+ memory_type: str = "",
514
+ importance: Optional[float] = None,
515
+ tags: str = "",
516
+ pinned: Optional[bool] = None,
517
+ reason: str,
518
+ ) -> Optional[Dict[str, Any]]:
519
+ """Edit an existing memory by ID."""
520
+ body: Dict[str, Any] = {"reason": reason}
521
+ if content:
522
+ body["content"] = content
523
+ if memory_type:
524
+ body["type"] = memory_type
525
+ if importance is not None:
526
+ body["importance"] = importance
527
+ if tags:
528
+ body["tags"] = tags
529
+ if pinned is not None:
530
+ body["pinned"] = pinned
531
+ return self._patch(f"/api/memory/{urllib.parse.quote(memory_id)}", body)
532
+
533
+ def forget_memory(
534
+ self,
535
+ memory_id: str,
536
+ *,
537
+ reason: str,
538
+ ) -> Optional[Dict[str, Any]]:
539
+ """Soft-delete a memory by ID."""
540
+ params = urllib.parse.urlencode({"reason": reason})
541
+ return self._delete(f"/api/memory/{urllib.parse.quote(memory_id)}?{params}")
542
+
543
+ def search(
544
+ self,
545
+ query: str,
546
+ *,
547
+ limit: int = 10,
548
+ memory_type: str = "",
549
+ ) -> List[Dict[str, Any]]:
550
+ """Search memories. Returns list of memory objects."""
551
+ params = f"?q={urllib.parse.quote(query)}&limit={limit}"
552
+ if memory_type:
553
+ params += f"&type={urllib.parse.quote(memory_type)}"
554
+ result = self._get(f"/api/memory/search{params}")
555
+ if result and isinstance(result, dict):
556
+ return result.get("results", result.get("memories", []))
557
+ if isinstance(result, list):
558
+ return result
559
+ return []
560
+
561
+ def feedback(
562
+ self,
563
+ ratings: Dict[str, float],
564
+ *,
565
+ session_key: str = "",
566
+ ) -> Optional[Dict[str, Any]]:
567
+ """Rate memory relevance for predictor training."""
568
+ body: Dict[str, Any] = {"ratings": ratings}
569
+ if session_key:
570
+ body["session_key"] = session_key
571
+ return self._post("/api/memory/feedback", body)
@@ -0,0 +1,16 @@
1
+ name: signet
2
+ version: 1.0.0
3
+ description: "Signet Memory — persistent cross-session memory with hybrid search, knowledge graph, and predictive recall via the Signet daemon."
4
+ hooks:
5
+ - initialize
6
+ - system_prompt_block
7
+ - queue_prefetch
8
+ - prefetch
9
+ - on_turn_start
10
+ - sync_turn
11
+ - on_memory_write
12
+ - on_pre_compress
13
+ - on_compaction_complete
14
+ - on_session_end
15
+ - on_delegation
16
+ - shutdown
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@signetai/connector-hermes-agent",
3
+ "version": "0.140.1",
4
+ "description": "Signet connector for Hermes Agent — installs Signet as a pluggable memory provider",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "hermes-plugin",
17
+ "bin",
18
+ "!dist/**/*.test.*",
19
+ "!dist/**/__tests__/**"
20
+ ],
21
+ "scripts": {
22
+ "build": "bun build src/index.ts --outdir dist --target node --external better-sqlite3 && bun run build:types",
23
+ "build:types": "tsc --emitDeclarationOnly --declaration --outDir dist",
24
+ "dev": "bun --watch src/index.ts",
25
+ "typecheck": "tsc --noEmit"
26
+ },
27
+ "dependencies": {
28
+ "@signetai/connector-base": "0.140.1",
29
+ "@signetai/core": "0.140.1"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.0.0",
33
+ "typescript": "^5.7.0"
34
+ },
35
+ "keywords": [
36
+ "signet",
37
+ "hermes",
38
+ "hermes-agent",
39
+ "connector",
40
+ "ai-memory",
41
+ "agent-identity",
42
+ "memory-provider"
43
+ ],
44
+ "license": "Apache-2.0",
45
+ "bin": {
46
+ "signet-connector-hermes-agent": "bin/install.js"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "https://github.com/Signet-AI/signetai.git"
54
+ },
55
+ "homepage": "https://github.com/Signet-AI/signetai",
56
+ "bugs": {
57
+ "url": "https://github.com/Signet-AI/signetai/issues"
58
+ }
59
+ }