@signetai/connector-hermes-agent 0.193.3 → 0.194.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.
@@ -26,7 +26,7 @@ Environment variables:
26
26
  - `SIGNET_HOST` / `SIGNET_PORT` — Host and port separately
27
27
  - `SIGNET_TOKEN` — Optional daemon bearer token; sent to loopback daemon URLs by default
28
28
  - `SIGNET_TRUSTED_DAEMON_ORIGINS` — Comma-separated remote daemon origins allowed to receive `SIGNET_TOKEN`
29
- - `SIGNET_AGENT_ID` — Agent scope identifier (default: `hermes-agent`)
29
+ - `SIGNET_AGENT_ID` — Agent scope identifier (unset: inherit the daemon's configured agent, usually `default`)
30
30
  - `SIGNET_AGENT_WORKSPACE` — Optional named-agent workspace path (for example `~/.agents/agents/dot`)
31
31
  - `SIGNET_AGENT_READ_POLICY` — Optional named-agent memory policy for first registration: `shared` (default), `isolated`, or `group`
32
32
  - `SIGNET_AGENT_POLICY_GROUP` — Required when `SIGNET_AGENT_READ_POLICY=group`
@@ -63,3 +63,24 @@ The plugin bridges Hermes Agent's memory lifecycle to the Signet daemon:
63
63
  3. **Session end** — Sends a transcript with internal Signet memory delimiters removed to Signet's session-end hook, which queues it for the memory pipeline: extraction, knowledge graph updates, retention decay, and MEMORY.md synthesis.
64
64
 
65
65
  4. **Explicit tools** — The agent can call canonical Signet tools such as `memory_search` and `memory_store` directly during conversation for on-demand memory operations. Legacy `signet_*` names are handled for compatibility but are not advertised to the model.
66
+
67
+ ## Built-in memory synchronization
68
+
69
+ The plugin uses a **synchronization** model for Hermes's built-in
70
+ `on_memory_write()` hook:
71
+
72
+ - `add` creates an immutable episodic Signet row tagged with
73
+ `hermes-builtin` and the Hermes target (`memory` or `user`).
74
+ - `replace` creates a new episodic row and atomically supersedes the mirrored
75
+ row identified by Hermes's `old_text`.
76
+ - `remove` soft-deletes the matching mirrored row.
77
+
78
+ Superseded and soft-deleted rows remain available as historical evidence and
79
+ audit history, but Signet's current recall/list views exclude them. The
80
+ connector queues hook callbacks on one FIFO worker, so operations emitted for
81
+ an already-committed Hermes batch retain their order. Each operation carries
82
+ session, project, agent, source, and a deterministic idempotency key. Mirror
83
+ rows use Signet's default `global` visibility, matching the connector's prior
84
+ write contract; agent and project filters still bound lookup and mutation.
85
+ Retrying the same callback does not create a second current row. A failed
86
+ lookup never falls back to mutating an untagged Signet memory.
@@ -20,12 +20,15 @@ Config:
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
+ import hashlib
23
24
  import json
24
25
  import logging
25
26
  import os
26
27
  import re
27
28
  import threading
29
+ import time
28
30
  from pathlib import Path
31
+ from queue import Empty, Queue
29
32
  from typing import Any, Dict, List, Optional
30
33
 
31
34
  from agent.memory_provider import MemoryProvider
@@ -302,6 +305,11 @@ ALL_TOOL_SCHEMAS = [
302
305
  REMEMBER_ALIAS_SCHEMA,
303
306
  ]
304
307
 
308
+ HERMES_MEMORY_SOURCE_TYPE = "hermes-memory-write"
309
+ HERMES_MEMORY_TAG = "hermes-builtin"
310
+ MIRROR_SEARCH_LIMIT = 100
311
+
312
+
305
313
  def _sanitize_env(value: str) -> str:
306
314
  return value.strip().replace("\r", "").replace("\n", "")
307
315
 
@@ -337,6 +345,7 @@ class SignetMemoryProvider(MemoryProvider):
337
345
 
338
346
  def __init__(self):
339
347
  self._client = None # SignetClient
348
+ self._agent_id = ""
340
349
  self._session_key = ""
341
350
  self._project = ""
342
351
  self._inject_cache = ""
@@ -362,6 +371,13 @@ class SignetMemoryProvider(MemoryProvider):
362
371
  _CHECKPOINT_INTERVAL = 30
363
372
  self._checkpoint_interval = _CHECKPOINT_INTERVAL
364
373
  self._last_checkpoint_turn = 0
374
+ # Hermes calls on_memory_write once per committed operation, including
375
+ # once for each operation in an atomic batch. Keep one FIFO worker so
376
+ # replace/remove cannot overtake the add that established their target.
377
+ self._mirror_queue: Queue = Queue()
378
+ self._mirror_worker: Optional[threading.Thread] = None
379
+ self._mirror_state_lock = threading.Lock()
380
+ self._mirror_shutdown = False
365
381
 
366
382
  @property
367
383
  def name(self) -> str:
@@ -432,6 +448,10 @@ class SignetMemoryProvider(MemoryProvider):
432
448
  # (its own SIGNET_AGENT_ID, or 'default' for the default workspace).
433
449
  logger.debug("SIGNET_AGENT_ID is not set; the daemon's configured agent scope applies.")
434
450
 
451
+ self._agent_id = agent_id
452
+ with self._mirror_state_lock:
453
+ self._mirror_shutdown = False
454
+
435
455
  # Skip for cron/flush contexts — no memory injection needed
436
456
  agent_context = kwargs.get("agent_context", "")
437
457
  platform = kwargs.get("platform", "cli")
@@ -698,7 +718,7 @@ class SignetMemoryProvider(MemoryProvider):
698
718
  self._prefetch_result = ""
699
719
  self._notification_result = ""
700
720
 
701
- agent_id = os.environ.get("SIGNET_AGENT_ID", "").strip() or "hermes-agent"
721
+ agent_id = self._agent_id
702
722
  self._project = _resolve_agent_workspace(agent_id, kwargs)
703
723
  client = self._client
704
724
  if not client:
@@ -723,49 +743,406 @@ class SignetMemoryProvider(MemoryProvider):
723
743
  except Exception as e:
724
744
  logger.debug("Signet session switch failed: %s", e)
725
745
 
726
- def on_memory_write(self, action: str, target: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> None:
727
- """Mirror built-in memory writes to Signet."""
728
- if action != "add" or not content:
729
- return
730
- client = self._client
746
+ @staticmethod
747
+ def _mirror_tags(raw: Any) -> set[str]:
748
+ if isinstance(raw, list):
749
+ return {str(tag).strip() for tag in raw if str(tag).strip()}
750
+ if isinstance(raw, str):
751
+ return {tag.strip() for tag in raw.split(",") if tag.strip()}
752
+ return set()
753
+
754
+ @staticmethod
755
+ def _mirror_tag(prefix: str, value: str) -> str:
756
+ clean = value.replace("\n", " ").replace("\r", " ").strip()
757
+ return f"{prefix}:{clean[:80]}" if clean else ""
758
+
759
+ def _mirror_operation_details(
760
+ self,
761
+ action: str,
762
+ target: str,
763
+ content: str,
764
+ metadata: Dict[str, Any],
765
+ ) -> Dict[str, str]:
766
+ """Derive durable, retry-stable identity for one Hermes write."""
767
+ old_text = str(metadata.get("old_text", "") or "").strip()
768
+ session_id = str(
769
+ metadata.get("session_id", "")
770
+ or metadata.get("_mirror_session_key", "")
771
+ or self._session_key
772
+ ).strip()
773
+ parent_session_id = str(metadata.get("parent_session_id", "") or "").strip()
774
+ tool_call_id = str(metadata.get("tool_call_id", "") or "").strip()
775
+ project = str(metadata.get("_mirror_project", self._project) or "").strip()
776
+ agent_id = str(metadata.get("_mirror_agent_id", self._agent_id) or "").strip()
777
+ seed = "\0".join(
778
+ (
779
+ agent_id,
780
+ project,
781
+ session_id,
782
+ parent_session_id,
783
+ target,
784
+ action,
785
+ old_text,
786
+ content,
787
+ tool_call_id,
788
+ )
789
+ )
790
+ digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()
791
+ operation_key = f"hermes-memory-write:{digest}"
792
+
793
+ # Preserve Hermes's tool-call id as the source id for the common add
794
+ # path. Replacements get an operation-specific suffix because every
795
+ # operation in a Hermes batch shares one tool-call id.
796
+ if tool_call_id and action == "add":
797
+ source_id = tool_call_id
798
+ elif tool_call_id:
799
+ source_id = f"{tool_call_id}:{action}:{digest[:16]}"
800
+ else:
801
+ source_id = f"hermes-memory-write:{digest}"
802
+
803
+ return {
804
+ "old_text": old_text,
805
+ "session_id": session_id,
806
+ "request_id": operation_key,
807
+ "source_id": source_id,
808
+ "idempotency_key": operation_key,
809
+ "mirror_tag": f"mirror:{digest[:24]}",
810
+ "project": project,
811
+ "agent_id": agent_id,
812
+ }
813
+
814
+ def _mirror_write_tags(
815
+ self,
816
+ target: str,
817
+ metadata: Dict[str, Any],
818
+ operation_tag: str,
819
+ session_id: str,
820
+ ) -> List[str]:
821
+ tags = [HERMES_MEMORY_TAG, target, operation_tag]
822
+ for tag in (
823
+ self._mirror_tag(
824
+ "origin",
825
+ str(metadata.get("write_origin", "") or metadata.get("source", "")),
826
+ ),
827
+ self._mirror_tag("context", str(metadata.get("execution_context", "") or "")),
828
+ self._mirror_tag("platform", str(metadata.get("platform", "") or "")),
829
+ self._mirror_tag("session", session_id),
830
+ self._mirror_tag("parent-session", str(metadata.get("parent_session_id", "") or "")),
831
+ self._mirror_tag("tool", str(metadata.get("tool_name", "") or "")),
832
+ ):
833
+ if tag:
834
+ tags.append(tag)
835
+ return tags
836
+
837
+ def _find_mirrored_entries(
838
+ self,
839
+ query: str,
840
+ target: str,
841
+ *,
842
+ client: Any = None,
843
+ project: str = "",
844
+ source_id: str = "",
845
+ operation_tag: str = "",
846
+ ) -> List[Dict[str, Any]]:
847
+ """Find active Hermes rows without crossing project/agent boundaries."""
848
+ client = client or self._client
849
+ if not client:
850
+ return []
851
+ scoped_project = project or self._project
852
+ rows = client.search(
853
+ query,
854
+ limit=MIRROR_SEARCH_LIMIT,
855
+ tags=HERMES_MEMORY_TAG,
856
+ project=scoped_project,
857
+ )
858
+
859
+ matches: List[Dict[str, Any]] = []
860
+ for row in rows or []:
861
+ if not isinstance(row, dict):
862
+ continue
863
+ content = str(row.get("content", "") or "")
864
+ tags = self._mirror_tags(row.get("tags"))
865
+ row_source_id = str(row.get("source_id", "") or row.get("sourceId", "") or "")
866
+ if HERMES_MEMORY_TAG not in tags or target not in tags:
867
+ continue
868
+ if source_id and row_source_id != source_id and operation_tag not in tags:
869
+ continue
870
+ if operation_tag and operation_tag not in tags:
871
+ continue
872
+ if query and query not in content:
873
+ continue
874
+ matches.append(row)
875
+ return matches
876
+
877
+ def _remember_mirror(
878
+ self,
879
+ content: str,
880
+ *,
881
+ client: Any = None,
882
+ tags: List[str],
883
+ project: str,
884
+ source_id: str,
885
+ operation_key: str,
886
+ visibility: str = "global",
887
+ supersedes: str = "",
888
+ reason: str = "",
889
+ session_id: str = "",
890
+ request_id: str = "",
891
+ ) -> Optional[Dict[str, Any]]:
892
+ """Write one mirror row, retrying a source-id collision safely."""
893
+ client = client or self._client
894
+ if not client:
895
+ return None
896
+ result = client.remember(
897
+ content,
898
+ importance=0.6,
899
+ tags=tags,
900
+ project=project,
901
+ visibility=visibility,
902
+ source_type=HERMES_MEMORY_SOURCE_TYPE,
903
+ source_id=source_id,
904
+ idempotency_key=operation_key,
905
+ supersedes=supersedes,
906
+ reason=reason,
907
+ session_id=session_id,
908
+ request_id=request_id,
909
+ )
910
+ if not result or not isinstance(result, dict):
911
+ return result
912
+
913
+ # A tool-call id is shared by every operation in a Hermes batch. If an
914
+ # earlier add claimed that source id, retry the new operation with its
915
+ # content-derived id rather than accepting a false dedupe.
916
+ returned_content = str(result.get("content", "") or "").strip()
917
+ if result.get("deduped") is True and returned_content and returned_content != content.strip():
918
+ fallback_source_id = f"{source_id}:{operation_key[-16:]}"
919
+ return client.remember(
920
+ content,
921
+ importance=0.6,
922
+ tags=tags,
923
+ project=project,
924
+ visibility=visibility,
925
+ source_type=HERMES_MEMORY_SOURCE_TYPE,
926
+ source_id=fallback_source_id,
927
+ idempotency_key=operation_key,
928
+ supersedes=supersedes,
929
+ reason=reason,
930
+ session_id=session_id,
931
+ request_id=request_id,
932
+ )
933
+ return result
934
+
935
+ def _mirror_operation(
936
+ self,
937
+ client: Any,
938
+ action: str,
939
+ target: str,
940
+ content: str,
941
+ metadata: Dict[str, Any],
942
+ ) -> None:
731
943
  if not client:
732
944
  return
733
- project = self._project
734
- metadata = metadata if isinstance(metadata, dict) else {}
735
- write_origin = str(metadata.get("write_origin", "") or metadata.get("source", "")).strip()
736
- execution_context = str(metadata.get("execution_context", "")).strip()
737
- platform = str(metadata.get("platform", "")).strip()
738
- source_id = str(metadata.get("tool_call_id", "") or "").strip()
739
- session_id = str(metadata.get("session_id", "") or self._session_key).strip()
740
-
741
- def _tag(prefix: str, value: str) -> str:
742
- clean = value.replace("\n", " ").replace("\r", " ").strip()
743
- return f"{prefix}:{clean[:80]}" if clean else ""
744
-
745
- def _write():
746
- try:
747
- tags = ["hermes-builtin", target]
748
- for tag in (
749
- _tag("origin", write_origin),
750
- _tag("context", execution_context),
751
- _tag("platform", platform),
752
- _tag("session", session_id),
753
- ):
754
- if tag:
755
- tags.append(tag)
756
- client.remember(
757
- content,
758
- importance=0.6,
759
- tags=tags,
760
- project=project,
761
- source_type="hermes-memory-write" if source_id else "",
762
- source_id=source_id,
945
+
946
+ details = self._mirror_operation_details(action, target, content, metadata)
947
+ old_text = details["old_text"]
948
+ session_id = details["session_id"]
949
+ source_id = details["source_id"]
950
+ operation_key = details["idempotency_key"]
951
+ operation_tag = details["mirror_tag"]
952
+ tags = self._mirror_write_tags(target, metadata, operation_tag, session_id)
953
+ request_id = details["request_id"]
954
+ project = details["project"]
955
+
956
+ if action == "add":
957
+ result = self._remember_mirror(
958
+ content,
959
+ client=client,
960
+ tags=tags,
961
+ project=project,
962
+ source_id=source_id,
963
+ operation_key=operation_key,
964
+ session_id=session_id,
965
+ request_id=request_id,
966
+ )
967
+ if result is None:
968
+ logger.warning("Signet Hermes add mirror returned no response")
969
+ return
970
+
971
+ if not old_text:
972
+ logger.warning("Signet Hermes %s mirror skipped: old_text was not supplied", action)
973
+ return
974
+
975
+ if action == "replace":
976
+ if not content:
977
+ logger.warning("Signet Hermes replace mirror skipped: content was empty")
978
+ return
979
+
980
+ # A completed replace is found by its operation tag/source id. This
981
+ # makes a retry a no-op even though the old row is no longer in the
982
+ # current recall view.
983
+ completed = self._find_mirrored_entries(
984
+ content,
985
+ target,
986
+ client=client,
987
+ project=project,
988
+ source_id=source_id,
989
+ operation_tag=operation_tag,
990
+ )
991
+ if completed:
992
+ return
993
+
994
+ matches = self._find_mirrored_entries(old_text, target, client=client, project=project)
995
+ distinct_contents = {str(row.get("content", "")) for row in matches}
996
+ if len(distinct_contents) > 1:
997
+ logger.warning(
998
+ "Signet Hermes replace mirror skipped: old_text matched multiple mirrored entries"
763
999
  )
1000
+ return
1001
+ if not matches:
1002
+ # The old row may already have been superseded by a prior
1003
+ # delivery. No current stale row can be reintroduced.
1004
+ logger.debug("Signet Hermes replace mirror found no active source row")
1005
+ return
1006
+
1007
+ old_id = str(matches[0].get("id", "") or "").strip()
1008
+ if not old_id:
1009
+ logger.warning("Signet Hermes replace mirror skipped: matched row had no id")
1010
+ return
1011
+ result = self._remember_mirror(
1012
+ content,
1013
+ client=client,
1014
+ tags=tags,
1015
+ project=project,
1016
+ source_id=source_id,
1017
+ operation_key=operation_key,
1018
+ supersedes=old_id,
1019
+ reason="Hermes built-in memory replacement",
1020
+ session_id=session_id,
1021
+ request_id=request_id,
1022
+ )
1023
+ if not result:
1024
+ logger.warning("Signet Hermes replace mirror returned no response")
1025
+ return
1026
+
1027
+ # A content/source dedupe can return an existing current row before
1028
+ # the daemon sees `supersedes`. Link that row explicitly so the old
1029
+ # Hermes entry still cannot remain current.
1030
+ if result.get("deduped") is True:
1031
+ replacement_id = str(result.get("id", "") or "").strip()
1032
+ if replacement_id and replacement_id != old_id and hasattr(client, "supersede_memory"):
1033
+ linked = client.supersede_memory(
1034
+ old_id,
1035
+ replacement_id,
1036
+ reason="Hermes built-in memory replacement",
1037
+ session_id=session_id,
1038
+ request_id=request_id,
1039
+ )
1040
+ if linked is None:
1041
+ logger.warning("Signet Hermes replace mirror could not link a deduped replacement")
1042
+ return
1043
+
1044
+ if action == "remove":
1045
+ matches = self._find_mirrored_entries(old_text, target, client=client, project=project)
1046
+ distinct_contents = {str(row.get("content", "")) for row in matches}
1047
+ if len(distinct_contents) > 1:
1048
+ logger.warning(
1049
+ "Signet Hermes remove mirror skipped: old_text matched multiple mirrored entries"
1050
+ )
1051
+ return
1052
+ if not matches:
1053
+ # Soft-delete is idempotent from the current-view perspective:
1054
+ # a prior delivery already removed this row, or it was never
1055
+ # mirrored. In neither case should a stale row be recreated.
1056
+ return
1057
+ memory_id = str(matches[0].get("id", "") or "").strip()
1058
+ if not memory_id:
1059
+ logger.warning("Signet Hermes remove mirror skipped: matched row had no id")
1060
+ return
1061
+ result = client.forget_memory(
1062
+ memory_id,
1063
+ reason="Hermes built-in memory removal",
1064
+ session_id=session_id,
1065
+ request_id=request_id,
1066
+ )
1067
+ if result is None:
1068
+ logger.warning("Signet Hermes remove mirror returned no response")
1069
+
1070
+ def _mirror_worker_loop(self) -> None:
1071
+ while True:
1072
+ try:
1073
+ client, action, target, content, metadata = self._mirror_queue.get(timeout=0.1)
1074
+ except Empty:
1075
+ with self._mirror_state_lock:
1076
+ if self._mirror_shutdown or self._mirror_queue.empty():
1077
+ self._mirror_worker = None
1078
+ return
1079
+ continue
1080
+ try:
1081
+ self._mirror_operation(client, action, target, content, metadata)
764
1082
  except Exception as e:
765
- logger.debug("Signet memory mirror failed: %s", e)
1083
+ logger.warning(
1084
+ "Signet Hermes memory mirror failed for %s/%s: %s",
1085
+ action,
1086
+ target,
1087
+ e,
1088
+ )
1089
+ finally:
1090
+ self._mirror_queue.task_done()
766
1091
 
767
- t = threading.Thread(target=_write, daemon=True, name="signet-memwrite")
768
- t.start()
1092
+ def flush_mirror_writes(self, timeout: float = 5.0) -> bool:
1093
+ """Wait for queued mirror operations, primarily for shutdown/tests."""
1094
+ deadline = time.monotonic() + max(0.0, timeout)
1095
+ while self._mirror_queue.unfinished_tasks > 0 and time.monotonic() < deadline:
1096
+ time.sleep(0.01)
1097
+ return self._mirror_queue.unfinished_tasks == 0
1098
+
1099
+ def on_memory_write(
1100
+ self,
1101
+ action: str,
1102
+ target: str,
1103
+ content: str,
1104
+ metadata: Optional[Dict[str, Any]] = None,
1105
+ ) -> None:
1106
+ """Mirror committed Hermes memory writes in FIFO order.
1107
+
1108
+ Hermes only calls this after the built-in memory tool commits. The
1109
+ single daemon worker therefore preserves the order of atomic batch
1110
+ operations without blocking the agent turn on a network request.
1111
+ """
1112
+ if not isinstance(action, str) or not isinstance(target, str) or not isinstance(content, str):
1113
+ logger.warning("Signet Hermes memory mirror skipped malformed operation")
1114
+ return
1115
+ action = action.strip()
1116
+ target = target.strip()
1117
+ content = content.strip()
1118
+ if action not in {"add", "replace", "remove"}:
1119
+ return
1120
+ if action in {"add", "replace"} and not content:
1121
+ return
1122
+ if target not in {"memory", "user"}:
1123
+ logger.warning("Signet Hermes memory mirror skipped unknown target: %s", target)
1124
+ return
1125
+ client = self._client
1126
+ if not client:
1127
+ return
1128
+ snapshot = dict(metadata) if isinstance(metadata, dict) else {}
1129
+ snapshot["_mirror_project"] = self._project
1130
+ snapshot["_mirror_agent_id"] = self._agent_id or str(
1131
+ getattr(client, "_agent_id", "") or ""
1132
+ )
1133
+ snapshot["_mirror_session_key"] = self._session_key
1134
+ with self._mirror_state_lock:
1135
+ if self._mirror_shutdown:
1136
+ logger.debug("Signet Hermes memory mirror rejected after shutdown")
1137
+ return
1138
+ self._mirror_queue.put((client, action, target, content, snapshot))
1139
+ if self._mirror_worker is None or not self._mirror_worker.is_alive():
1140
+ self._mirror_worker = threading.Thread(
1141
+ target=self._mirror_worker_loop,
1142
+ daemon=True,
1143
+ name="signet-memwrite-serial",
1144
+ )
1145
+ self._mirror_worker.start()
769
1146
 
770
1147
  def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
771
1148
  """Call session-end hook to trigger memory extraction from transcript."""
@@ -1134,6 +1511,12 @@ class SignetMemoryProvider(MemoryProvider):
1134
1511
 
1135
1512
  def shutdown(self) -> None:
1136
1513
  """Clean shutdown — wait for background threads."""
1514
+ with self._mirror_state_lock:
1515
+ self._mirror_shutdown = True
1516
+ mirror_worker = self._mirror_worker
1517
+ self.flush_mirror_writes(timeout=5.0)
1518
+ if mirror_worker and mirror_worker.is_alive():
1519
+ mirror_worker.join(timeout=0.1)
1137
1520
  if self._prefetch_thread and self._prefetch_thread.is_alive():
1138
1521
  self._prefetch_thread.join(timeout=5.0)
1139
1522
 
@@ -194,6 +194,22 @@ class SignetClient:
194
194
  h.update(extra)
195
195
  return h
196
196
 
197
+ @staticmethod
198
+ def _mutation_context_headers(
199
+ *,
200
+ session_id: str = "",
201
+ request_id: str = "",
202
+ ) -> Optional[Dict[str, str]]:
203
+ """Build optional audit headers for a mirrored memory mutation."""
204
+ headers: Dict[str, str] = {}
205
+ clean_session_id = _sanitize(session_id)
206
+ clean_request_id = _sanitize(request_id)
207
+ if clean_session_id:
208
+ headers["x-signet-session-id"] = clean_session_id
209
+ if clean_request_id:
210
+ headers["x-signet-request-id"] = clean_request_id
211
+ return headers or None
212
+
197
213
  def _post(
198
214
  self,
199
215
  path: str,
@@ -261,10 +277,11 @@ class SignetClient:
261
277
  path: str,
262
278
  *,
263
279
  timeout: float = _TIMEOUT_SECS,
280
+ extra_headers: Optional[Dict[str, str]] = None,
264
281
  ) -> Optional[Dict[str, Any]]:
265
282
  """DELETE from the daemon. Returns parsed response or None on failure."""
266
283
  url = f"{self._base_url}{path}"
267
- req = urllib.request.Request(url, headers=self._headers(), method="DELETE")
284
+ req = urllib.request.Request(url, headers=self._headers(extra_headers), method="DELETE")
268
285
  try:
269
286
  with urllib.request.urlopen(req, timeout=timeout) as resp:
270
287
  return _read_json_response(resp)
@@ -449,6 +466,12 @@ class SignetClient:
449
466
  structured: Optional[Dict[str, Any]] = None,
450
467
  review_after: str = "",
451
468
  who: str = "hermes-agent",
469
+ visibility: str = "",
470
+ supersedes: str = "",
471
+ reason: str = "",
472
+ idempotency_key: str = "",
473
+ session_id: str = "",
474
+ request_id: str = "",
452
475
  ) -> Optional[Dict[str, Any]]:
453
476
  """Store a memory via the daemon API."""
454
477
  body: Dict[str, Any] = {
@@ -478,6 +501,22 @@ class SignetClient:
478
501
  body["structured"] = structured
479
502
  if review_after:
480
503
  body["reviewAfter"] = review_after
504
+ if visibility in {"global", "private", "archived"}:
505
+ body["visibility"] = visibility
506
+ if supersedes:
507
+ body["supersedes"] = supersedes
508
+ if reason:
509
+ body["reason"] = reason
510
+ if idempotency_key:
511
+ body["idempotencyKey"] = idempotency_key
512
+ context_headers = self._mutation_context_headers(session_id=session_id, request_id=request_id)
513
+ if context_headers:
514
+ return self._post(
515
+ "/api/memory/remember",
516
+ body,
517
+ timeout=_LONG_TIMEOUT_SECS,
518
+ extra_headers=context_headers,
519
+ )
481
520
  return self._post("/api/memory/remember", body, timeout=_LONG_TIMEOUT_SECS)
482
521
 
483
522
  def recall(
@@ -611,10 +650,36 @@ class SignetClient:
611
650
  memory_id: str,
612
651
  *,
613
652
  reason: str,
653
+ session_id: str = "",
654
+ request_id: str = "",
614
655
  ) -> Optional[Dict[str, Any]]:
615
656
  """Soft-delete a memory by ID."""
616
657
  params = urllib.parse.urlencode({"reason": reason})
617
- return self._delete(f"/api/memory/{urllib.parse.quote(memory_id)}?{params}")
658
+ path = f"/api/memory/{urllib.parse.quote(memory_id, safe='')}?{params}"
659
+ context_headers = self._mutation_context_headers(session_id=session_id, request_id=request_id)
660
+ if context_headers:
661
+ return self._delete(path, extra_headers=context_headers)
662
+ return self._delete(path)
663
+
664
+ def supersede_memory(
665
+ self,
666
+ memory_id: str,
667
+ superseded_by: str,
668
+ *,
669
+ reason: str,
670
+ session_id: str = "",
671
+ request_id: str = "",
672
+ ) -> Optional[Dict[str, Any]]:
673
+ """Mark one memory as superseded by another in an audited transaction."""
674
+ path = f"/api/memories/{urllib.parse.quote(memory_id, safe='')}/supersede"
675
+ body = {
676
+ "superseded_by": superseded_by,
677
+ "reason": reason,
678
+ }
679
+ context_headers = self._mutation_context_headers(session_id=session_id, request_id=request_id)
680
+ if context_headers:
681
+ return self._post(path, body, extra_headers=context_headers)
682
+ return self._post(path, body)
618
683
 
619
684
  def search(
620
685
  self,
@@ -622,11 +687,17 @@ class SignetClient:
622
687
  *,
623
688
  limit: int = 10,
624
689
  memory_type: str = "",
690
+ tags: str = "",
691
+ project: str = "",
625
692
  ) -> List[Dict[str, Any]]:
626
693
  """Search memories. Returns list of memory objects."""
627
- params = f"?q={urllib.parse.quote(query)}&limit={limit}"
694
+ params = f"?q={urllib.parse.quote(query, safe='')}&limit={limit}"
628
695
  if memory_type:
629
- params += f"&type={urllib.parse.quote(memory_type)}"
696
+ params += f"&type={urllib.parse.quote(memory_type, safe='')}"
697
+ if tags:
698
+ params += f"&tags={urllib.parse.quote(tags, safe='')}"
699
+ if project:
700
+ params += f"&project={urllib.parse.quote(project, safe='')}"
630
701
  result = self._get(f"/api/memory/search{params}")
631
702
  if result and isinstance(result, dict):
632
703
  return result.get("results", result.get("memories", []))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signetai/connector-hermes-agent",
3
- "version": "0.193.3",
3
+ "version": "0.194.1",
4
4
  "description": "Signet connector for Hermes Agent — installs Signet as a pluggable memory provider",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -25,8 +25,8 @@
25
25
  "typecheck": "tsc --noEmit"
26
26
  },
27
27
  "dependencies": {
28
- "@signetai/connector-base": "0.193.3",
29
- "@signetai/core": "0.193.3"
28
+ "@signetai/connector-base": "0.194.1",
29
+ "@signetai/core": "0.194.1"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.0.0",