@gamaze/hicortex 0.19.0 → 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
@@ -26,6 +41,7 @@
26
41
  */
27
42
  Object.defineProperty(exports, "__esModule", { value: true });
28
43
  exports.formatToolResults = formatToolResults;
44
+ exports.resolveOcPluginConfig = resolveOcPluginConfig;
29
45
  const paths_js_1 = require("./paths.js");
30
46
  const features_js_1 = require("./features.js");
31
47
  const extensions_js_1 = require("./extensions.js");
@@ -42,7 +58,35 @@ const type_labels_js_1 = require("./type-labels.js");
42
58
  const DEFAULT_SERVER_URL = "http://127.0.0.1:8787";
43
59
  const LESSONS_TIMEOUT_MS = 3000;
44
60
  const IDENTITY_TIMEOUT_MS = 3000;
45
- 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;
46
90
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
47
91
  /** Harness name this plugin injects for — used to self-gate on GET /identity `clients`. */
48
92
  const THIS_HARNESS = "oc";
@@ -53,14 +97,20 @@ let serverUrl = DEFAULT_SERVER_URL;
53
97
  let authToken;
54
98
  let hicortexHome = HICORTEX_HOME;
55
99
  /** Resolved plugin config captured at service start — used for tunable knobs
56
- * (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. */
57
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;
58
107
  /** Old-server guard (F2): 0 = not latched; otherwise the Date.now() epoch-ms
59
- * until which /recall-index is skipped after a 404 (pre-0.14 server). The
60
- * latch EXPIRES so a client-first rollout heals itself once the server is
61
- * upgraded a permanent latch would silently disable recall on a
62
- * long-running gateway until restart. OC has no pre-0.14 per-turn recall to
63
- * 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. */
64
114
  let recallIndexRetryAtMs = 0;
65
115
  /** How long a 404 latches the guard before re-probing. Long enough not to
66
116
  * hammer an old server every turn, short enough that a server upgrade is
@@ -70,11 +120,124 @@ const RECALL_REPROBE_INTERVAL_MS = 600_000;
70
120
  * gateway; if a gateway variant doesn't pass it the feature must not run
71
121
  * silently dead. */
72
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;
73
131
  /** Plugin logger captured at service start (ctx.logger or console). */
74
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
+ }
75
215
  function recallIndexLatched() {
76
216
  return recallIndexRetryAtMs !== 0 && Date.now() < recallIndexRetryAtMs;
77
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
+ }
78
241
  // ---------------------------------------------------------------------------
79
242
  // HTTP helpers
80
243
  // ---------------------------------------------------------------------------
@@ -131,33 +294,72 @@ async function serverPost(path, body, timeoutMs) {
131
294
  return { ok: false, status: 0, data: null };
132
295
  }
133
296
  }
134
- // ---------------------------------------------------------------------------
135
- // Identity layer — per-agent standing identity (0.13; renamed from context
136
- // layer in 0.18 #264)
137
- // ---------------------------------------------------------------------------
138
297
  /**
139
298
  * Fetch GET /identity (per-agent when an id is supplied) and build the
140
- * `## Identity` block via the shared gate (gateAndRenderIdentity), or null when
141
- * nothing should be injected. The old-server guard is required only when an
142
- * agent id was actually sent (amendment A2 — a bare fetch skips it). The server
143
- * 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`).
144
305
  */
145
- async function fetchOcIdentityBlock(agentId) {
306
+ async function fetchOcIdentity(agentId) {
146
307
  const path = agentId ? `/identity?agent=${encodeURIComponent(agentId)}` : "/identity";
147
- const { data } = await serverGet(path, IDENTITY_TIMEOUT_MS);
308
+ const { data, status } = await serverGet(path, IDENTITY_TIMEOUT_MS);
148
309
  if (!data)
149
- return null;
150
- 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
+ };
151
316
  }
152
317
  /**
153
- * Fetch /lessons and build the `## Hicortex Learnings` block, or null on any
154
- * failure or when no lessons survive selection. Preserves the pre-0.13 lesson
155
- * 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.
156
356
  */
157
357
  async function buildLessonsBlock(project) {
158
358
  const { data } = await serverGet("/lessons", LESSONS_TIMEOUT_MS);
159
- if (!data || !data.lessons || data.lessons.length === 0)
160
- 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 };
161
363
  const maxLessons = (0, features_js_1.lessonsLimit)(pluginConfig);
