@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 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 injects fresh lessons every turn and exposes the full 8-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`.
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` (+ `prefetch` recall) / 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 | CC UserPromptSubmit hook (calls `hicortex recall-hook` server `POST /recall-index`). Turn-based dedup per session; resets on new session/compaction. Fail-soft, 1 s timeout |
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 /lessons (fail-soft, 3s timeout) inject context
13
- * - Tools → HTTP proxies to /search, /recent, /ingest, /lessons
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 /lessons (fail-soft, 3s timeout) inject context
14
- * - Tools → HTTP proxies to /search, /recent, /ingest, /lessons
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 from server (fail-soft)
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 (_event, ctx) => {
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 both concurrently with INDEPENDENT fail-soft: a /context
227
- // failure must never cost the lessons block, and vice versa. The
228
- // `## Context` block (standing context, 0.13) is prepended before
229
- // `## Hicortex Lessons`, mirroring the CC hook.
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",
@@ -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
- const mem = storage.getMemory(db, id);
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
- retrieveFn: (query, limit) => retrieval.retrieve(db, embedder_js_1.embed, query, { limit, noStrengthen: true }),
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 counterpart
663
- // of /recall-index for REST clients: Hermes/OC plugins). Marks it as used.
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 mem = storage.getMemory(db, id);
676
- if (!mem) {
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
- function resolveMemoryId(database, idPrefix) {
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
  }
@@ -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;
@@ -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 relevant memories before each turn | `GET /search` |
16
- | `queue_prefetch(query)` | background recall for the next turn | `GET /search` |
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 8 unified tools | see tool table below |
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 8)
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
- - The server needs the REST `/search`, `/recent`, `/lessons` endpoints (Hicortex 0.12 this plugin version does not talk to older servers; upgrade the server first).
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() -> GET /search (relevant memories before each turn)
4
- tools -> hicortex_search / hicortex_recent
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 _get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
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
- 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"))
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 _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")
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
- 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
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": "Max memories returned per recall (default 5).",
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.6.0
3
- description: "Self-learning memory for Hermes agents — every session is distilled into lessons overnight, and your agent wakes up wiser. Injects fresh lessons each turn plus a per-agent standing context block, and exposes the full 8-tool memory surface (search, recent, ingest, lessons, index, graph, update, delete) via a shared Hicortex server. Stdlib-only."
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() -> GET /search (relevant memories before each turn)
4
- queue_prefetch() -> GET /search (background recall for the next turn)
5
- tools -> hicortex_search / hicortex_recent
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 and `hicortex_recent` for recent memories by project.",
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.1",
4
- "description": "Self-learning memory for AI agents \u2014 experience captured automatically, distilled into lessons overnight, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, and Pi.",
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"