@gamaze/hicortex 0.7.1 → 0.10.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.
Files changed (49) hide show
  1. package/README.md +72 -39
  2. package/dist/claude-md.d.ts +9 -21
  3. package/dist/claude-md.js +9 -241
  4. package/dist/cli.d.ts +3 -2
  5. package/dist/cli.js +29 -11
  6. package/dist/consolidate.js +0 -7
  7. package/dist/db.js +24 -0
  8. package/dist/embedder.d.ts +11 -0
  9. package/dist/embedder.js +27 -0
  10. package/dist/extensions.d.ts +41 -88
  11. package/dist/extensions.js +36 -61
  12. package/dist/features.d.ts +21 -25
  13. package/dist/features.js +47 -83
  14. package/dist/hermes-transcript-reader.d.ts +27 -0
  15. package/dist/hermes-transcript-reader.js +134 -0
  16. package/dist/index.d.ts +16 -4
  17. package/dist/index.js +252 -344
  18. package/dist/init.d.ts +41 -1
  19. package/dist/init.js +545 -190
  20. package/dist/lesson-selection.d.ts +62 -0
  21. package/dist/lesson-selection.js +159 -0
  22. package/dist/lessons-context.d.ts +17 -0
  23. package/dist/lessons-context.js +96 -0
  24. package/dist/llm.d.ts +42 -29
  25. package/dist/llm.js +89 -270
  26. package/dist/mcp-server.d.ts +0 -1
  27. package/dist/mcp-server.js +404 -86
  28. package/dist/nightly.d.ts +9 -6
  29. package/dist/nightly.js +197 -357
  30. package/dist/oc-transcript-reader.d.ts +20 -0
  31. package/dist/oc-transcript-reader.js +61 -0
  32. package/dist/pi-transcript-reader.d.ts +1 -0
  33. package/dist/status.js +22 -2
  34. package/dist/storage.d.ts +7 -1
  35. package/dist/storage.js +28 -7
  36. package/dist/transcript-reader.d.ts +19 -0
  37. package/dist/transcript-reader.js +17 -3
  38. package/dist/types.d.ts +10 -0
  39. package/dist/uninstall.js +31 -1
  40. package/hermes-plugin/hicortex/README.md +77 -0
  41. package/hermes-plugin/hicortex/__init__.py +17 -0
  42. package/hermes-plugin/hicortex/client.py +162 -0
  43. package/hermes-plugin/hicortex/config.py +105 -0
  44. package/hermes-plugin/hicortex/plugin.yaml +12 -0
  45. package/hermes-plugin/hicortex/provider.py +432 -0
  46. package/openclaw.plugin.json +17 -44
  47. package/package.json +7 -5
  48. package/dist/pro-loader.d.ts +0 -33
  49. package/dist/pro-loader.js +0 -187
