@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,1053 @@
1
+ """Signet memory plugin — MemoryProvider for Signet persistent memory.
2
+
3
+ Bridges Hermes Agent's memory provider interface to the Signet daemon
4
+ (localhost:3850), providing hybrid search (BM25 + vector + knowledge graph),
5
+ predictive recall, cross-session memory, and the full Signet pipeline
6
+ (extraction, knowledge graph, retention decay, synthesis).
7
+
8
+ Canonical Signet memory tools (memory_search, signet_session_search, memory_store,
9
+ memory_get, memory_list, memory_modify, memory_forget, plus recall/remember aliases) are
10
+ exposed through the MemoryProvider interface. The daemon handles all heavy
11
+ lifting: embedding, reranking, knowledge graph traversal, and predictive
12
+ scoring.
13
+
14
+ Config:
15
+ - SIGNET_HOST / SIGNET_PORT env vars (default: localhost:3850)
16
+ - SIGNET_DAEMON_URL env var for full URL override
17
+ - SIGNET_AGENT_ID env var for agent scoping (default: "hermes-agent")
18
+ - SIGNET_AGENT_WORKSPACE env var for the active named-agent workspace
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ import os
26
+ import threading
27
+ from pathlib import Path
28
+ from typing import Any, Dict, List, Optional
29
+
30
+ from agent.memory_provider import MemoryProvider
31
+
32
+ try:
33
+ from .client import SignetClient
34
+ except ImportError: # pragma: no cover — only missing during Hermes bootstrap
35
+ try:
36
+ from plugins.memory.signet.client import SignetClient
37
+ except ImportError:
38
+ SignetClient = None # type: ignore[assignment,misc]
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Tool schemas
45
+ # ---------------------------------------------------------------------------
46
+
47
+ MEMORY_SEARCH_SCHEMA = {
48
+ "name": "memory_search",
49
+ "description": (
50
+ "Search Signet memories using hybrid vector + keyword search. "
51
+ "Ask a natural-language question with entity, event, and timeframe when possible. "
52
+ "Avoid bag-of-keywords queries; use keyword_query only when you intentionally need exact lexical matching."
53
+ ),
54
+ "parameters": {
55
+ "type": "object",
56
+ "properties": {
57
+ "query": {
58
+ "type": "string",
59
+ "description": (
60
+ "Natural-language recall question. Include the relevant entity/person/project, event or decision, "
61
+ "and timeframe when known; avoid diagnostic keyword soup."
62
+ ),
63
+ },
64
+ "limit": {"type": "integer", "description": "Max results to return (default 10, max 50)."},
65
+ "project": {"type": "string", "description": "Optional project path filter."},
66
+ "type": {"type": "string", "description": "Filter by memory type."},
67
+ "tags": {"type": "string", "description": "Filter by tags, comma-separated."},
68
+ "who": {"type": "string", "description": "Filter by author."},
69
+ "since": {"type": "string", "description": "Only include memories created after this date."},
70
+ "until": {"type": "string", "description": "Only include memories created before this date."},
71
+ "keyword_query": {"type": "string", "description": "Override the keyword/FTS query used for recall."},
72
+ "pinned": {"type": "boolean", "description": "Only return pinned memories."},
73
+ "importance_min": {"type": "number", "description": "Minimum memory importance threshold."},
74
+ "min_score": {
75
+ "type": "number",
76
+ "description": "Deprecated compatibility alias for importance_min; ignored when importance_min is set.",
77
+ },
78
+ "score_min": {"type": "number", "description": "Minimum recall score threshold, applied client-side."},
79
+ "aggregate": {
80
+ "type": "boolean",
81
+ "description": "Synthesize an aggregate answer from bounded recall evidence.",
82
+ },
83
+ "aggregate_budget": {
84
+ "type": "string",
85
+ "enum": ["small", "medium", "large"],
86
+ "description": "Aggregate recall budget.",
87
+ },
88
+ "save_aggregate": {
89
+ "type": "boolean",
90
+ "description": "Save aggregate answers as memories.",
91
+ },
92
+ "agent_scoped": {
93
+ "type": "boolean",
94
+ "description": "When true, scope recall to SIGNET_AGENT_ID instead of searching shared effective memory.",
95
+ },
96
+ },
97
+ "required": ["query"],
98
+ },
99
+ }
100
+
101
+ SESSION_SEARCH_SCHEMA = {
102
+ # Hermes reserves `session_search` as a built-in core tool name;
103
+ # registering under that name would be silently dropped by
104
+ # `MemoryManager.add_provider`. The Signet provider exposes the
105
+ # transcript-search tool under the `signet_` namespace instead.
106
+ "name": "signet_session_search",
107
+ "description": "Search active or completed Signet session transcripts.",
108
+ "parameters": {
109
+ "type": "object",
110
+ "properties": {
111
+ "query": {"type": "string", "description": "Natural language or keyword query."},
112
+ "session_key": {"type": "string", "description": "Specific transcript session key to search."},
113
+ "current_session_key": {
114
+ "type": "string",
115
+ "description": "Current session key; sub-agent lineage may resolve this to the parent session.",
116
+ },
117
+ "agent_id": {"type": "string", "description": "Agent scope, default default."},
118
+ "project": {"type": "string", "description": "Optional project path filter."},
119
+ "limit": {"type": "integer", "description": "Max results to return (default 10, max 20)."},
120
+ },
121
+ "required": ["query"],
122
+ },
123
+ }
124
+
125
+ STRUCTURED_ENTITY_SCHEMA = {
126
+ "type": "object",
127
+ "properties": {
128
+ "source": {"type": "string", "description": "Source entity name."},
129
+ "sourceType": {"type": "string", "description": "Optional source entity type."},
130
+ "relationship": {"type": "string", "description": "Relationship from source to target."},
131
+ "target": {"type": "string", "description": "Target entity name."},
132
+ "targetType": {"type": "string", "description": "Optional target entity type."},
133
+ "confidence": {"type": "number", "description": "Optional confidence score 0-1."},
134
+ },
135
+ "required": ["source", "relationship", "target"],
136
+ }
137
+
138
+ STRUCTURED_ATTRIBUTE_SCHEMA = {
139
+ "type": "object",
140
+ "properties": {
141
+ "content": {"type": "string", "description": "Attribute or constraint text."},
142
+ "confidence": {"type": "number", "description": "Optional confidence score 0-1."},
143
+ "importance": {"type": "number", "description": "Optional importance score 0-1."},
144
+ },
145
+ "required": ["content"],
146
+ }
147
+
148
+ STRUCTURED_ASPECT_SCHEMA = {
149
+ "type": "object",
150
+ "properties": {
151
+ "entityName": {"type": "string", "description": "Entity the aspect belongs to."},
152
+ "aspect": {"type": "string", "description": "Aspect name, e.g. preference, workflow, constraint."},
153
+ "attributes": {
154
+ "type": "array",
155
+ "items": STRUCTURED_ATTRIBUTE_SCHEMA,
156
+ "description": "Facts, constraints, or attributes for this aspect.",
157
+ },
158
+ },
159
+ "required": ["entityName", "aspect", "attributes"],
160
+ }
161
+
162
+ MEMORY_STORE_SCHEMA = {
163
+ "name": "memory_store",
164
+ "description": "Save a new memory to Signet.",
165
+ "parameters": {
166
+ "type": "object",
167
+ "properties": {
168
+ "content": {"type": "string", "description": "Memory content to save."},
169
+ "type": {"type": "string", "description": "Memory type, e.g. fact, preference, decision."},
170
+ "importance": {"type": "number", "description": "Importance score 0-1."},
171
+ "tags": {"type": "string", "description": "Comma-separated tags for categorization."},
172
+ "pinned": {"type": "boolean", "description": "Pin this memory so it does not decay."},
173
+ "project": {"type": "string", "description": "Optional project path. Defaults to the active Hermes Signet workspace."},
174
+ "hints": {
175
+ "type": "array",
176
+ "items": {"type": "string"},
177
+ "minItems": 1,
178
+ "description": "Required agent-provided prospective recall hints and alternate phrasings for retrieving this memory later.",
179
+ },
180
+ "transcript": {
181
+ "type": "string",
182
+ "description": "Raw source text or conversation transcript to preserve alongside this memory.",
183
+ },
184
+ "structured": {
185
+ "type": "object",
186
+ "description": "Pre-extracted structured data. When provided, Signet can persist graph links and hints directly.",
187
+ "properties": {
188
+ "entities": {
189
+ "type": "array",
190
+ "items": STRUCTURED_ENTITY_SCHEMA,
191
+ "description": "Entity relationships to link to this memory.",
192
+ },
193
+ "aspects": {
194
+ "type": "array",
195
+ "items": STRUCTURED_ASPECT_SCHEMA,
196
+ "description": "Entity aspects and attributes to persist for graph recall.",
197
+ },
198
+ "hints": {
199
+ "type": "array",
200
+ "items": {"type": "string"},
201
+ "description": "Prospective recall hints and alternate phrasings.",
202
+ },
203
+ },
204
+ },
205
+ },
206
+ "required": ["content", "hints"],
207
+ },
208
+ }
209
+
210
+ MEMORY_GET_SCHEMA = {
211
+ "name": "memory_get",
212
+ "description": "Get a single memory by its ID.",
213
+ "parameters": {
214
+ "type": "object",
215
+ "properties": {"id": {"type": "string", "description": "Memory ID to retrieve."}},
216
+ "required": ["id"],
217
+ },
218
+ }
219
+
220
+ MEMORY_LIST_SCHEMA = {
221
+ "name": "memory_list",
222
+ "description": "List memories with optional filters.",
223
+ "parameters": {
224
+ "type": "object",
225
+ "properties": {
226
+ "limit": {"type": "integer", "description": "Max results to return, default 100."},
227
+ "offset": {"type": "integer", "description": "Pagination offset."},
228
+ "type": {"type": "string", "description": "Filter by memory type."},
229
+ },
230
+ "required": [],
231
+ },
232
+ }
233
+
234
+ MEMORY_MODIFY_SCHEMA = {
235
+ "name": "memory_modify",
236
+ "description": "Edit an existing memory by ID.",
237
+ "parameters": {
238
+ "type": "object",
239
+ "properties": {
240
+ "id": {"type": "string", "description": "Memory ID to modify."},
241
+ "content": {"type": "string", "description": "New content."},
242
+ "type": {"type": "string", "description": "New memory type."},
243
+ "importance": {"type": "number", "description": "New importance score 0-1."},
244
+ "tags": {"type": "string", "description": "New tags, comma-separated."},
245
+ "pinned": {"type": "boolean", "description": "Pin or unpin this memory."},
246
+ "reason": {"type": "string", "description": "Why this edit is being made."},
247
+ },
248
+ "required": ["id", "reason"],
249
+ },
250
+ }
251
+
252
+ MEMORY_FORGET_SCHEMA = {
253
+ "name": "memory_forget",
254
+ "description": "Soft-delete a memory by ID.",
255
+ "parameters": {
256
+ "type": "object",
257
+ "properties": {
258
+ "id": {"type": "string", "description": "Memory ID to forget."},
259
+ "reason": {"type": "string", "description": "Why this memory should be forgotten."},
260
+ },
261
+ "required": ["id", "reason"],
262
+ },
263
+ }
264
+
265
+ RECALL_ALIAS_SCHEMA = {
266
+ "name": "recall",
267
+ "description": "Alias for memory_search. Use the same natural-language query discipline; avoid bag-of-keywords queries.",
268
+ "parameters": MEMORY_SEARCH_SCHEMA["parameters"],
269
+ }
270
+
271
+ REMEMBER_ALIAS_SCHEMA = {
272
+ "name": "remember",
273
+ "description": "Alias for memory_store.",
274
+ "parameters": MEMORY_STORE_SCHEMA["parameters"],
275
+ }
276
+
277
+ ALL_TOOL_SCHEMAS = [
278
+ MEMORY_SEARCH_SCHEMA,
279
+ SESSION_SEARCH_SCHEMA,
280
+ MEMORY_STORE_SCHEMA,
281
+ MEMORY_GET_SCHEMA,
282
+ MEMORY_LIST_SCHEMA,
283
+ MEMORY_MODIFY_SCHEMA,
284
+ MEMORY_FORGET_SCHEMA,
285
+ RECALL_ALIAS_SCHEMA,
286
+ REMEMBER_ALIAS_SCHEMA,
287
+ ]
288
+
289
+ def _sanitize_env(value: str) -> str:
290
+ return value.strip().replace("\r", "").replace("\n", "")
291
+
292
+
293
+ def _resolve_agent_workspace(agent_id: str, kwargs: Dict[str, Any]) -> str:
294
+ """Resolve the project/workspace path sent to Signet hooks.
295
+
296
+ Named Signet agents can have their own workspace at
297
+ $SIGNET_PATH/agents/{agent_id}. Prefer that workspace so daemon
298
+ session-start can load the agent's scoped identity files.
299
+ """
300
+ explicit = _sanitize_env(os.environ.get("SIGNET_AGENT_WORKSPACE", ""))
301
+ if explicit:
302
+ return str(Path(explicit).expanduser())
303
+
304
+ signet_path = _sanitize_env(os.environ.get("SIGNET_PATH", ""))
305
+ agents_root = Path(signet_path).expanduser() if signet_path else Path.home() / ".agents"
306
+ if agent_id and agent_id not in ("default", "hermes-agent"):
307
+ candidate = agents_root / "agents" / agent_id
308
+ if candidate.exists():
309
+ return str(candidate)
310
+
311
+ fallback = kwargs.get("cwd", kwargs.get("project", os.getcwd()))
312
+ return str(Path(str(fallback)).expanduser())
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # MemoryProvider implementation
317
+ # ---------------------------------------------------------------------------
318
+
319
+ class SignetMemoryProvider(MemoryProvider):
320
+ """Signet persistent memory with hybrid search and knowledge graph."""
321
+
322
+ def __init__(self):
323
+ self._client = None # SignetClient
324
+ self._session_key = ""
325
+ self._project = ""
326
+ self._inject_cache = ""
327
+ self._inject_lock = threading.Lock()
328
+ self._prefetch_result = ""
329
+ self._prefetch_lock = threading.Lock()
330
+ self._prefetch_thread: Optional[threading.Thread] = None
331
+ self._prefetch_generation = 0
332
+ self._turn_count = 0
333
+ self._last_user_message = ""
334
+ self._last_assistant_message = ""
335
+ self._transcript_lines: List[str] = []
336
+ self._transcript_lock = threading.Lock()
337
+ self._identity: Optional[Dict[str, Any]] = None
338
+ self._warnings: List[str] = []
339
+ self._session_initialized = False
340
+ # Checkpoint: extract mid-session every N turns
341
+ _CHECKPOINT_INTERVAL = 30
342
+ self._checkpoint_interval = _CHECKPOINT_INTERVAL
343
+ self._last_checkpoint_turn = 0
344
+
345
+ @property
346
+ def name(self) -> str:
347
+ return "signet"
348
+
349
+ def is_available(self) -> bool:
350
+ """Check if the Signet daemon is reachable. No credentials needed."""
351
+ if SignetClient is None:
352
+ logger.debug("Signet is_available(): SignetClient not importable")
353
+ return False
354
+ try:
355
+ return SignetClient().is_available()
356
+ except Exception as err:
357
+ logger.debug("Signet is_available() check failed: %s", err)
358
+ return False
359
+
360
+ def save_config(self, values: Dict[str, Any], hermes_home: str) -> None:
361
+ """Write config to $HERMES_HOME/signet.json."""
362
+ config_path = Path(hermes_home) / "signet.json"
363
+ existing: Dict[str, Any] = {}
364
+ if config_path.exists():
365
+ try:
366
+ existing = json.loads(config_path.read_text())
367
+ except Exception as err:
368
+ logger.warning("Failed to parse %s, overwriting: %s", config_path, err)
369
+ existing.update(values)
370
+ config_path.write_text(json.dumps(existing, indent=2))
371
+
372
+ def get_config_schema(self) -> List[Dict[str, Any]]:
373
+ return [
374
+ {
375
+ "key": "daemon_url",
376
+ "description": "Signet daemon URL",
377
+ "default": "http://localhost:3850",
378
+ "env_var": "SIGNET_DAEMON_URL",
379
+ },
380
+ {
381
+ "key": "agent_id",
382
+ "description": "Agent scope identifier",
383
+ "default": "hermes-agent",
384
+ "env_var": "SIGNET_AGENT_ID",
385
+ },
386
+ ]
387
+
388
+ def initialize(self, session_id: str, **kwargs) -> None:
389
+ """Connect to the Signet daemon and call session-start hook.
390
+
391
+ Retrieves identity, memories, and system prompt injection from
392
+ the daemon. Caches the inject text for system_prompt_block().
393
+ """
394
+ if SignetClient is None:
395
+ logger.warning("Signet plugin: SignetClient not importable — skipping initialization")
396
+ return
397
+
398
+ agent_id = os.environ.get("SIGNET_AGENT_ID", "").strip()
399
+ if not agent_id:
400
+ logger.warning(
401
+ "SIGNET_AGENT_ID is not set; memory will be stored under the 'hermes-agent' "
402
+ "scope. Set SIGNET_AGENT_ID to scope memories to a specific agent."
403
+ )
404
+ agent_id = "hermes-agent"
405
+
406
+ # Skip for cron/flush contexts — no memory injection needed
407
+ agent_context = kwargs.get("agent_context", "")
408
+ platform = kwargs.get("platform", "cli")
409
+ if agent_context in ("cron", "flush") or platform == "cron":
410
+ logger.debug("Signet skipped: cron/flush context")
411
+ return
412
+
413
+ self._client = SignetClient(agent_id=agent_id, harness="hermes-agent")
414
+
415
+ if not self._client.is_available():
416
+ logger.debug("Signet daemon not reachable at %s", self._client.base_url)
417
+ self._client = None
418
+ return
419
+
420
+ self._session_key = session_id or "hermes-default"
421
+ self._project = _resolve_agent_workspace(agent_id, kwargs)
422
+
423
+ # Call session-start hook — get identity + memories + inject
424
+ result = self._client.session_start(
425
+ self._session_key,
426
+ project=self._project,
427
+ )
428
+ if result:
429
+ inject = result.get("inject", "")
430
+ if inject:
431
+ with self._inject_lock:
432
+ self._inject_cache = inject
433
+ # Capture identity and warnings for downstream consumers
434
+ self._identity = result.get("identity")
435
+ self._warnings = result.get("warnings", [])
436
+ self._session_initialized = True
437
+ logger.debug(
438
+ "Signet session-start: %d chars inject, %d memories",
439
+ len(inject),
440
+ len(result.get("memories", [])),
441
+ )
442
+ else:
443
+ logger.debug("Signet session-start returned no data")
444
+
445
+ def system_prompt_block(self) -> str:
446
+ """Return the Signet system prompt injection.
447
+
448
+ On the first call, returns the full session-start inject
449
+ (identity, memories, context). Subsequent calls return a
450
+ minimal header since per-turn recall is handled by prefetch().
451
+ """
452
+ if not self._client:
453
+ return ""
454
+
455
+ with self._inject_lock:
456
+ if self._inject_cache:
457
+ # First call — return full inject and clear cache
458
+ block = self._inject_cache
459
+ self._inject_cache = ""
460
+ return block
461
+
462
+ # Subsequent calls — minimal header
463
+ return (
464
+ "# Signet Memory\n"
465
+ "Active. Memories are auto-recalled each turn via hybrid search. "
466
+ "Use memory_search to query memory, memory_store to save facts, "
467
+ "and memory_get/memory_list/memory_modify/memory_forget for direct "
468
+ "memory management. If Hermes reports Unknown tool for these names, "
469
+ "run `signet doctor hermes` and restart Hermes."
470
+ )
471
+
472
+ def prefetch(self, query: str, *, session_id: str = "") -> str:
473
+ """Return prefetched recall results from background thread."""
474
+ if not self._client:
475
+ return ""
476
+
477
+ if self._prefetch_thread and self._prefetch_thread.is_alive():
478
+ self._prefetch_thread.join(timeout=3.0)
479
+
480
+ with self._prefetch_lock:
481
+ result = self._prefetch_result
482
+ self._prefetch_result = ""
483
+
484
+ return result
485
+
486
+ def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
487
+ """Fire a background recall via user-prompt-submit hook.
488
+
489
+ Also accumulates transcript and sends it for per-turn recall.
490
+ If the daemon reports sessionKnown=false (daemon restarted),
491
+ re-initializes the session.
492
+ """
493
+ if not self._client or not query:
494
+ return
495
+
496
+ # Accumulate transcript for checkpoint/session-end
497
+ with self._transcript_lock:
498
+ self._transcript_lines.append(f"user: {query}")
499
+
500
+ # Capture mutable state before spawning the thread to avoid
501
+ # data races: sync_turn() can update _last_assistant_message
502
+ # concurrently, and shutdown() can null _client.
503
+ client = self._client
504
+ last_assistant = self._last_assistant_message
505
+ with self._prefetch_lock:
506
+ self._prefetch_generation += 1
507
+ self._prefetch_result = ""
508
+ session_key = self._session_key
509
+ project = self._project
510
+ prefetch_generation = self._prefetch_generation
511
+
512
+ def _run():
513
+ try:
514
+ result = client.user_prompt_submit(
515
+ session_key,
516
+ query,
517
+ last_assistant_message=last_assistant,
518
+ project=project,
519
+ )
520
+ if result:
521
+ # Handle daemon restart detection: re-initialize and refresh context.
522
+ # Always return after this branch — result came from a session the
523
+ # daemon no longer recognizes, so its inject would be stale/wrong.
524
+ if not result.get("sessionKnown", True) and self._session_initialized:
525
+ logger.debug("Signet daemon restarted mid-session, re-initializing")
526
+ reinit = client.session_start(
527
+ session_key, project=project,
528
+ )
529
+ if reinit:
530
+ inject_from_reinit = reinit.get("inject", "")
531
+ if inject_from_reinit and inject_from_reinit.strip():
532
+ with self._prefetch_lock:
533
+ if prefetch_generation == self._prefetch_generation and session_key == self._session_key:
534
+ self._prefetch_result = inject_from_reinit
535
+ else:
536
+ logger.warning(
537
+ "Signet re-initialization after daemon restart returned no data; "
538
+ "session context will be missing until next turn"
539
+ )
540
+ return
541
+ inject = result.get("inject", "")
542
+ if inject and inject.strip():
543
+ with self._prefetch_lock:
544
+ if prefetch_generation == self._prefetch_generation and session_key == self._session_key:
545
+ self._prefetch_result = inject
546
+ except Exception as e:
547
+ logger.debug("Signet prefetch failed: %s", e)
548
+
549
+ # Join the previous prefetch thread before starting a new one to prevent
550
+ # a stale turn-N result from overwriting a turn-N+1 cleared prefetch.
551
+ prev_thread = self._prefetch_thread
552
+ if prev_thread and prev_thread.is_alive():
553
+ prev_thread.join(timeout=2.0)
554
+
555
+ self._prefetch_thread = threading.Thread(
556
+ target=_run, daemon=True, name="signet-prefetch"
557
+ )
558
+ self._prefetch_thread.start()
559
+
560
+ def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
561
+ """Track turn count and trigger periodic checkpoint extraction."""
562
+ self._turn_count = turn_number
563
+ self._last_user_message = message
564
+
565
+ # Periodic checkpoint extraction for long-running sessions
566
+ if (
567
+ self._client
568
+ and self._turn_count > 0
569
+ and self._checkpoint_interval > 0
570
+ and (self._turn_count - self._last_checkpoint_turn) >= self._checkpoint_interval
571
+ ):
572
+ self._last_checkpoint_turn = self._turn_count
573
+ self._fire_checkpoint()
574
+
575
+ def sync_turn(
576
+ self, user_content: str, assistant_content: str, *, session_id: str = ""
577
+ ) -> None:
578
+ """Track assistant response and accumulate transcript."""
579
+ self._last_assistant_message = assistant_content
580
+ # Accumulate assistant side of transcript
581
+ if assistant_content:
582
+ with self._transcript_lock:
583
+ self._transcript_lines.append(f"assistant: {assistant_content}")
584
+
585
+ def on_session_switch(
586
+ self,
587
+ new_session_id: str,
588
+ *,
589
+ parent_session_id: str = "",
590
+ reset: bool = False,
591
+ **kwargs: Any,
592
+ ) -> None:
593
+ """Refresh cached session state when Hermes rotates session_id.
594
+
595
+ Hermes Agent keeps memory providers alive across /new, /resume,
596
+ /branch, and compression. Signet caches the active session key and a
597
+ transcript buffer, so update the target session and clear stale buffered
598
+ lines before subsequent writes land in the wrong session.
599
+ """
600
+ if not new_session_id:
601
+ return
602
+ self._session_key = new_session_id
603
+ self._session_initialized = False
604
+ self._turn_count = 0
605
+ self._last_checkpoint_turn = 0
606
+ self._last_user_message = ""
607
+ self._last_assistant_message = ""
608
+ with self._transcript_lock:
609
+ self._transcript_lines = []
610
+ with self._inject_lock:
611
+ self._inject_cache = ""
612
+ with self._prefetch_lock:
613
+ self._prefetch_generation += 1
614
+ self._prefetch_result = ""
615
+
616
+ agent_id = os.environ.get("SIGNET_AGENT_ID", "").strip() or "hermes-agent"
617
+ self._project = _resolve_agent_workspace(agent_id, kwargs)
618
+ client = self._client
619
+ if not client:
620
+ return
621
+
622
+ try:
623
+ result = client.session_start(
624
+ self._session_key,
625
+ project=self._project,
626
+ )
627
+ if result:
628
+ inject = result.get("inject", "")
629
+ if inject and inject.strip():
630
+ with self._inject_lock:
631
+ self._inject_cache = inject
632
+ self._identity = result.get("identity")
633
+ self._warnings = result.get("warnings", [])
634
+ self._session_initialized = True
635
+ except Exception as e:
636
+ logger.debug("Signet session switch failed: %s", e)
637
+
638
+ def on_memory_write(self, action: str, target: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> None:
639
+ """Mirror built-in memory writes to Signet."""
640
+ if action != "add" or not content:
641
+ return
642
+ client = self._client
643
+ if not client:
644
+ return
645
+ project = self._project
646
+ metadata = metadata if isinstance(metadata, dict) else {}
647
+ write_origin = str(metadata.get("write_origin", "") or metadata.get("source", "")).strip()
648
+ execution_context = str(metadata.get("execution_context", "")).strip()
649
+ platform = str(metadata.get("platform", "")).strip()
650
+ source_id = str(metadata.get("tool_call_id", "") or "").strip()
651
+ session_id = str(metadata.get("session_id", "") or self._session_key).strip()
652
+
653
+ def _tag(prefix: str, value: str) -> str:
654
+ clean = value.replace("\n", " ").replace("\r", " ").strip()
655
+ return f"{prefix}:{clean[:80]}" if clean else ""
656
+
657
+ def _write():
658
+ try:
659
+ tags = ["hermes-builtin", target]
660
+ for tag in (
661
+ _tag("origin", write_origin),
662
+ _tag("context", execution_context),
663
+ _tag("platform", platform),
664
+ _tag("session", session_id),
665
+ ):
666
+ if tag:
667
+ tags.append(tag)
668
+ client.remember(
669
+ content,
670
+ importance=0.6,
671
+ tags=tags,
672
+ project=project,
673
+ source_type="hermes-memory-write" if source_id else "",
674
+ source_id=source_id,
675
+ )
676
+ except Exception as e:
677
+ logger.debug("Signet memory mirror failed: %s", e)
678
+
679
+ t = threading.Thread(target=_write, daemon=True, name="signet-memwrite")
680
+ t.start()
681
+
682
+ def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
683
+ """Call session-end hook to trigger memory extraction from transcript."""
684
+ if not self._client:
685
+ return
686
+
687
+ # Prefer accumulated transcript (captures tool calls, etc.),
688
+ # fall back to rebuilding from messages argument
689
+ with self._transcript_lock:
690
+ transcript = "\n\n".join(self._transcript_lines)
691
+
692
+ if not transcript:
693
+ transcript_lines = []
694
+ for msg in messages:
695
+ role = msg.get("role", "unknown")
696
+ content = msg.get("content", "")
697
+ if content:
698
+ transcript_lines.append(f"{role}: {content}")
699
+ transcript = "\n\n".join(transcript_lines)
700
+
701
+ if not transcript:
702
+ return
703
+
704
+ # Truncate to ~100k chars, snapping to the nearest message boundary so
705
+ # the extraction pipeline never receives a partial user/assistant line.
706
+ if len(transcript) > 100_000:
707
+ cutoff = len(transcript) - 100_000
708
+ # Scan forward from the cutoff to the next message boundary
709
+ boundary = transcript.find("\n\nuser: ", cutoff)
710
+ if boundary == -1:
711
+ boundary = transcript.find("\n\nassistant: ", cutoff)
712
+ if boundary != -1:
713
+ transcript = transcript[boundary + 2:] # skip leading \n\n
714
+ else:
715
+ # No boundary found after cutoff; drop the leading fragment
716
+ transcript = transcript[cutoff:]
717
+
718
+ try:
719
+ result = self._client.session_end(
720
+ self._session_key,
721
+ transcript,
722
+ project=self._project,
723
+ )
724
+ if result:
725
+ saved = result.get("memoriesSaved", 0)
726
+ queued = result.get("queued", False)
727
+ job_id = result.get("jobId", "")
728
+ logger.info(
729
+ "Signet session-end: %d saved, queued=%s, jobId=%s",
730
+ saved,
731
+ queued,
732
+ job_id,
733
+ )
734
+ except Exception as e:
735
+ logger.warning("Signet session-end failed: %s", e)
736
+
737
+ def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
738
+ """Called before context compression. Calls the pre-compaction hook
739
+ to get summary guidance, then returns instructions for the compressor."""
740
+ if not self._client:
741
+ return ""
742
+
743
+ try:
744
+ result = self._client.pre_compaction(
745
+ self._session_key,
746
+ session_context=self._last_user_message,
747
+ message_count=len(messages),
748
+ )
749
+ if result:
750
+ prompt = result.get("summaryPrompt", "")
751
+ guidelines = result.get("guidelines", "")
752
+ parts = []
753
+ if prompt:
754
+ parts.append(prompt)
755
+ if guidelines:
756
+ parts.append(guidelines)
757
+ if parts:
758
+ return "\n\n".join(parts)
759
+ except Exception as e:
760
+ logger.debug("Signet pre-compaction failed: %s", e)
761
+
762
+ return (
763
+ "Preserve any explicitly remembered facts, user preferences, "
764
+ "project decisions, and technical context that Signet's memory "
765
+ "system would benefit from retaining."
766
+ )
767
+
768
+ def on_compaction_complete(self, summary: str) -> None:
769
+ """Called after context compression with the generated summary.
770
+
771
+ Forwards to the compaction-complete hook so the daemon can save
772
+ the summary as a session memory and trigger MEMORY.md synthesis.
773
+ """
774
+ if not self._client or not summary:
775
+ return
776
+
777
+ def _run():
778
+ try:
779
+ result = self._client.compaction_complete(
780
+ self._session_key,
781
+ summary,
782
+ project=self._project,
783
+ )
784
+ if result:
785
+ logger.debug(
786
+ "Signet compaction-complete: memoryId=%s",
787
+ result.get("memoryId", ""),
788
+ )
789
+ except Exception as e:
790
+ logger.debug("Signet compaction-complete failed: %s", e)
791
+
792
+ t = threading.Thread(target=_run, daemon=True, name="signet-compact")
793
+ t.start()
794
+
795
+ def on_delegation(self, task: str, result: str, *,
796
+ child_session_id: str = "", **kwargs) -> None:
797
+ """Observe subagent delegation results — store as a memory."""
798
+ client = self._client
799
+ if not client or not result:
800
+ return
801
+ project = self._project
802
+
803
+ content = f"Delegated task: {task[:200]}\nResult: {result[:500]}"
804
+
805
+ def _run():
806
+ try:
807
+ client.remember(
808
+ content,
809
+ importance=0.6,
810
+ tags=["delegation", "subagent"],
811
+ project=project,
812
+ )
813
+ except Exception as e:
814
+ logger.debug("Signet delegation memory failed: %s", e)
815
+
816
+ t = threading.Thread(target=_run, daemon=True, name="signet-delegation")
817
+ t.start()
818
+
819
+ def _fire_checkpoint(self) -> None:
820
+ """Fire a checkpoint-extract for long-running sessions."""
821
+ client = self._client
822
+ if not client:
823
+ return
824
+
825
+ with self._transcript_lock:
826
+ transcript = "\n\n".join(self._transcript_lines)
827
+
828
+ if not transcript or len(transcript) < 500:
829
+ return
830
+
831
+ session_key = self._session_key
832
+ project = self._project
833
+
834
+ def _run():
835
+ try:
836
+ result = client.checkpoint_extract(
837
+ session_key,
838
+ transcript,
839
+ project=project,
840
+ )
841
+ if result:
842
+ logger.debug(
843
+ "Signet checkpoint: queued=%s, jobId=%s",
844
+ result.get("queued", False),
845
+ result.get("jobId", ""),
846
+ )
847
+ except Exception as e:
848
+ logger.debug("Signet checkpoint failed: %s", e)
849
+
850
+ t = threading.Thread(target=_run, daemon=True, name="signet-checkpoint")
851
+ t.start()
852
+
853
+ def get_tool_schemas(self) -> List[Dict[str, Any]]:
854
+ """Return Signet tool schemas.
855
+
856
+ Hermes indexes memory-provider tool dispatch before provider
857
+ initialization. Keep schemas stable even while the daemon is offline;
858
+ handle_tool_call() returns the runtime connectivity error.
859
+ """
860
+ return list(ALL_TOOL_SCHEMAS)
861
+
862
+ def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
863
+ """Handle a Signet tool call."""
864
+ if not self._client:
865
+ return json.dumps({"error": "Signet daemon is not connected."})
866
+
867
+ def _as_int(value: Any, default: int, *, minimum: int = 0, maximum: int = 10_000) -> int:
868
+ try:
869
+ parsed = int(value)
870
+ except (TypeError, ValueError):
871
+ parsed = default
872
+ return max(minimum, min(maximum, parsed))
873
+
874
+ def _as_float(value: Any) -> Optional[float]:
875
+ if value is None or value == "":
876
+ return None
877
+ try:
878
+ return float(value)
879
+ except (TypeError, ValueError):
880
+ return None
881
+
882
+ def _tags(value: Any) -> Optional[List[str]]:
883
+ if value is None or value == "":
884
+ return None
885
+ if isinstance(value, list):
886
+ return [str(t).strip() for t in value if str(t).strip()]
887
+ if isinstance(value, str):
888
+ return [t.strip() for t in value.split(",") if t.strip()]
889
+ return [str(value).strip()] if str(value).strip() else None
890
+
891
+ def _string_list(value: Any) -> Optional[List[str]]:
892
+ if value is None or value == "":
893
+ return None
894
+ if isinstance(value, list):
895
+ items = [str(item).strip() for item in value if str(item).strip()]
896
+ return items or None
897
+ if isinstance(value, str):
898
+ stripped = value.strip()
899
+ return [stripped] if stripped else None
900
+ return None
901
+
902
+ def _search(search_args: Dict[str, Any]) -> str:
903
+ query = str(search_args.get("query", "")).strip()
904
+ if not query:
905
+ return json.dumps({"error": "Missing required parameter: query"})
906
+
907
+ importance_min = _as_float(search_args.get("importance_min"))
908
+ if importance_min is None:
909
+ importance_min = _as_float(search_args.get("min_score"))
910
+
911
+ result = self._client.recall(
912
+ query,
913
+ limit=_as_int(search_args.get("limit"), 10, minimum=1, maximum=50),
914
+ project=str(search_args.get("project", "") or ""),
915
+ memory_type=str(search_args.get("type", "") or ""),
916
+ tags=str(search_args.get("tags", "") or ""),
917
+ who=str(search_args.get("who", "") or ""),
918
+ pinned=search_args.get("pinned") if isinstance(search_args.get("pinned"), bool) else None,
919
+ importance_min=importance_min,
920
+ since=str(search_args.get("since", "") or ""),
921
+ until=str(search_args.get("until", "") or ""),
922
+ keyword_query=str(search_args.get("keyword_query", "") or ""),
923
+ score_min=_as_float(search_args.get("score_min")),
924
+ aggregate=bool(search_args.get("aggregate", False)),
925
+ aggregate_budget=str(search_args.get("aggregate_budget", "") or ""),
926
+ save_aggregate=search_args.get("save_aggregate")
927
+ if isinstance(search_args.get("save_aggregate"), bool)
928
+ else None,
929
+ agent_scoped=bool(search_args.get("agent_scoped", False)),
930
+ )
931
+ if not result:
932
+ return json.dumps({"error": "Search failed or Signet daemon returned no response.", "results": []})
933
+ return json.dumps(result)
934
+
935
+ def _store(store_args: Dict[str, Any]) -> str:
936
+ content = str(store_args.get("content", "")).strip()
937
+ if not content:
938
+ return json.dumps({"error": "Missing required parameter: content"})
939
+ importance = _as_float(store_args.get("importance"))
940
+ if importance is None:
941
+ importance = 0.5
942
+ importance = max(0.0, min(1.0, importance))
943
+ structured = store_args.get("structured")
944
+ if not isinstance(structured, dict):
945
+ structured = None
946
+ hints = _string_list(store_args.get("hints"))
947
+ if not hints:
948
+ return json.dumps({"error": "Missing required parameter: hints"})
949
+ result = self._client.remember(
950
+ content,
951
+ importance=importance,
952
+ tags=_tags(store_args.get("tags")),
953
+ memory_type=str(store_args.get("type", "") or ""),
954
+ pinned=store_args.get("pinned") if isinstance(store_args.get("pinned"), bool) else None,
955
+ project=str(store_args.get("project", "") or self._project),
956
+ hints=hints,
957
+ transcript=str(store_args.get("transcript", "") or ""),
958
+ structured=structured,
959
+ who="hermes-agent",
960
+ )
961
+ if not result:
962
+ return json.dumps({"error": "Failed to store memory."})
963
+ return json.dumps({"result": "Memory saved.", "id": result.get("id", result.get("memoryId", ""))})
964
+
965
+ try:
966
+ if tool_name in ("memory_search", "recall", "signet_search"):
967
+ return _search(args)
968
+
969
+ if tool_name == "signet_session_search":
970
+ query = str(args.get("query", "")).strip()
971
+ if not query:
972
+ return json.dumps({"error": "Missing required parameter: query"})
973
+ result = self._client.session_search(
974
+ query,
975
+ session_key=str(args.get("session_key", "") or ""),
976
+ current_session_key=str(args.get("current_session_key", "") or ""),
977
+ agent_id=str(args.get("agent_id", "") or ""),
978
+ project=str(args.get("project", "") or ""),
979
+ limit=_as_int(args.get("limit"), 10, minimum=1, maximum=20),
980
+ )
981
+ return json.dumps(result if result else {"error": "Session search failed.", "hits": []})
982
+
983
+ if tool_name in ("memory_store", "remember", "signet_store"):
984
+ return _store(args)
985
+
986
+ if tool_name == "signet_profile":
987
+ return _search({"query": "user profile preferences context", "limit": 15})
988
+
989
+ if tool_name == "memory_get":
990
+ memory_id = str(args.get("id", "")).strip()
991
+ if not memory_id:
992
+ return json.dumps({"error": "Missing required parameter: id"})
993
+ result = self._client.get_memory(memory_id)
994
+ return json.dumps(result if result else {"error": "Memory not found."})
995
+
996
+ if tool_name == "memory_list":
997
+ result = self._client.list_memories(
998
+ limit=_as_int(args.get("limit"), 100, minimum=1, maximum=500),
999
+ offset=_as_int(args.get("offset"), 0, minimum=0, maximum=1_000_000),
1000
+ memory_type=str(args.get("type", "") or ""),
1001
+ )
1002
+ return json.dumps(result if result else {"memories": [], "result": "No memories found."})
1003
+
1004
+ if tool_name == "memory_modify":
1005
+ memory_id = str(args.get("id", "")).strip()
1006
+ reason = str(args.get("reason", "")).strip()
1007
+ if not memory_id:
1008
+ return json.dumps({"error": "Missing required parameter: id"})
1009
+ if not reason:
1010
+ return json.dumps({"error": "Missing required parameter: reason"})
1011
+ result = self._client.modify_memory(
1012
+ memory_id,
1013
+ content=str(args.get("content", "") or ""),
1014
+ memory_type=str(args.get("type", "") or ""),
1015
+ importance=_as_float(args.get("importance")),
1016
+ tags=str(args.get("tags", "") or ""),
1017
+ pinned=args.get("pinned") if isinstance(args.get("pinned"), bool) else None,
1018
+ reason=reason,
1019
+ )
1020
+ return json.dumps(result if result else {"error": "Failed to modify memory."})
1021
+
1022
+ if tool_name == "memory_forget":
1023
+ memory_id = str(args.get("id", "")).strip()
1024
+ reason = str(args.get("reason", "")).strip()
1025
+ if not memory_id:
1026
+ return json.dumps({"error": "Missing required parameter: id"})
1027
+ if not reason:
1028
+ return json.dumps({"error": "Missing required parameter: reason"})
1029
+ result = self._client.forget_memory(
1030
+ memory_id,
1031
+ reason=reason,
1032
+ )
1033
+ return json.dumps(result if result else {"error": "Failed to forget memory."})
1034
+
1035
+ return json.dumps({"error": f"Unknown tool: {tool_name}"})
1036
+
1037
+ except Exception as e:
1038
+ logger.error("Signet tool %s failed: %s", tool_name, e)
1039
+ return json.dumps({"error": f"Signet {tool_name} failed: {e}"})
1040
+
1041
+ def shutdown(self) -> None:
1042
+ """Clean shutdown — wait for background threads."""
1043
+ if self._prefetch_thread and self._prefetch_thread.is_alive():
1044
+ self._prefetch_thread.join(timeout=5.0)
1045
+
1046
+
1047
+ # ---------------------------------------------------------------------------
1048
+ # Plugin entry point
1049
+ # ---------------------------------------------------------------------------
1050
+
1051
+ def register(ctx) -> None:
1052
+ """Register Signet as a memory provider plugin."""
1053
+ ctx.register_memory_provider(SignetMemoryProvider())