162
364
  const state = (0, state_js_1.loadState)(hicortexHome);
163
365
  const moduleIndex = data.moduleIndex ?? state.moduleIndex;
@@ -167,7 +369,7 @@ async function buildLessonsBlock(project) {
167
369
  moduleIndex,
168
370
  });
169
371
  if (selected.length === 0)
170
- return null;
372
+ return { block: null, failed: false };
171
373
  const formatted = selected.map((l) => {
172
374
  const typeMatch = l.content.match(/\*\*Type:\*\* (\w+)/);
173
375
  const severityMatch = l.content.match(/\*\*Severity:\*\* (\w+)/);
@@ -177,23 +379,99 @@ async function buildLessonsBlock(project) {
177
379
  const meta = [severityMatch?.[1], typeMatch?.[1]].filter(Boolean).join(", ");
178
380
  return `- ${title}${meta ? ` (${meta})` : ""}`;
179
381
  });
180
- return (`## Hicortex Learnings (auto-injected from long-term memory)\n` +
181
- `These are actionable Learnings from past sessions:\n\n` +
182
- 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
+ };
183
388
  }
184
389
  // ---------------------------------------------------------------------------
185
390
  // Pushed recall index (#193) — per-turn POST /recall-index
186
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
+ }
187
404
  /**
188
- * Fetch the pushed recall index for this turn, or null when there is nothing
189
- * to inject (null block, no session id, failure, or a pre-0.14 server). The
190
- * server does all relevance gating and per-session TURN-based dedup — the
191
- * plugin sends every turn and carries no tuning constants. A 404 flips the
192
- * 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.
193
415
  */
194
- async function fetchRecallIndexBlock(sessionId, prompt, project) {
195
- 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
+ }
196
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) {
197
475
  if (!sessionId || !prompt) {
198
476
  // Verified against the installed OpenClaw gateway dist (auth-profiles
199
477
  // bundle, runEmbeddedPiAgent → hookCtx): before_agent_start receives
@@ -208,40 +486,93 @@ async function fetchRecallIndexBlock(sessionId, prompt, project) {
208
486
  }
209
487
  return null;
210
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);
211
505
  // #203 scope: send the gateway-supplied project so retrieval can apply a soft
212
506
  // project-affinity boost (no hard filter — "no hard filters in brains").
213
- // Absent ⇒ no scope sent ⇒ no-op (preserves pre-#203 behavior).
507
+ // Absent ⇒ defaultProject (if configured)else no scope sent.
214
508
  const body = {
215
509
  session_id: sessionId,
216
510
  prompt,
217
511
  };
218
- if (project)
219
- body.project = project;
512
+ const effectiveProject = project ?? defaultProject;
513
+ if (effectiveProject)
514
+ body.project = effectiveProject;
220
515
  const { ok, status, data } = await serverPost("/recall-index", body, RECALL_TIMEOUT_MS);
221
516
  if (status === 404) {
222
- 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
+ }
223
534
  return null;
224
535
  }
225
- if (!ok || !data)
536
+ if (!data)
226
537
  return null;
227
538
  recallIndexRetryAtMs = 0;
228
539
  return typeof data.block === "string" && data.block.trim() !== "" ? data.block : null;
229
540
  }
230
541
  /**
231
- * Reset the session's server-side recall dedup the context window was
232
- * rebuilt (compaction or session reset), so the shown-set is stale by
233
- * definition. Fire-and-forget fail-soft: a reset that is lost only means some
234
- * memories stay suppressed until the re-show window (`recallReshowTurns`)
235
- * 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.
236
547
  */
237
548
  async function postRecallReset(sessionId) {
238
549
  if (recallIndexLatched())
239
550
  return;
240
- if (!sessionId)
241
- return;
242
551
  const { status } = await serverPost("/recall-index", { session_id: sessionId, reset: true }, RECALL_TIMEOUT_MS);
243
552
  if (status === 404)
244
- 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;
245
576
  }
246
577
  // ---------------------------------------------------------------------------
247
578
  // Tool result formatter
@@ -256,6 +587,109 @@ function formatToolResults(results) {
256
587
  return { content: [{ type: "text", text }] };
257
588
  }
