@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.
- package/README.md +48 -8
- package/assets/context.html +115 -6
- package/dist/cli-args.d.ts +16 -0
- package/dist/cli-args.js +30 -0
- package/dist/cli.js +13 -1
- package/dist/context-cli.d.ts +14 -3
- package/dist/context-cli.js +71 -17
- package/dist/context-store.d.ts +108 -2
- package/dist/context-store.js +308 -16
- package/dist/distiller.d.ts +27 -1
- package/dist/distiller.js +81 -9
- package/dist/index.js +73 -28
- package/dist/init.d.ts +39 -0
- package/dist/init.js +171 -4
- package/dist/lessons-context.d.ts +56 -0
- package/dist/lessons-context.js +77 -18
- package/dist/llm.d.ts +24 -1
- package/dist/llm.js +119 -2
- package/dist/mcp-server.js +27 -5
- package/dist/nightly-status.js +4 -1
- package/dist/nightly.js +8 -0
- package/dist/status.d.ts +9 -0
- package/dist/status.js +29 -4
- package/dist/types.d.ts +20 -0
- package/hermes-plugin/hicortex/README.md +15 -2
- package/hermes-plugin/hicortex/client.py +7 -0
- package/hermes-plugin/hicortex/config.py +10 -0
- package/hermes-plugin/hicortex/plugin.yaml +2 -2
- package/hermes-plugin/hicortex/provider.py +134 -1
- package/package.json +1 -1
|
@@ -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
|
|
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.
|
|
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": {
|