-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathplugin_init.py
More file actions
3490 lines (3252 loc) · 140 KB
/
Copy pathplugin_init.py
File metadata and controls
3490 lines (3252 loc) · 140 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
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
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""tracedecay Hermes plugin registration."""
import json
import hashlib
import logging
import os
import re
import shutil
import threading
import time
from pathlib import Path
from . import schemas, tools
logger = logging.getLogger(__name__)
# Canonical profile-home resolver (hermes_constants), with the legacy
# hermes_cli.config location as fallback; both guarded so the plugin still
# imports outside a Hermes install.
try:
from hermes_constants import get_hermes_home as _hermes_get_hermes_home
except Exception:
try:
from hermes_cli.config import get_hermes_home as _hermes_get_hermes_home
except Exception:
_hermes_get_hermes_home = None
# Canonical config read/write path for provider save_config(); guarded for
# use outside Hermes (raw-YAML fallback below).
try:
from hermes_cli import config as _hermes_cli_config
except Exception:
_hermes_cli_config = None
try:
from agent.memory_provider import MemoryProvider
except Exception:
class MemoryProvider:
pass
try:
from agent.context_engine import ContextEngine
except Exception:
class ContextEngine:
pass
# Hermes' centralized auxiliary LLM facade is the MODULE-LEVEL
# agent.auxiliary_client.call_llm(task=..., messages=..., ...) — AIAgent
# instances carry no ``auxiliary_client`` attribute and no host call site
# hands the plugin an agent object. Guarded so the plugin still degrades
# gracefully (deterministic fallback summaries) outside a hermes install.
try:
from agent import auxiliary_client as _hermes_auxiliary_client
except Exception:
_hermes_auxiliary_client = None
def _resolve_auxiliary_client(agent=None):
"""Best auxiliary LLM client: an agent-attached one, else hermes' module-level facade."""
client = getattr(agent, "auxiliary_client", None)
if client is not None and callable(getattr(client, "call_llm", None)):
return client
if _hermes_auxiliary_client is not None and callable(
getattr(_hermes_auxiliary_client, "call_llm", None)
):
return _hermes_auxiliary_client
return None
MEMORY_FACT_ACTIONS = {
"fact_add": "add",
"fact_search": "search",
"fact_probe": "probe",
"fact_related": "related",
"fact_reason": "reason",
"fact_contradict": "contradict",
"fact_update": "update",
"fact_remove": "remove",
"fact_list": "list",
}
MEMORY_ACTION_DESCRIPTIONS = {
"fact_add": (
"Add a holographic memory fact. The result includes a write-time diff "
"report (diff/closest_fact_id/similarity/reason): 'near_duplicate' "
"means a very similar fact already exists (consider updating it "
"instead), 'possible_conflict' means a negation/state-change cue "
"suggests supersession (confirm which fact is current), and "
"'rejected_secret_like' means the content looked like a credential "
"and was NOT stored. Calibrate trust instead of defaulting high: "
"reserve >=0.85 for verified/durable facts, use ~0.7 for ordinary "
"observations and ~0.5 when unsure - aim for a spread across facts."
),
"fact_search": (
"Search holographic memory facts by query. Recall memory FIRST "
"before reaching for external or web search - prior sessions often "
"already answered the question."
),
"fact_probe": "Find facts connected to one entity.",
"fact_related": "List entities related to one entity.",
"fact_reason": "Reason over facts that connect multiple entities.",
"fact_contradict": "Scan memory facts for likely contradictions.",
"fact_update": "Update an existing holographic memory fact.",
"fact_remove": "Remove a holographic memory fact.",
"fact_list": "List holographic memory facts.",
}
MEMORY_TOOL_MAP = {"fact_store": {"tracedecay_name": "tracedecay_fact_store"}}
for _hermes_name, _action in MEMORY_FACT_ACTIONS.items():
MEMORY_TOOL_MAP[_hermes_name] = {
"tracedecay_name": "tracedecay_fact_store",
"fixed_args": {"action": _action},
}
MEMORY_TOOL_MAP["fact_feedback"] = {"tracedecay_name": "tracedecay_fact_feedback"}
MEMORY_TOOL_MAP["memory_status"] = {"tracedecay_name": "tracedecay_memory_status"}
LCM_TOOL_ALIASES = {
"lcm_grep": "tracedecay_lcm_grep",
"lcm_load_session": "tracedecay_lcm_load_session",
"lcm_describe": "tracedecay_lcm_describe",
"lcm_expand": "tracedecay_lcm_expand",
"lcm_expand_query": "tracedecay_lcm_expand_query",
"lcm_status": "tracedecay_lcm_status",
"lcm_doctor": "tracedecay_lcm_doctor",
}
LCM_DIRECT_TOOL_NAMES = frozenset(LCM_TOOL_ALIASES.values())
LCM_DIRECT_TO_NATIVE = {tracedecay_name: native_name for native_name, tracedecay_name in LCM_TOOL_ALIASES.items()}
LCM_NATIVE_SCHEMAS = [
{
"name": "lcm_grep",
"description": (
"Search the plugin-local LCM database for past conversation content. "
"Default scope is the active session and returns raw messages and summary nodes."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query."},
"limit": {"type": "integer", "description": "Max results to return.", "default": 10},
"sort": {
"type": "string",
"enum": ["recency", "relevance", "hybrid"],
"description": "How to order matches.",
"default": "recency",
},
"session_scope": {
"type": "string",
"enum": ["current", "all", "session"],
"description": "Search scope across the local LCM database.",
"default": "current",
},
"session_id": {"type": "string", "description": "Session id when session_scope='session'."},
"source": {"type": "string", "description": "Optional source/platform filter."},
"role": {
"type": "string",
"enum": ["system", "user", "assistant", "tool", "unknown"],
"description": "Optional raw-message role filter.",
},
"time_from": {
"anyOf": [{"type": "number"}, {"type": "string"}],
"description": "Optional inclusive minimum raw-message timestamp.",
},
"time_to": {
"anyOf": [{"type": "number"}, {"type": "string"}],
"description": "Optional inclusive maximum raw-message timestamp.",
},
},
"required": ["query"],
},
},
{
"name": "lcm_load_session",
"description": "Load an ordered raw-message transcript page for one explicit session_id.",
"parameters": {
"type": "object",
"properties": {
"session_id": {"type": "string", "description": "Explicit LCM session id to load."},
"limit": {"type": "integer", "description": "Maximum raw messages to return.", "default": 100},
"max_content_chars": {
"type": "integer",
"description": "Maximum content characters to include per message.",
"default": 4000,
},
"after_store_id": {
"type": "integer",
"description": "Exclusive cursor for pagination.",
"default": 0,
},
"roles": {
"type": "array",
"items": {"type": "string"},
"description": "Optional role filter.",
},
"time_from": {
"type": "number",
"description": "Optional inclusive minimum message timestamp.",
},
"time_to": {
"type": "number",
"description": "Optional inclusive maximum message timestamp.",
},
},
"required": ["session_id"],
},
},
{
"name": "lcm_describe",
"description": "Inspect a current-session summary node, externalized payload, or top-level DAG overview.",
"parameters": {
"type": "object",
"properties": {
"node_id": {"type": "integer", "description": "Summary node ID to inspect."},
"externalized_ref": {
"type": "string",
"description": "Externalized payload ref filename to inspect.",
},
},
"required": [],
},
},
{
"name": "lcm_expand",
"description": "Recover detail behind a summary node, externalized payload, or raw message.",
"parameters": {
"type": "object",
"properties": {
"node_id": {"type": "integer", "description": "Summary node ID to expand."},
"externalized_ref": {
"type": "string",
"description": "Externalized payload ref filename to expand.",
},
"store_id": {"type": "integer", "description": "Raw message store_id to fetch."},
"session_id": {
"type": "string",
"description": "Optional session id override (for example, expand a cross-session grep hit in its owning session).",
},
"max_tokens": {"type": "integer", "description": "Token budget for returned content.", "default": 4000},
"source_offset": {
"type": "integer",
"description": "Source pagination offset for node_id mode.",
"default": 0,
},
"source_limit": {
"type": "integer",
"description": "Maximum immediate sources to return from source_offset. If a returned source marks content_truncated=true, continue from its own store_id + content_offset.",
},
"content_offset": {
"type": "integer",
"description": "Character offset used to continue oversized content.",
"default": 0,
},
},
"required": [],
},
},
{
"name": "lcm_expand_query",
"description": "Answer a natural-language question using expanded LCM context from the current session.",
"parameters": {
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "The question or task to answer from expanded LCM context."},
"query": {"type": "string", "description": "Optional search query used to find candidate summaries."},
"node_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "Optional explicit summary node IDs.",
},
"max_results": {"type": "integer", "description": "Max candidate summaries.", "default": 5},
"max_tokens": {"type": "integer", "description": "Max answer tokens.", "default": 2000},
"context_max_tokens": {
"type": "integer",
"description": "Expanded context budget for the auxiliary LLM.",
"default": 32000,
},
},
"required": ["prompt"],
},
},
{
"name": "lcm_status",
"description": "Get a quick health overview of the LCM engine for the current session.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
{
"name": "lcm_doctor",
"description": "Run diagnostics on the LCM database/configuration, including payload GC preview/apply via gc mode.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
]
# Tools whose registered value depends on the host forwarding the live
# in-memory ``messages`` list to plugin tool handlers (their schemas carry a
# ``messages`` parameter used for lossless LCM ingest). Everything else in
# TOOL_SCHEMAS works without that capability.
MESSAGE_DEPENDENT_TOOLS = frozenset((
"tracedecay_lcm_compress",
"tracedecay_lcm_preflight",
))
STANDARD_HERMES_LCM_PROVIDER = "cursor"
LCM_PROVIDER_LOCAL_TOOL_NAMES = frozenset((
"tracedecay_lcm_compress",
"tracedecay_lcm_describe",
"tracedecay_lcm_doctor",
"tracedecay_lcm_expand",
"tracedecay_lcm_expand_query",
"tracedecay_lcm_preflight",
"tracedecay_lcm_session_boundary",
))
# Direct duplicates of the memory provider's own tool surface
# (fact_store / fact_feedback / memory_status). Skipped at register() time
# when tracedecay is the active memory.provider so the same store is not
# exposed twice per API call. tracedecay_message_search stays registered —
# the provider does not expose transcript search.
MEMORY_PROVIDER_TOOLS = frozenset((
"tracedecay_fact_store",
"tracedecay_fact_feedback",
"tracedecay_memory_status",
))
# Tool names successfully registered with this host. Consulted by the
# first-turn guidance nudge so it never advertises tools that are not
# actually registered.
_REGISTERED_TOOL_NAMES = set()
_CONTEXT_TOOL_NAMES = set()
_HOST_FORWARDS_MESSAGES = None
# Auxiliary task key for pre-compaction extraction. Upgraded to the
# plugin-registered "lcm_extraction" task when the host supports
# ctx.register_auxiliary_task (users can then pin its model under
# auxiliary.lcm_extraction); otherwise hermes' generic "extraction"
# defaults apply.
_EXTRACTION_TASK = {"key": "extraction"}
def _active_memory_provider(ctx=None):
"""The memory.provider configured for this profile, if any."""
config = getattr(ctx, "config", None) if ctx is not None else None
if isinstance(config, dict):
memory = config.get("memory")
if isinstance(memory, dict) and memory.get("provider"):
return str(memory.get("provider"))
try:
import yaml
config_path = os.path.join(tools.hermes_home_dir(), "config.yaml")
with open(config_path, encoding="utf-8-sig") as config_file:
raw = yaml.safe_load(config_file) or {}
memory = raw.get("memory")
if isinstance(memory, dict) and memory.get("provider"):
return str(memory.get("provider"))
except Exception:
pass
return None
def _make_wrapped_lcm_handler(tool_name: str, engine):
def _wrapped(args: dict, **kwargs) -> str:
return engine.handle_tool_call(tool_name, args, **kwargs)
return _wrapped
def _host_forwards_registered_tool_messages(ctx) -> bool:
capability = getattr(ctx, "context_engine_tool_handlers_receive_messages", False)
if callable(capability):
try:
capability = capability()
except Exception:
return False
return bool(capability)
def _pre_llm_call(*args, **kwargs):
# Inject guidance ONLY on the first turn: the hook result is appended to
# the user message, so emitting it every turn would change every turn
# boundary and break the conversation's prompt-cache prefix. Skip it
# entirely when no tracedecay tools actually registered on this host —
# advertising unregistered tools invites hallucinated calls.
if not kwargs.get("is_first_turn"):
return None
if not _REGISTERED_TOOL_NAMES:
return None
if not _plugin_toggle("nudge", True):
return None
return (
"Prefer tracedecay tools for codebase exploration, symbol lookup, call graphs, "
"impact analysis, affected files, and architectural navigation before broad file reads."
)
def _tracedecay_status(raw_args: str = ""):
raw = tools.call_tracedecay_tool("tracedecay_status", {})
try:
payload = json.loads(json.loads(raw)["content"][0]["text"])
except Exception:
return raw
if not isinstance(payload, dict) or payload.get("error"):
return raw
lines = ["tracedecay status:"]
for label, key in (
("project", "project_root"),
("files", "file_count"),
("nodes", "node_count"),
("edges", "edge_count"),
("branch", "branch"),
("db", "db_path"),
("last sync", "last_sync"),
):
value = payload.get(key)
if value not in (None, ""):
lines.append(f" {label}: {value}")
if len(lines) == 1:
return raw
return "\n".join(lines)
def _bridge_preview(value, limit: int = 2048) -> str:
if isinstance(value, str):
preview = value
else:
try:
preview = json.dumps(value, sort_keys=True)
except Exception:
preview = repr(value)
if len(preview) > limit:
return preview[:limit] + "...[truncated]"
return preview
_LCM_CONTRACT_KEYS = frozenset((
"answer",
"context_blocks",
"expansion",
"frontier",
"lcm",
"matches",
"needs_synthesis",
"replay_messages",
"context_recovery_hint",
"should_compress",
"status",
"summary_request",
))
_RETRIEVAL_HANDLE_KEYS = ("handle", "response_handle", "retrieval_handle")
def _json_or_none(text):
if not isinstance(text, str):
return None
try:
return json.loads(text)
except json.JSONDecodeError:
return None
def _content_text_candidates(content):
if isinstance(content, str):
return [content]
if not isinstance(content, list):
return []
parts = [
item.get("text")
for item in content
if isinstance(item, dict) and isinstance(item.get("text"), str)
]
candidates = []
if parts:
candidates.append("".join(parts))
joined = "\n".join(parts)
if joined != candidates[0]:
candidates.append(joined)
candidates.extend(parts)
return candidates
def _decode_content_json(content):
for candidate in _content_text_candidates(content):
decoded = _json_or_none(candidate)
if decoded is not None:
return decoded
return None
def _looks_like_lcm_contract(value):
return isinstance(value, dict) and bool(_LCM_CONTRACT_KEYS.intersection(value))
def _retrieval_handle(value):
if not isinstance(value, dict):
return None
if value.get("truncated") is not True and not value.get("retrieve_tool"):
if not any(key in value for key in ("response_handle", "retrieval_handle")):
return None
for key in _RETRIEVAL_HANDLE_KEYS:
handle = value.get(key)
if isinstance(handle, str) and handle.strip():
return handle.strip()
return None
def _lcm_retrieve_kwargs(args: dict, kwargs: dict) -> dict:
retrieve_kwargs = dict(kwargs or {})
if retrieve_kwargs.get("project_root"):
return retrieve_kwargs
if isinstance(args, dict):
root = args.get("response_handle_project_root") or args.get("project_root")
if not root and args.get("storage_scope") == "hermes_profile":
root = args.get("hermes_home")
if isinstance(root, str) and root.strip():
retrieve_kwargs["project_root"] = root.strip()
return retrieve_kwargs
def _decode_tool_payload(value, name: str, args: dict, kwargs: dict, depth: int = 0, seen_handles=None):
if depth > 8:
return value
if seen_handles is None:
seen_handles = set()
if isinstance(value, str):
decoded = _json_or_none(value)
if decoded is None:
return value
return _decode_tool_payload(decoded, name, args, kwargs, depth + 1, seen_handles)
if not isinstance(value, dict):
return value
handle = _retrieval_handle(value)
if handle and name.startswith("tracedecay_lcm_"):
if handle in seen_handles:
return value
seen_handles.add(handle)
retrieved = call_tracedecay_json(
"tracedecay_retrieve",
{"handle": handle},
**_lcm_retrieve_kwargs(args, kwargs),
)
if isinstance(retrieved, dict) and not retrieved.get("error"):
return _decode_tool_payload(retrieved, name, args, kwargs, depth + 1, seen_handles)
return retrieved
if _looks_like_lcm_contract(value):
return value
if "content" in value:
decoded = _decode_content_json(value.get("content"))
if decoded is not None:
return _decode_tool_payload(decoded, name, args, kwargs, depth + 1, seen_handles)
return value
def call_tracedecay_json(name: str, args: dict, **kwargs) -> dict:
raw = tools.call_tracedecay_tool(name, args, **kwargs)
try:
outer = json.loads(raw)
except json.JSONDecodeError:
return {
"error": "tracedecay tool returned invalid JSON",
"raw_preview": _bridge_preview(raw),
}
if isinstance(outer, dict) and "error" in outer:
return outer
if not isinstance(outer, dict):
return {
"error": "tracedecay tool response missing text content",
"raw_preview": _bridge_preview(raw),
}
if not _looks_like_lcm_contract(outer) and "content" not in outer:
return {
"error": "tracedecay tool response missing text content",
"raw_preview": _bridge_preview(raw),
}
if "content" in outer and not _content_text_candidates(outer.get("content")):
return {
"error": "tracedecay tool response missing text content",
"raw_preview": _bridge_preview(raw),
}
payload = _decode_tool_payload(outer, name, args, kwargs)
if isinstance(payload, dict) and "content" in outer and payload is outer:
return {
"error": "tracedecay tool returned invalid nested JSON",
"text_preview": _bridge_preview(outer.get("content")),
}
if not isinstance(payload, dict):
return {
"error": "tracedecay tool response missing text content",
"raw_preview": _bridge_preview(raw),
}
return payload
def _memory_schema(tracedecay_name: str, hermes_name: str, action: str = None) -> dict:
for schema in schemas.TOOL_SCHEMAS:
if schema.get("name") == tracedecay_name:
parameters = json.loads(json.dumps(schema.get("parameters", {})))
if action is not None:
properties = parameters.get("properties")
if isinstance(properties, dict):
properties.pop("action", None)
required = parameters.get("required")
if isinstance(required, list):
required = [field for field in required if field != "action"]
if required:
parameters["required"] = required
else:
parameters.pop("required", None)
return {
"name": hermes_name,
"description": MEMORY_ACTION_DESCRIPTIONS.get(
hermes_name, schema.get("description", "")
),
"parameters": parameters,
}
return {
"name": hermes_name,
"description": f"Tracedecay memory tool {hermes_name}.",
"parameters": {"type": "object", "properties": {}},
}
def _lcm_tool_schemas() -> list:
return list(LCM_NATIVE_SCHEMAS)
def _decode_tool_args(arguments):
if arguments is None:
return {}
if isinstance(arguments, dict):
return arguments
if isinstance(arguments, str):
if not arguments.strip():
return {}
try:
return json.loads(arguments)
except json.JSONDecodeError:
return {"arguments": arguments}
return {"arguments": arguments}
def _normalize_memory_tool_call(name, arguments):
if isinstance(name, dict):
function = name.get("function") or {}
tool_name = name.get("name") or function.get("name")
tool_args = name.get("arguments", function.get("arguments", arguments))
return tool_name, _decode_tool_args(tool_args)
return name, _decode_tool_args(arguments)
def _tracedecay_binary_available() -> bool:
if os.path.dirname(tools.TRACEDECAY_BIN):
return Path(tools.TRACEDECAY_BIN).is_file() and os.access(tools.TRACEDECAY_BIN, os.X_OK)
return shutil.which(tools.TRACEDECAY_BIN) is not None
def _storage_args(project_root=None, hermes_home=None):
"""Storage args for LCM/session state in the unified tracedecay store."""
if project_root:
return {"project_root": str(project_root)}
home = hermes_home or _resolve_hermes_home()
if home:
return {"storage_scope": "hermes_profile", "hermes_home": str(home)}
return {}
# Conventional config home: a `plugins.tracedecay` block in the profile
# config.yaml (the same `plugins.<name>` convention bundled Hermes plugins
# use). Keys are flat and mirror the host-config attribute names the
# `_configured_*` / `_lcm_*_setting` helpers read. Every key the plugin
# consults is declared here (default, dashboard description) so
# register_config_defaults()/get_config_field_meta() expose the real
# surface instead of just the install pin.
PLUGIN_CONFIG_FIELDS = {
"project_root": ("", "Code project pinned for code-graph tool calls (set by `tracedecay install --agent hermes --project-root`)."),
"nudge": (True, "Inject the first-turn tracedecay tool guidance nudge."),
"sync_turn": (True, "Mirror each completed turn into the LCM raw store."),
"prefetch": (True, "Background fact recall injected at turn start."),
"context_threshold": ("", "Compression trigger as a fraction of the context window (default: hermes compression.threshold)."),
"threshold_tokens": ("", "Absolute compression trigger in tokens (overrides context_threshold)."),
"context_length": ("", "Context window override when the host does not report one."),
"fresh_tail_count": ("", "Newest messages always kept verbatim (default 64)."),
"leaf_chunk_tokens": ("", "Token size of LCM leaf summary chunks (default 20000)."),
"dynamic_leaf_chunk_enabled": ("", "Scale leaf chunk size with the context window."),
"dynamic_leaf_chunk_max": ("", "Ceiling for dynamic leaf chunk sizing (default 40000)."),
"max_assembly_tokens": ("", "Hard cap for assembled replay tokens (0 = derive from context)."),
"reserve_tokens_floor": ("", "Tokens reserved below the context window when deriving the assembly cap."),
"summary_fan_in": ("", "Summary nodes condensed per parent (default 4)."),
"incremental_max_depth": ("", "Maximum condensation depth (default 1)."),
"summary_model": ("", "Primary auxiliary model for LCM summaries."),
"summary_fallback_models": ("", "Comma-separated fallback models for LCM summaries."),
"summary_timeout_ms": ("", "Auxiliary summary timeout in milliseconds."),
"summary_circuit_breaker_failure_threshold": ("", "Failures before a summary route is cooled down (default 2)."),
"summary_circuit_breaker_cooldown_seconds": ("", "Cooldown seconds for a tripped summary route (default 300)."),
"custom_instructions": ("", "Extra instructions appended to the LCM summary prompt."),
"expansion_model": ("", "Model used for lcm_expand_query synthesis."),
"expansion_context_tokens": ("", "Expanded-context budget for lcm_expand_query (default 32000)."),
"expansion_timeout_ms": ("", "lcm_expand_query synthesis timeout in milliseconds."),
"extraction_enabled": ("", "Run pre-compaction decision/insight extraction."),
"extraction_model": ("", "Model used for pre-compaction extraction."),
"extraction_output_path": ("", "Extraction output path surfaced in the extraction contract."),
"ignore_session_patterns": ("", "Comma-separated session-id patterns LCM ignores."),
"stateless_session_patterns": ("", "Comma-separated session-id patterns treated as stateless."),
"ignore_message_patterns": ("", "Comma-separated message patterns excluded from ingest."),
}
PLUGIN_CONFIG_DEFAULTS = {
key: default for key, (default, _description) in PLUGIN_CONFIG_FIELDS.items()
}
def _plugin_config_defaults():
# The install-time pin lives in the profile config.yaml itself
# (plugins.tracedecay.project_root), so the defaults carry no pin.
return dict(PLUGIN_CONFIG_DEFAULTS)
def _plugin_toggle(name, default=True):
"""Read a boolean kill switch from the plugins.tracedecay config block.
config.yaml is the home for behavioral settings (host policy: .env is
for secrets only).
"""
value = tools.plugin_config_block().get(name)
if value is None:
return default
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in ("0", "false", "no", "off"):
return False
if normalized in ("1", "true", "yes", "on"):
return True
return default
return bool(value)
class _ConfigChain:
"""Attribute-style config wrapper layering plugins.tracedecay under a host config object."""
def __init__(self, primary, block):
self._primary = primary
self._block = dict(block)
def __getattr__(self, name):
if name.startswith("_"):
raise AttributeError(name)
value = getattr(self._primary, name, None)
if value is None:
value = self._block.get(name)
if value is None:
raise AttributeError(name)
return value
def _with_plugin_block(config, hermes_home=None):
"""Layer the profile's plugins.tracedecay config block under a host config.
Host-provided values always win; the block fills the gaps so profile
config.yaml settings reach the engine/provider without bespoke env vars.
"""
block = {
key: value
for key, value in tools.plugin_config_block(hermes_home).items()
if value is not None and value != ""
}
if not block:
return config
if config is None:
return dict(block)
if isinstance(config, dict):
merged = dict(block)
for key, value in config.items():
if value is not None:
merged[key] = value
return merged
return _ConfigChain(config, block)
def _configured_hermes_home(config):
if config is None:
return None
if isinstance(config, dict):
return config.get("hermes_home") or config.get("home")
for attr in ("hermes_home", "home"):
value = getattr(config, attr, None)
if value:
return value
return None
def _configured_project_root(config):
# Profiles can pin the indexed project via a `project_root` config key
# (kwargs from the host take precedence; cwd is the last fallback).
if config is None:
return None
if isinstance(config, dict):
value = config.get("project_root") or config.get("tracedecay_project_root")
return str(value) if value else None
for attr in ("project_root", "tracedecay_project_root"):
value = getattr(config, attr, None)
if value:
return str(value)
return None
def _has_tracedecay_index(path):
if not (isinstance(path, str) and path.strip() and os.path.isabs(path)):
return False
return os.path.isdir(os.path.join(path, ".tracedecay"))
def _code_project_root(explicit=None, cwd=None, configured=None):
if explicit:
return str(explicit)
if _has_tracedecay_index(cwd):
return str(cwd)
if configured:
return str(configured)
if isinstance(cwd, str) and os.path.isabs(cwd):
return str(cwd)
return None
def _resolve_hermes_home(config=None, hermes_home=None):
for candidate in (
hermes_home,
_configured_hermes_home(config),
os.environ.get("HERMES_HOME"),
):
if candidate:
return str(candidate)
if _hermes_get_hermes_home is not None:
try:
resolved = _hermes_get_hermes_home()
if resolved:
return str(resolved)
except Exception:
pass
fallback = os.path.expanduser("~/.hermes")
return fallback or None
def _configured_value(config, *names, default=None):
if config is None:
return default
if isinstance(config, dict):
for name in names:
if name in config and config[name] is not None:
return config[name]
return default
for name in names:
value = getattr(config, name, None)
if value is not None:
return value
return default
def _configured_int(config, *names, default=None):
value = _configured_value(config, *names, default=default)
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _configured_bool(config, *names, default=None):
value = _configured_value(config, *names, default=default)
if value is None:
return None
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "on")
return bool(value)
def _parse_pattern_list(raw):
return [part.strip() for part in str(raw).split(",") if part.strip()]
# Env-aware settings mirroring hermes-lcm LCMConfig.from_env: documented LCM_*
# env vars take precedence over host ctx.config attributes, which take
# precedence over the hermes-lcm hardcoded defaults.
def _lcm_str_setting(config, env_key, *names, default=None):
env_value = os.environ.get(env_key)
if env_value is not None:
return env_value
value = _configured_value(config, *names)
return value if value is not None else default
def _lcm_int_setting(config, env_key, *names, default=None):
raw = os.environ.get(env_key)
if raw is not None:
try:
return int(raw)
except (TypeError, ValueError):
pass
return _configured_int(config, *names, default=default)
def _lcm_reserve_tokens_floor_setting(config, context_length):
raw = os.environ.get("LCM_RESERVE_TOKENS_FLOOR")
if raw is not None:
try:
return int(raw)
except (TypeError, ValueError):
pass
configured = _configured_value(config, "reserve_tokens_floor")
if configured is not None:
try:
return int(configured)
except (TypeError, ValueError):
return 0
try:
return 4096 if int(context_length or 0) > 0 else 0
except (TypeError, ValueError):
return 0
def _lcm_float_setting(config, env_key, *names, default=None):
raw = os.environ.get(env_key)
if raw is not None:
try:
return float(raw)
except (TypeError, ValueError):
pass
value = _configured_value(config, *names)
if value is not None:
try:
return float(value)
except (TypeError, ValueError):
pass
return default
def _lcm_bool_setting(config, env_key, *names, default=None):
raw = os.environ.get(env_key)
if raw is not None:
normalized = raw.strip().lower()
if normalized in ("1", "true", "yes", "on"):
return True
if normalized in ("0", "false", "no", "off"):
return False
return _configured_bool(config, *names, default=default)
def _lcm_list_setting(config, env_key, *names, default=None):
raw = os.environ.get(env_key)
if raw is not None:
return _parse_pattern_list(raw)
value = _configured_value(config, *names)
if value is None:
return default
if isinstance(value, str):
return _parse_pattern_list(value)
if isinstance(value, (list, tuple)):
return [str(item).strip() for item in value if str(item).strip()]
return default
def _config_bool_disabled(value):
if isinstance(value, bool):
return value is False
if isinstance(value, (int, float)):
return value == 0
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in ("0", "false", "no", "off"):
return True
try:
return float(normalized) == 0
except ValueError:
return False
return False
def _hermes_yaml_compression_threshold(default, hermes_home=None):
# Port of hermes-lcm config._hermes_compression_threshold: read the main
# Hermes compression.threshold from {HERMES_HOME}/config.yaml when no LCM
# override exists. Disabled Hermes compression must not leak its threshold.
home = (
hermes_home
or os.environ.get("HERMES_HOME")
or os.path.join(os.path.expanduser("~"), ".hermes")
)
cfg_path = Path(home) / "config.yaml"
try:
text = cfg_path.read_text()
except Exception:
return default
try:
import yaml
except Exception:
yaml = None
try:
if yaml is not None:
cfg = yaml.safe_load(text) or {}
compression = cfg.get("compression") or {}
if _config_bool_disabled(compression.get("enabled")):
return default
value = compression.get("threshold")
if value is None:
return default
return float(value)
in_compression = False
direct_indent = None
compression_disabled = False
threshold_value = None
for raw_line in text.splitlines():
line = raw_line.split('#', 1)[0].rstrip()
if not line.strip():
continue
if not line.startswith((" ", "\t")):
in_compression = line.strip() == "compression:"
direct_indent = None
continue
if not in_compression:
continue
indent = len(line) - len(line.lstrip(" \t"))
if direct_indent is None:
direct_indent = indent
if indent != direct_indent or ":" not in line:
continue
key, raw_value = line.strip().split(":", 1)
value = raw_value.strip().strip("'\"")
if key == "enabled" and _config_bool_disabled(value):
compression_disabled = True
elif key == "threshold":
threshold_value = value
if compression_disabled or threshold_value is None:
return default
return float(threshold_value)
except Exception:
return default
def _hermes_yaml_auxiliary_compression_timeout_ms(default, hermes_home=None):
# Port of hermes-lcm config._hermes_auxiliary_compression_timeout_ms:
# read auxiliary.compression.timeout (seconds) from config.yaml and expose
# it in milliseconds for LCM summary timeout parity.
home = (
hermes_home