@@ -0,0 +1,20 @@
1
+ /**
2
+ * OpenClaw transcript reader — reads OC session JSONL files.
3
+ *
4
+ * OC persists sessions at ~/.openclaw/agents/<agentId>/sessions/*.jsonl in the
5
+ * Pi version-3 event format (OpenClaw is Pi-runtime based): event types
6
+ * `session`, `model_change`, `thinking_level_change`, `message`, `custom`.
7
+ * The Pi parser handles the format; this wrapper only adapts the directory
8
+ * layout (one extra `agents/<agentId>` level) and sets OC provenance.
9
+ *
10
+ * Known limitation: rotated files (`*.jsonl.reset.<ts>`) are not read — only
11
+ * live `*.jsonl` files. Server-side session dedup keeps re-reads idempotent.
12
+ */
13
+ import { type TranscriptBatch } from "./pi-transcript-reader.js";
14
+ /**
15
+ * Read OpenClaw session transcripts modified after `since`.
16
+ *
17
+ * @param since Only return sessions with mtime > this date
18
+ * @param agentsDir Override the OC agents directory (default: ~/.openclaw/agents/)
19
+ */
20
+ export declare function readOcTranscripts(since: Date, agentsDir?: string): TranscriptBatch[];
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ /**
3
+ * OpenClaw transcript reader — reads OC session JSONL files.
4
+ *
5
+ * OC persists sessions at ~/.openclaw/agents/<agentId>/sessions/*.jsonl in the
6
+ * Pi version-3 event format (OpenClaw is Pi-runtime based): event types
7
+ * `session`, `model_change`, `thinking_level_change`, `message`, `custom`.
8
+ * The Pi parser handles the format; this wrapper only adapts the directory
9
+ * layout (one extra `agents/<agentId>` level) and sets OC provenance.
10
+ *
11
+ * Known limitation: rotated files (`*.jsonl.reset.<ts>`) are not read — only
12
+ * live `*.jsonl` files. Server-side session dedup keeps re-reads idempotent.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.readOcTranscripts = readOcTranscripts;
16
+ const node_fs_1 = require("node:fs");
17
+ const node_path_1 = require("node:path");
18
+ const node_os_1 = require("node:os");
19
+ const pi_transcript_reader_js_1 = require("./pi-transcript-reader.js");
20
+ const DEFAULT_OC_AGENTS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".openclaw", "agents");
21
+ /**
22
+ * Read OpenClaw session transcripts modified after `since`.
23
+ *
24
+ * @param since Only return sessions with mtime > this date
25
+ * @param agentsDir Override the OC agents directory (default: ~/.openclaw/agents/)
26
+ */
27
+ function readOcTranscripts(since, agentsDir = DEFAULT_OC_AGENTS_DIR) {
28
+ let agentIds;
29
+ try {
30
+ agentIds = (0, node_fs_1.readdirSync)(agentsDir);
31
+ }
32
+ catch {
33
+ // No OpenClaw install — not an error.
34
+ return [];
35
+ }
36
+ const batches = [];
37
+ for (const agentId of agentIds) {
38
+ const agentPath = (0, node_path_1.join)(agentsDir, agentId);
39
+ try {
40
+ if (!(0, node_fs_1.statSync)(agentPath).isDirectory())
41
+ continue;
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ // agents/<agentId>/ contains a `sessions/` child with *.jsonl — exactly
47
+ // the <root>/<projectDir>/*.jsonl shape readPiTranscripts walks.
48
+ for (const batch of (0, pi_transcript_reader_js_1.readPiTranscripts)(since, agentPath)) {
49
+ batches.push({
50
+ ...batch,
51
+ // The Pi walk labels the project from the cwd or the "sessions" dir
52
+ // name — the agent id is the meaningful label for OC.
53
+ projectName: batch.projectName && batch.projectName !== "sessions"
54
+ ? batch.projectName
55
+ : agentId,
56
+ sourceAgent: `openclaw/${agentId}`,
57
+ });
58
+ }
59
+ }
60
+ return batches;
61
+ }
@@ -31,6 +31,7 @@ export interface TranscriptBatch {
31
31
  projectName: string;
32
32
  date: string;
33
33
  entries: unknown[];
34
+ sourceAgent?: string;
34
35
  }
35
36
  /**
36
37
  * Read Pi session transcripts modified after `since`.
package/dist/status.js CHANGED
@@ -9,6 +9,7 @@ const node_path_1 = require("node:path");
9
9
  const node_os_1 = require("node:os");
10
10
  const node_child_process_1 = require("node:child_process");
11
11
  const db_js_1 = require("./db.js");
12
+ const features_js_1 = require("./features.js");
12
13
  const HICORTEX_HOME = (0, node_path_1.join)((0, node_os_1.homedir)(), ".hicortex");
13
14
  const CC_SETTINGS = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "settings.json");
14
15
  const OC_CONFIG = (0, node_path_1.join)((0, node_os_1.homedir)(), ".openclaw", "openclaw.json");
@@ -34,15 +35,34 @@ async function runStatus() {
34
35
  console.log(`DB error: ${err instanceof Error ? err.message : String(err)}`);
35
36
  }
36
37
  }
37
- // License
38
+ // Config: license key + auth token
38
39
  const configPath = (0, node_path_1.join)(HICORTEX_HOME, "config.json");
39
40
  let licenseKey = "";
41
+ let savedAuthToken = "";
42
+ let isClientMode = false;
40
43
  try {
41
44
  const config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
42
45
  licenseKey = config.licenseKey ?? "";
46
+ savedAuthToken = config.authToken ?? "";
47
+ isClientMode = config.mode === "client";
43
48
  }
44
49
  catch { /* no config */ }
