@gamaze/hicortex 0.12.1 → 0.13.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.
@@ -16,8 +16,11 @@ from __future__ import annotations
16
16
  import hashlib
17
17
  import json
18
18
  import logging
19
+ import os
20
+ import re
19
21
  import threading
20
- from typing import Any, Dict, List, Optional
22
+ from concurrent.futures import ThreadPoolExecutor
23
+ from typing import Any, Dict, Iterable, List, Optional
21
24
 
22
25
  from agent.memory_provider import MemoryProvider
23
26
 
@@ -28,6 +31,89 @@ logger = logging.getLogger(__name__)
28
31
 
29
32
  _INJECT_CONTENT_CAP = 500
30
33
 
34
+ # Agent ids are joined into a filesystem path server-side, so they share the
35
+ # section-name allowlist. \Z (NOT $) anchors the END OF STRING: Python's $ also
36
+ # matches just before a trailing "\n", so "nano\n" would pass and go out as
37
+ # agent=nano%0A → a 400 the fail-soft path silently swallows.
38
+ _AGENT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*\Z")
39
+
40
+
41
+ def _valid_agent_id(name: Optional[str]) -> bool:
42
+ return bool(name) and len(name) <= 64 and bool(_AGENT_ID_RE.match(name))
43
+
44
+
45
+ def _sanitize_agent_id(raw: Optional[str]) -> Optional[str]:
46
+ """Sanitize a raw identity (profile name / env value) into a valid agent id,
47
+ or None when nothing valid remains — mirrors the TS ``sanitizeAgentId``
48
+ EXACTLY so a profile resolves to the SAME id on both harnesses (a mismatch
49
+ would make one honor the persona firewall and the other leak global context
50
+ into an ``off``/``override`` persona): lowercase → collapse invalid runs to
51
+ "-" → strip leading -/_ → truncate 64 → validate. "Lenny" → "lenny";
52
+ "MacBook-Pro.local" → "macbook-pro-local"; all-symbols → None."""
53
+ if not isinstance(raw, str):
54
+ return None
55
+ cleaned = re.sub(r"[^a-z0-9_-]+", "-", raw.lower())
56
+ cleaned = re.sub(r"^[-_]+", "", cleaned)[:64]
57
+ return cleaned if _valid_agent_id(cleaned) else None
58
+
59
+
60
+ def _profile_from_home(home: str) -> Optional[str]:
61
+ """Parse a Hermes profile name from a ``HERMES_HOME`` ending in
62
+ ``…/profiles/<name>``; None when the path is not profile-shaped."""
63
+ home = (home or "").strip().rstrip("/")
64
+ if not home:
65
+ return None
66
+ parent, name = os.path.split(home)
67
+ return name if name and os.path.basename(parent) == "profiles" else None
68
+
69
+
70
+ def _resolve_agent_name(cfg: Dict[str, Any]) -> Optional[str]:
71
+ """Resolve the per-agent context id (0.13), in priority order:
72
+ 1. config ``agent_name`` (explicit override);
73
+ 2. ``HERMES_PROFILE`` env;
74
+ 3. parse ``HERMES_HOME`` when it ends ``profiles/<name>``;
75
+ 4. None → bare fetch → the global set.
76
+ Each source is stripped then SANITIZED (not rejected) so "Lenny" → "lenny"
77
+ matches the TS contract; a source that sanitizes to None yields None (bare
78
+ fetch), never a fall-through to another identity."""
79
+ configured = (cfg.get("agent_name") or "").strip()
80
+ if configured:
81
+ return _sanitize_agent_id(configured)
82
+ prof = (os.environ.get("HERMES_PROFILE") or "").strip()
83
+ if prof:
84
+ return _sanitize_agent_id(prof)
85
+ parsed = _profile_from_home(os.environ.get("HERMES_HOME") or "")
86
+ if parsed:
87
+ return _sanitize_agent_id(parsed)
88
+ return None
89
+
90
+
91
+ def _title_case_section(name: str) -> str:
92
+ """"user" → "User", "my_notes" → "My Notes" (mirrors the CC/OC helper)."""
93
+ words = [w for w in re.split(r"[-_]+", name) if w]
94
+ return " ".join(w[:1].upper() + w[1:] for w in words)
95
+
96
+
97
+ def _order_section_names(names: Iterable[str]) -> List[str]:
98
+ """Stable ordering: user, rules, then the rest alphabetically."""
99
+ names = list(names)
100
+ primaries = [p for p in ("user", "rules") if p in names]
101
+ rest = sorted(n for n in names if n not in ("user", "rules"))
102
+ return primaries + rest
103
+
104
+
105
+ def _render_context_block(sections: Dict[str, Any]) -> str:
106
+ """Render the ``## Context`` block, or "" when every section is blank."""
107
+ body_parts: List[str] = []
108
+ for name in _order_section_names(sections.keys()):
109
+ body = sections.get(name)
110
+ if not isinstance(body, str) or not body.strip():
111
+ continue
112
+ body_parts.extend([f"### {_title_case_section(name)}", "", body.strip()])
113
+ if not body_parts:
114
+ return ""
115
+ return "\n".join(["## Context", "", *body_parts])
116
+
31
117
 
