@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.
- package/hermes-plugin/README.md +22 -1
- package/hermes-plugin/__init__.py +422 -39
- package/hermes-plugin/client.py +75 -4
- package/package.json +3 -3
package/hermes-plugin/README.md
CHANGED
|
@@ -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 (
|
|
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 =
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
if
|
|
729
|
-
return
|
|
730
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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.
|
|
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
|
-
|
|
768
|
-
|
|
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
|
|
package/hermes-plugin/client.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
29
|
-
"@signetai/core": "0.
|
|
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",
|