45
- console.log(`License: ${licenseKey ? "configured" : "free tier (250 memories)"}`);
50
+ const validated = (0, features_js_1.getValidatedLicense)();
51
+ if (validated?.valid && validated.tier) {
52
+ console.log(`License: ${validated.tier} (licensed)`);
53
+ }
54
+ else if (licenseKey) {
55
+ console.log(`License: key configured (not yet validated)`);
56
+ }
57
+ else {
58
+ console.log(`License: noncommercial (no key)`);
59
+ }
60
+ if (!isClientMode && savedAuthToken) {
61
+ console.log(`Auth token: ${savedAuthToken} (clients connect with this token)`);
62
+ }
63
+ else if (!isClientMode && !savedAuthToken) {
64
+ console.log(`Auth token: not configured (run: npx @gamaze/hicortex init)`);
65
+ }
46
66
  console.log();
47
67
  // Adapters
48
68
  console.log("Adapters:");
package/dist/storage.d.ts CHANGED
@@ -9,7 +9,13 @@ import type { Memory, MemoryLink, InsertMemoryOptions } from "./types.js";
9
9
  */
10
10
  export declare function embedToBlob(embedding: Float32Array): Buffer;
11
11
  /**
12
- * Insert a memory and its vector embedding. Returns the generated UUID.
12
+ * Insert a memory and its vector embedding. Returns the memory's UUID.
13
+ *
14
+ * Idempotent on `sourceSession`: if a memory with that source_session already
15
+ * exists (UNIQUE index from migration v4), the insert is skipped and the
16
+ * EXISTING memory's id is returned (no vector re-insert). This lets `/ingest`
17
+ * and `/distill` safely retry a segment without double-inserting. Callers that
18
+ * omit sourceSession (NULL — nightly distillation, tests) never collide.
13
19
  */
14
20
  export declare function insertMemory(db: Database.Database, content: string, embedding: Float32Array, opts?: InsertMemoryOptions): string;
15
21
  /**
package/dist/storage.js CHANGED
@@ -43,18 +43,39 @@ function rowToMemory(row) {
43
43
  // Single memory CRUD
44
44
  // ---------------------------------------------------------------------------
45
45
  /**
46
- * Insert a memory and its vector embedding. Returns the generated UUID.
46
+ * Insert a memory and its vector embedding. Returns the memory's UUID.
47
+ *
48
+ * Idempotent on `sourceSession`: if a memory with that source_session already
49
+ * exists (UNIQUE index from migration v4), the insert is skipped and the
50
+ * EXISTING memory's id is returned (no vector re-insert). This lets `/ingest`
51
+ * and `/distill` safely retry a segment without double-inserting. Callers that
52
+ * omit sourceSession (NULL — nightly distillation, tests) never collide.
47
53
  */