258
589
  // ---------------------------------------------------------------------------
590
+ // Config resolution
591
+ // ---------------------------------------------------------------------------
592
+ /** Object (not null, not array) → itself as a record; anything else → undefined. */
593
+ function isRecord(v) {
594
+ return typeof v === "object" && v !== null && !Array.isArray(v)
595
+ ? v
596
+ : undefined;
597
+ }
598
+ function isNonEmptyString(v) {
599
+ return typeof v === "string" && v.length > 0;
600
+ }
601
+ /** Human name for a config value that failed validation. Only ever called
602
+ * with INVALID values (non-strings and empty strings), so the string branch
603
+ * means "empty string". */
604
+ function describeInvalid(v) {
605
+ if (v === null)
606
+ return "null";
607
+ if (Array.isArray(v))
608
+ return "an array";
609
+ if (typeof v === "string")
610
+ return "an empty string";
611
+ return typeof v === "object" ? "an object" : `a ${typeof v}`;
612
+ }
613
+ /**
614
+ * Resolve the plugin's config from the raw `ctx.config` OC hands the service
615
+ * (the ENTIRE openclaw.json — verified against gateway-cli createServiceContext:
616
+ * config = params.cfg — not a per-plugin section). Stateless: touches no module
617
+ * state, never mutates the input, never throws (console.warn is its only side
618
+ * effect). Three branches, first match wins:
619
+ *
620
+ * 1. `plugins.entries.hicortex.config` — the canonical OC per-plugin
621
+ * section. Only eligible when it is a non-null object with ≥1 OWN key:
622
+ * OC scaffolds `config: {}` for an installed-but-unconfigured plugin,
623
+ * and that empty object must not shadow real config further down.
624
+ * 2. `hicortex` at the top level — but only when `typeof === "object"`:
625
+ * a string/bool/number there (e.g. `"hicortex": true` as a feature
626
+ * toggle) is not a config and is skipped, not cast.
627
+ * 3. the top level itself — bare keys (`serverUrl`, `authToken`, …) at the
628
+ * root of openclaw.json; the pre-0.19 shape, kept for backcompat.
629
+ *
630
+ * `serverUrl` and `authToken` are validated at this boundary: a present but
631
+ * non-string (or empty-string) value warns naming the key and degrades —
632
+ * serverUrl falls back to DEFAULT_SERVER_URL, authToken to undefined. No
633
+ * throw paths: a malformed config degrades to defaults instead of leaving
634
+ * the plugin half-initialized.
635
+ */
636
+ function resolveOcPluginConfig(raw) {
637
+ const warn = (msg) => console.warn(`[hicortex] WARNING: ${msg}`);
638
+ const full = isRecord(raw) ?? {};
639
+ // Branch 1 — isRecord at every level: a missing key, string, array, or
640
+ // null anywhere in the chain just falls through to the next branch.
641
+ const plugins = isRecord(full.plugins);
642
+ const entries = isRecord(plugins?.entries);
643
+ const entry = isRecord(entries?.hicortex);
644
+ const entryConfig = isRecord(entry?.config);
645
+ const nested = entryConfig !== undefined && Object.keys(entryConfig).length > 0
646
+ ? entryConfig
647
+ : undefined;
648
+ // Branch 2 — top-level `hicortex`, object-guarded (see doc block).
649
+ const hicortexObj = isRecord(full.hicortex);
650
+ const winner = nested ?? hicortexObj ?? full;
651
+ const winnerPath = nested !== undefined
652
+ ? "plugins.entries.hicortex.config"
653
+ : hicortexObj !== undefined ? "hicortex" : "the top level of openclaw.json";
654
+ // Copy, never the caller's object: sanitizing below must not mutate
655
+ // ctx.config, and pluginConfig must not alias OC's config state.
656
+ const resolved = { ...winner };
657
+ const rawUrl = winner.serverUrl;
658
+ if (rawUrl !== undefined && !isNonEmptyString(rawUrl)) {
659
+ warn(`plugin config key "serverUrl" must be a non-empty string (got ${describeInvalid(rawUrl)}) ` +
660
+ `— falling back to ${DEFAULT_SERVER_URL}`);
661
+ resolved.serverUrl = DEFAULT_SERVER_URL;
662
+ }
663
+ const rawToken = winner.authToken;
664
+ if (rawToken !== undefined && !isNonEmptyString(rawToken)) {
665
+ warn(`plugin config key "authToken" must be a non-empty string (got ${describeInvalid(rawToken)}) — ignoring it`);
666
+ resolved.authToken = undefined;
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
+ }
673
+ // Shadow detection (F2) — two configs disagreeing, surfaced instead of
674
+ // silently honoring one of them. Case 1: an OC-scaffolded EMPTY
675
+ // plugins.entries.hicortex.config was skipped while a bare top-level
676
+ // serverUrl exists (the exact shape the ≥1-own-key rule exists for).
677
+ // Gate on the ACTUAL winner (re-CR F1): in the compound shape (empty
678
+ // nested config + a top-level hicortex object + bare serverUrl) the hicortex
679
+ // object wins — the warn must not claim the bare key is being used.
680
+ if (nested === undefined && entryConfig !== undefined && winner === full && isNonEmptyString(full.serverUrl)) {
681
+ warn(`plugins.entries.hicortex.config is empty, so the top-level serverUrl is used instead — ` +
682
+ `remove the empty config section or move serverUrl into it`);
683
+ }
684
+ // Case 2: a real nested/hicortex section won the chain but carries no valid
685
+ // serverUrl, while a bare top-level serverUrl is set and will NOT be read.
686
+ if (winner !== full && !isNonEmptyString(rawUrl) && isNonEmptyString(full.serverUrl)) {
687
+ warn(`top-level serverUrl is set but ${winnerPath} takes precedence and has no valid serverUrl — ` +
688
+ `the plugin will NOT use the top-level value; move serverUrl into ${winnerPath}`);
689
+ }
690
+ return resolved;
691
+ }
692
+ // ---------------------------------------------------------------------------
259
693
  // Plugin export
