@gamaze/hicortex 0.19.1 → 0.19.2

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/dist/index.js CHANGED
@@ -9,14 +9,29 @@
9
9
  * Install once: `openclaw plugins install @gamaze/hicortex`
10
10
  * Run server: `npx @gamaze/hicortex init`
11
11
  *
12
- * Responsibilities (recall-only adapter, like the Hermes plugin):
13
- * - before_agent_start → GET /identity + GET /lessons + POST /recall-index
14
- * (fail-soft, 3s timeout each, concurrent) inject identity + lessons. In
15
- * OpenClaw every inbound message spawns an embedded run, so this hook fires
16
- * PER TURN with the current prompt and session id it is the per-turn
17
- * /recall-index surface, not just session start.
12
+ * Responsibilities (recall-only adapter, behaviorally aligned with the Hermes
13
+ * reference plugin hermes-plugin/hicortex/provider.py since #316):
14
+ * - before_agent_start GET /identity + GET /lessons once per SESSION
15
+ * (#316: standing blocks are not re-sent every turn; a FAILED identity
16
+ * fetch is retried next turn only success memoizes, so the #313 dead-man
17
+ * banner keeps firing until identity returns) + POST /recall-index EVERY
18
+ * turn (fail-soft, concurrent; recall hot path capped at 1.5 s like
19
+ * Hermes). The FIRST recall fetch of a session is preceded by an AWAITED
20
+ * {reset:true} so a gateway restart resuming a session cannot inherit a
21
+ * stale server-side shown-set, and no reset can land after a fetch and
22
+ * wipe what it built. In OpenClaw every inbound message spawns an embedded
23
+ * run, so this hook fires PER TURN — it is the per-turn /recall-index
24
+ * surface, not just session start.
25
+ * - 404 on /recall-index → 600 s TTL latch + GET /search fallback that
26
+ * renders CONTENT (Hermes's legacy `_format_hits` shape — the pushed
27
+ * index's `hicortex_get(id)` menu is useless against the pre-0.14 servers
28
+ * the fallback exists for) — old servers keep full-content recall instead
29
+ * of degrading to nothing. Auth/5xx errors do NOT latch-fallback (they
30
+ * are errors, not version skew): fail soft per turn + warn ONCE per HTTP
31
+ * status.
18
32
  * - after_compaction / before_reset → POST /recall-index {reset:true}
19
- * (context window rebuilt → the server's per-session shown-set is stale)
33
+ * (context window rebuilt → the server's per-session shown-set is stale
34
+ * AND the standing blocks may have been dropped → re-injected next turn)
20
35
  * - Tools → HTTP proxies to /search, /memory, /recent, /ingest, /lessons
21
36
  *
22
37
  * CAPTURE IS NOT THIS PLUGIN'S JOB. OpenClaw persists sessions at
@@ -43,7 +58,35 @@ const type_labels_js_1 = require("./type-labels.js");
43
58
  const DEFAULT_SERVER_URL = "http://127.0.0.1:8787";
44
59
  const LESSONS_TIMEOUT_MS = 3000;
45
60
  const IDENTITY_TIMEOUT_MS = 3000;
46
- const RECALL_TIMEOUT_MS = 3000;
61
+ /**
62
+ * Recall hot-path ceiling (#316): /recall-index (and /memory via hicortex_get)
63
+ * run inside every turn / tool call, so a slow or wedged server must cost at
64
+ * most this much — matching the Hermes reference (client.py RECALL_TIMEOUT
65
+ * 1.5 s), NOT the general tool ceilings (5–15 s) meant for interactive use.
66
+ */
67
+ const RECALL_TIMEOUT_MS = 1500;
68
+ /**
69
+ * Pre-0.14 /search fallback ceiling (#316 CR): its own budget, NOT the pushed-
70
+ * index hot path — /search runs a server-side embed of the prompt (a cold
71
+ * embedder loads the ONNX model), which the 1.5 s ceiling can starve. Hermes
72
+ * gives this exact path its 5 s client default; we match it.
73
+ */
74
+ const SEARCH_FALLBACK_TIMEOUT_MS = 5000;
75
+ /** Content cap per line in the pre-0.14 /search fallback block — Hermes's
76
+ * `_INJECT_CONTENT_CAP` (provider.py): the fallback renders CONTENT (the
77
+ * pushed index's one-liner menu is useless here — its `hicortex_get(id)`
78
+ * instruction points at a 0.14+ endpoint that 404s on these servers). */
79
+ const LEGACY_CONTENT_CAP = 500;
80
+ /** Default max memories per recall on the legacy /search fallback (config
81
+ * `recallLimit`, #316). The pushed /recall-index is sized by SERVER config
82
+ * (`recallMaxItems`) — the server accepts no client limit — so this knob
83
+ * applies where a client limit actually exists: the pre-0.14 fallback. */
84
+ const DEFAULT_RECALL_LIMIT = 8;
85
+ /** Cap for the per-session LRU trackers (#316 CR: raised from 50 — a
86
+ * multi-agent gateway fans sessions across agents; evicting a live session's
87
+ * memo only costs ONE re-injection / re-reset, never per-turn spam, so a
88
+ * generous cap is cheap insurance. Eviction is accepted behavior.) */
89
+ const SESSION_TRACKER_CAP = 200;
47
90
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
48
91
  /** Harness name this plugin injects for — used to self-gate on GET /identity `clients`. */
49
92
  const THIS_HARNESS = "oc";
@@ -54,14 +97,20 @@ let serverUrl = DEFAULT_SERVER_URL;
54
97
  let authToken;
55
98
  let hicortexHome = HICORTEX_HOME;
56
99
  /** Resolved plugin config captured at service start — used for tunable knobs
57
- * (e.g. lessonsLimit) that injected context blocks read at hook time. */
100
+ * (e.g. lessonsLimit, recallLimit) that injected context blocks read at hook
101
+ * time. */
58
102
  let pluginConfig = null;
103
+ /** `defaultProject` from plugin config (#316): sent as the `project` fallback
104
+ * on recall-index, the /search fallback, and the search/recent/ingest tools
105
+ * whenever the gateway supplies no project (Hermes `default_project`). */
106
+ let defaultProject;
59
107
  /** Old-server guard (F2): 0 = not latched; otherwise the Date.now() epoch-ms
60
- * until which /recall-index is skipped after a 404 (pre-0.14 server). The
61
- * latch EXPIRES so a client-first rollout heals itself once the server is
62
- * upgraded a permanent latch would silently disable recall on a
63
- * long-running gateway until restart. OC has no pre-0.14 per-turn recall to
64
- * fall back to, so "skip" IS the old behavior. */
108
+ * until which /recall-index is skipped after a 404 (pre-0.14 server) and the
109
+ * legacy /search fallback is used instead (#316 full Hermes parity: recall
110
+ * degrades to full-content /search injection, not to nothing). The latch
111
+ * EXPIRES so a client-first rollout heals itself once the server is
112
+ * upgraded a permanent latch would pin the legacy path on a long-running
113
+ * gateway until restart. */
65
114
  let recallIndexRetryAtMs = 0;
66
115
  /** How long a 404 latches the guard before re-probing. Long enough not to
67
116
  * hammer an old server every turn, short enough that a server upgrade is
@@ -71,11 +120,124 @@ const RECALL_REPROBE_INTERVAL_MS = 600_000;
71
120
  * gateway; if a gateway variant doesn't pass it the feature must not run
72
121
  * silently dead. */
73
122
  let warnedMissingSessionId = false;
123
+ /** Warn-once bookkeeping for /recall-index HTTP errors (#316, mirrors Hermes
124
+ * review F3): a persistent non-404 error — especially 401/403 from a bad
125
+ * token — surfaces at WARNING once per DISTINCT status, then fails soft per
126
+ * turn. Without this, a bad token kills per-turn recall with zero logging. */
127
+ const warnedRecallStatuses = new Set();
128
+ /** Warn-once flag for /search fallback failures (#316 CR finding 3): fires on
129
+ * the first failure of a streak, re-armed by any successful fallback fetch. */
130
+ let warnedLegacyFallbackFailure = false;
74
131
  /** Plugin logger captured at service start (ctx.logger or console). */
75
132
  let pluginLog = console.log;
133
+ /** Sessions whose server-side recall dedup was already reset this process
134
+ * (#316): the reset rides BEFORE the session's first recall fetch so a
135
+ * gateway restart resuming a live session cannot inherit a stale shown-set
136
+ * (suppression otherwise persists for recallReshowTurns turns). */
137
+ const sessionsReset = makeBoundedSessionTracker(SESSION_TRACKER_CAP);
138
+ /**
139
+ * #316 once-per-session standing blocks — per-turn injection is the recall
140
+ * block ONLY. Two INDEPENDENT memos (#316 CR finding 4): identity and lessons
141
+ * settle separately (an OK identity + a failed /lessons must not memoize the
142
+ * lessons away for the whole session), so each block memoizes on its own
143
+ * success and is skipped only when ITS memo holds.
144
+ *
145
+ * PERSISTENCE PREMISE (verified live against the gateway dist on the fleet,
146
+ * reply-Bm8VrLQh.js, #316 CR finding 1): the gateway composes hook results as
147
+ * `prependSystemContext + baseSystemPrompt + appendSystemContext` and applies
148
+ * them via `applySystemPromptOverrideToSession(activeSession, composed)` — the
149
+ * append PERSISTS on the session across turns. So the once-per-session memo
150
+ * is not just token hygiene, it is CORRECTNESS: re-sending identity/lessons
151
+ * every turn would duplicate them into the stored prompt.
152
+ *
153
+ * Residual (accepted): after a gateway RESTART resumes a persisted session the
154
+ * memos are empty while the session may already carry an overridden prompt —
155
+ * one duplicate append per restart is possible. Whether the override persists
156
+ * to disk was not determined (no local dist to verify); even in the worst case
157
+ * the cost is a single duplicated block per restart, never per turn.
158
+ *
159
+ * Residual (delta CR N5, accepted): two OVERLAPPING before_agent_start runs
160
+ * for the same session can both pass has() before either add() — one duplicate
161
+ * standing append. OC embedded runs are serialized per session in practice;
162
+ * if that ever changes, an in-flight promise per key (pendingResets shape)
163
+ * closes it. Not a regression: pre-#316 injected every turn.
164
+ */
165
+ const identityInjected = makeBoundedSessionTracker(SESSION_TRACKER_CAP);
166
+ const lessonsInjected = makeBoundedSessionTracker(SESSION_TRACKER_CAP);
167
+ /** In-flight {reset:true} POSTs by session key (#316). A compaction hook fires
168
+ * a reset fire-and-forget (F9 — no latency on compaction); the NEXT recall
169
+ * fetch for that session AWAITS the entry here, so a slow reset can never
170
+ * land after the fetch and wipe the shown-set the fetch just built. */
171
+ const pendingResets = new Map();
172
+ /**
173
+ * Tracker key: `${agentId}:${sessionId}` (#316 CR finding 7) — the trackers
174
+ * are process-global, and a multi-agent gateway can reuse a session id across
175
+ * agents; keying on the bare sessionId would let one agent's memo suppress
176
+ * another's injection. The sanitized agent id charset ([a-z0-9_-]) makes ":"
177
+ * an unambiguous separator; null id (symbols-only / absent) → "" prefix.
178
+ */
179
+ function sessionKey(agentId, sessionId) {
180
+ return `${agentId ?? ""}:${sessionId}`;
181
+ }
182
+ /**
183
+ * Insertion-ordered Map used as a bounded LRU set of session ids. `has`
184
+ * touches (re-inserts) the entry; `add` evicts the least-recently-used when
185
+ * over cap. Long-running gateways see unbounded sessions — the plugin's view
186
+ * of them must stay bounded.
187
+ */
188
+ function makeBoundedSessionTracker(cap) {
189
+ const seen = new Map();
190
+ return {
191
+ has(id) {
192
+ if (!seen.has(id))
193
+ return false;
194
+ seen.delete(id);
195
+ seen.set(id, true);
196
+ return true;
197
+ },
198
+ add(id) {
199
+ seen.delete(id);
200
+ seen.set(id, true);
201
+ if (seen.size > cap) {
202
+ const oldest = seen.keys().next().value;
203
+ if (oldest !== undefined)
204
+ seen.delete(oldest);
205
+ }
206
+ },
207
+ evict(id) {
208
+ seen.delete(id);
209
+ },
210
+ clear() {
211
+ seen.clear();
212
+ },
213
+ };
214
+ }
76
215
  function recallIndexLatched() {
77
216
  return recallIndexRetryAtMs !== 0 && Date.now() < recallIndexRetryAtMs;
78
217
  }
218
+ /** Recall limit for the legacy /search fallback (#316): config `recallLimit`
219
+ * (a POSITIVE INTEGER, default 8). Validated at the boundary like
220
+ * readPositiveConfig, but integer-strict (#316 CR 8b): readPositiveConfig +
221
+ * Math.floor accepts 0.5 and floors it to 0 — an empty (header-only) fallback
222
+ * block. A non-integer or non-positive value warns once and uses 8. */
223
+ let warnedRecallLimitInvalid = false;
224
+ function recallLimit(config) {
225
+ if (!config)
226
+ return DEFAULT_RECALL_LIMIT;
227
+ const v = config.recallLimit;
228
+ if (v === undefined)
229
+ return DEFAULT_RECALL_LIMIT;
230
+ if (typeof v === "number" && Number.isInteger(v) && v > 0)
231
+ return v;
232
+ // Warn ONCE (delta CR N2): recallLimit() runs per fallback fetch — a raw
233
+ // per-turn console.warn on a latched pre-0.14 server is stderr spam, and it
234
+ // bypassed the gateway's plugin-log capture like no other #316 warning.
235
+ if (!warnedRecallLimitInvalid) {
236
+ warnedRecallLimitInvalid = true;
237
+ pluginLog(`[hicortex] WARNING: config "recallLimit" = ${String(v)} is not a positive integer — using default ${DEFAULT_RECALL_LIMIT}.`);
238
+ }
239
+ return DEFAULT_RECALL_LIMIT;
240
+ }
79
241
  // ---------------------------------------------------------------------------
80
242
  // HTTP helpers
81
243
  // ---------------------------------------------------------------------------
@@ -132,33 +294,72 @@ async function serverPost(path, body, timeoutMs) {
132
294
  return { ok: false, status: 0, data: null };
133
295
  }
134
296
  }
135
- // ---------------------------------------------------------------------------
136
- // Identity layer — per-agent standing identity (0.13; renamed from context
137
- // layer in 0.18 #264)
138
- // ---------------------------------------------------------------------------
139
297
  /**
140
298
  * Fetch GET /identity (per-agent when an id is supplied) and build the
141
- * `## Identity` block via the shared gate (gateAndRenderIdentity), or null when
142
- * nothing should be injected. The old-server guard is required only when an
143
- * agent id was actually sent (amendment A2 — a bare fetch skips it). The server
144
- * does the merge; the plugin stays dumb (no client-side mode logic).
299
+ * `## Identity` block via the shared gate (gateAndRenderIdentity). The
300
+ * old-server guard is required only when an agent id was actually sent
301
+ * (amendment A2 — a bare fetch skips it). The server does the merge; the
302
+ * plugin stays dumb (no client-side mode logic). `failed: true` ONLY when the
303
+ * fetch itself failed (serverGet null data) — gating to null stays `failed:
304
+ * false` (the guard fired, or the harness is not in `clients`).
145
305
  */
146
- async function fetchOcIdentityBlock(agentId) {
306
+ async function fetchOcIdentity(agentId) {
147
307
  const path = agentId ? `/identity?agent=${encodeURIComponent(agentId)}` : "/identity";
148
- const { data } = await serverGet(path, IDENTITY_TIMEOUT_MS);
308
+ const { data, status } = await serverGet(path, IDENTITY_TIMEOUT_MS);
149
309
  if (!data)
150
- return null;
151
- return (0, learnings_identity_js_1.gateAndRenderIdentity)(data, THIS_HARNESS, { requireAgentEcho: agentId !== null });
310
+ return { block: null, failed: true, status };
311
+ return {
312
+ block: (0, learnings_identity_js_1.gateAndRenderIdentity)(data, THIS_HARNESS, { requireAgentEcho: agentId !== null }),
313
+ failed: false,
314
+ status,
315
+ };
152
316
  }
153
317
  /**
154
- * Fetch /lessons and build the `## Hicortex Learnings` block, or null on any
155
- * failure or when no lessons survive selection. Preserves the pre-0.13 lesson
156
- * output; the caller prepends the `## Identity` block and adds separators.
318
+ * Dead-man banner (#313): injected INSTEAD of nothing when the /identity fetch
319
+ * fails at session start. OC agents are PUBLIC-facing an agent whose
320
+ * standing identity (who it is + its conduct rules) could not be fetched must
321
+ * be told so mechanically, not run voiceless on silence: suspend public
322
+ * actions until identity returns. A prompt-only sentence is weak; as plugin
323
+ * behavior it is testable, and the next successful turn replaces it
324
+ * automatically (the hook re-fetches per turn). Scope: OC plugin only — CC and
325
+ * Hermes keep plain fail-soft (they are operator-facing harnesses, not
326
+ * autonomous public agents). A 404 is NOT a banner-worthy failure — version
327
+ * skew gets IDENTITY_VERSION_SKEW_NOTE (CR2).
328
+ */
329
+ const IDENTITY_UNAVAILABLE_BANNER = [
330
+ "## IDENTITY UNAVAILABLE — public actions suspended",
331
+ "",
332
+ "The standing identity layer (who this agent is, and its conduct rules) could not be fetched from the memory server. Until it returns:",
333
+ "- Do NOT take public-facing actions (posting, replying, messaging, purchasing — anything visible to third parties).",
334
+ "- Do NOT speak or act in the agent persona's voice.",
335
+ "- Limit yourself to private, reversible work.",
336
+ "",
337
+ "This banner stands in for the missing identity block. Restore the Hicortex server; identity is re-fetched on the next turn.",
338
+ ].join("\n");
339
+ /**
340
+ * Version-skew note (#313 CR2): the dead-man banner must NOT fire on a 404.
341
+ * A plugin pinned in a gateway against a pre-0.12 server (no /identity
342
+ * route) would otherwise inject "public actions suspended" on EVERY turn —
343
+ * permanent self-suspension from version skew, with the wrong remediation
344
+ * (the server is not down, it is old). One diagnostic line, reusing
345
+ * describeGetFailure's wording; identity injection resumes once the server
346
+ * is upgraded.
347
+ */
348
+ const IDENTITY_VERSION_SKEW_NOTE = `[hicortex] Identity layer skipped — ${describeGetFailure(404, "/identity")}. ` +
349
+ `This is version skew, not an outage: no action suspension applies; identity returns once the server is upgraded.`;
350
+ /**
351
+ * Fetch /lessons and build the `## Hicortex Learnings` block. `failed: true`
352
+ * ONLY when the fetch itself failed (serverGet null data — unreachable,
353
+ * non-2xx, parse error); a successful fetch that selects zero lessons is
354
+ * `failed: false` with a null block. Preserves the pre-0.13 lesson output;
355
+ * the caller prepends the `## Identity` block and adds separators.
157
356
  */
158
357
  async function buildLessonsBlock(project) {
159
358
  const { data } = await serverGet("/lessons", LESSONS_TIMEOUT_MS);
160
- if (!data || !data.lessons || data.lessons.length === 0)
161
- return null;
359
+ if (!data)
360
+ return { block: null, failed: true };
361
+ if (!data.lessons || data.lessons.length === 0)
362
+ return { block: null, failed: false };
162
363
  const maxLessons = (0, features_js_1.lessonsLimit)(pluginConfig);
163
364
  const state = (0, state_js_1.loadState)(hicortexHome);
164
365
  const moduleIndex = data.moduleIndex ?? state.moduleIndex;
@@ -168,7 +369,7 @@ async function buildLessonsBlock(project) {
168
369
  moduleIndex,
169
370
  });
170
371
  if (selected.length === 0)
171
- return null;
372
+ return { block: null, failed: false };
172
373
  const formatted = selected.map((l) => {
173
374
  const typeMatch = l.content.match(/\*\*Type:\*\* (\w+)/);
174
375
  const severityMatch = l.content.match(/\*\*Severity:\*\* (\w+)/);
@@ -178,23 +379,99 @@ async function buildLessonsBlock(project) {
178
379
  const meta = [severityMatch?.[1], typeMatch?.[1]].filter(Boolean).join(", ");
179
380
  return `- ${title}${meta ? ` (${meta})` : ""}`;
180
381
  });
181
- return (`## Hicortex Learnings (auto-injected from long-term memory)\n` +
182
- `These are actionable Learnings from past sessions:\n\n` +
183
- formatted.join("\n"));
382
+ return {
383
+ block: `## Hicortex Learnings (auto-injected from long-term memory)\n` +
384
+ `These are actionable Learnings from past sessions:\n\n` +
385
+ formatted.join("\n"),
386
+ failed: false,
387
+ };
184
388
  }
185
389
  // ---------------------------------------------------------------------------
186
390
  // Pushed recall index (#193) — per-turn POST /recall-index
187
391
  // ---------------------------------------------------------------------------
392
+ /** Arm the pre-0.14 latch and log it — called only on a definitive 404. The
393
+ * guard makes the log fire at most ONCE per latch window: two probes can see
394
+ * the same 404 inside one turn (session-start reset + recall fetch), and
395
+ * while latched no further probe runs to 404 again. Hermes parity (#316):
396
+ * the latch note must be visible, not a silent skip to nothing. */
397
+ function latchRecallIndex() {
398
+ if (recallIndexLatched())
399
+ return;
400
+ recallIndexRetryAtMs = Date.now() + RECALL_REPROBE_INTERVAL_MS;
401
+ pluginLog("[hicortex] /recall-index not on the server (pre-0.14) — recall falls " +
402
+ "back to /search; re-probing in 10 min.");
403
+ }
188
404
  /**
189
- * Fetch the pushed recall index for this turn, or null when there is nothing
190
- * to inject (null block, no session id, failure, or a pre-0.14 server). The
191
- * server does all relevance gating and per-session TURN-based dedup — the
192
- * plugin sends every turn and carries no tuning constants. A 404 flips the
193
- * module-level guard so an old server is probed once per gateway process.
405
+ * Pre-0.14 fallback recall (#316, Hermes parity): GET /search with the turn's
406
+ * prompt and render CONTENT, not the pushed index's one-liner menu Hermes's
407
+ * legacy `_format_hits` shape (`provider.py`): the index shape is useless
408
+ * here because its `hicortex_get(id)` instruction points at a 0.14+ endpoint
409
+ * that 404s on exactly the servers this fallback exists for (#316 CR
410
+ * finding 2). So an old server degrades to full-content recall, not nothing.
411
+ * Timeout: 5 s (its OWN ceiling — /search embeds the prompt server-side and a
412
+ * cold embedder loads the ONNX model; this is not the pushed-index hot path).
413
+ * No per-session dedup exists on this path (same as Hermes's legacy
414
+ * prefetch); a latched plugin re-probes /recall-index when the TTL expires.
194
415
  */
195
- async function fetchRecallIndexBlock(sessionId, prompt, project) {
196
- if (recallIndexLatched())
416
+ async function legacySearchRecallBlock(prompt, project) {
417
+ const limit = recallLimit(pluginConfig);
418
+ const params = new URLSearchParams({ query: prompt, limit: String(limit) });
419
+ const effectiveProject = project ?? defaultProject;
420
+ if (effectiveProject)
421
+ params.set("project", effectiveProject);
422
+ const { data, status } = await serverGet(`/search?${params}`, SEARCH_FALLBACK_TIMEOUT_MS);
423
+ if (!data || !Array.isArray(data.results)) {
424
+ // Warn ONCE per failure streak (#316 CR finding 3 — this path was totally
425
+ // silent): a server old enough to latch the fallback can also be broken
426
+ // for /search, and a permanently-empty recall must be diagnosable. A
427
+ // SUCCESSFUL fetch re-arms the warning so a later regression is heard.
428
+ // Delta CR N4: carry the status (and the token hint for 401/403) so the
429
+ // operator isn't always told to "check server health" on an auth problem.
430
+ if (!warnedLegacyFallbackFailure) {
431
+ warnedLegacyFallbackFailure = true;
432
+ const tokenHint = status === 401 || status === 403 ? " — check the authToken config" : "";
433
+ const statusNote = status ? `; last status ${status}` : "";
434
+ pluginLog("[hicortex] WARNING: /search recall fallback failed — recall is empty " +
435
+ `while this persists (the server predates /recall-index; check its health${statusNote}${tokenHint}).`);
436
+ }
197
437
  return null;
438
+ }
439
+ warnedLegacyFallbackFailure = false;
440
+ if (data.results.length === 0)
441
+ return null;
442
+ // Hermes's `_format_hits` line: `- [YYYY-MM-DD, project] content…` — content
443
+ // flattened (newlines → spaces) and capped at LEGACY_CONTENT_CAP.
444
+ const lines = data.results.slice(0, limit).map((r) => {
445
+ const date = (r.created_at ?? "").slice(0, 10);
446
+ const proj = r.project || "global";
447
+ let content = (r.content ?? "").trim().replace(/\n+/g, " ");
448
+ if (content.length > LEGACY_CONTENT_CAP) {
449
+ // Code-point truncation (Hermes slices a Python str): a UTF-16 cut at
450
+ // exactly 500 can split an astral char (emoji are common in chat-derived
451
+ // memories) and inject a lone surrogate at the boundary.
452
+ content = `${Array.from(content).slice(0, LEGACY_CONTENT_CAP).join("")}…`;
453
+ }
454
+ return `- [${date}, ${proj}] ${content}`;
455
+ });
456
+ return [
457
+ "## Memory recall (auto)",
458
+ "Relevant prior context from your long-term memory (verify before relying on these — each shows date and project):",
459
+ ...lines,
460
+ ].join("\n");
461
+ }
462
+ /**
463
+ * Fetch the pushed recall index for this turn, or null when there is nothing
464
+ * to inject (null block, no session id, failure, or a pre-0.14 server on the
465
+ * /search fallback). The server does all relevance gating and per-session
466
+ * TURN-based dedup — the plugin sends every turn and carries no tuning
467
+ * constants. Error taxonomy (#316, Hermes parity):
468
+ * - 404 → version skew: latch the TTL guard + fall back to /search THIS
469
+ * turn and until the latch expires.
470
+ * - other !ok → an error, not a version signal: warn ONCE per distinct
471
+ * status (401/403 with a token hint) and fail soft this turn —
472
+ * the /search fallback would hit the same wall anyway.
473
+ */
474
+ async function fetchRecallIndexBlock(sessionId, prompt, project, resetKey) {
198
475
  if (!sessionId || !prompt) {
199
476
  // Verified against the installed OpenClaw gateway dist (auth-profiles
200
477
  // bundle, runEmbeddedPiAgent → hookCtx): before_agent_start receives
@@ -209,40 +486,93 @@ async function fetchRecallIndexBlock(sessionId, prompt, project) {
209
486
  }
210
487
  return null;
211
488
  }
489
+ if (recallIndexLatched())
490
+ return legacySearchRecallBlock(prompt, project);
491
+ // #316 ordering: a pending {reset:true} for THIS session (session-start or
492
+ // compaction) registered BEFORE this fetch must complete first — a reset
493
+ // landing after the fetch would wipe the shown-set/turn state the fetch
494
+ // just built. Awaited by the composite agentId:sessionId key (finding 7)
495
+ // so concurrent agents on one gateway never wait on each other's resets.
496
+ // Residual (delta CR N3, accepted): a compaction reset registered WHILE a
497
+ // same-session fetch is already in flight, or under a degraded compaction
498
+ // ctx (missing agentId → ":sid" key mismatch), can still land after it —
499
+ // one turn of re-show, never suppression; sequential turns are covered.
500
+ await pendingResets.get(resetKey ?? sessionId);
501
+ // The awaited reset may itself have 404-latched (that is often how the
502
+ // latch is first discovered) — skip the doomed probe and go to fallback.
503
+ if (recallIndexLatched())
504
+ return legacySearchRecallBlock(prompt, project);
212
505
  // #203 scope: send the gateway-supplied project so retrieval can apply a soft
213
506
  // project-affinity boost (no hard filter — "no hard filters in brains").
214
- // Absent ⇒ no scope sent ⇒ no-op (preserves pre-#203 behavior).
507
+ // Absent ⇒ defaultProject (if configured)else no scope sent.
215
508
  const body = {
216
509
  session_id: sessionId,
217
510
  prompt,
218
511
  };
219
- if (project)
220
- body.project = project;
512
+ const effectiveProject = project ?? defaultProject;
513
+ if (effectiveProject)
514
+ body.project = effectiveProject;
221
515
  const { ok, status, data } = await serverPost("/recall-index", body, RECALL_TIMEOUT_MS);
222
516
  if (status === 404) {
223
- recallIndexRetryAtMs = Date.now() + RECALL_REPROBE_INTERVAL_MS;
517
+ latchRecallIndex();
518
+ return legacySearchRecallBlock(prompt, project);
519
+ }
520
+ if (!ok) {
521
+ // Warn once per distinct status; network errors (status 0) stay silent —
522
+ // the server-down case is already surfaced by the startup probe and the
523
+ // #313 identity banner (Hermes logs those at debug too). Routed through
524
+ // pluginLog (#316 CR 8a), not raw console.warn: a gateway capturing plugin
525
+ // logs must not lose the one warning that explains dead recall.
526
+ if (status > 0 && !warnedRecallStatuses.has(status)) {
527
+ warnedRecallStatuses.add(status);
528
+ const hint = status === 401 || status === 403
529
+ ? " — check the authToken config (or the HICORTEX_AUTH_TOKEN env var)"
530
+ : "";
531
+ pluginLog(`[hicortex] WARNING: /recall-index returned HTTP ${status}; recall ` +
532
+ `injection is disabled while this persists${hint}.`);
533
+ }
224
534
  return null;
225
535
  }
226
- if (!ok || !data)
536
+ if (!data)
227
537
  return null;
228
538
  recallIndexRetryAtMs = 0;
229
539
  return typeof data.block === "string" && data.block.trim() !== "" ? data.block : null;
230
540
  }
231
541
  /**
232
- * Reset the session's server-side recall dedup the context window was
233
- * rebuilt (compaction or session reset), so the shown-set is stale by
234
- * definition. Fire-and-forget fail-soft: a reset that is lost only means some
235
- * memories stay suppressed until the re-show window (`recallReshowTurns`)
236
- * passes.
542
+ * POST {session_id, reset:true}. Fail-soft: a reset that is lost only means
543
+ * some memories stay suppressed until the re-show window
544
+ * (`recallReshowTurns`) passes. The wire session_id stays the RAW gateway
545
+ * session id (the server registry's key unchanged contract); only the
546
+ * LOCAL in-flight map uses the composite agentId:sessionId key.
237
547
  */
238
548
  async function postRecallReset(sessionId) {
239
549
  if (recallIndexLatched())
240
550
  return;
241
- if (!sessionId)
242
- return;
243
551
  const { status } = await serverPost("/recall-index", { session_id: sessionId, reset: true }, RECALL_TIMEOUT_MS);
244
552
  if (status === 404)
245
- recallIndexRetryAtMs = Date.now() + RECALL_REPROBE_INTERVAL_MS;
553
+ latchRecallIndex();
554
+ }
555
+ /**
556
+ * Reset the session's server-side recall dedup, deduplicated per session:
557
+ * concurrent callers share ONE in-flight POST (registered in pendingResets so
558
+ * the next recall fetch for the session awaits it — see fetchRecallIndexBlock).
559
+ * `key` is the composite agentId:sessionId (#316 CR finding 7) used for the
560
+ * LOCAL map only; the wire body carries the raw `sessionId`. Never rejects.
561
+ */
562
+ function resetRecallDedup(sessionId, key) {
563
+ const inFlight = pendingResets.get(key);
564
+ if (inFlight)
565
+ return inFlight;
566
+ const p = postRecallReset(sessionId)
567
+ .catch(() => {
568
+ /* fail-soft — never surface into the gateway */
569
+ })
570
+ .finally(() => {
571
+ if (pendingResets.get(key) === p)
572
+ pendingResets.delete(key);
573
+ });
574
+ pendingResets.set(key, p);
575
+ return p;
246
576
  }
247
577
  // ---------------------------------------------------------------------------
248
578
  // Tool result formatter
@@ -335,6 +665,11 @@ function resolveOcPluginConfig(raw) {
335
665
  warn(`plugin config key "authToken" must be a non-empty string (got ${describeInvalid(rawToken)}) — ignoring it`);
336
666
  resolved.authToken = undefined;
337
667
  }
668
+ const rawProject = winner.defaultProject;
669
+ if (rawProject !== undefined && !isNonEmptyString(rawProject)) {
670
+ warn(`plugin config key "defaultProject" must be a non-empty string (got ${describeInvalid(rawProject)}) — ignoring it`);
671
+ resolved.defaultProject = undefined;
672
+ }
338
673
  // Shadow detection (F2) — two configs disagreeing, surfaced instead of
339
674
  // silently honoring one of them. Case 1: an OC-scaffolded EMPTY
340
675
  // plugins.entries.hicortex.config was skipped while a bare top-level
@@ -379,16 +714,33 @@ exports.default = {
379
714
  const log = ctx.logger
380
715
  ? (msg) => ctx.logger.info(msg)
381
716
  : console.log;
382
- // Resolve server URL and auth token from plugin config (both already
383
- // validated strings or absent, hence the ?? default)
384
- serverUrl = (config.serverUrl ?? DEFAULT_SERVER_URL).replace(/\/+$/, "");
385
- authToken = config.authToken;
717
+ // Resolve server URL and auth token: plugin config first, then the
718
+ // HICORTEX_URL / HICORTEX_AUTH_TOKEN env vars as FALLBACKS when config
719
+ // omits them (#316). Deliberate divergence from Hermes (whose env
720
+ // OVERRIDES its config file): openclaw.json is operator-managed via
721
+ // the gateway UI, so an env var silently winning over it would
722
+ // surprise; here env only fills gaps (empty env values = unset).
723
+ const envUrl = process.env.HICORTEX_URL?.trim();
724
+ const envToken = process.env.HICORTEX_AUTH_TOKEN?.trim();
725
+ serverUrl = (config.serverUrl ?? (envUrl || undefined) ?? DEFAULT_SERVER_URL)
726
+ .replace(/\/+$/, "");
727
+ authToken = config.authToken ?? (envToken || undefined);
728
+ defaultProject = config.defaultProject;
386
729
  // Use stateDir from context so tests can redirect state writes
387
730
  hicortexHome = ctx.stateDir ?? HICORTEX_HOME;
388
731
  // Re-probe /recall-index support on every (re)start — the server may
389
- // have been upgraded while the gateway was down.
732
+ // have been upgraded while the gateway was down. Session trackers
733
+ // reset too: a restarted gateway re-injects standing blocks and re-
734
+ // resets the dedup for any session it resumes.
390
735
  recallIndexRetryAtMs = 0;
391
736
  warnedMissingSessionId = false;
737
+ warnedRecallStatuses.clear();
738
+ warnedLegacyFallbackFailure = false;
739
+ warnedRecallLimitInvalid = false;
740
+ sessionsReset.clear();
741
+ identityInjected.clear();
742
+ lessonsInjected.clear();
743
+ pendingResets.clear();
392
744
  pluginLog = log;
393
745
  log(`[hicortex] Thin-client mode — server: ${serverUrl}`);
394
746
  // License: init feature cache (only needs licenseKey, no DB access)
@@ -403,10 +755,13 @@ exports.default = {
403
755
  // header when a token actually exists — an empty `Bearer ` header
404
756
  // is worse than absent (it can trip strict middlewares and signals
405
757
  // a misconfigured client). When there's no token, fall straight to
406
- // the public /health liveness probe below.
758
+ // the public /health liveness probe below. Uses the RESOLVED token
759
+ // (config ?? env, #316 CR finding 5) — an env-token deployment must
760
+ // not probe unauthenticated and log a misleading "diagnostics
761
+ // gated" line every start.
407
762
  const headers = {};
408
- if (config.authToken)
409
- headers.Authorization = `Bearer ${config.authToken}`;
763
+ if (authToken)
764
+ headers.Authorization = `Bearer ${authToken}`;
410
765
  const resp = await fetch(`${serverUrl}/health/detail`, {
411
766
  signal: AbortSignal.timeout(5000),
412
767
  headers,
@@ -458,15 +813,68 @@ exports.default = {
458
813
  // sanitizes to null → bare /identity → global set). Null id never sends
459
814
  // ?agent=, so an old server behaves exactly as before.
460
815
  const agentId = (0, identity_store_js_1.sanitizeAgentId)(ctx?.agentId ?? "");
461
- // Fetch all three concurrently with INDEPENDENT fail-soft: no block
462
- // may ever cost another. Order in the injected output: `## Identity`
463
- // (standing identity, 0.13) `## Hicortex Learnings` the per-turn
464
- // `## Memory recall (auto)` index (#193, closest to the prompt).
465
- const [identityBlock, lessonsBlock, recallBlock] = await Promise.all([
466
- fetchOcIdentityBlock(agentId).catch(() => null),
467
- buildLessonsBlock(ctx?.project).catch(() => null),
468
- fetchRecallIndexBlock(ctx?.sessionId, event?.prompt, ctx?.project).catch(() => null),
816
+ const sessionId = ctx?.sessionId || undefined;
817
+ const prompt = event?.prompt;
818
+ // Composite tracker/reset key (#316 CR finding 7): agentId + sessionId.
819
+ const sKey = sessionId !== undefined ? sessionKey(agentId, sessionId) : undefined;
820
+ // #316 session-start dedup reset (Hermes initialize parity): the
821
+ // FIRST recall fetch of a session is preceded by an {reset:true} —
822
+ // a gateway restart resuming a live session would otherwise inherit
823
+ // a stale server-side shown-set (suppression for up to
824
+ // recallReshowTurns turns). Registered SYNCHRONOUSLY here (before
825
+ // the fetch below) and awaited by fetchRecallIndexBlock, so it can
826
+ // never land after the fetch and wipe what it built. Gated on a
827
+ // real prompt+session: no recall traffic without a real turn.
828
+ if (sessionId && sKey !== undefined && prompt && !sessionsReset.has(sKey)) {
829
+ sessionsReset.add(sKey);
830
+ void resetRecallDedup(sessionId, sKey);
831
+ }
832
+ // #316 once-per-session standing blocks: `## Identity` +
833
+ // `## Hicortex Learnings` are injected on a session's FIRST turn
834
+ // only (they PERSIST on the session prompt — see the tracker docs);
835
+ // subsequent turns inject the per-turn recall block alone. The two
836
+ // blocks memoize INDEPENDENTLY (CR finding 4): identity on identity
837
+ // success, lessons on a successful lessons fetch — an OK identity +
838
+ // a failed /lessons must not bury the learnings for the session.
839
+ // Without a sessionId there is no per-session key — inject per turn
840
+ // (pre-#316 shape) rather than never.
841
+ const skipIdentity = sKey !== undefined && identityInjected.has(sKey);
842
+ const skipLessons = sKey !== undefined && lessonsInjected.has(sKey);
843
+ const [identity, lessons, recallBlock] = await Promise.all([
844
+ skipIdentity
845
+ ? Promise.resolve(null)
846
+ : fetchOcIdentity(agentId).catch(() => ({ block: null, failed: true, status: null })),
847
+ skipLessons
848
+ ? Promise.resolve(null)
849
+ : buildLessonsBlock(ctx?.project).catch(() => ({ block: null, failed: true })),
850
+ fetchRecallIndexBlock(sessionId, prompt, ctx?.project, sKey).catch(() => null),
469
851
  ]);
852
+ // Memoize each block ONLY on its own success (#313 + #316): a
853
+ // FAILED identity fetch (banner / version-skew note) must retry next
854
+ // turn — the banner's "identity is re-fetched on the next turn"
855
+ // promise stays true — and a failed /lessons fetch must retry too.
856
+ // A gated identity response (no echo, oc ∉ clients, mode off) and
857
+ // an empty-but-successful lessons fetch are settled outcomes and
858
+ // memoize like any other success.
859
+ if (!skipIdentity && sKey !== undefined && identity !== null && !identity.failed) {
860
+ identityInjected.add(sKey);
861
+ }
862
+ if (!skipLessons && sKey !== undefined && lessons !== null && !lessons.failed) {
863
+ lessonsInjected.add(sKey);
864
+ }
865
+ // Dead-man enforcement (#313): a FAILED identity fetch injects the
866
+ // hard banner in the identity slot — never silence. Two non-failure
867
+ // exceptions: a gated-null (old-server guard, harness not in
868
+ // clients) stays silent, and a 404 is version skew — the one-line
869
+ // note, NOT the banner (CR2: a pinned plugin on an old server must
870
+ // not self-suspend every turn). Lessons/recall keep their own
871
+ // independent fail-soft.
872
+ const identityBlock = identity !== null && identity.block !== null
873
+ ? identity.block
874
+ : identity !== null && identity.failed
875
+ ? (identity.status === 404 ? IDENTITY_VERSION_SKEW_NOTE : IDENTITY_UNAVAILABLE_BANNER)
876
+ : null;
877
+ const lessonsBlock = lessons !== null ? lessons.block : null;
470
878
  const blocks = [identityBlock, lessonsBlock, recallBlock].filter((b) => b !== null && b !== "");
471
879
  if (blocks.length === 0)
472
880
  return {};
@@ -477,18 +885,32 @@ exports.default = {
477
885
  }
478
886
  });
479
887
  // -----------------------------------------------------------------------
480
- // Hooks: after_compaction / before_reset — reset the session's recall
481
- // dedup (#193). Both rebuild the context window, so the server's
482
- // per-session shown-set no longer reflects what the agent can see.
483
- // Unknown hook names are ignored by older gateways (typed-hook registry
484
- // warns and drops them), so registering both is safe everywhere.
888
+ // Hooks: after_compaction / before_reset — the context window was
889
+ // rebuilt (#193): the server's per-session shown-set no longer reflects
890
+ // what the agent can see, and the standing blocks injected on earlier
891
+ // turns may have been dropped from the rebuilt window — so the session's
892
+ // memo is evicted and the next turn re-injects them (#316; once per
893
+ // rebuild, never per turn). Unknown hook names are ignored by older
894
+ // gateways (typed-hook registry warns and drops them), so registering
895
+ // both is safe everywhere.
485
896
  // -----------------------------------------------------------------------
486
897
  const recallResetHook = (_event, ctx) => {
898
+ const sid = ctx?.sessionId || undefined;
899
+ if (!sid)
900
+ return;
901
+ // Same composite key as before_agent_start (finding 7) — an agent id
902
+ // absent from the compaction ctx degrades to the bare-session key, and
903
+ // the eviction may miss (accepted: one duplicate standing injection).
904
+ const key = sessionKey((0, identity_store_js_1.sanitizeAgentId)(ctx?.agentId ?? ""), sid);
487
905
  // Genuinely fire-and-forget (F9): no await — a slow server must never
488
- // add latency to compaction or session reset in the gateway.
489
- void postRecallReset(ctx?.sessionId).catch(() => {
490
- /* fail-soft never surface into the gateway */
491
- });
906
+ // add latency to compaction or session reset in the gateway. The race
907
+ // with the next turn's recall fetch is closed by ORDERING (#316), not
908
+ // by awaiting here: resetRecallDedup registers the POST in
909
+ // pendingResets, and fetchRecallIndexBlock awaits that entry before
910
+ // fetching — the reset can no longer land after the fetch.
911
+ void resetRecallDedup(sid, key);
912
+ identityInjected.evict(key);
913
+ lessonsInjected.evict(key);
492
914
  };
493
915
  api.on("after_compaction", recallResetHook);
494
916
  api.on("before_reset", recallResetHook);
@@ -512,8 +934,9 @@ exports.default = {
512
934
  const params = new URLSearchParams({ query: args.query });
513
935
  if (args.limit)
514
936
  params.set("limit", String(args.limit));
515
- if (args.project)
516
- params.set("project", args.project);
937
+ const projectFilter = args.project ?? defaultProject;
938
+ if (projectFilter)
939
+ params.set("project", projectFilter);
517
940
  const { data, status } = await serverGet(`/search?${params}`, 10000);
518
941
  if (!data)
519
942
  return { error: `Search failed: ${describeGetFailure(status, "/search")}` };
@@ -539,7 +962,7 @@ exports.default = {
539
962
  if (!args?.id)
540
963
  return { error: "id is required" };
541
964
  const params = new URLSearchParams({ id: String(args.id) });
542
- const { data, status } = await serverGet(`/memory?${params}`, 10000);
965
+ const { data, status } = await serverGet(`/memory?${params}`, RECALL_TIMEOUT_MS);
543
966
  if (status === 404) {
544
967
  // Either no such memory (0.14+) or a pre-0.14 server with no
545
968
  // /memory endpoint — the id hint covers the common case.
@@ -570,8 +993,9 @@ exports.default = {
570
993
  async execute(_callId, args, _ctx) {
571
994
  try {
572
995
  const params = new URLSearchParams();
573
- if (args?.project)
574
- params.set("project", args.project);
996
+ const projectFilter = args?.project ?? defaultProject;
997
+ if (projectFilter)
998
+ params.set("project", projectFilter);
575
999
  if (args?.limit)
576
1000
  params.set("limit", String(args.limit));
577
1001
  const qs = params.toString();
@@ -606,7 +1030,7 @@ exports.default = {
606
1030
  const result = await serverPost("/ingest", {
607
1031
  content: args.content,
608
1032
  source_agent: `openclaw/${context?.agentId ?? "manual"}`,
609
- project: args.project,
1033
+ project: args.project ?? defaultProject,
610
1034
  memory_type: args.memory_type ? (0, type_labels_js_1.normalizeMemoryType)(args.memory_type) : "experience",
611
1035
  }, 15000);
612
1036
  if (!result.ok) {