@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.
- package/bin/install.js +5 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21626 -0
- package/hermes-plugin/README.md +64 -0
- package/hermes-plugin/__init__.py +1053 -0
- package/hermes-plugin/client.py +571 -0
- package/hermes-plugin/plugin.yaml +16 -0
- package/package.json +59 -0
|
@@ -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())
|