260
694
  // ---------------------------------------------------------------------------
261
695
  exports.default = {
@@ -269,20 +703,44 @@ exports.default = {
269
703
  api.registerService({
270
704
  id: "hicortex-service",
271
705
  async start(ctx) {
272
- const config = (ctx.config ?? {});
706
+ // OC passes the ENTIRE openclaw.json as ctx.config (verified against
707
+ // gateway-cli createServiceContext: config = params.cfg), not a
708
+ // per-plugin section. resolveOcPluginConfig picks our section out of
709
+ // it (branch order documented on the function) and validates the
710
+ // scalar keys at the boundary. It never throws, so a malformed config
711
+ // can only degrade (warn + default), never half-initialize the plugin.
712
+ const config = resolveOcPluginConfig(ctx.config);
273
713
  pluginConfig = config;
274
714
  const log = ctx.logger
275
715
  ? (msg) => ctx.logger.info(msg)
276
716
  : console.log;
277
- // Resolve server URL and auth token from plugin config
278
- serverUrl = (config.serverUrl ?? DEFAULT_SERVER_URL).replace(/\/+$/, "");
279
- 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;
280
729
  // Use stateDir from context so tests can redirect state writes
281
730
  hicortexHome = ctx.stateDir ?? HICORTEX_HOME;
282
731
  // Re-probe /recall-index support on every (re)start — the server may
283
- // 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.
284
735
  recallIndexRetryAtMs = 0;
285
736
  warnedMissingSessionId = false;
737
+ warnedRecallStatuses.clear();
738
+ warnedLegacyFallbackFailure = false;
739
+ warnedRecallLimitInvalid = false;
740
+ sessionsReset.clear();
741
+ identityInjected.clear();
742
+ lessonsInjected.clear();
743
+ pendingResets.clear();
286
744
  pluginLog = log;
287
745
  log(`[hicortex] Thin-client mode — server: ${serverUrl}`);
288
746
  // License: init feature cache (only needs licenseKey, no DB access)
@@ -297,10 +755,13 @@ exports.default = {
297
755
  // header when a token actually exists — an empty `Bearer ` header
298
756
  // is worse than absent (it can trip strict middlewares and signals
299
757
  // a misconfigured client). When there's no token, fall straight to
300
- // 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.
301
762
  const headers = {};
302
- if (config.authToken)
303
- headers.Authorization = `Bearer ${config.authToken}`;
763
+ if (authToken)
764
+ headers.Authorization = `Bearer ${authToken}`;
304
765
  const resp = await fetch(`${serverUrl}/health/detail`, {
305
766
  signal: AbortSignal.timeout(5000),
306
767
  headers,
@@ -352,15 +813,68 @@ exports.default = {
352
813
  // sanitizes to null → bare /identity → global set). Null id never sends
353
814
  // ?agent=, so an old server behaves exactly as before.
354
815
  const agentId = (0, identity_store_js_1.sanitizeAgentId)(ctx?.agentId ?? "");
355
- // Fetch all three concurrently with INDEPENDENT fail-soft: no block
356
- // may ever cost another. Order in the injected output: `## Identity`
357
- // (standing identity, 0.13) `## Hicortex Learnings` the per-turn
358
- // `## Memory recall (auto)` index (#193, closest to the prompt).
359
- const [identityBlock, lessonsBlock, recallBlock] = await Promise.all([
360
- fetchOcIdentityBlock(agentId).catch(() => null),
361
- buildLessonsBlock(ctx?.project).catch(() => null),
362
- 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),
363
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;
364
878
  const blocks = [identityBlock, lessonsBlock, recallBlock].filter((b) => b !== null && b !== "");
365
879
  if (blocks.length === 0)
366
880
  return {};
@@ -371,18 +885,32 @@ exports.default = {
371
885
  }
372
886
  });
373
887
  // -----------------------------------------------------------------------
374
- // Hooks: after_compaction / before_reset — reset the session's recall
375
- // dedup (#193). Both rebuild the context window, so the server's
376
- // per-session shown-set no longer reflects what the agent can see.
377
- // Unknown hook names are ignored by older gateways (typed-hook registry
378
- // 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.
379
896
  // -----------------------------------------------------------------------
380
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);
381
905
  // Genuinely fire-and-forget (F9): no await — a slow server must never
382
- // add latency to compaction or session reset in the gateway.
383
- void postRecallReset(ctx?.sessionId).catch(() => {
384
- /* fail-soft never surface into the gateway */
385
- });
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);
386
914
  };
387
915
  api.on("after_compaction", recallResetHook);
388
916
  api.on("before_reset", recallResetHook);
@@ -406,8 +934,9 @@ exports.default = {
406
934
  const params = new URLSearchParams({ query: args.query });
407
935
  if (args.limit)
408
936
  params.set("limit", String(args.limit));
409
- if (args.project)
410
- params.set("project", args.project);
937
+ const projectFilter = args.project ?? defaultProject;
938
+ if (projectFilter)
939
+ params.set("project", projectFilter);
411
940
  const { data, status } = await serverGet(`/search?${params}`, 10000);
412
941
  if (!data)
413
942
  return { error: `Search failed: ${describeGetFailure(status, "/search")}` };
@@ -433,7 +962,7 @@ exports.default = {
433
962
  if (!args?.id)
434
963
  return { error: "id is required" };
435
964
  const params = new URLSearchParams({ id: String(args.id) });
436
- const { data, status } = await serverGet(`/memory?${params}`, 10000);
965
+ const { data, status } = await serverGet(`/memory?${params}`, RECALL_TIMEOUT_MS);
437
966
  if (status === 404) {
438
967
  // Either no such memory (0.14+) or a pre-0.14 server with no
439
968
  // /memory endpoint — the id hint covers the common case.
@@ -464,8 +993,9 @@ exports.default = {
464
993
  async execute(_callId, args, _ctx) {
465
994
  try {
466
995
  const params = new URLSearchParams();
467
- if (args?.project)
468
- params.set("project", args.project);
996
+ const projectFilter = args?.project ?? defaultProject;
997
+ if (projectFilter)
998
+ params.set("project", projectFilter);
469
999
  if (args?.limit)
470
1000
  params.set("limit", String(args.limit));
471
1001
  const qs = params.toString();
@@ -500,7 +1030,7 @@ exports.default = {
500
1030
  const result = await serverPost("/ingest", {
501
1031
  content: args.content,
502
1032
  source_agent: `openclaw/${context?.agentId ?? "manual"}`,
503
- project: args.project,
1033
+ project: args.project ?? defaultProject,
504
1034
  memory_type: args.memory_type ? (0, type_labels_js_1.normalizeMemoryType)(args.memory_type) : "experience",
505
1035
  }, 15000);
506
1036
  if (!result.ok) {