@gamaze/hicortex 0.14.1 → 0.14.3
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 +3 -3
- package/dist/index.d.ts +8 -2
- package/dist/index.js +148 -10
- package/dist/mcp-server.js +25 -38
- package/dist/recall-index.d.ts +29 -1
- package/dist/recall-index.js +62 -1
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +17 -0
- package/hermes-plugin/hicortex/README.md +11 -5
- package/hermes-plugin/hicortex/__init__.py +3 -2
- package/hermes-plugin/hicortex/client.py +97 -17
- package/hermes-plugin/hicortex/config.py +5 -1
- package/hermes-plugin/hicortex/plugin.yaml +2 -2
- package/hermes-plugin/hicortex/provider.py +200 -8
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ Connects to a remote Hicortex server. No local database or local LLM needed. The
|
|
|
26
26
|
|
|
27
27
|
## Install — Hermes
|
|
28
28
|
|
|
29
|
-
The Hermes plugin is a recall-only adapter: it
|
|
29
|
+
The Hermes plugin is a recall-only adapter: it pushes a compact recall index every turn (lazy-loaded with `hicortex_get`), injects fresh lessons, and exposes the full 9-tool memory surface, backed by a Hicortex server (local or remote). Capture happens automatically — the server machine's nightly job reads each Hermes profile's `state.db`.
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
32
|
# 1. Install the plugin (prompts for server URL + auth token; leave empty for a local server)
|
|
@@ -65,8 +65,8 @@ The plugin connects to `http://127.0.0.1:8787` by default. For a remote server,
|
|
|
65
65
|
|
|
66
66
|
| When | What | How |
|
|
67
67
|
|------|------|-----|
|
|
68
|
-
| Agent start | Standing context (`## Context`) + recent lessons fetched fresh and injected | CC SessionStart hook (calls `hicortex lessons-context`) / Hermes plugin `system_prompt_block`
|
|
69
|
-
| Every prompt (0.14) | A compact **recall index** of relevant memories is injected — one line per memory; the agent lazy-loads full content with `hicortex_get` only when needed | CC UserPromptSubmit hook (
|
|
68
|
+
| Agent start | Standing context (`## Context`) + recent lessons fetched fresh and injected | CC SessionStart hook (calls `hicortex lessons-context`) / Hermes plugin `system_prompt_block` / OC `before_agent_start` hook |
|
|
69
|
+
| Every prompt (0.14) | A compact **recall index** of relevant memories is injected — one line per memory; the agent lazy-loads full content with `hicortex_get` only when needed | All three harnesses call server `POST /recall-index` per turn: CC UserPromptSubmit hook (`hicortex recall-hook`), Hermes plugin `prefetch` (0.7.0; falls back to `/search` injection against a pre-0.14 server), OC `before_agent_start` hook (fires per inbound message). Turn-based dedup per session; resets on new session/compaction. Fail-soft |
|
|
70
70
|
| Nightly | Denoise sessions → POST /distill → server distills + embeds + stores → consolidate (score, reflect, link, decay) | Automatic pipeline — no manual steps |
|
|
71
71
|
|
|
72
72
|
**Exposure vs use (0.14):** appearing in the recall index only marks a memory as *shown* (it stops decaying while topically active); fetching it with `hicortex_get` marks it as *used* (durable strengthening). Memory importance is driven by what agents actually use, not by what was pushed at them.
|
package/dist/index.d.ts
CHANGED
|
@@ -9,8 +9,14 @@
|
|
|
9
9
|
* Run server: `npx @gamaze/hicortex init`
|
|
10
10
|
*
|
|
11
11
|
* Responsibilities (recall-only adapter, like the Hermes plugin):
|
|
12
|
-
* - before_agent_start → GET /
|
|
13
|
-
*
|
|
12
|
+
* - before_agent_start → GET /context + GET /lessons + POST /recall-index
|
|
13
|
+
* (fail-soft, 3s timeout each, concurrent) → inject context. In OpenClaw
|
|
14
|
+
* every inbound message spawns an embedded run, so this hook fires PER
|
|
15
|
+
* TURN with the current prompt and session id — it is the per-turn
|
|
16
|
+
* /recall-index surface, not just session start.
|
|
17
|
+
* - after_compaction / before_reset → POST /recall-index {reset:true}
|
|
18
|
+
* (context rebuilt → the server's per-session shown-set is stale)
|
|
19
|
+
* - Tools → HTTP proxies to /search, /memory, /recent, /ingest, /lessons
|
|
14
20
|
*
|
|
15
21
|
* CAPTURE IS NOT THIS PLUGIN'S JOB. OpenClaw persists sessions at
|
|
16
22
|
* ~/.openclaw/agents/<agentId>/sessions/*.jsonl in the Pi v3 format; the
|
package/dist/index.js
CHANGED
|
@@ -10,8 +10,14 @@
|
|
|
10
10
|
* Run server: `npx @gamaze/hicortex init`
|
|
11
11
|
*
|
|
12
12
|
* Responsibilities (recall-only adapter, like the Hermes plugin):
|
|
13
|
-
* - before_agent_start → GET /
|
|
14
|
-
*
|
|
13
|
+
* - before_agent_start → GET /context + GET /lessons + POST /recall-index
|
|
14
|
+
* (fail-soft, 3s timeout each, concurrent) → inject context. In OpenClaw
|
|
15
|
+
* every inbound message spawns an embedded run, so this hook fires PER
|
|
16
|
+
* TURN with the current prompt and session id — it is the per-turn
|
|
17
|
+
* /recall-index surface, not just session start.
|
|
18
|
+
* - after_compaction / before_reset → POST /recall-index {reset:true}
|
|
19
|
+
* (context rebuilt → the server's per-session shown-set is stale)
|
|
20
|
+
* - Tools → HTTP proxies to /search, /memory, /recent, /ingest, /lessons
|
|
15
21
|
*
|
|
16
22
|
* CAPTURE IS NOT THIS PLUGIN'S JOB. OpenClaw persists sessions at
|
|
17
23
|
* ~/.openclaw/agents/<agentId>/sessions/*.jsonl in the Pi v3 format; the
|
|
@@ -34,6 +40,7 @@ const node_os_1 = require("node:os");
|
|
|
34
40
|
const DEFAULT_SERVER_URL = "http://127.0.0.1:8787";
|
|
35
41
|
const LESSONS_TIMEOUT_MS = 3000;
|
|
36
42
|
const CONTEXT_TIMEOUT_MS = 3000;
|
|
43
|
+
const RECALL_TIMEOUT_MS = 3000;
|
|
37
44
|
const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
|
|
38
45
|
/** Harness name this plugin injects for — used to self-gate on GET /context `clients`. */
|
|
39
46
|
const THIS_HARNESS = "oc";
|
|
@@ -43,6 +50,26 @@ const THIS_HARNESS = "oc";
|
|
|
43
50
|
let serverUrl = DEFAULT_SERVER_URL;
|
|
44
51
|
let authToken;
|
|
45
52
|
let hicortexHome = HICORTEX_HOME;
|
|
53
|
+
/** Old-server guard (F2): 0 = not latched; otherwise the Date.now() epoch-ms
|
|
54
|
+
* until which /recall-index is skipped after a 404 (pre-0.14 server). The
|
|
55
|
+
* latch EXPIRES so a client-first rollout heals itself once the server is
|
|
56
|
+
* upgraded — a permanent latch would silently disable recall on a
|
|
57
|
+
* long-running gateway until restart. OC has no pre-0.14 per-turn recall to
|
|
58
|
+
* fall back to, so "skip" IS the old behavior. */
|
|
59
|
+
let recallIndexRetryAtMs = 0;
|
|
60
|
+
/** How long a 404 latches the guard before re-probing. Long enough not to
|
|
61
|
+
* hammer an old server every turn, short enough that a server upgrade is
|
|
62
|
+
* picked up within minutes. */
|
|
63
|
+
const RECALL_REPROBE_INTERVAL_MS = 600_000;
|
|
64
|
+
/** Warn-once flag (F5): the recall index needs ctx.sessionId from the
|
|
65
|
+
* gateway; if a gateway variant doesn't pass it the feature must not run
|
|
66
|
+
* silently dead. */
|
|
67
|
+
let warnedMissingSessionId = false;
|
|
68
|
+
/** Plugin logger captured at service start (ctx.logger or console). */
|
|
69
|
+
let pluginLog = console.log;
|
|
70
|
+
function recallIndexLatched() {
|
|
71
|
+
return recallIndexRetryAtMs !== 0 && Date.now() < recallIndexRetryAtMs;
|
|
72
|
+
}
|
|
46
73
|
// ---------------------------------------------------------------------------
|
|
47
74
|
// HTTP helpers
|
|
48
75
|
// ---------------------------------------------------------------------------
|
|
@@ -148,6 +175,59 @@ async function buildLessonsBlock(project) {
|
|
|
148
175
|
formatted.join("\n"));
|
|
149
176
|
}
|
|
150
177
|
// ---------------------------------------------------------------------------
|
|
178
|
+
// Pushed recall index (#193) — per-turn POST /recall-index
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
/**
|
|
181
|
+
* Fetch the pushed recall index for this turn, or null when there is nothing
|
|
182
|
+
* to inject (null block, no session id, failure, or a pre-0.14 server). The
|
|
183
|
+
* server does all relevance gating and per-session TURN-based dedup — the
|
|
184
|
+
* plugin sends every turn and carries no tuning constants. A 404 flips the
|
|
185
|
+
* module-level guard so an old server is probed once per gateway process.
|
|
186
|
+
*/
|
|
187
|
+
async function fetchRecallIndexBlock(sessionId, prompt) {
|
|
188
|
+
if (recallIndexLatched())
|
|
189
|
+
return null;
|
|
190
|
+
if (!sessionId || !prompt) {
|
|
191
|
+
// Verified against the installed OpenClaw gateway dist (auth-profiles
|
|
192
|
+
// bundle, runEmbeddedPiAgent → hookCtx): before_agent_start receives
|
|
193
|
+
// {agentId, sessionKey, sessionId, workspaceDir, …} on every run. If a
|
|
194
|
+
// gateway variant does NOT pass sessionId, the feature would run silently
|
|
195
|
+
// dead behind fail-soft — warn once per process so it is diagnosable.
|
|
196
|
+
if (!sessionId && prompt && !warnedMissingSessionId) {
|
|
197
|
+
warnedMissingSessionId = true;
|
|
198
|
+
pluginLog("[hicortex] WARNING: before_agent_start ctx has no sessionId — " +
|
|
199
|
+
"per-turn memory recall is disabled. Upgrade OpenClaw (the gateway " +
|
|
200
|
+
"must pass sessionId to plugin hooks).");
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const { ok, status, data } = await serverPost("/recall-index", { session_id: sessionId, prompt }, RECALL_TIMEOUT_MS);
|
|
205
|
+
if (status === 404) {
|
|
206
|
+
recallIndexRetryAtMs = Date.now() + RECALL_REPROBE_INTERVAL_MS;
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
if (!ok || !data)
|
|
210
|
+
return null;
|
|
211
|
+
recallIndexRetryAtMs = 0;
|
|
212
|
+
return typeof data.block === "string" && data.block.trim() !== "" ? data.block : null;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Reset the session's server-side recall dedup — the context window was
|
|
216
|
+
* rebuilt (compaction or session reset), so the shown-set is stale by
|
|
217
|
+
* definition. Fire-and-forget fail-soft: a reset that is lost only means some
|
|
218
|
+
* memories stay suppressed until the re-show window (`recallReshowTurns`)
|
|
219
|
+
* passes.
|
|
220
|
+
*/
|
|
221
|
+
async function postRecallReset(sessionId) {
|
|
222
|
+
if (recallIndexLatched())
|
|
223
|
+
return;
|
|
224
|
+
if (!sessionId)
|
|
225
|
+
return;
|
|
226
|
+
const { status } = await serverPost("/recall-index", { session_id: sessionId, reset: true }, RECALL_TIMEOUT_MS);
|
|
227
|
+
if (status === 404)
|
|
228
|
+
recallIndexRetryAtMs = Date.now() + RECALL_REPROBE_INTERVAL_MS;
|
|
229
|
+
}
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
151
231
|
// Tool result formatter
|
|
152
232
|
// ---------------------------------------------------------------------------
|
|
153
233
|
function formatToolResults(results) {
|
|
@@ -182,6 +262,11 @@ exports.default = {
|
|
|
182
262
|
authToken = config.authToken;
|
|
183
263
|
// Use stateDir from context so tests can redirect state writes
|
|
184
264
|
hicortexHome = ctx.stateDir ?? HICORTEX_HOME;
|
|
265
|
+
// Re-probe /recall-index support on every (re)start — the server may
|
|
266
|
+
// have been upgraded while the gateway was down.
|
|
267
|
+
recallIndexRetryAtMs = 0;
|
|
268
|
+
warnedMissingSessionId = false;
|
|
269
|
+
pluginLog = log;
|
|
185
270
|
log(`[hicortex] Thin-client mode — server: ${serverUrl}`);
|
|
186
271
|
// License: init feature cache (only needs licenseKey, no DB access)
|
|
187
272
|
await (0, features_js_1.initFeatures)(config.licenseKey, hicortexHome);
|
|
@@ -211,9 +296,11 @@ exports.default = {
|
|
|
211
296
|
},
|
|
212
297
|
});
|
|
213
298
|
// -----------------------------------------------------------------------
|
|
214
|
-
// Hook: before_agent_start — fetch context + lessons
|
|
299
|
+
// Hook: before_agent_start — fetch context + lessons + recall index
|
|
300
|
+
// (fail-soft). Fires per embedded run = per inbound message in OpenClaw,
|
|
301
|
+
// so the recall index rides the same hook as the per-turn surface.
|
|
215
302
|
// -----------------------------------------------------------------------
|
|
216
|
-
api.on("before_agent_start", async (
|
|
303
|
+
api.on("before_agent_start", async (event, ctx) => {
|
|
217
304
|
// Outer guard: the hook must NEVER throw (a rejection could block the
|
|
218
305
|
// agent). `ctx` itself can be nullish on some gateway variants, and the
|
|
219
306
|
// synchronous sanitize below runs before any per-fetch .catch — so the
|
|
@@ -223,15 +310,16 @@ exports.default = {
|
|
|
223
310
|
// sanitizes to null → bare /context → global set). Null id never sends
|
|
224
311
|
// ?agent=, so an old server behaves exactly as before.
|
|
225
312
|
const agentId = (0, context_store_js_1.sanitizeAgentId)(ctx?.agentId ?? "");
|
|
226
|
-
// Fetch
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
// `##
|
|
230
|
-
const [contextBlock, lessonsBlock] = await Promise.all([
|
|
313
|
+
// Fetch all three concurrently with INDEPENDENT fail-soft: no block
|
|
314
|
+
// may ever cost another. Order in the injected context: `## Context`
|
|
315
|
+
// (standing context, 0.13) → `## Hicortex Lessons` → the per-turn
|
|
316
|
+
// `## Memory recall (auto)` index (#193, closest to the prompt).
|
|
317
|
+
const [contextBlock, lessonsBlock, recallBlock] = await Promise.all([
|
|
231
318
|
fetchOcContextBlock(agentId).catch(() => null),
|
|
232
319
|
buildLessonsBlock(ctx?.project).catch(() => null),
|
|
320
|
+
fetchRecallIndexBlock(ctx?.sessionId, event?.prompt).catch(() => null),
|
|
233
321
|
]);
|
|
234
|
-
const blocks = [contextBlock, lessonsBlock].filter((b) => b !== null && b !== "");
|
|
322
|
+
const blocks = [contextBlock, lessonsBlock, recallBlock].filter((b) => b !== null && b !== "");
|
|
235
323
|
if (blocks.length === 0)
|
|
236
324
|
return {};
|
|
237
325
|
return { appendSystemContext: `\n\n${blocks.join("\n\n")}\n` };
|
|
@@ -241,6 +329,22 @@ exports.default = {
|
|
|
241
329
|
}
|
|
242
330
|
});
|
|
243
331
|
// -----------------------------------------------------------------------
|
|
332
|
+
// Hooks: after_compaction / before_reset — reset the session's recall
|
|
333
|
+
// dedup (#193). Both rebuild the context window, so the server's
|
|
334
|
+
// per-session shown-set no longer reflects what the agent can see.
|
|
335
|
+
// Unknown hook names are ignored by older gateways (typed-hook registry
|
|
336
|
+
// warns and drops them), so registering both is safe everywhere.
|
|
337
|
+
// -----------------------------------------------------------------------
|
|
338
|
+
const recallResetHook = (_event, ctx) => {
|
|
339
|
+
// Genuinely fire-and-forget (F9): no await — a slow server must never
|
|
340
|
+
// add latency to compaction or session reset in the gateway.
|
|
341
|
+
void postRecallReset(ctx?.sessionId).catch(() => {
|
|
342
|
+
/* fail-soft — never surface into the gateway */
|
|
343
|
+
});
|
|
344
|
+
};
|
|
345
|
+
api.on("after_compaction", recallResetHook);
|
|
346
|
+
api.on("before_reset", recallResetHook);
|
|
347
|
+
// -----------------------------------------------------------------------
|
|
244
348
|
// Tools — HTTP proxies to server REST API
|
|
245
349
|
// -----------------------------------------------------------------------
|
|
246
350
|
api.registerTool((_ctx) => ({
|
|
@@ -272,6 +376,39 @@ exports.default = {
|
|
|
272
376
|
}
|
|
273
377
|
},
|
|
274
378
|
}), { name: "hicortex_search" });
|
|
379
|
+
api.registerTool((_ctx) => ({
|
|
380
|
+
name: "hicortex_get",
|
|
381
|
+
description: "Fetch ONE memory's full content by id — use this to lazy-load entries from the '## Memory recall (auto)' index or from search results whose snippet was not enough. Fetching a memory marks it as used (strengthens it), so only fetch what you actually need. When the memory shapes your answer, cite it as given in the response.",
|
|
382
|
+
parameters: {
|
|
383
|
+
type: "object",
|
|
384
|
+
properties: {
|
|
385
|
+
id: { type: "string", description: "Memory ID (as shown in the recall index or search results)" },
|
|
386
|
+
},
|
|
387
|
+
required: ["id"],
|
|
388
|
+
},
|
|
389
|
+
async execute(_callId, args, _ctx) {
|
|
390
|
+
try {
|
|
391
|
+
if (!args?.id)
|
|
392
|
+
return { error: "id is required" };
|
|
393
|
+
const params = new URLSearchParams({ id: String(args.id) });
|
|
394
|
+
const { data, status } = await serverGet(`/memory?${params}`, 10000);
|
|
395
|
+
if (status === 404) {
|
|
396
|
+
// Either no such memory (0.14+) or a pre-0.14 server with no
|
|
397
|
+
// /memory endpoint — the id hint covers the common case.
|
|
398
|
+
return { error: `Memory not found: ${args.id} (or the server predates 0.14 — upgrade the server)` };
|
|
399
|
+
}
|
|
400
|
+
if (!data)
|
|
401
|
+
return { error: `Get failed: ${describeGetFailure(status, "/memory")}` };
|
|
402
|
+
// Render the content BEHIND the server's citation string — the
|
|
403
|
+
// server-side rendering is the single provenance norm (0.14.1).
|
|
404
|
+
const text = `${data.citation ?? ""}\n\n${data.memory?.content ?? ""}`.trim();
|
|
405
|
+
return { content: [{ type: "text", text }] };
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
return { error: `Get failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
}), { name: "hicortex_get" });
|
|
275
412
|
api.registerTool((_ctx) => ({
|
|
276
413
|
name: "hicortex_recent",
|
|
277
414
|
description: "Get recent memories, optionally filtered by project. Queryless recall of the latest memories by project, ranked by importance. Useful to catch up on what happened recently.",
|
|
@@ -495,6 +632,7 @@ exports.default = {
|
|
|
495
632
|
// ---------------------------------------------------------------------------
|
|
496
633
|
const HICORTEX_TOOLS = [
|
|
497
634
|
"hicortex_search",
|
|
635
|
+
"hicortex_get",
|
|
498
636
|
"hicortex_recent",
|
|
499
637
|
"hicortex_ingest",
|
|
500
638
|
"hicortex_lessons",
|
package/dist/mcp-server.js
CHANGED
|
@@ -130,11 +130,14 @@ function createMcpServer() {
|
|
|
130
130
|
if (!db)
|
|
131
131
|
return { content: [{ type: "text", text: "Hicortex not initialized" }], isError: true };
|
|
132
132
|
try {
|
|
133
|
-
|
|
133
|
+
// Prefix ids resolve (F6): citations show the 8-char id, so this tool
|
|
134
|
+
// must accept it like /update and /delete do.
|
|
135
|
+
const fullId = storage.resolveMemoryId(db, id);
|
|
136
|
+
const mem = fullId ? storage.getMemory(db, fullId) : null;
|
|
134
137
|
if (!mem)
|
|
135
138
|
return { content: [{ type: "text", text: `No memory with id ${id}` }], isError: true };
|
|
136
139
|
// Real use → full strengthen (access_count + hardening + prune shield).
|
|
137
|
-
storage.strengthenMemory(db, id, new Date().toISOString());
|
|
140
|
+
storage.strengthenMemory(db, mem.id, new Date().toISOString());
|
|
138
141
|
// Provenance header (built-in citing norm, 0.14.1): id, type, project,
|
|
139
142
|
// ORIGIN AGENT (shared brain — the memory may come from another
|
|
140
143
|
// agent's session), and date, plus the explicit citation instruction.
|
|
@@ -654,37 +657,30 @@ async function startServer(options = {}) {
|
|
|
654
657
|
const r = await (0, recall_index_js_1.handleRecallIndex)({
|
|
655
658
|
db,
|
|
656
659
|
registry: recallRegistry,
|
|
657
|
-
|
|
660
|
+
// Client-pushed project/privacy scoping (F1) rides through to
|
|
661
|
+
// retrieval, which handles the filtered over-fetch itself.
|
|
662
|
+
retrieveFn: (query, limit, filters) => retrieval.retrieve(db, embedder_js_1.embed, query, {
|
|
663
|
+
limit,
|
|
664
|
+
noStrengthen: true,
|
|
665
|
+
project: filters?.project,
|
|
666
|
+
privacy: filters?.privacy,
|
|
667
|
+
}),
|
|
658
668
|
options: recallIndexOptions,
|
|
659
669
|
}, req.body);
|
|
660
670
|
res.status(r.status).json(r.body);
|
|
661
671
|
});
|
|
662
|
-
// REST /memory?id= — fetch one memory's full content (lazy-load
|
|
663
|
-
// of /recall-index for REST clients: Hermes/OC plugins). Marks
|
|
672
|
+
// REST /memory?id=[&privacy=] — fetch one memory's full content (lazy-load
|
|
673
|
+
// counterpart of /recall-index for REST clients: Hermes/OC plugins). Marks
|
|
674
|
+
// it as used. Prefix ids resolve; a privacy filter miss reads as 404 (no
|
|
675
|
+
// existence leak). Logic in handleMemoryGet (recall-index.ts).
|
|
664
676
|
app.get("/memory", (req, res) => {
|
|
665
677
|
if (!db) {
|
|
666
678
|
res.status(503).json({ error: "Server not initialized" });
|
|
667
679
|
return;
|
|
668
680
|
}
|
|
669
|
-
const id = typeof req.query.id === "string" ? req.query.id : "";
|
|
670
|
-
if (!id) {
|
|
671
|
-
res.status(400).json({ error: "Missing 'id'" });
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
681
|
try {
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
res.status(404).json({ error: `No memory with id ${id}` });
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
storage.strengthenMemory(db, id, new Date().toISOString());
|
|
681
|
-
// `citation` is server-rendered so every plugin surfaces the same
|
|
682
|
-
// built-in provenance norm (owner directive 27.07) — see #193.
|
|
683
|
-
const date = (mem.created_at ?? "").slice(0, 10);
|
|
684
|
-
res.json({
|
|
685
|
-
memory: mem,
|
|
686
|
-
citation: `(memory ${String(mem.id).slice(0, 8)}, ${date}, from ${mem.source_agent ?? "unknown"})`,
|
|
687
|
-
});
|
|
682
|
+
const r = (0, recall_index_js_1.handleMemoryGet)(db, { id: req.query.id, privacy: req.query.privacy });
|
|
683
|
+
res.status(r.status).json(r.body);
|
|
688
684
|
}
|
|
689
685
|
catch (err) {
|
|
690
686
|
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
@@ -1201,21 +1197,9 @@ async function startServer(options = {}) {
|
|
|
1201
1197
|
// ---------------------------------------------------------------------------
|
|
1202
1198
|
/**
|
|
1203
1199
|
* Resolve a short ID prefix (e.g. "a1b2c3d4") to a full memory UUID.
|
|
1200
|
+
* Canonical implementation lives in storage.ts (shared with handleMemoryGet).
|
|
1204
1201
|
*/
|
|
1205
|
-
|
|
1206
|
-
if (idPrefix.length >= 36) {
|
|
1207
|
-
// Full UUID — check existence
|
|
1208
|
-
const row = database.prepare("SELECT id FROM memories WHERE id = ?").get(idPrefix);
|
|
1209
|
-
return row?.id ?? null;
|
|
1210
|
-
}
|
|
1211
|
-
// Short prefix — find matching memory
|
|
1212
|
-
const rows = database.prepare("SELECT id FROM memories WHERE id LIKE ?").all(`${idPrefix}%`);
|
|
1213
|
-
if (rows.length === 1)
|
|
1214
|
-
return rows[0].id;
|
|
1215
|
-
if (rows.length > 1)
|
|
1216
|
-
return null; // Ambiguous
|
|
1217
|
-
return null;
|
|
1218
|
-
}
|
|
1202
|
+
const resolveMemoryId = storage.resolveMemoryId;
|
|
1219
1203
|
/**
|
|
1220
1204
|
* Read ~/.hicortex/config.json (persisted by init with LLM and license config).
|
|
1221
1205
|
*/
|
|
@@ -1271,7 +1255,10 @@ function fixDaemonVersionPin() {
|
|
|
1271
1255
|
function formatResults(results) {
|
|
1272
1256
|
if (results.length === 0)
|
|
1273
1257
|
return "No memories found.";
|
|
1258
|
+
// The id makes every recall surface feed the rest of the toolset: cite-on-use
|
|
1259
|
+
// (id + date), hicortex_get lazy-load of truncated content, hicortex_graph
|
|
1260
|
+
// entry points, and hicortex_update/delete self-correction (#192).
|
|
1274
1261
|
return results
|
|
1275
|
-
.map((r) => `[${r.memory_type}] (score: ${r.score.toFixed(3)}, strength: ${r.effective_strength.toFixed(3)}) ${r.content.slice(0, 500)}`)
|
|
1262
|
+
.map((r) => `[${r.id}] [${r.memory_type}] (${(r.created_at ?? "").slice(0, 10)}, score: ${r.score.toFixed(3)}, strength: ${r.effective_strength.toFixed(3)}) ${r.content.slice(0, 500)}`)
|
|
1276
1263
|
.join("\n\n");
|
|
1277
1264
|
}
|
package/dist/recall-index.d.ts
CHANGED
|
@@ -41,14 +41,42 @@ export interface RecallIndexResult {
|
|
|
41
41
|
export declare function memoryTitle(content: string, maxLen?: number): string;
|
|
42
42
|
/** Relevance gate: real text match, or measured cosine above the floor. */
|
|
43
43
|
export declare function passesRelevanceGate(r: MemorySearchResult, minSimilarity: number): boolean;
|
|
44
|
+
/** Recall filters a client may push per request (#193 review F1): a scoped
|
|
45
|
+
* plugin (Hermes privacy_filter / default_project) must be able to narrow
|
|
46
|
+
* recall exactly like the legacy /search prefetch did — dropping them
|
|
47
|
+
* silently would leak out-of-scope memory titles into the injected index. */
|
|
48
|
+
export interface RecallFilters {
|
|
49
|
+
project?: string;
|
|
50
|
+
privacy?: string[];
|
|
51
|
+
}
|
|
44
52
|
export interface RecallIndexDeps {
|
|
45
53
|
db: Database.Database;
|
|
46
54
|
registry: SessionRecallRegistry;
|
|
47
|
-
retrieveFn: (query: string, limit: number) => Promise<MemorySearchResult[]>;
|
|
55
|
+
retrieveFn: (query: string, limit: number, filters?: RecallFilters) => Promise<MemorySearchResult[]>;
|
|
48
56
|
options?: RecallIndexOptions;
|
|
49
57
|
}
|
|
58
|
+
/** Normalize a request-supplied privacy filter: array of strings or a CSV
|
|
59
|
+
* string → string[] | undefined. Anything else (or an empty result) means
|
|
60
|
+
* "no filter" — never a partial guess. */
|
|
61
|
+
export declare function parsePrivacyParam(v: unknown): string[] | undefined;
|
|
50
62
|
/**
|
|
51
63
|
* Handle a /recall-index request body. Thin Express adapter in mcp-server.ts;
|
|
52
64
|
* all behavior lives here so tests exercise it directly.
|
|
53
65
|
*/
|
|
54
66
|
export declare function handleRecallIndex(deps: RecallIndexDeps, body: unknown): Promise<RecallIndexResult>;
|
|
67
|
+
/**
|
|
68
|
+
* Handle a GET /memory request (lazy-load counterpart of the recall index for
|
|
69
|
+
* REST clients). Thin Express adapter in mcp-server.ts; behavior lives here so
|
|
70
|
+
* tests exercise it directly.
|
|
71
|
+
*
|
|
72
|
+
* - Short/prefix ids resolve via storage.resolveMemoryId (F6) — the 8-char
|
|
73
|
+
* citation ids agents are taught must work here like on /update, /delete.
|
|
74
|
+
* - Optional `privacy` filter (array or CSV): when present and the memory's
|
|
75
|
+
* privacy level is not in the allowed set, respond 404 with the SAME
|
|
76
|
+
* not-found message — a scoped client must not learn the memory exists.
|
|
77
|
+
* - A successful fetch is real use: access_count + 1 (strengthen).
|
|
78
|
+
*/
|
|
79
|
+
export declare function handleMemoryGet(db: Database.Database, query: {
|
|
80
|
+
id?: unknown;
|
|
81
|
+
privacy?: unknown;
|
|
82
|
+
}): RecallIndexResult;
|
package/dist/recall-index.js
CHANGED
|
@@ -56,7 +56,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
56
56
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
57
57
|
exports.memoryTitle = memoryTitle;
|
|
58
58
|
exports.passesRelevanceGate = passesRelevanceGate;
|
|
59
|
+
exports.parsePrivacyParam = parsePrivacyParam;
|
|
59
60
|
exports.handleRecallIndex = handleRecallIndex;
|
|
61
|
+
exports.handleMemoryGet = handleMemoryGet;
|
|
60
62
|
const storage = __importStar(require("./storage.js"));
|
|
61
63
|
const DEFAULT_MIN_SIMILARITY = 0.55;
|
|
62
64
|
const DEFAULT_MAX_ITEMS = 6;
|
|
@@ -96,6 +98,18 @@ function passesRelevanceGate(r, minSimilarity) {
|
|
|
96
98
|
return true;
|
|
97
99
|
return typeof r.similarity === "number" && r.similarity >= minSimilarity;
|
|
98
100
|
}
|
|
101
|
+
/** Normalize a request-supplied privacy filter: array of strings or a CSV
|
|
102
|
+
* string → string[] | undefined. Anything else (or an empty result) means
|
|
103
|
+
* "no filter" — never a partial guess. */
|
|
104
|
+
function parsePrivacyParam(v) {
|
|
105
|
+
const items = Array.isArray(v)
|
|
106
|
+
? v.filter((x) => typeof x === "string")
|
|
107
|
+
: typeof v === "string"
|
|
108
|
+
? v.split(",")
|
|
109
|
+
: [];
|
|
110
|
+
const cleaned = items.map((s) => s.trim()).filter(Boolean);
|
|
111
|
+
return cleaned.length > 0 ? cleaned : undefined;
|
|
112
|
+
}
|
|
99
113
|
/**
|
|
100
114
|
* Handle a /recall-index request body. Thin Express adapter in mcp-server.ts;
|
|
101
115
|
* all behavior lives here so tests exercise it directly.
|
|
@@ -120,9 +134,15 @@ async function handleRecallIndex(deps, body) {
|
|
|
120
134
|
const maxItems = clampInt(deps.options?.maxItems, DEFAULT_MAX_ITEMS, 1, 20);
|
|
121
135
|
const minSimilarity = clampNumber(deps.options?.minSimilarity, DEFAULT_MIN_SIMILARITY, 0, 1);
|
|
122
136
|
const turn = deps.registry.beginTurn(sessionId);
|
|
137
|
+
// Optional client-side scoping (F1): project + privacy ride the body and
|
|
138
|
+
// are pushed into retrieval (which handles filtered over-fetch itself).
|
|
139
|
+
const filters = {
|
|
140
|
+
project: typeof req.project === "string" && req.project ? req.project : undefined,
|
|
141
|
+
privacy: parsePrivacyParam(req.privacy),
|
|
142
|
+
};
|
|
123
143
|
let results;
|
|
124
144
|
try {
|
|
125
|
-
results = await deps.retrieveFn(prompt, maxItems * CANDIDATE_MULTIPLIER);
|
|
145
|
+
results = await deps.retrieveFn(prompt, maxItems * CANDIDATE_MULTIPLIER, filters);
|
|
126
146
|
}
|
|
127
147
|
catch (err) {
|
|
128
148
|
return {
|
|
@@ -155,6 +175,47 @@ async function handleRecallIndex(deps, body) {
|
|
|
155
175
|
].join("\n");
|
|
156
176
|
return { status: 200, body: { block, shown: ids, turn } };
|
|
157
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Handle a GET /memory request (lazy-load counterpart of the recall index for
|
|
180
|
+
* REST clients). Thin Express adapter in mcp-server.ts; behavior lives here so
|
|
181
|
+
* tests exercise it directly.
|
|
182
|
+
*
|
|
183
|
+
* - Short/prefix ids resolve via storage.resolveMemoryId (F6) — the 8-char
|
|
184
|
+
* citation ids agents are taught must work here like on /update, /delete.
|
|
185
|
+
* - Optional `privacy` filter (array or CSV): when present and the memory's
|
|
186
|
+
* privacy level is not in the allowed set, respond 404 with the SAME
|
|
187
|
+
* not-found message — a scoped client must not learn the memory exists.
|
|
188
|
+
* - A successful fetch is real use: access_count + 1 (strengthen).
|
|
189
|
+
*/
|
|
190
|
+
function handleMemoryGet(db, query) {
|
|
191
|
+
const id = typeof query.id === "string" ? query.id : "";
|
|
192
|
+
if (!id)
|
|
193
|
+
return { status: 400, body: { error: "Missing 'id'" } };
|
|
194
|
+
const notFound = {
|
|
195
|
+
status: 404,
|
|
196
|
+
body: { error: `No memory with id ${id}` },
|
|
197
|
+
};
|
|
198
|
+
const fullId = storage.resolveMemoryId(db, id);
|
|
199
|
+
if (!fullId)
|
|
200
|
+
return notFound;
|
|
201
|
+
const mem = storage.getMemory(db, fullId);
|
|
202
|
+
if (!mem)
|
|
203
|
+
return notFound;
|
|
204
|
+
const privacy = parsePrivacyParam(query.privacy);
|
|
205
|
+
if (privacy && !privacy.includes(mem.privacy))
|
|
206
|
+
return notFound;
|
|
207
|
+
storage.strengthenMemory(db, fullId, new Date().toISOString());
|
|
208
|
+
// `citation` is server-rendered so every plugin surfaces the same built-in
|
|
209
|
+
// provenance norm (owner directive 27.07) — see #193.
|
|
210
|
+
const date = (mem.created_at ?? "").slice(0, 10);
|
|
211
|
+
return {
|
|
212
|
+
status: 200,
|
|
213
|
+
body: {
|
|
214
|
+
memory: mem,
|
|
215
|
+
citation: `(memory ${String(mem.id).slice(0, 8)}, ${date}, from ${mem.source_agent ?? "unknown"})`,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
158
219
|
function clampInt(v, dflt, min, max) {
|
|
159
220
|
const n = Number(v);
|
|
160
221
|
if (!Number.isFinite(n))
|
package/dist/storage.d.ts
CHANGED
|
@@ -18,6 +18,11 @@ export declare function embedToBlob(embedding: Float32Array): Buffer;
|
|
|
18
18
|
* omit sourceSession (NULL — nightly distillation, tests) never collide.
|
|
19
19
|
*/
|
|
20
20
|
export declare function insertMemory(db: Database.Database, content: string, embedding: Float32Array, opts?: InsertMemoryOptions): string;
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a short ID prefix (e.g. the 8-char id shown in citations and the
|
|
23
|
+
* recall index) to a full memory UUID. Null when unknown or ambiguous.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveMemoryId(db: Database.Database, idPrefix: string): string | null;
|
|
21
26
|
/**
|
|
22
27
|
* Get a single memory by ID. Returns null if not found.
|
|
23
28
|
*/
|
package/dist/storage.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
exports.embedToBlob = embedToBlob;
|
|
8
8
|
exports.insertMemory = insertMemory;
|
|
9
|
+
exports.resolveMemoryId = resolveMemoryId;
|
|
9
10
|
exports.getMemory = getMemory;
|
|
10
11
|
exports.updateMemory = updateMemory;
|
|
11
12
|
exports.strengthenMemory = strengthenMemory;
|
|
@@ -83,6 +84,22 @@ function insertMemory(db, content, embedding, opts = {}) {
|
|
|
83
84
|
}
|
|
84
85
|
return id;
|
|
85
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Resolve a short ID prefix (e.g. the 8-char id shown in citations and the
|
|
89
|
+
* recall index) to a full memory UUID. Null when unknown or ambiguous.
|
|
90
|
+
*/
|
|
91
|
+
function resolveMemoryId(db, idPrefix) {
|
|
92
|
+
if (idPrefix.length >= 36) {
|
|
93
|
+
const row = db
|
|
94
|
+
.prepare("SELECT id FROM memories WHERE id = ?")
|
|
95
|
+
.get(idPrefix);
|
|
96
|
+
return row?.id ?? null;
|
|
97
|
+
}
|
|
98
|
+
const rows = db
|
|
99
|
+
.prepare("SELECT id FROM memories WHERE id LIKE ?")
|
|
100
|
+
.all(`${idPrefix}%`);
|
|
101
|
+
return rows.length === 1 ? rows[0].id : null;
|
|
102
|
+
}
|
|
86
103
|
/**
|
|
87
104
|
* Get a single memory by ID. Returns null if not found.
|
|
88
105
|
*/
|
|
@@ -12,13 +12,18 @@ Gives [Hermes](https://github.com/nousresearch/hermes-agent) agents self-learnin
|
|
|
12
12
|
|
|
13
13
|
| Hermes hook | What it does | Hicortex call |
|
|
14
14
|
|---|---|---|
|
|
15
|
-
| `prefetch(query)` | recall
|
|
16
|
-
| `queue_prefetch(query)` |
|
|
15
|
+
| `prefetch(query)` | pushed **recall index** each turn — a compact one-line-per-memory menu; the agent lazy-loads full content with `hicortex_get` | `POST /recall-index` (falls back to `GET /search` full-content injection on a pre-0.14 server) |
|
|
16
|
+
| `queue_prefetch(query)` | no-op on the recall-index path (the server dedups per turn; a client cache would double-suppress). Background `GET /search` only on the pre-0.14 fallback path | — / `GET /search` |
|
|
17
|
+
| `initialize(session_id)` | reset the session's server-side recall dedup (new session = fresh context) | `POST /recall-index` `{reset: true}` |
|
|
17
18
|
| `system_prompt_block()` | inject per-agent standing context + distilled lessons + memory index | `GET /context`, `GET /lessons` |
|
|
18
|
-
| `get_tool_schemas()` | exposes the
|
|
19
|
+
| `get_tool_schemas()` | exposes the 9 unified tools | see tool table below |
|
|
19
20
|
|
|
20
21
|
That's the whole surface. No `sync_turn`, no compaction/session-end capture — those are intentionally absent.
|
|
21
22
|
|
|
23
|
+
### Pushed recall index (0.7.0, server ≥ 0.14)
|
|
24
|
+
|
|
25
|
+
Instead of injecting full memory content every turn, `prefetch` sends the user's message to the server's `POST /recall-index` and injects the returned **index block** verbatim — one line per memory (id, title, date), capped and relevance-gated server-side. The agent fetches full content with `hicortex_get(id)` only when a line is actually relevant; that fetch is what strengthens the memory (exposure ≠ use). All tuning knobs (`recallMaxItems`, `recallMinSimilarity`, `recallReshowTurns`, `recallMinPromptChars`, …) live in the **server** config — the plugin carries none. Dedup is turn-based and server-side per session; the plugin resets it at `initialize` (the Hermes `MemoryProvider` interface exposes no compaction signal, so a mid-session context rebuild cannot trigger a reset — the server's turn-based re-show window covers that gap). Against a pre-0.14 server (404) the plugin falls back to the 0.6.x `GET /search` full-content prefetch, fail-soft, re-probing the endpoint every 10 minutes so a later server upgrade is picked up without a gateway restart. The recall calls carry the profile's configured `privacy_filter`/`default_project` and use a short dedicated timeout (1.5 s) so a slow server can never stall a turn.
|
|
26
|
+
|
|
22
27
|
### Per-agent standing context (0.13)
|
|
23
28
|
|
|
24
29
|
`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.
|
|
@@ -32,11 +37,12 @@ The plugin sends its **profile name** as `?agent=`, resolved in this order:
|
|
|
32
37
|
|
|
33
38
|
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
39
|
|
|
35
|
-
### Tools (unified
|
|
40
|
+
### Tools (unified 9)
|
|
36
41
|
|
|
37
42
|
| Tool | REST call | Description |
|
|
38
43
|
|---|---|---|
|
|
39
44
|
| `hicortex_search` | `GET /search` | Semantic search over long-term memory |
|
|
45
|
+
| `hicortex_get` | `GET /memory` | Fetch one memory's full content by id (lazy-load counterpart of the recall index; marks the memory as used) |
|
|
40
46
|
| `hicortex_recent` | `GET /recent` | Recent memories by project (queryless recall; was `hicortex_context`/`hicortex_recall_recent` before 0.12) |
|
|
41
47
|
| `hicortex_ingest` | `POST /ingest` | Store a new memory |
|
|
42
48
|
| `hicortex_lessons` | `GET /lessons` | Get distilled lessons |
|
|
@@ -48,7 +54,7 @@ Leave `agent_name` blank to auto-derive (2–4). Context injection needs a Hicor
|
|
|
48
54
|
## Prerequisites
|
|
49
55
|
|
|
50
56
|
- A reachable Hicortex server (default `http://localhost:8787`). Stand one up with `npx @gamaze/hicortex init`.
|
|
51
|
-
-
|
|
57
|
+
- Hicortex ≥ **0.14** for the pushed recall index and `hicortex_get` (`POST /recall-index`, `GET /memory`). Against a 0.12/0.13 server the plugin falls back to the 0.6.x `/search` prefetch; servers < 0.12 are not supported — upgrade the server first.
|
|
52
58
|
|
|
53
59
|
## Install
|
|
54
60
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Hicortex memory provider plugin for Hermes — recall-only.
|
|
2
2
|
|
|
3
|
-
Recall: prefetch() ->
|
|
4
|
-
|
|
3
|
+
Recall: prefetch() -> POST /recall-index (pushed recall index; falls
|
|
4
|
+
back to GET /search on a pre-0.14 server)
|
|
5
|
+
tools -> hicortex_search / hicortex_get / hicortex_recent / …
|
|
5
6
|
system_prompt_block -> lessons injected into the system prompt
|
|
6
7
|
|
|
7
8
|
Capture is NOT the plugin's job. A nightly reader on the Hicortex server
|
|
@@ -39,32 +39,69 @@ class HicortexClient:
|
|
|
39
39
|
h["Authorization"] = f"Bearer {self.auth_token}"
|
|
40
40
|
return h
|
|
41
41
|
|
|
42
|
-
def
|
|
42
|
+
def _build_url(self, path: str, params: Optional[dict[str, Any]] = None) -> str:
|
|
43
43
|
url = f"{self.base_url}{path}"
|
|
44
44
|
if params:
|
|
45
45
|
qs = urllib.parse.urlencode(
|
|
46
46
|
{k: v for k, v in params.items() if v is not None}
|
|
47
47
|
)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
if qs:
|
|
49
|
+
url = f"{url}?{qs}"
|
|
50
|
+
return url
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _parse_http_error(e: urllib.error.HTTPError) -> Any:
|
|
54
|
+
"""Parse an HTTPError body: JSON when possible, else {'error': text}."""
|
|
55
|
+
body_bytes = e.read()
|
|
56
|
+
try:
|
|
57
|
+
return json.loads(body_bytes.decode("utf-8"))
|
|
58
|
+
except Exception:
|
|
59
|
+
return {"error": body_bytes.decode("utf-8", errors="replace")}
|
|
52
60
|
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
def _request(
|
|
62
|
+
self,
|
|
63
|
+
method: str,
|
|
64
|
+
url: str,
|
|
65
|
+
data: Optional[bytes] = None,
|
|
66
|
+
timeout: Optional[float] = None,
|
|
67
|
+
) -> tuple[int, Any]:
|
|
68
|
+
"""Perform a request; returns (status_code, parsed_response) with HTTP
|
|
69
|
+
errors converted to statuses (never raised)."""
|
|
70
|
+
req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
|
|
58
71
|
try:
|
|
59
|
-
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
72
|
+
with urllib.request.urlopen(req, timeout=timeout or self.timeout) as resp:
|
|
60
73
|
return resp.status, json.loads(resp.read().decode("utf-8"))
|
|
61
74
|
except urllib.error.HTTPError as e:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
return e.code, self._parse_http_error(e)
|
|
76
|
+
|
|
77
|
+
def _get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
|
|
78
|
+
status, parsed = self._get_with_status(path, params)
|
|
79
|
+
if status >= 400:
|
|
80
|
+
err = parsed.get("error") if isinstance(parsed, dict) else None
|
|
81
|
+
raise RuntimeError(f"HTTP {status}: {err or 'request failed'}")
|
|
82
|
+
return parsed
|
|
83
|
+
|
|
84
|
+
def _post(
|
|
85
|
+
self,
|
|
86
|
+
path: str,
|
|
87
|
+
body: dict[str, Any],
|
|
88
|
+
timeout: Optional[float] = None,
|
|
89
|
+
) -> tuple[int, Any]:
|
|
90
|
+
"""POST JSON body; returns (status_code, parsed_response)."""
|
|
91
|
+
return self._request(
|
|
92
|
+
"POST", self._build_url(path), json.dumps(body).encode("utf-8"), timeout
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def _get_with_status(
|
|
96
|
+
self,
|
|
97
|
+
path: str,
|
|
98
|
+
params: Optional[dict[str, Any]] = None,
|
|
99
|
+
timeout: Optional[float] = None,
|
|
100
|
+
) -> tuple[int, Any]:
|
|
101
|
+
"""GET returning (status_code, parsed_response) — unlike ``_get``, an
|
|
102
|
+
HTTP error is returned as a status, not raised. Needed where the caller
|
|
103
|
+
must tell a 404 apart from other failures (old-server guards)."""
|
|
104
|
+
return self._request("GET", self._build_url(path, params), None, timeout)
|
|
68
105
|
|
|
69
106
|
# -- endpoints ------------------------------------------------------------
|
|
70
107
|
|
|
@@ -167,3 +204,46 @@ class HicortexClient:
|
|
|
167
204
|
|
|
168
205
|
def delete(self, id: str) -> tuple[int, dict[str, Any]]:
|
|
169
206
|
return self._post("/delete", {"id": id})
|
|
207
|
+
|
|
208
|
+
# Per-turn hot path (F4): /recall-index runs synchronously inside every
|
|
209
|
+
# prefetch and /memory inside an agent tool call — a slow/wedged server
|
|
210
|
+
# must cost at most this much per turn, NOT the general default timeout
|
|
211
|
+
# (5 s) meant for background/tool traffic.
|
|
212
|
+
RECALL_TIMEOUT: float = 1.5
|
|
213
|
+
|
|
214
|
+
def recall_index(
|
|
215
|
+
self,
|
|
216
|
+
session_id: str,
|
|
217
|
+
prompt: Optional[str] = None,
|
|
218
|
+
reset: bool = False,
|
|
219
|
+
project: Optional[str] = None,
|
|
220
|
+
privacy: Optional[str] = None,
|
|
221
|
+
) -> tuple[int, dict[str, Any]]:
|
|
222
|
+
"""Pushed recall index (0.14). ``prompt`` → ``{block, shown, turn}``
|
|
223
|
+
where ``block`` is None when nothing is new/relevant; ``reset=True``
|
|
224
|
+
clears the session's server-side dedup (context rebuilt). ``project``
|
|
225
|
+
and ``privacy`` (CSV accepted server-side) scope the recall exactly
|
|
226
|
+
like the legacy /search prefetch did. Returns the status so the caller
|
|
227
|
+
can old-server-guard on 404 (pre-0.14)."""
|
|
228
|
+
body: dict[str, Any] = {"session_id": session_id}
|
|
229
|
+
if reset:
|
|
230
|
+
body["reset"] = True
|
|
231
|
+
else:
|
|
232
|
+
body["prompt"] = prompt or ""
|
|
233
|
+
if project:
|
|
234
|
+
body["project"] = project
|
|
235
|
+
if privacy:
|
|
236
|
+
body["privacy"] = privacy
|
|
237
|
+
return self._post("/recall-index", body, timeout=self.RECALL_TIMEOUT)
|
|
238
|
+
|
|
239
|
+
def get_memory(
|
|
240
|
+
self, id: str, privacy: Optional[str] = None
|
|
241
|
+
) -> tuple[int, dict[str, Any]]:
|
|
242
|
+
"""Fetch ONE memory's full content (lazy-load counterpart of the recall
|
|
243
|
+
index). The server marks it as USED (access_count + 1) and returns
|
|
244
|
+
``{memory, citation}`` — citation is server-rendered so every harness
|
|
245
|
+
surfaces the same provenance norm. ``privacy`` (CSV) makes an
|
|
246
|
+
out-of-scope memory read as 404 (the server never reveals existence)."""
|
|
247
|
+
return self._get_with_status(
|
|
248
|
+
"/memory", {"id": id, "privacy": privacy}, timeout=self.RECALL_TIMEOUT
|
|
249
|
+
)
|
|
@@ -46,7 +46,11 @@ CONFIG_SCHEMA: list[dict[str, Any]] = [
|
|
|
46
46
|
{
|
|
47
47
|
"key": "recall_limit",
|
|
48
48
|
"label": "Recall limit",
|
|
49
|
-
"description":
|
|
49
|
+
"description": (
|
|
50
|
+
"Max memories returned per recall (default 5). Applies to the "
|
|
51
|
+
"tools and the legacy pre-0.14 /search prefetch fallback only — "
|
|
52
|
+
"the pushed recall index is sized by SERVER config (recallMaxItems)."
|
|
53
|
+
),
|
|
50
54
|
"default": "5",
|
|
51
55
|
"required": False,
|
|
52
56
|
},
|
|
@@ -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.
|
|
2
|
+
version: 0.7.0
|
|
3
|
+
description: "Self-learning memory for Hermes agents — every session is distilled into lessons overnight, and your agent wakes up wiser. Pushes a compact per-turn recall index (lazy-loaded with hicortex_get), injects fresh lessons plus a per-agent standing context block, and exposes the full 9-tool memory surface (search, get, 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:
|
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
"""Hicortex MemoryProvider for Hermes — recall-only.
|
|
2
2
|
|
|
3
|
-
Recall: prefetch() ->
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Recall: prefetch() -> POST /recall-index (pushed recall index, 0.14 —
|
|
4
|
+
compact one-line-per-memory menu; the agent
|
|
5
|
+
lazy-loads full content with hicortex_get).
|
|
6
|
+
Falls back to GET /search full-content
|
|
7
|
+
injection against a pre-0.14 server (404).
|
|
8
|
+
queue_prefetch() -> no-op on the recall-index path (the server
|
|
9
|
+
dedups per turn; a client-side cache would
|
|
10
|
+
double-suppress). Legacy background GET /search
|
|
11
|
+
only on the 404 fallback path.
|
|
12
|
+
tools -> hicortex_search / hicortex_get / hicortex_recent / …
|
|
6
13
|
system_prompt_block -> lessons + memory index injected into the prompt
|
|
14
|
+
initialize() -> POST /recall-index {reset:true} (new session =
|
|
15
|
+
fresh context, so the server's per-session
|
|
16
|
+
shown-set is cleared). Synchronous with the
|
|
17
|
+
short recall timeout so it can never land
|
|
18
|
+
AFTER the first turn's prefetch and wipe the
|
|
19
|
+
registry state that turn just built. The
|
|
20
|
+
MemoryProvider interface exposes NO
|
|
21
|
+
compaction/context-rebuild signal, so a
|
|
22
|
+
mid-session compaction cannot trigger a reset
|
|
23
|
+
— the server's turn-based re-show window
|
|
24
|
+
(recallReshowTurns) covers that gap by
|
|
25
|
+
re-showing after enough turns.
|
|
7
26
|
|
|
8
27
|
Capture is NOT the plugin's job. A nightly reader on the Hicortex server
|
|
9
28
|
distills each agent's own session store (Hermes: ~/.hermes/profiles/<agent>/
|
|
@@ -19,8 +38,10 @@ import logging
|
|
|
19
38
|
import os
|
|
20
39
|
import re
|
|
21
40
|
import threading
|
|
41
|
+
import time
|
|
42
|
+
import uuid
|
|
22
43
|
from concurrent.futures import ThreadPoolExecutor
|
|
23
|
-
from typing import Any, Dict, Iterable, List, Optional
|
|
44
|
+
from typing import Any, Dict, Iterable, List, Optional, Set
|
|
24
45
|
|
|
25
46
|
from agent.memory_provider import MemoryProvider
|
|
26
47
|
|
|
@@ -31,6 +52,14 @@ logger = logging.getLogger(__name__)
|
|
|
31
52
|
|
|
32
53
|
_INJECT_CONTENT_CAP = 500
|
|
33
54
|
|
|
55
|
+
# How long a /recall-index 404 latches the legacy-fallback path before the
|
|
56
|
+
# endpoint is re-probed. The latch must EXPIRE (review F2): during a
|
|
57
|
+
# client-first rollout the plugin may probe a still-0.13 server once and would
|
|
58
|
+
# otherwise stay on the legacy path until a gateway restart nobody knows to do.
|
|
59
|
+
# Long enough not to hammer an old server every turn, short enough that a
|
|
60
|
+
# server upgrade is picked up within minutes.
|
|
61
|
+
_FALLBACK_RETRY_SECONDS = 600.0
|
|
62
|
+
|
|
34
63
|
# Agent ids are joined into a filesystem path server-side, so they share the
|
|
35
64
|
# section-name allowlist. \Z (NOT $) anchors the END OF STRING: Python's $ also
|
|
36
65
|
# matches just before a trailing "\n", so "alice\n" would pass and go out as
|
|
@@ -126,6 +155,17 @@ class HicortexProvider(MemoryProvider):
|
|
|
126
155
|
self._agent_name: Optional[str] = None
|
|
127
156
|
self._prefetch_cache: Dict[str, str] = {}
|
|
128
157
|
self._bg_threads: List[threading.Thread] = []
|
|
158
|
+
self._session_id: Optional[str] = None
|
|
159
|
+
# /recall-index 404 latch: 0.0 = not latched; otherwise the
|
|
160
|
+
# time.monotonic() deadline until which the legacy /search prefetch is
|
|
161
|
+
# used. Expires (re-probe) per _FALLBACK_RETRY_SECONDS. Only a
|
|
162
|
+
# definitive 404 latches; network errors stay fail-soft per turn.
|
|
163
|
+
self._recall_index_retry_at: float = 0.0
|
|
164
|
+
# Warn-once bookkeeping (review F3): a persistent non-404 HTTP error —
|
|
165
|
+
# especially 401/403 from a bad token — must surface at WARNING level
|
|
166
|
+
# once per distinct status, not vanish at debug level (the class of
|
|
167
|
+
# silent auth failure that once hid a dead recall path for days).
|
|
168
|
+
self._warned_recall_statuses: Set[int] = set()
|
|
129
169
|
|
|
130
170
|
@property
|
|
131
171
|
def name(self) -> str:
|
|
@@ -173,6 +213,26 @@ class HicortexProvider(MemoryProvider):
|
|
|
173
213
|
self._client = self._build_client()
|
|
174
214
|
except Exception as e:
|
|
175
215
|
logger.warning("hicortex: init client build failed: %s", e)
|
|
216
|
+
# New session = fresh context window, so the server's per-session
|
|
217
|
+
# shown-set is stale by definition. SYNCHRONOUS (review F8): a
|
|
218
|
+
# background reset could land AFTER the first turn's prefetch and wipe
|
|
219
|
+
# the shown-set/turn counter that turn just built. The client's short
|
|
220
|
+
# recall timeout (1.5 s) bounds the startup cost; fail-soft. The id
|
|
221
|
+
# goes through the SAME resolver as prefetch (review F7) so the reset
|
|
222
|
+
# hits the key the turns will accumulate under.
|
|
223
|
+
sid = self._resolve_session_id(session_id)
|
|
224
|
+
client = self._client
|
|
225
|
+
if client is not None:
|
|
226
|
+
try:
|
|
227
|
+
status, _ = client.recall_index(sid, reset=True)
|
|
228
|
+
if status == 404:
|
|
229
|
+
# Pre-0.14 server — latch the legacy path now rather than
|
|
230
|
+
# paying another probe on the first turn; the TTL heals it.
|
|
231
|
+
self._recall_index_retry_at = (
|
|
232
|
+
time.monotonic() + _FALLBACK_RETRY_SECONDS
|
|
233
|
+
)
|
|
234
|
+
except Exception as e:
|
|
235
|
+
logger.debug("hicortex recall reset failed: %s", e)
|
|
176
236
|
|
|
177
237
|
# ------------------------------------------------------------------- recall
|
|
178
238
|
def _format_hits(self, hits: list[dict]) -> str:
|
|
@@ -191,14 +251,95 @@ class HicortexProvider(MemoryProvider):
|
|
|
191
251
|
lines.append(f"- [{date}, {proj}] {content}")
|
|
192
252
|
return "\n".join(lines)
|
|
193
253
|
|
|
254
|
+
def _resolve_session_id(self, session_id: str) -> str:
|
|
255
|
+
"""Session id for /recall-index, unified across initialize() and
|
|
256
|
+
prefetch() (review F7). Precedence: an explicit non-empty id ALWAYS
|
|
257
|
+
wins and becomes the stored id (so a Hermes that hands initialize an
|
|
258
|
+
empty id but passes real ids per turn converges on the real id — the
|
|
259
|
+
turns and any later reset then share one registry key); else the
|
|
260
|
+
stored id; else a generated ``hermes-<uuid4>`` stored once (dedup
|
|
261
|
+
degrades from session to provider-instance scope — still correct,
|
|
262
|
+
never a shared "" that would merge every session on the server)."""
|
|
263
|
+
if session_id:
|
|
264
|
+
self._session_id = session_id
|
|
265
|
+
return session_id
|
|
266
|
+
if not self._session_id:
|
|
267
|
+
self._session_id = f"hermes-{uuid.uuid4()}"
|
|
268
|
+
return self._session_id
|
|
269
|
+
|
|
270
|
+
def _recall_index_latched(self) -> bool:
|
|
271
|
+
"""True while the /recall-index 404 latch is active (legacy path)."""
|
|
272
|
+
return (
|
|
273
|
+
self._recall_index_retry_at > 0
|
|
274
|
+
and time.monotonic() < self._recall_index_retry_at
|
|
275
|
+
)
|
|
276
|
+
|
|
194
277
|
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
|
278
|
+
client = self._client_or_none()
|
|
279
|
+
if client is None:
|
|
280
|
+
return ""
|
|
281
|
+
# Pushed recall index (0.14): the server does relevance gating and
|
|
282
|
+
# TURN-based dedup — every user turn is sent, the server decides what
|
|
283
|
+
# is new. The sha1 prefetch cache is deliberately NOT consulted on this
|
|
284
|
+
# path: replaying a cached block would skip the server call, so the
|
|
285
|
+
# registry's turn counter would drift and dedup would double-suppress.
|
|
286
|
+
if not self._recall_index_latched():
|
|
287
|
+
try:
|
|
288
|
+
status, resp = client.recall_index(
|
|
289
|
+
self._resolve_session_id(session_id),
|
|
290
|
+
prompt=query,
|
|
291
|
+
project=self._project,
|
|
292
|
+
privacy=self._privacy,
|
|
293
|
+
)
|
|
294
|
+
if status == 404:
|
|
295
|
+
# Old-server guard: pre-0.14 has no /recall-index. Latch
|
|
296
|
+
# the legacy /search prefetch, re-probe after the TTL.
|
|
297
|
+
logger.info(
|
|
298
|
+
"hicortex: server has no /recall-index (pre-0.14) — "
|
|
299
|
+
"falling back to /search prefetch for %.0f s",
|
|
300
|
+
_FALLBACK_RETRY_SECONDS,
|
|
301
|
+
)
|
|
302
|
+
self._recall_index_retry_at = (
|
|
303
|
+
time.monotonic() + _FALLBACK_RETRY_SECONDS
|
|
304
|
+
)
|
|
305
|
+
elif status == 200:
|
|
306
|
+
self._recall_index_retry_at = 0.0
|
|
307
|
+
block = resp.get("block") if isinstance(resp, dict) else None
|
|
308
|
+
# block is None when nothing is new/relevant → inject nothing.
|
|
309
|
+
return block if isinstance(block, str) else ""
|
|
310
|
+
else:
|
|
311
|
+
# Auth/5xx/…: not a version signal — fail soft this turn
|
|
312
|
+
# (legacy /search would hit the same wall anyway), but
|
|
313
|
+
# surface it ONCE per status at WARNING: a persistent 401
|
|
314
|
+
# from a bad token must never hide at debug level.
|
|
315
|
+
if status not in self._warned_recall_statuses:
|
|
316
|
+
self._warned_recall_statuses.add(status)
|
|
317
|
+
hint = (
|
|
318
|
+
" — check hicortex_auth_token/HICORTEX_AUTH_TOKEN"
|
|
319
|
+
if status in (401, 403)
|
|
320
|
+
else ""
|
|
321
|
+
)
|
|
322
|
+
logger.warning(
|
|
323
|
+
"hicortex: /recall-index returned HTTP %s; recall "
|
|
324
|
+
"injection is disabled while this persists%s",
|
|
325
|
+
status,
|
|
326
|
+
hint,
|
|
327
|
+
)
|
|
328
|
+
else:
|
|
329
|
+
logger.debug("hicortex recall-index HTTP %s", status)
|
|
330
|
+
return ""
|
|
331
|
+
except Exception as e:
|
|
332
|
+
logger.debug("hicortex recall-index failed: %s", e)
|
|
333
|
+
return ""
|
|
334
|
+
return self._legacy_search_prefetch(client, query)
|
|
335
|
+
|
|
336
|
+
def _legacy_search_prefetch(self, client: HicortexClient, query: str) -> str:
|
|
337
|
+
"""Pre-0.14 behavior: full-content /search injection with the one-shot
|
|
338
|
+
sha1 cache warmed by queue_prefetch."""
|
|
195
339
|
key = hashlib.sha1(query.encode("utf-8")).hexdigest()
|
|
196
340
|
cached = self._prefetch_cache.pop(key, None)
|
|
197
341
|
if cached is not None:
|
|
198
342
|
return cached
|
|
199
|
-
client = self._client_or_none()
|
|
200
|
-
if client is None:
|
|
201
|
-
return ""
|
|
202
343
|
try:
|
|
203
344
|
hits = client.search(
|
|
204
345
|
query, limit=self._recall_limit, project=self._project, privacy=self._privacy
|
|
@@ -209,6 +350,13 @@ class HicortexProvider(MemoryProvider):
|
|
|
209
350
|
return ""
|
|
210
351
|
|
|
211
352
|
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
|
353
|
+
# Recall-index path: no background warm-up. One /recall-index POST per
|
|
354
|
+
# turn (from prefetch) is the contract — a queued call here would burn
|
|
355
|
+
# a registry turn AND cache a block the server thinks it already
|
|
356
|
+
# showed. Only the latched (confirmed-404) legacy path keeps the old
|
|
357
|
+
# behavior.
|
|
358
|
+
if not self._recall_index_latched():
|
|
359
|
+
return
|
|
212
360
|
client = self._client_or_none()
|
|
213
361
|
if client is None:
|
|
214
362
|
return
|
|
@@ -285,7 +433,8 @@ class HicortexProvider(MemoryProvider):
|
|
|
285
433
|
lines = [
|
|
286
434
|
"## Hicortex long-term memory",
|
|
287
435
|
"You have shared long-term memory across sessions. Use `hicortex_search` "
|
|
288
|
-
"for specific recall
|
|
436
|
+
"for specific recall, `hicortex_get` to fetch one memory by id (e.g. from "
|
|
437
|
+
"the recall index), and `hicortex_recent` for recent memories by project.",
|
|
289
438
|
]
|
|
290
439
|
if lessons:
|
|
291
440
|
lines.append("Lessons:")
|
|
@@ -320,6 +469,26 @@ class HicortexProvider(MemoryProvider):
|
|
|
320
469
|
"required": ["query"],
|
|
321
470
|
},
|
|
322
471
|
},
|
|
472
|
+
{
|
|
473
|
+
"name": "hicortex_get",
|
|
474
|
+
"description": (
|
|
475
|
+
"Fetch ONE memory's full content by id — use this to lazy-load "
|
|
476
|
+
"entries from the recall index or from search results whose "
|
|
477
|
+
"snippet was not enough. Fetching a memory marks it as used "
|
|
478
|
+
"(strengthens it), so only fetch what you actually need. When "
|
|
479
|
+
"the memory shapes your answer, cite it as given in the response."
|
|
480
|
+
),
|
|
481
|
+
"parameters": {
|
|
482
|
+
"type": "object",
|
|
483
|
+
"properties": {
|
|
484
|
+
"id": {
|
|
485
|
+
"type": "string",
|
|
486
|
+
"description": "Memory ID (as shown in the recall index or search results)",
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
"required": ["id"],
|
|
490
|
+
},
|
|
491
|
+
},
|
|
323
492
|
{
|
|
324
493
|
"name": "hicortex_recent",
|
|
325
494
|
"description": (
|
|
@@ -455,6 +624,29 @@ class HicortexProvider(MemoryProvider):
|
|
|
455
624
|
)
|
|
456
625
|
return json.dumps(hits)
|
|
457
626
|
|
|
627
|
+
elif tool_name == "hicortex_get":
|
|
628
|
+
id_val = args.get("id", "")
|
|
629
|
+
if not id_val:
|
|
630
|
+
return json.dumps({"error": "id is required"})
|
|
631
|
+
# The configured privacy filter rides along (review F1): an
|
|
632
|
+
# out-of-scope memory reads as 404 server-side.
|
|
633
|
+
status, resp = client.get_memory(id_val, privacy=self._privacy)
|
|
634
|
+
if status == 404:
|
|
635
|
+
# Either no such memory (0.14+) or a pre-0.14 server with
|
|
636
|
+
# no /memory endpoint — the id hint covers the common case.
|
|
637
|
+
return json.dumps(
|
|
638
|
+
{"error": f"Memory not found: {id_val} (or the server predates 0.14)"}
|
|
639
|
+
)
|
|
640
|
+
if status != 200 or not isinstance(resp, dict):
|
|
641
|
+
err = resp.get("error") if isinstance(resp, dict) else None
|
|
642
|
+
return json.dumps({"error": err or f"HTTP {status}"})
|
|
643
|
+
memory = resp.get("memory") or {}
|
|
644
|
+
content = memory.get("content") or ""
|
|
645
|
+
citation = resp.get("citation") or ""
|
|
646
|
+
# Render the content BEHIND the server's citation string — the
|
|
647
|
+
# server-side rendering is the single provenance norm (0.14.1).
|
|
648
|
+
return f"{citation}\n\n{content}".strip()
|
|
649
|
+
|
|
458
650
|
elif tool_name == "hicortex_recent":
|
|
459
651
|
hits = client.recent(
|
|
460
652
|
project=args.get("project") or self._project,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gamaze/hicortex",
|
|
3
|
-
"version": "0.14.
|
|
4
|
-
"description": "Self-learning memory for AI agents
|
|
3
|
+
"version": "0.14.3",
|
|
4
|
+
"description": "Self-learning memory for AI agents — 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": {
|
|
7
7
|
"hicortex": "dist/cli.js"
|