@gamaze/hicortex 0.19.1 → 0.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/assets/identity.html +59 -8
- package/dist/identity-store.d.ts +65 -1
- package/dist/identity-store.js +132 -9
- package/dist/index.d.ts +22 -7
- package/dist/index.js +510 -86
- package/dist/learnings-identity.d.ts +19 -10
- package/dist/learnings-identity.js +30 -19
- package/dist/mcp-server.js +29 -42
- package/dist/prompts.js +21 -1
- package/dist/recall-index.d.ts +70 -4
- package/dist/recall-index.js +134 -2
- package/dist/retrieval.d.ts +26 -0
- package/dist/retrieval.js +11 -0
- package/dist/types.d.ts +14 -0
- package/hermes-plugin/hicortex/provider.py +21 -5
- package/openclaw.plugin.json +15 -7
- package/package.json +1 -1
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,
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* /recall-index
|
|
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
|
-
|
|
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
|
|
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)
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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)
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
|
196
|
-
|
|
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 ⇒
|
|
507
|
+
// Absent ⇒ defaultProject (if configured) ⇒ else no scope sent.
|
|
215
508
|
const body = {
|
|
216
509
|
session_id: sessionId,
|
|
217
510
|
prompt,
|
|
218
511
|
};
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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
|
-
|
|
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
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
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 (
|
|
409
|
-
headers.Authorization = `Bearer ${
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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 —
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
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
|
-
|
|
490
|
-
|
|
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
|
-
|
|
516
|
-
|
|
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}`,
|
|
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
|
-
|
|
574
|
-
|
|
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) {
|