@gamaze/hicortex 0.12.1 → 0.13.0

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/dist/status.d.ts CHANGED
@@ -1,4 +1,13 @@
1
1
  /**
2
2
  * Hicortex status — show current configuration and stats.
3
3
  */
4
+ /**
5
+ * The value shown after "Agent name:" in `hicortex status` (#179, A3). Reports
6
+ * EXACTLY what the CC hook resolves (shared `resolveAgentIdentity`), so the
7
+ * operator never keys `contextAgents`/`agents/<id>/` on an id the install does
8
+ * not actually send. Unset → the install sends no `?agent=` and shares the
9
+ * global context (CC default). A configured-but-unsanitizable value is called
10
+ * out as invalid (the hook sends none) rather than silently accepted.
11
+ */
12
+ export declare function statusAgentLine(config: Record<string, unknown>): string;
4
13
  export declare function runStatus(): Promise<void>;
package/dist/status.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * Hicortex status — show current configuration and stats.
4
4
  */
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.statusAgentLine = statusAgentLine;
6
7
  exports.runStatus = runStatus;
7
8
  const paths_js_1 = require("./paths.js");
8
9
  const node_fs_1 = require("node:fs");
@@ -12,9 +13,29 @@ const node_child_process_1 = require("node:child_process");
12
13
  const db_js_1 = require("./db.js");
13
14
  const features_js_1 = require("./features.js");
14
15
  const state_js_1 = require("./state.js");
16
+ const context_store_js_1 = require("./context-store.js");
15
17
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
16
18
  const CC_SETTINGS = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "settings.json");
17
19
  const OC_CONFIG = (0, node_path_1.join)((0, node_os_1.homedir)(), ".openclaw", "openclaw.json");
20
+ /**
21
+ * The value shown after "Agent name:" in `hicortex status` (#179, A3). Reports
22
+ * EXACTLY what the CC hook resolves (shared `resolveAgentIdentity`), so the
23
+ * operator never keys `contextAgents`/`agents/<id>/` on an id the install does
24
+ * not actually send. Unset → the install sends no `?agent=` and shares the
25
+ * global context (CC default). A configured-but-unsanitizable value is called
26
+ * out as invalid (the hook sends none) rather than silently accepted.
27
+ */
28
+ function statusAgentLine(config) {
29
+ const id = (0, context_store_js_1.resolveAgentIdentity)(config);
30
+ switch (id.source) {
31
+ case "configured":
32
+ return id.agentId;
33
+ case "invalid-config":
34
+ return `(invalid configured value "${id.rawConfigured}" — fix config.agentName; hook sends none)`;
35
+ default: // unset
36
+ return "(not set — global context)";
37
+ }
38
+ }
18
39
  async function runStatus() {
19
40
  console.log("Hicortex Status");
20
41
  console.log("─".repeat(40));
@@ -42,11 +63,12 @@ async function runStatus() {
42
63
  let licenseKey = "";
43
64
  let savedAuthToken = "";
44
65
  let isClientMode = false;
66
+ let parsedConfig = {};
45
67
  try {
46
- const config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
47
- licenseKey = config.licenseKey ?? "";
48
- savedAuthToken = config.authToken ?? "";
49
- isClientMode = config.mode === "client";
68
+ parsedConfig = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
69
+ licenseKey = parsedConfig.licenseKey ?? "";
70
+ savedAuthToken = parsedConfig.authToken ?? "";
71
+ isClientMode = parsedConfig.mode === "client";
50
72
  }
51
73
  catch { /* no config */ }
52
74
  const validated = (0, features_js_1.getValidatedLicense)();
@@ -65,6 +87,9 @@ async function runStatus() {
65
87
  else if (!isClientMode && !savedAuthToken) {
66
88
  console.log(`Auth token: not configured (run: npx @gamaze/hicortex init)`);
67
89
  }
90
+ // Per-agent context id (#179) — the id this install sends as ?agent= and the
91
+ // key operators use for contextAgents / agents/<id>/ dirs.
92
+ console.log(`Agent name: ${statusAgentLine(parsedConfig)}`);
68
93
  console.log();
69
94
  // Adapters
70
95
  console.log("Adapters:");
@@ -14,11 +14,24 @@ Gives [Hermes](https://github.com/nousresearch/hermes-agent) agents self-learnin
14
14
  |---|---|---|
15
15
  | `prefetch(query)` | recall relevant memories before each turn | `GET /search` |
16
16
  | `queue_prefetch(query)` | background recall for the next turn | `GET /search` |
17
- | `system_prompt_block()` | inject distilled lessons + memory index | `GET /lessons` |
17
+ | `system_prompt_block()` | inject per-agent standing context + distilled lessons + memory index | `GET /context`, `GET /lessons` |
18
18
  | `get_tool_schemas()` | exposes the 8 unified tools | see tool table below |
19
19
 
20
20
  That's the whole surface. No `sync_turn`, no compaction/session-end capture — those are intentionally absent.
21
21
 
22
+ ### Per-agent standing context (0.13)
23
+
24
+ `system_prompt_block()` also injects the hand-edited **standing context layer** (`## Context`, above the lessons block) — "who you are + how to work", distinct from episodic memory. The server resolves it **per agent**: this profile's own sections override the global set (`override`), or it can be `global` or `off`. See the main repo's `/context` layer docs.
25
+
26
+ The plugin sends its **profile name** as `?agent=`, resolved in this order:
27
+
28
+ 1. `agent_name` in the plugin config (explicit override);
29
+ 2. the `HERMES_PROFILE` environment variable;
30
+ 3. a `HERMES_HOME` ending in `profiles/<name>` (the per-profile install path);
31
+ 4. none → the global context (backward compatible).
32
+
33
+ Leave `agent_name` blank to auto-derive (2–4). Context injection needs a Hicortex server **≥ 0.13**; against an older server the plugin detects the missing per-agent support and injects no context (lessons are unaffected). Context and lessons fail soft independently — a context failure never costs the lessons block.
34
+
22
35
  ### Tools (unified 8)
23
36
 
24
37
  | Tool | REST call | Description |
@@ -57,7 +70,7 @@ hermes memory setup # select "hicortex", enter the server URL/token when promp
57
70
 
58
71
  Run it once per profile if you use Hermes profiles. Hermes allows **one** external memory provider at a time, so disable Honcho (or any other) first, then restart the gateway.
59
72
 
60
- Config fields (`hicortex_url`, `default_project`, `recall_limit`, `privacy_filter`) can also be written to `$HERMES_HOME/plugins/hicortex/config.json` directly. The auth token is a **secret** — set it via env, not the JSON file:
73
+ Config fields (`hicortex_url`, `default_project`, `recall_limit`, `privacy_filter`, `agent_name`) can also be written to `$HERMES_HOME/plugins/hicortex/config.json` directly. `agent_name` pins the per-agent context id for this profile (leave blank to auto-derive — see [Per-agent standing context](#per-agent-standing-context-013)). The auth token is a **secret** — set it via env, not the JSON file:
61
74
 
62
75
  ```bash
63
76
  export HICORTEX_AUTH_TOKEN=hctx-default-token # or your custom token
@@ -96,6 +96,13 @@ class HicortexClient:
96
96
  def lessons(self) -> dict[str, Any]:
97
97
  return self._get("/lessons")
98
98
 
99
+ def context(self, agent: Optional[str] = None) -> dict[str, Any]:
100
+ """Standing context layer (L2). When ``agent`` is set, the server
101
+ resolves the per-agent scope and echoes ``agent``/``mode`` (0.13); a
102
+ pre-0.13 server ignores the param and returns the global set with no
103
+ echo — the caller uses that echo as an old-server guard."""
104
+ return self._get("/context", {"agent": agent})
105
+
99
106
  def index(self) -> dict[str, Any]:
100
107
  return self._get("/index")
101
108
 
@@ -57,6 +57,16 @@ CONFIG_SCHEMA: list[dict[str, Any]] = [
57
57
  "default": "WORK,PERSONAL",
58
58
  "required": False,
59
59
  },
60
+ {
61
+ "key": "agent_name",
62
+ "label": "Agent name (per-agent context)",
63
+ "description": (
64
+ "Identity sent as ?agent= when fetching the standing context layer, "
65
+ "so this profile gets its own context (0.13). Leave blank to "
66
+ "auto-derive from the running profile (HERMES_PROFILE / HERMES_HOME)."
67
+ ),
68
+ "required": False,
69
+ },
60
70
  # NOTE: recall-only plugin — no capture config. Capture is handled by the
61
71
  # nightly server-side reader of each agent's session store.
62
72
  ]
@@ -1,6 +1,6 @@
1
1
  name: hicortex
2
- version: 0.5.0
3
- description: "Self-learning memory for Hermes agents — every session is distilled into lessons overnight, and your agent wakes up wiser. Injects fresh lessons each turn and exposes the full 8-tool memory surface (search, recent, ingest, lessons, index, graph, update, delete) via a shared Hicortex server. Stdlib-only."
2
+ version: 0.6.0
3
+ description: "Self-learning memory for Hermes agents — every session is distilled into lessons overnight, and your agent wakes up wiser. Injects fresh lessons each turn plus a per-agent standing context block, and exposes the full 8-tool memory surface (search, recent, ingest, lessons, index, graph, update, delete) via a shared Hicortex server. Stdlib-only."
4
4
  pip_dependencies: []
5
5
  hooks: []
6
6
  requires_env:
@@ -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.0",
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": {