@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/README.md +20 -1
- 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/index.js +73 -28
- package/dist/init.d.ts +31 -0
- package/dist/init.js +156 -2
- package/dist/lessons-context.d.ts +56 -0
- package/dist/lessons-context.js +77 -18
- package/dist/mcp-server.js +16 -2
- package/dist/status.d.ts +9 -0
- package/dist/status.js +29 -4
- 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
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
|
-
|
|
47
|
-
licenseKey =
|
|
48
|
-
savedAuthToken =
|
|
49
|
-
isClientMode =
|
|
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.
|
|
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
|
|
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.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": {
|