48
54
  function insertMemory(db, content, embedding, opts = {}) {
49
55
  const id = (0, node_crypto_1.randomUUID)();
50
56
  const ts = opts.createdAt ?? nowIso();
51
57
  const ingestedTs = nowIso();
52
- db.prepare(`INSERT INTO memories
53
- (id, content, base_strength, last_accessed, access_count,
54
- created_at, ingested_at, source_agent, source_session, project,
55
- privacy, memory_type)
56
- VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`).run(id, content, opts.baseStrength ?? 0.5, ts, ts, ingestedTs, opts.sourceAgent ?? "default", opts.sourceSession ?? null, opts.project ?? null, opts.privacy ?? "WORK", opts.memoryType ?? "episode");
57
- db.prepare("INSERT INTO memory_vectors (id, embedding) VALUES (?, ?)").run(id, embedToBlob(embedding));
58
+ const sourceSession = opts.sourceSession ?? null;
59
+ const result = db
60
+ .prepare(`INSERT OR IGNORE INTO memories
61
+ (id, content, base_strength, last_accessed, access_count,
62
+ created_at, ingested_at, source_agent, source_session, project,
63
+ privacy, memory_type)
64
+ VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`)
65
+ .run(id, content, opts.baseStrength ?? 0.5, ts, ts, ingestedTs, opts.sourceAgent ?? "default", sourceSession, opts.project ?? null, opts.privacy ?? "WORK", opts.memoryType ?? "episode");
66
+ if (result.changes > 0) {
67
+ // New row — store its vector.
68
+ db.prepare("INSERT INTO memory_vectors (id, embedding) VALUES (?, ?)").run(id, embedToBlob(embedding));
69
+ return id;
70
+ }
71
+ // Collision on UNIQUE source_session — return the existing memory's id.
72
+ if (sourceSession) {
73
+ const existing = db
74
+ .prepare("SELECT id FROM memories WHERE source_session = ?")
75
+ .get(sourceSession);
76
+ if (existing)
77
+ return existing.id;
78
+ }
58
79
  return id;
59
80
  }
60
81
  /**
@@ -12,7 +12,26 @@ export interface TranscriptBatch {
12
12
  projectName: string;
13
13
  date: string;
14
14
  entries: unknown[];
15
+ /**
16
+ * Optional source-agent label (e.g. "hermes/lenny"). When set, the nightly
17
+ * pipeline uses it verbatim for provenance instead of the default
18
+ * `claude-code/<project>`. Lets per-harness readers stamp their own origin.
19
+ */
20
+ sourceAgent?: string;
15
21
  }
22
+ /**
23
+ * Cheap pre-filter: skip CC session FILES with fewer than this many raw JSONL
24
+ * lines/entries — they're degenerate (aborted/empty) and not worth parsing.
25
+ *
26
+ * This is deliberately NOT the "is there meaningful content" gate. That is the
27
+ * post-denoise `transcript.length < MIN_CONVERSATION_CHARS` (200) check in
28
+ * nightly.ts, which measures actual conversation after tool/system noise is
29
+ * stripped. Raw entry count is a lossy proxy — a dense 2-message exchange can
30
+ * be very meaningful — so it's used only as a degenerate-file floor here, and
31
+ * intentionally NOT applied to the Hermes reader (which lets the 200-char
32
+ * content gate decide, so short dense sessions aren't dropped on count).
33
+ */
34
+ export declare const MIN_TRANSCRIPT_ENTRIES = 4;
16
35
  /**
17
36
  * Read all CC transcripts modified since `since`.
18
37
  * Returns one batch per session file.
@@ -9,10 +9,24 @@
9
9
  * and feeds them to the existing distiller pipeline.
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.MIN_TRANSCRIPT_ENTRIES = void 0;
12
13
  exports.readCcTranscripts = readCcTranscripts;
13
14
  const node_fs_1 = require("node:fs");
14
15
  const node_path_1 = require("node:path");
15
16
  const node_os_1 = require("node:os");
17
+ /**
18
+ * Cheap pre-filter: skip CC session FILES with fewer than this many raw JSONL
19
+ * lines/entries — they're degenerate (aborted/empty) and not worth parsing.
20
+ *
21
+ * This is deliberately NOT the "is there meaningful content" gate. That is the
22
+ * post-denoise `transcript.length < MIN_CONVERSATION_CHARS` (200) check in
23
+ * nightly.ts, which measures actual conversation after tool/system noise is
24
+ * stripped. Raw entry count is a lossy proxy — a dense 2-message exchange can
25
+ * be very meaningful — so it's used only as a degenerate-file floor here, and
26
+ * intentionally NOT applied to the Hermes reader (which lets the 200-char
27
+ * content gate decide, so short dense sessions aren't dropped on count).
28
+ */
29
+ exports.MIN_TRANSCRIPT_ENTRIES = 4;
16
30
  const CC_PROJECTS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "projects");
17
31
  /**
18
32
  * Read all CC transcripts modified since `since`.
@@ -81,8 +95,8 @@ function parseTranscriptFile(filePath, projectName) {
81
95
  return null;
82
96
  }
83
97
  const lines = raw.split("\n").filter((l) => l.trim());
84
- if (lines.length < 4)
85
- return null; // Too short to be meaningful
98
+ if (lines.length < exports.MIN_TRANSCRIPT_ENTRIES)
99
+ return null; // degenerate/empty file
86
100
  const entries = [];
87
101
  let lastTimestamp = "";
88
102
  for (const line of lines) {
@@ -97,7 +111,7 @@ function parseTranscriptFile(filePath, projectName) {
97
111
  // Skip malformed lines
98
112
  }
99
113
  }
100
- if (entries.length < 4)
114
+ if (entries.length < exports.MIN_TRANSCRIPT_ENTRIES)
101
115
  return null;
102
116
  // Extract session ID from filename (UUID.jsonl)
103
117
  const sessionId = (0, node_path_1.basename)(filePath, ".jsonl");
package/dist/types.d.ts CHANGED
@@ -100,11 +100,21 @@ export interface ConsolidationReport {
100
100
  /** Plugin configuration from openclaw.plugin.json configSchema. */
101
101
  export interface HicortexConfig {
102
102
  licenseKey?: string;
103
+ /** Hicortex server URL. Defaults to http://127.0.0.1:8787 (co-located server). */
104
+ serverUrl?: string;
105
+ /** Bearer token for the Hicortex server. Localhost bypasses auth by default. */
106
+ authToken?: string;
107
+ /** @deprecated Use the Hicortex server for distillation and consolidation. */
103
108
  llmBaseUrl?: string;
109
+ /** @deprecated Use the Hicortex server for distillation and consolidation. */
104
110
  llmApiKey?: string;
111
+ /** @deprecated Use the Hicortex server for distillation and consolidation. */
105
112
  llmModel?: string;
113
+ /** @deprecated Use the Hicortex server for distillation and consolidation. */
106
114
  reflectModel?: string;
115
+ /** @deprecated Consolidation is owned by the server nightly. */
107
116
  consolidateHour?: number;
117
+ /** @deprecated The OC plugin no longer opens its own database. */
108
118
  dbPath?: string;
109
119
  }
110
120
  /** Response from license validation API. */
package/dist/uninstall.js CHANGED
@@ -82,7 +82,37 @@ async function runUninstall() {
82
82
  }
83
83
  }
84
84
  console.log(" ✓ Removed /learn and /hicortex-activate commands");
85
- // 4. Remove CLAUDE.md block
85
+ // 4. Remove SessionStart hook (JSON merge — filter out entries containing "lessons-context")
86
+ try {
87
+ const raw = (0, node_fs_1.readFileSync)(CC_SETTINGS, "utf-8");
88
+ const settings = JSON.parse(raw);
89
+ const hooks = settings.hooks;
90
+ const sessionStart = hooks && Array.isArray(hooks.SessionStart) ? hooks.SessionStart : null;
91
+ if (hooks && sessionStart) {
92
+ const before = sessionStart.length;
93
+ const filtered = sessionStart.filter((entry) => {
94
+ if (typeof entry !== "object" || entry === null)
95
+ return true;
96
+ const e = entry;
97
+ if (Array.isArray(e.hooks)) {
98
+ return !e.hooks.some((h) => {
99
+ if (typeof h !== "object" || h === null)
100
+ return false;
101
+ const hook = h;
102
+ return typeof hook.command === "string" && hook.command.includes("lessons-context");
103
+ });
104
+ }
105
+ return true;
106
+ });
107
+ if (filtered.length < before) {
108
+ hooks.SessionStart = filtered;
109
+ (0, node_fs_1.writeFileSync)(CC_SETTINGS, JSON.stringify(settings, null, 2));
110
+ console.log(" ✓ Removed SessionStart lessons-context hook");
111
+ }
112
+ }
113
+ }
114
+ catch { /* no settings file or parse error — nothing to remove */ }
115
+ // 5. Remove CLAUDE.md block (old static block from pre-0.9.0; may still exist on upgrades)
86
116
  if ((0, claude_md_js_1.removeLessonsBlock)(CLAUDE_MD)) {
87
117
  console.log(" ✓ Removed Hicortex Learnings block from CLAUDE.md");
88
118
  }