32
118
  class HicortexProvider(MemoryProvider):
33
119
  """Hicortex long-term memory backend for Hermes (recall-only)."""
@@ -37,6 +123,7 @@ class HicortexProvider(MemoryProvider):
37
123
  self._project: Optional[str] = None
38
124
  self._recall_limit: int = 5
39
125
  self._privacy: Optional[str] = "WORK,PERSONAL"
126
+ self._agent_name: Optional[str] = None
40
127
  self._prefetch_cache: Dict[str, str] = {}
41
128
  self._bg_threads: List[threading.Thread] = []
42
129
 
@@ -81,6 +168,7 @@ class HicortexProvider(MemoryProvider):
81
168
  except (TypeError, ValueError):
82
169
  self._recall_limit = 5
83
170
  self._privacy = cfg.get("privacy_filter", "WORK,PERSONAL")
171
+ self._agent_name = _resolve_agent_name(cfg)
84
172
  try:
85
173
  self._client = self._build_client()
86
174
  except Exception as e:
@@ -142,6 +230,51 @@ class HicortexProvider(MemoryProvider):
142
230
  client = self._client_or_none()
143
231
  if client is None:
144
232
  return ""
233
+ # Standing context (L2, 0.13) is prepended ABOVE the lessons block. The
234
+ # two fetches run CONCURRENTLY (matching the TS Promise.all paths): run
235
+ # serially, a blackholed server would stall the turn for up to 2× the
236
+ # client timeout. Each block fails soft independently — a context failure
237
+ # must never cost the lessons block, and vice versa.
238
+ with ThreadPoolExecutor(max_workers=2) as executor:
239
+ f_context = executor.submit(self._context_block, client)
240
+ f_lessons = executor.submit(self._lessons_block, client)
241
+ blocks = [f_context.result(), f_lessons.result()]
242
+ return "\n\n".join(b for b in blocks if b)
243
+
244
+ def _context_block(self, client: HicortexClient) -> str:
245
+ """Fetch the standing context layer and render a ``## Context`` block,
246
+ or "" when nothing should be injected. Gates (ALL): "hermes" in the
247
+ server-resolved ``clients``; when an agent id was SENT, the response
248
+ echoes ``agent`` (old-server guard — a pre-0.13 server ignores ?agent=
249
+ and returns global with no echo; injecting would push global context
250
+ into every persona; the check is skipped on a bare fetch); and the
251
+ resolved section set is non-empty (mode "off" → {}).
252
+
253
+ Reference implementation for the gate: TS ``gateAndRenderContext`` in
254
+ ``packages/hicortex/src/lessons-context.ts`` (keep the two in sync).
255
+
256
+ The ENTIRE path — fetch, parse, gate, render — is inside the try: a
257
+ malformed ``clients`` value (e.g. an int from a proxy error page) would
258
+ otherwise raise during the ``in`` check, escape, and cost the lessons
259
+ block too (mirrors the TS ``.catch(() => null)`` totality)."""
260
+ try:
261
+ data = client.context(agent=self._agent_name)
262
+ if not isinstance(data, dict):
263
+ return ""
264
+ clients = data.get("clients") or []
265
+ if "hermes" not in clients:
266
+ return ""
267
+ if self._agent_name is not None and not isinstance(data.get("agent"), str):
268
+ return ""
269
+ sections = data.get("sections") or {}
270
+ if not isinstance(sections, dict):
271
+ return ""
272
+ return _render_context_block(sections)
273
+ except Exception as e:
274
+ logger.debug("hicortex context injection failed: %s", e)
275
+ return ""
276
+
277
+ def _lessons_block(self, client: HicortexClient) -> str:
145
278
  try:
146
279
  data = client.lessons()
147
280
  except Exception as e:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "description": "Self-learning memory for AI agents \u2014 experience captured automatically, distilled into lessons overnight, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, and Pi.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {