@gamaze/hicortex 0.7.1 → 0.10.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 +57 -39
- package/dist/claude-md.d.ts +9 -21
- package/dist/claude-md.js +9 -241
- package/dist/cli.d.ts +3 -2
- package/dist/cli.js +29 -11
- package/dist/consolidate.js +0 -7
- package/dist/db.js +24 -0
- package/dist/embedder.d.ts +11 -0
- package/dist/embedder.js +27 -0
- package/dist/extensions.d.ts +41 -88
- package/dist/extensions.js +36 -61
- package/dist/features.d.ts +21 -25
- package/dist/features.js +47 -83
- package/dist/hermes-transcript-reader.d.ts +27 -0
- package/dist/hermes-transcript-reader.js +134 -0
- package/dist/index.d.ts +16 -4
- package/dist/index.js +252 -344
- package/dist/init.d.ts +41 -1
- package/dist/init.js +545 -190
- package/dist/lesson-selection.d.ts +62 -0
- package/dist/lesson-selection.js +159 -0
- package/dist/lessons-context.d.ts +17 -0
- package/dist/lessons-context.js +96 -0
- package/dist/llm.d.ts +42 -29
- package/dist/llm.js +89 -270
- package/dist/mcp-server.d.ts +0 -1
- package/dist/mcp-server.js +404 -86
- package/dist/nightly.d.ts +9 -6
- package/dist/nightly.js +197 -357
- package/dist/oc-transcript-reader.d.ts +20 -0
- package/dist/oc-transcript-reader.js +61 -0
- package/dist/pi-transcript-reader.d.ts +1 -0
- package/dist/status.js +22 -2
- package/dist/storage.d.ts +7 -1
- package/dist/storage.js +28 -7
- package/dist/transcript-reader.d.ts +19 -0
- package/dist/transcript-reader.js +17 -3
- package/dist/types.d.ts +10 -0
- package/dist/uninstall.js +31 -1
- package/hermes-plugin/hicortex/README.md +77 -0
- package/hermes-plugin/hicortex/__init__.py +17 -0
- package/hermes-plugin/hicortex/client.py +162 -0
- package/hermes-plugin/hicortex/config.py +105 -0
- package/hermes-plugin/hicortex/plugin.yaml +12 -0
- package/hermes-plugin/hicortex/provider.py +432 -0
- package/openclaw.plugin.json +17 -44
- package/package.json +7 -5
- package/dist/pro-loader.d.ts +0 -33
- 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
|
+
}
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 <
|
|
85
|
-
return null; //
|
|
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 <
|
|
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
|
|
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})
|