@@ -0,0 +1,77 @@
1
+ # Hicortex memory plugin for Hermes
2
+
3
+ > **Install:** `hermes plugins install gamaze-labs/hicortex-hermes-plugin` → `hermes memory setup hicortex` → restart your gateway.
4
+ >
5
+ > The [gamaze-labs/hicortex-hermes-plugin](https://github.com/gamaze-labs/hicortex-hermes-plugin) repo is a **generated read-only mirror** of `hermes-plugin/hicortex/` in the main Hicortex repo — do not open PRs there. Requires a running [Hicortex server](https://hicortex.gamaze.com/docs/installation.html) (local or remote) for recall; capture of Hermes sessions is handled by the server machine's nightly job.
6
+
7
+ Gives [Hermes](https://github.com/nousresearch/hermes-agent) agents self-learning memory backed by a [Hicortex](https://hicortex.gamaze.com/) server: their experience is distilled into lessons overnight, and they wake up wiser. **Recall-only:** the plugin retrieves relevant memories on every turn and injects distilled lessons into the system prompt. It has **no local LLM, no capture, no cron** — it is a thin recall shim.
8
+
9
+ **Capture happens centrally.** A nightly reader on the Hicortex server distills each agent's own session store (Hermes keeps full history in `~/.hermes/profiles/<agent>/state.db`), so nothing needs to be captured in real time. See `specs/2026-07-01-memory-capture-architecture.md` in the main repo.
10
+
11
+ ## How it works
12
+
13
+ | Hermes hook | What it does | Hicortex call |
14
+ |---|---|---|
15
+ | `prefetch(query)` | recall relevant memories before each turn | `GET /search` |
16
+ | `queue_prefetch(query)` | background recall for the next turn | `GET /search` |
17
+ | `system_prompt_block()` | inject distilled lessons + memory index | `GET /lessons` |
18
+ | `get_tool_schemas()` | exposes the 8 unified tools + `hicortex_recall_recent` | see tool table below |
19
+
20
+ That's the whole surface. No `sync_turn`, no compaction/session-end capture — those are intentionally absent.
21
+
22
+ ### Tools (unified 8 + 1 Hermes-specific)
23
+
24
+ | Tool | REST call | Description |
25
+ |---|---|---|
26
+ | `hicortex_search` | `GET /search` | Semantic search over long-term memory |
27
+ | `hicortex_context` | `GET /context` | Recent context memories by project |
28
+ | `hicortex_ingest` | `POST /ingest` | Store a new memory |
29
+ | `hicortex_lessons` | `GET /lessons` | Get distilled lessons |
30
+ | `hicortex_index` | `GET /index` | Knowledge domain index |
31
+ | `hicortex_graph` | `GET /graph` | Graph queries (neighbors/hubs/path) |
32
+ | `hicortex_update` | `POST /update` | Update a memory (re-embeds on content change) |
33
+ | `hicortex_delete` | `POST /delete` | Permanently delete a memory and its links |
34
+ | `hicortex_recall_recent` | `GET /context` | Hermes-specific alias for context recall |
35
+
36
+ ## Prerequisites
37
+
38
+ - A reachable Hicortex server (default `http://localhost:8787`). Stand one up with `npx @gamaze/hicortex init`.
39
+ - The server needs the REST `/search`, `/context`, `/lessons` endpoints (Hicortex ≥ 0.7).
40
+
41
+ ## Install
42
+
43
+ Hermes discovers user-installed providers from `$HERMES_HOME/plugins/<name>/`:
44
+
45
+ ```bash
46
+ cp -r hermes-plugin/hicortex "$HERMES_HOME/plugins/hicortex"
47
+ ```
48
+
49
+ (No `pip install` — the plugin is stdlib-only.)
50
+
51
+ ## Configure & activate
52
+
53
+ Use Hermes' own tooling — it discovers this plugin automatically and writes `config.yaml` correctly (**never hand-edit `config.yaml` with scripts/regex**):
54
+
55
+ ```bash
56
+ hermes memory setup # select "hicortex", enter the server URL/token when prompted
57
+ ```
58
+
59
+ 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.
60
+
61
+ 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:
62
+
63
+ ```bash
64
+ export HICORTEX_AUTH_TOKEN=hctx-default-token # or your custom token
65
+ ```
66
+
67
+ Env overrides: `HICORTEX_URL`, `HICORTEX_AUTH_TOKEN`.
68
+
69
+ ## Topology
70
+
71
+ - **Server host:** runs Hicortex. Set `hicortex_url: http://localhost:8787` (localhost bypasses auth).
72
+ - **Other Hermes boxes:** set `hicortex_url` to the server's Tailscale hostname (e.g. `http://memory-server:8787`) and `HICORTEX_AUTH_TOKEN` to the server's token. Each box recalls from the same shared brain.
73
+
74
+ ## Notes
75
+
76
+ - Localhost requests skip auth; remote requests require the bearer token.
77
+ - Recall failures are non-fatal — the plugin returns empty context and the turn proceeds.
@@ -0,0 +1,17 @@
1
+ """Hicortex memory provider plugin for Hermes — recall-only.
2
+
3
+ Recall: prefetch() -> GET /search (relevant memories before each turn)
4
+ tools -> hicortex_search / hicortex_recall_recent
5
+ system_prompt_block -> lessons injected into the system prompt
6
+
7
+ Capture is NOT the plugin's job. A nightly reader on the Hicortex server
8
+ distills each agent's own session store (Hermes: ~/.hermes/profiles/<agent>/
9
+ state.db) centrally. The plugin has no local LLM, no spool, no timer, and no
10
+ capture path.
11
+ """
12
+
13
+ from agent.memory_provider import MemoryProvider # noqa: F401 (loader scans for this name)
14
+
15
+ from .provider import HicortexProvider
16
+
17
+ __all__ = ["HicortexProvider"]
@@ -0,0 +1,162 @@
1
+ """Thin HTTP client for the Hicortex memory server.
2
+
3
+ Stdlib-only (no pip dependencies) so the plugin installs with zero friction.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ from typing import Any, Optional
13
+
14
+
15
+ class HicortexClient:
16
+ """Stateless HTTP client for the Hicortex REST surface."""
17
+
18
+ def __init__(
19
+ self,
20
+ base_url: str,
21
+ auth_token: Optional[str] = None,
22
+ timeout: float = 5.0,
23
+ ):
24
+ self.base_url = base_url.rstrip("/")
25
+ # Omit the token when targeting localhost — the server bypasses auth there.
26
+ # Match the server's bypass list exactly (mcp-server.ts): IPv4, IPv6,
27
+ # and IPv4-mapped-IPv6 (which Node reports for v4 clients on a 0.0.0.0 bind).
28
+ host = urllib.parse.urlparse(self.base_url).hostname or ""
29
+ self.auth_token = (
30
+ None
31
+ if host in ("127.0.0.1", "localhost", "::1", "::ffff:127.0.0.1")
32
+ else auth_token
33
+ )
34
+ self.timeout = timeout
35
+
36
+ def _headers(self) -> dict[str, str]:
37
+ h = {"Content-Type": "application/json", "Accept": "application/json"}
38
+ if self.auth_token:
39
+ h["Authorization"] = f"Bearer {self.auth_token}"
40
+ return h
41
+
42
+ def _get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
43
+ url = f"{self.base_url}{path}"
44
+ if params:
45
+ qs = urllib.parse.urlencode(
46
+ {k: v for k, v in params.items() if v is not None}
47
+ )
48
+ url = f"{url}?{qs}"
49
+ req = urllib.request.Request(url, headers=self._headers(), method="GET")
50
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
51
+ return json.loads(resp.read().decode("utf-8"))
52
+
53
+ def _post(self, path: str, body: dict[str, Any]) -> tuple[int, Any]:
54
+ """POST JSON body; returns (status_code, parsed_response)."""
55
+ url = f"{self.base_url}{path}"
56
+ data = json.dumps(body).encode("utf-8")
57
+ req = urllib.request.Request(url, data=data, headers=self._headers(), method="POST")
58
+ try:
59
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
60
+ return resp.status, json.loads(resp.read().decode("utf-8"))
61
+ except urllib.error.HTTPError as e:
62
+ body_bytes = e.read()
63
+ try:
64
+ parsed = json.loads(body_bytes.decode("utf-8"))
65
+ except Exception:
66
+ parsed = {"error": body_bytes.decode("utf-8", errors="replace")}
67
+ return e.code, parsed
68
+
69
+ # -- endpoints ------------------------------------------------------------
70
+
71
+ def health(self) -> dict[str, Any]:
72
+ return self._get("/health")
73
+
74
+ def search(
75
+ self,
76
+ query: str,
77
+ limit: int = 5,
78
+ project: Optional[str] = None,
79
+ privacy: Optional[str] = None,
80
+ ) -> list[dict]:
81
+ return self._get(
82
+ "/search",
83
+ {"query": query, "limit": limit, "project": project, "privacy": privacy},
84
+ ).get("results", [])
85
+
86
+ def context(
87
+ self,
88
+ project: Optional[str] = None,
89
+ limit: int = 10,
90
+ privacy: Optional[str] = None,
91
+ ) -> list[dict]:
92
+ return self._get(
93
+ "/context", {"project": project, "limit": limit, "privacy": privacy}
94
+ ).get("results", [])
95
+
96
+ def lessons(self) -> dict[str, Any]:
97
+ return self._get("/lessons")
98
+
99
+ def index(self) -> dict[str, Any]:
100
+ return self._get("/index")
101
+
102
+ def graph(
103
+ self,
104
+ op: str,
105
+ id: Optional[str] = None,
106
+ target_id: Optional[str] = None,
107
+ limit: Optional[int] = None,
108
+ domain: Optional[str] = None,
109
+ relationship: Optional[str] = None,
110
+ ) -> dict[str, Any]:
111
+ return self._get(
112
+ "/graph",
113
+ {
114
+ "op": op,
115
+ "id": id,
116
+ "target_id": target_id,
117
+ "limit": limit,
118
+ "domain": domain,
119
+ "relationship": relationship,
120
+ },
121
+ )
122
+
123
+ def ingest(
124
+ self,
125
+ content: str,
126
+ source_agent: Optional[str] = None,
127
+ project: Optional[str] = None,
128
+ memory_type: str = "episode",
129
+ privacy: str = "WORK",
130
+ ) -> tuple[int, dict[str, Any]]:
131
+ return self._post(
132
+ "/ingest",
133
+ {
134
+ "content": content,
135
+ "source_agent": source_agent or "hermes/manual",
136
+ "project": project,
137
+ "memory_type": memory_type,
138
+ "privacy": privacy,
139
+ },
140
+ )
141
+
142
+ def update(
143
+ self,
144
+ id: str,
145
+ content: Optional[str] = None,
146
+ project: Optional[str] = None,
147
+ memory_type: Optional[str] = None,
148
+ privacy: Optional[str] = None,
149
+ ) -> tuple[int, dict[str, Any]]:
150
+ body: dict[str, Any] = {"id": id}
151
+ if content is not None:
152
+ body["content"] = content
153
+ if project is not None:
154
+ body["project"] = project
155
+ if memory_type is not None:
156
+ body["memory_type"] = memory_type
157
+ if privacy is not None:
158
+ body["privacy"] = privacy
159
+ return self._post("/update", body)
160
+
161
+ def delete(self, id: str) -> tuple[int, dict[str, Any]]:
162
+ return self._post("/delete", {"id": id})