@etzelm/openclaw-memory-hindsight 0.4.0 → 0.6.0
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/CHANGELOG.md +52 -0
- package/README.md +18 -0
- package/dist/config.js +23 -0
- package/dist/index.js +74 -21
- package/dist/routing.js +48 -3
- package/dist/tools.js +10 -6
- package/dist/transcript.js +74 -7
- package/openclaw.plugin.json +45 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 (2026-11-11)
|
|
4
|
+
|
|
5
|
+
### Upgrade notes
|
|
6
|
+
|
|
7
|
+
- Needs an OpenClaw build with openclaw/openclaw#197274 (merged 2026-11-11) for `hindsight_*`
|
|
8
|
+
tools on claude-cli sessions. That host change passes the turn's memory audience when it lists
|
|
9
|
+
claude-cli MCP tools, so the tools list through the normal audience route. On an older
|
|
10
|
+
2026.11.x host, claude-cli sessions keep auto-recall but lose the `hindsight_*` tools; stay on
|
|
11
|
+
0.5.0 there.
|
|
12
|
+
- `listToolsWithoutAudience` is deprecated and ignored. The key still validates, so an existing
|
|
13
|
+
config keeps loading; remove it. A later major release rejects it.
|
|
14
|
+
|
|
15
|
+
### Removed
|
|
16
|
+
|
|
17
|
+
- The `listToolsWithoutAudience` workaround from 0.5.0: a tool context without a memory audience
|
|
18
|
+
gets no `hindsight_*` tools again, whatever the config says.
|
|
19
|
+
|
|
20
|
+
## 0.5.0 (2026-11-10)
|
|
21
|
+
|
|
22
|
+
### Upgrade notes
|
|
23
|
+
|
|
24
|
+
- No config change is needed. All new keys default off.
|
|
25
|
+
- The auto-recall query strip is always on. Recall queries change slightly, because host
|
|
26
|
+
boilerplate is no longer sent.
|
|
27
|
+
- The config schema rejects unknown keys. A config that uses `listToolsWithoutAudience`,
|
|
28
|
+
`ignoreSessionPatterns`, `recallOnlySessionPatterns`, `recallCacheTtlMs` or
|
|
29
|
+
`recallFollowupMaxWords` fails validation on 0.3.0 and 0.4.0. Remove those keys before a
|
|
30
|
+
downgrade.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- `listToolsWithoutAudience` (default off): list the `hindsight_*` tools for agent sessions whose
|
|
35
|
+
tool list the host builds without a memory audience. OpenClaw 2026.11.x does this for claude-cli
|
|
36
|
+
turns (MCP tool projection), so those sessions had no Hindsight tools. A call still resolves its
|
|
37
|
+
bank only from the host-minted audience and fails without one.
|
|
38
|
+
- `ignoreSessionPatterns` and `recallOnlySessionPatterns` (session-key globs, both default `[]`).
|
|
39
|
+
An ignored session gets no auto-recall, no auto-retain and no `hindsight_*` tools. A
|
|
40
|
+
recall-only session gets auto-recall and the read tools (`hindsight_recall`,
|
|
41
|
+
`hindsight_reflect`, and the knowledge-base read tools when `knowledgeReadTools` is on) even if
|
|
42
|
+
`skipOperationalSessions` would skip it, but never retains. Ignore wins over recall-only;
|
|
43
|
+
`excludeProviders`, `temp:` and incognito sessions still win over both. Patterns never select
|
|
44
|
+
a bank. See README, Configuration.
|
|
45
|
+
- Auto-recall drops host boilerplate from the query (inter-session header, routed-by line,
|
|
46
|
+
messages-recorded banner, subagent context lines, cron tag, CLI-resumed and automation-repair
|
|
47
|
+
notices); a message that is only boilerplate no longer triggers a recall.
|
|
48
|
+
- `recallCacheTtlMs` (default `0`, off): per-session cache of auto-recall results, keyed by
|
|
49
|
+
session, bank and query. Never shared across sessions or banks.
|
|
50
|
+
- `recallFollowupMaxWords` (default `0`, off): short follow-ups skip auto-recall while the session
|
|
51
|
+
has a recent recall that returned memories, and otherwise recall with the previous user message
|
|
52
|
+
added to the query. An empty recall never enables the skip. Debug log tags each auto-recall
|
|
53
|
+
`fresh`, `cached`, `followup-skip` or `followup-expanded`.
|
|
54
|
+
|
|
3
55
|
## 0.4.0 (2026-11-09)
|
|
4
56
|
|
|
5
57
|
### Upgrade notes
|
package/README.md
CHANGED
|
@@ -119,13 +119,26 @@ tool conflict.
|
|
|
119
119
|
| `retainTags` | `[]` | Tags added to every retained memory (auto-retain, `hindsight_retain`, pre-compaction flush) |
|
|
120
120
|
| `excludeProviders` | `[]` | Message providers that never recall or retain (for example `heartbeat`) |
|
|
121
121
|
| `skipOperationalSessions` | `true` | Skip cron, heartbeat and subagent sessions |
|
|
122
|
+
| `ignoreSessionPatterns` | `[]` | Session-key globs with no recall, retain or `hindsight_*` tools (see below) |
|
|
123
|
+
| `recallOnlySessionPatterns` | `[]` | Session-key globs that recall and read but never retain (see below) |
|
|
122
124
|
| `agentBankMap` | `{}` | Agent id to bank id, so several agents share one owner bank |
|
|
123
125
|
| `allowInsecureHttp` | `false` | Allow plain http to a non-loopback `hindsightApiUrl` (token and conversation text unencrypted) |
|
|
124
126
|
| `retainQueueEnabled` | `true` | Queue auto-retains that fail because Hindsight is unreachable and send them later (see below) |
|
|
125
127
|
| `retainQueueMaxItems` | `500` | Most queued retains kept (1 to 10000); the oldest are dropped first |
|
|
126
128
|
| `retainQueueMaxAgeMs` | `604800000` (7 days) | Queued retains older than this are dropped |
|
|
129
|
+
| `recallCacheTtlMs` | `0` (off) | Auto-recall cache per session and bank, 0 to 3600000 ms; a repeated query skips the API call |
|
|
130
|
+
| `recallFollowupMaxWords` | `0` (off) | At most N words, no URL or path: skip after a recent recall with hits, else add the prior user turn |
|
|
127
131
|
| `knowledgeReadTools` | `false` | Offer the read-only knowledge-base tools (see below); turning it on needs a gateway restart |
|
|
128
132
|
|
|
133
|
+
Session patterns: `*` matches any run of characters except `:`, `**` matches anything including
|
|
134
|
+
`:`, everything else is literal. Patterns match the trimmed session key. `ignoreSessionPatterns`
|
|
135
|
+
sessions get no auto-recall, no auto-retain and no `hindsight_*` tools. `recallOnlySessionPatterns`
|
|
136
|
+
sessions (for example `agent:dev:subagent:**`) get auto-recall, `hindsight_recall`,
|
|
137
|
+
`hindsight_reflect` and, with `knowledgeReadTools`, the knowledge-base read tools, even when
|
|
138
|
+
`skipOperationalSessions` would skip them, but never auto-retain and no `hindsight_retain`.
|
|
139
|
+
`excludeProviders`, `temp:` and incognito sessions win over both lists, and ignore wins over
|
|
140
|
+
recall-only. A pattern never selects a bank; the bank still comes from the memory audience.
|
|
141
|
+
|
|
129
142
|
Fixed values (not configurable): recall max tokens 1024, query cap 800 characters, recall
|
|
130
143
|
timeout 10 s, retain and reflect timeout 30 s, memory get and knowledge-base tool timeout 10 s
|
|
131
144
|
(each combined with the host's abort signal when there is one), retained roles user and
|
|
@@ -135,6 +148,11 @@ prompt, so the cached prefix stays stable). Knowledge-base tool caps: a response
|
|
|
135
148
|
the tree shows 500 entries, then `(truncated, N more entries)`; search shows at most 50 hits
|
|
136
149
|
with snippets cut at 500 characters and names and ids at 200.
|
|
137
150
|
|
|
151
|
+
`hindsight_*` tools on claude-cli sessions need an OpenClaw build that passes the turn's memory
|
|
152
|
+
audience when it lists MCP tools (openclaw/openclaw#197274). On older 2026.11.x builds those
|
|
153
|
+
sessions get auto-recall but no `hindsight_*` tools. `listToolsWithoutAudience` (0.5.0) is
|
|
154
|
+
deprecated and ignored since 0.6.0; remove it from your config.
|
|
155
|
+
|
|
138
156
|
Config changes apply without a restart: the plugin re-reads its entry from the host's current
|
|
139
157
|
config snapshot (`api.runtime.config.current()`), falling back to the register-time
|
|
140
158
|
`api.pluginConfig`. One exception: `knowledgeReadTools` registers its tools at startup, so turning
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export const RECALL_BUDGETS = ["low", "mid", "high"];
|
|
2
2
|
export const RECALL_TYPES = ["world", "experience", "observation"];
|
|
3
3
|
export const MEMORY_MODES = ["hybrid", "context", "tools"];
|
|
4
|
+
export const MAX_RECALL_CACHE_TTL_MS = 3_600_000;
|
|
5
|
+
export const MAX_RECALL_FOLLOWUP_WORDS = 100;
|
|
4
6
|
export const DEFAULT_RETAIN_QUEUE_MAX_ITEMS = 500;
|
|
5
7
|
export const MAX_RETAIN_QUEUE_MAX_ITEMS = 10_000;
|
|
6
8
|
export const DEFAULT_RETAIN_QUEUE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -30,12 +32,18 @@ const CONFIG_KEYS = [
|
|
|
30
32
|
"retainTags",
|
|
31
33
|
"excludeProviders",
|
|
32
34
|
"skipOperationalSessions",
|
|
35
|
+
"ignoreSessionPatterns",
|
|
36
|
+
"recallOnlySessionPatterns",
|
|
33
37
|
"agentBankMap",
|
|
34
38
|
"allowInsecureHttp",
|
|
35
39
|
"retainQueueEnabled",
|
|
36
40
|
"retainQueueMaxItems",
|
|
37
41
|
"retainQueueMaxAgeMs",
|
|
38
42
|
"knowledgeReadTools",
|
|
43
|
+
// Deprecated in 0.6.0 and ignored: accepted so a config that still sets it keeps validating.
|
|
44
|
+
"listToolsWithoutAudience",
|
|
45
|
+
"recallCacheTtlMs",
|
|
46
|
+
"recallFollowupMaxWords",
|
|
39
47
|
];
|
|
40
48
|
function isRecord(value) {
|
|
41
49
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -93,6 +101,17 @@ function readPositiveInt(cfg, key, fallback, max = Number.MAX_SAFE_INTEGER) {
|
|
|
93
101
|
}
|
|
94
102
|
return value;
|
|
95
103
|
}
|
|
104
|
+
/** Integer from 0 to max; 0 means off. */
|
|
105
|
+
function readNonNegativeInt(cfg, key, fallback, max) {
|
|
106
|
+
const value = cfg[key];
|
|
107
|
+
if (value === undefined) {
|
|
108
|
+
return fallback;
|
|
109
|
+
}
|
|
110
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > max) {
|
|
111
|
+
throw new Error(`${key} must be an integer from 0 to ${max}`);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
96
115
|
function readBankMap(cfg) {
|
|
97
116
|
const value = cfg.agentBankMap;
|
|
98
117
|
if (value === undefined) {
|
|
@@ -201,12 +220,16 @@ export const hindsightConfigSchema = {
|
|
|
201
220
|
retainTags: readStringList(value, "retainTags") ?? [],
|
|
202
221
|
excludeProviders: readStringList(value, "excludeProviders") ?? [],
|
|
203
222
|
skipOperationalSessions: readBool(value, "skipOperationalSessions", true),
|
|
223
|
+
ignoreSessionPatterns: readStringList(value, "ignoreSessionPatterns") ?? [],
|
|
224
|
+
recallOnlySessionPatterns: readStringList(value, "recallOnlySessionPatterns") ?? [],
|
|
204
225
|
agentBankMap: readBankMap(value),
|
|
205
226
|
allowInsecureHttp,
|
|
206
227
|
retainQueueEnabled: readBool(value, "retainQueueEnabled", true),
|
|
207
228
|
retainQueueMaxItems: readPositiveInt(value, "retainQueueMaxItems", DEFAULT_RETAIN_QUEUE_MAX_ITEMS, MAX_RETAIN_QUEUE_MAX_ITEMS),
|
|
208
229
|
retainQueueMaxAgeMs: readPositiveInt(value, "retainQueueMaxAgeMs", DEFAULT_RETAIN_QUEUE_MAX_AGE_MS),
|
|
209
230
|
knowledgeReadTools: readBool(value, "knowledgeReadTools", false),
|
|
231
|
+
recallCacheTtlMs: readNonNegativeInt(value, "recallCacheTtlMs", 0, MAX_RECALL_CACHE_TTL_MS),
|
|
232
|
+
recallFollowupMaxWords: readNonNegativeInt(value, "recallFollowupMaxWords", 0, MAX_RECALL_FOLLOWUP_WORDS),
|
|
210
233
|
};
|
|
211
234
|
},
|
|
212
235
|
};
|
package/dist/index.js
CHANGED
|
@@ -12,10 +12,15 @@ import { isMaintenanceTurn, routeAudience, sessionUsesMemory } from "./routing.j
|
|
|
12
12
|
import { resolveLearnRegistrar } from "./sdk-types.js";
|
|
13
13
|
import { createHindsightService } from "./service.js";
|
|
14
14
|
import { createFlushDeduper, createKnowledgePageTool, createKnowledgeSearchTool, createKnowledgeTreeTool, createRecallTool, createReflectTool, createRetainTool, KB_PAGE_TOOL, KB_SEARCH_TOOL, KB_TREE_TOOL, RECALL_TOOL, REFLECT_TOOL, RETAIN_TOOL, } from "./tools.js";
|
|
15
|
-
import { buildRecallContext, extractRecallQuery, isEphemeralOperationalText, prepareRetentionTranscript, retainStartIndex, runStartMarker, } from "./transcript.js";
|
|
15
|
+
import { buildRecallContext, expandFollowupQuery, extractRecallQuery, isEphemeralOperationalText, isShortFollowup, prepareRetentionTranscript, previousUserQuery, retainStartIndex, runStartMarker, } from "./transcript.js";
|
|
16
16
|
export const PLUGIN_ID = "hindsight-native";
|
|
17
17
|
// Bounds per-session run markers; one entry per active session is enough.
|
|
18
18
|
const MAX_RUN_MARKERS = 1_000;
|
|
19
|
+
// Bounds the auto-recall cache; one entry per active session, oldest dropped first.
|
|
20
|
+
const MAX_RECALL_CACHE_SESSIONS = 512;
|
|
21
|
+
function queryDigest(query) {
|
|
22
|
+
return createHash("sha256").update(query).digest("hex");
|
|
23
|
+
}
|
|
19
24
|
/**
|
|
20
25
|
* Reads `plugins.entries[<id>].config` from a runtime config snapshot. Returns undefined when the
|
|
21
26
|
* snapshot has no entry, so callers fall back to the register-time `api.pluginConfig`.
|
|
@@ -104,6 +109,8 @@ export default definePluginEntry({
|
|
|
104
109
|
// Per session: where the current run started, kept for the whole run (by runId) so a second
|
|
105
110
|
// agent_end for the run (host continuation after a retry, or a re-delivery) re-sends all of it.
|
|
106
111
|
const runStartBySession = new Map();
|
|
112
|
+
// Last auto-recall per session (never shared across sessions; the bank is checked on read).
|
|
113
|
+
const recallCache = new Map();
|
|
107
114
|
const capability = {
|
|
108
115
|
providerRuntime: createHindsightProviderRuntime({
|
|
109
116
|
providerId: PLUGIN_ID,
|
|
@@ -168,6 +175,7 @@ export default definePluginEntry({
|
|
|
168
175
|
!sessionUsesMemory(config, {
|
|
169
176
|
sessionKey,
|
|
170
177
|
messageProvider: ctx.messageProvider,
|
|
178
|
+
purpose: "recall",
|
|
171
179
|
})) {
|
|
172
180
|
return undefined;
|
|
173
181
|
}
|
|
@@ -182,29 +190,71 @@ export default definePluginEntry({
|
|
|
182
190
|
}
|
|
183
191
|
try {
|
|
184
192
|
ctx.assertMemoryAudienceCurrent?.();
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
193
|
+
const ttl = config.recallCacheTtlMs;
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
const cached = ttl > 0 && sessionKey ? recallCache.get(sessionKey) : undefined;
|
|
196
|
+
const live = cached && cached.bankId === route.bankId && now - cached.at < ttl;
|
|
197
|
+
const followup = isShortFollowup(query, config.recallFollowupMaxWords);
|
|
198
|
+
if (followup && live && cached.results.length > 0) {
|
|
199
|
+
api.logger.debug?.("memory-hindsight: auto-recall source=followup-skip");
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
let recallQuery = query;
|
|
203
|
+
let source = "fresh";
|
|
204
|
+
if (followup) {
|
|
205
|
+
const previous = previousUserQuery(event.messages, query);
|
|
206
|
+
if (previous) {
|
|
207
|
+
recallQuery = expandFollowupQuery(previous, query);
|
|
208
|
+
source = "followup-expanded";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const queryHash = queryDigest(recallQuery);
|
|
188
212
|
let results;
|
|
189
|
-
|
|
190
|
-
results =
|
|
191
|
-
|
|
192
|
-
maxTokens: RECALL_MAX_TOKENS,
|
|
193
|
-
...(config.recallTypes ? { types: config.recallTypes } : {}),
|
|
194
|
-
...(config.preferObservations === undefined
|
|
195
|
-
? {}
|
|
196
|
-
: { preferObservations: config.preferObservations }),
|
|
197
|
-
signal: AbortSignal.timeout(RECALL_TIMEOUT_MS),
|
|
198
|
-
});
|
|
213
|
+
if (live && cached.queryHash === queryHash) {
|
|
214
|
+
results = cached.results;
|
|
215
|
+
source = "cached";
|
|
199
216
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
217
|
+
else {
|
|
218
|
+
// Metrics count the Hindsight request only. A host assert that throws (audience changed,
|
|
219
|
+
// hook invocation stale) is host lifecycle, not a recall outcome, so it records nothing.
|
|
220
|
+
const recallStarted = performance.now();
|
|
221
|
+
try {
|
|
222
|
+
results = await service.client.recall(route.bankId, recallQuery, {
|
|
223
|
+
budget: config.recallBudget,
|
|
224
|
+
maxTokens: RECALL_MAX_TOKENS,
|
|
225
|
+
...(config.recallTypes ? { types: config.recallTypes } : {}),
|
|
226
|
+
...(config.preferObservations === undefined
|
|
227
|
+
? {}
|
|
228
|
+
: { preferObservations: config.preferObservations }),
|
|
229
|
+
signal: AbortSignal.timeout(RECALL_TIMEOUT_MS),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
metrics.recordRecall(ctx.agentId, route.bankId, isBankNotFound(error) ? "empty" : isTimeoutError(error) ? "timeout" : "error", performance.now() - recallStarted);
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
const recallMs = performance.now() - recallStarted;
|
|
237
|
+
ctx.assertMemoryAudienceCurrent?.();
|
|
238
|
+
ctx.hookInvocation?.assertActive();
|
|
239
|
+
metrics.recordRecall(ctx.agentId, route.bankId, results.length === 0 ? "empty" : "injected", recallMs);
|
|
240
|
+
if (ttl > 0 && sessionKey) {
|
|
241
|
+
recallCache.delete(sessionKey);
|
|
242
|
+
recallCache.set(sessionKey, {
|
|
243
|
+
bankId: route.bankId,
|
|
244
|
+
queryHash,
|
|
245
|
+
results,
|
|
246
|
+
at: Date.now(),
|
|
247
|
+
});
|
|
248
|
+
if (recallCache.size > MAX_RECALL_CACHE_SESSIONS) {
|
|
249
|
+
recallCache.delete(recallCache.keys().next().value);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
api.logger.debug?.(`memory-hindsight: auto-recall source=${source}`);
|
|
254
|
+
if (source === "cached") {
|
|
255
|
+
ctx.assertMemoryAudienceCurrent?.();
|
|
256
|
+
ctx.hookInvocation?.assertActive();
|
|
203
257
|
}
|
|
204
|
-
const recallMs = performance.now() - recallStarted;
|
|
205
|
-
ctx.assertMemoryAudienceCurrent?.();
|
|
206
|
-
ctx.hookInvocation?.assertActive();
|
|
207
|
-
metrics.recordRecall(ctx.agentId, route.bankId, results.length === 0 ? "empty" : "injected", recallMs);
|
|
208
258
|
if (results.length === 0) {
|
|
209
259
|
return undefined;
|
|
210
260
|
}
|
|
@@ -249,6 +299,7 @@ export default definePluginEntry({
|
|
|
249
299
|
!sessionUsesMemory(config, {
|
|
250
300
|
sessionKey,
|
|
251
301
|
messageProvider: ctx.messageProvider,
|
|
302
|
+
purpose: "retain",
|
|
252
303
|
})) {
|
|
253
304
|
return;
|
|
254
305
|
}
|
|
@@ -309,6 +360,7 @@ export default definePluginEntry({
|
|
|
309
360
|
const key = ctx.sessionKey ?? event.sessionKey;
|
|
310
361
|
if (key) {
|
|
311
362
|
runStartBySession.delete(key);
|
|
363
|
+
recallCache.delete(key);
|
|
312
364
|
}
|
|
313
365
|
});
|
|
314
366
|
api.registerService({
|
|
@@ -330,6 +382,7 @@ export default definePluginEntry({
|
|
|
330
382
|
stop: () => {
|
|
331
383
|
service.stop();
|
|
332
384
|
runStartBySession.clear();
|
|
385
|
+
recallCache.clear();
|
|
333
386
|
api.logger.info("memory-hindsight: stopped");
|
|
334
387
|
},
|
|
335
388
|
});
|
package/dist/routing.js
CHANGED
|
@@ -43,15 +43,60 @@ export function routeAudience(agentId, audience, bankMap) {
|
|
|
43
43
|
}
|
|
44
44
|
const OPERATIONAL_SESSION_RE = /^agent:[^:]+:(cron|heartbeat|subagent):/;
|
|
45
45
|
const OPERATIONAL_PROVIDERS = new Set(["cron", "heartbeat", "subagent"]);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
const globCache = new Map();
|
|
47
|
+
/**
|
|
48
|
+
* Session-key glob: `*` matches any run of characters except ":", `**` matches anything
|
|
49
|
+
* including ":", everything else is literal.
|
|
50
|
+
*/
|
|
51
|
+
function sessionGlobRegExp(glob) {
|
|
52
|
+
let re = globCache.get(glob);
|
|
53
|
+
if (!re) {
|
|
54
|
+
let source = "";
|
|
55
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
56
|
+
const ch = glob[i];
|
|
57
|
+
if (ch === "*") {
|
|
58
|
+
if (glob[i + 1] === "*") {
|
|
59
|
+
source += ".*";
|
|
60
|
+
i += 1;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
source += "[^:]*";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
source += ch.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
re = new RegExp(`^${source}$`, "s");
|
|
71
|
+
globCache.set(glob, re);
|
|
72
|
+
}
|
|
73
|
+
return re;
|
|
74
|
+
}
|
|
75
|
+
/** True when the trimmed session key matches any glob; a missing key matches nothing. */
|
|
76
|
+
export function matchesSessionPattern(sessionKey, globs) {
|
|
77
|
+
const key = sessionKey?.trim();
|
|
78
|
+
return Boolean(key) && globs.some((glob) => sessionGlobRegExp(glob).test(key));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Session filter shared by auto-recall, auto-retain and tool registration. Patterns only
|
|
82
|
+
* decide whether a session uses memory; they never select a bank.
|
|
83
|
+
*/
|
|
84
|
+
export function sessionUsesMemory(config,
|
|
85
|
+
// A missing purpose is treated as "retain", the stricter side.
|
|
86
|
+
params) {
|
|
87
|
+
const { sessionKey, messageProvider, purpose } = params;
|
|
49
88
|
if (messageProvider && config.excludeProviders.includes(messageProvider)) {
|
|
50
89
|
return false;
|
|
51
90
|
}
|
|
52
91
|
if (sessionKey?.startsWith("temp:")) {
|
|
53
92
|
return false;
|
|
54
93
|
}
|
|
94
|
+
if (matchesSessionPattern(sessionKey, config.ignoreSessionPatterns ?? [])) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
if (matchesSessionPattern(sessionKey, config.recallOnlySessionPatterns ?? [])) {
|
|
98
|
+
return purpose === "recall";
|
|
99
|
+
}
|
|
55
100
|
if (!config.skipOperationalSessions) {
|
|
56
101
|
return true;
|
|
57
102
|
}
|
package/dist/tools.js
CHANGED
|
@@ -28,11 +28,15 @@ function requiredString(params, name) {
|
|
|
28
28
|
* Resolves the bank for one tool context, or null when the session must not get the tool.
|
|
29
29
|
* The bank comes only from the host-minted audience; the model never names a bank.
|
|
30
30
|
*/
|
|
31
|
-
function resolveToolBank(service, ctx) {
|
|
31
|
+
function resolveToolBank(service, ctx, purpose) {
|
|
32
32
|
const config = service.config();
|
|
33
33
|
if (config.memoryMode === "context" ||
|
|
34
34
|
isIncognitoSessionKey(ctx.sessionKey) ||
|
|
35
|
-
!sessionUsesMemory(config, {
|
|
35
|
+
!sessionUsesMemory(config, {
|
|
36
|
+
sessionKey: ctx.sessionKey,
|
|
37
|
+
messageProvider: ctx.messageChannel,
|
|
38
|
+
purpose,
|
|
39
|
+
})) {
|
|
36
40
|
return null;
|
|
37
41
|
}
|
|
38
42
|
const route = routeAudience(ctx.agentId, ctx.memoryAudience, config.agentBankMap);
|
|
@@ -69,7 +73,7 @@ export function flushDocumentId(flushId, content) {
|
|
|
69
73
|
return `openclaw-flush:${digest.slice(0, 32)}`;
|
|
70
74
|
}
|
|
71
75
|
export function createRetainTool(service, deduper, ctx) {
|
|
72
|
-
const bank = resolveToolBank(service, ctx);
|
|
76
|
+
const bank = resolveToolBank(service, ctx, "retain");
|
|
73
77
|
if (!bank) {
|
|
74
78
|
return null;
|
|
75
79
|
}
|
|
@@ -134,7 +138,7 @@ export function createRetainTool(service, deduper, ctx) {
|
|
|
134
138
|
};
|
|
135
139
|
}
|
|
136
140
|
export function createRecallTool(service, ctx) {
|
|
137
|
-
const bank = resolveToolBank(service, ctx);
|
|
141
|
+
const bank = resolveToolBank(service, ctx, "recall");
|
|
138
142
|
if (!bank) {
|
|
139
143
|
return null;
|
|
140
144
|
}
|
|
@@ -176,7 +180,7 @@ export function createRecallTool(service, ctx) {
|
|
|
176
180
|
};
|
|
177
181
|
}
|
|
178
182
|
export function createReflectTool(service, ctx) {
|
|
179
|
-
const bank = resolveToolBank(service, ctx);
|
|
183
|
+
const bank = resolveToolBank(service, ctx, "recall");
|
|
180
184
|
if (!bank) {
|
|
181
185
|
return null;
|
|
182
186
|
}
|
|
@@ -220,7 +224,7 @@ export const KB_NAME_MAX_CHARS = 200;
|
|
|
220
224
|
const KB_TREE_MAX_DEPTH = 32;
|
|
221
225
|
/** The knowledge tools are opt-in and otherwise follow the recall tools' bank routing. */
|
|
222
226
|
function resolveKnowledgeBank(service, ctx) {
|
|
223
|
-
return service.config().knowledgeReadTools ? resolveToolBank(service, ctx) : null;
|
|
227
|
+
return service.config().knowledgeReadTools ? resolveToolBank(service, ctx, "recall") : null;
|
|
224
228
|
}
|
|
225
229
|
/** Server text as one line, so a name or snippet cannot forge extra list entries. */
|
|
226
230
|
function oneLine(value, maxChars = Number.MAX_SAFE_INTEGER) {
|
package/dist/transcript.js
CHANGED
|
@@ -116,22 +116,89 @@ export function isEphemeralOperationalText(text) {
|
|
|
116
116
|
return OPERATIONAL_TEXT_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
117
117
|
}
|
|
118
118
|
const MIN_QUERY_CHARS = 5;
|
|
119
|
+
// Optional host timestamp prefix some of these lines carry, as in `[Wed 2026-10-28 19:24 PDT] `.
|
|
120
|
+
const BOILERPLATE_TS = "(?:\\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)[^\\]\\n]*\\]\\s*)?";
|
|
121
|
+
const BOILERPLATE_PATTERNS = [
|
|
122
|
+
// Inter-session header, routed-by line, subagent tags, banners and scheduler notices.
|
|
123
|
+
/^\[Inter-session message\][^\n]*(?:\n|$)/gm,
|
|
124
|
+
/^This content was routed by OpenClaw[^\n]*(?:\n|$)/gm,
|
|
125
|
+
/^\[OpenClaw: \d+ (?:messages recorded|earlier messages|messages occurred)[^\n]*\](?:\n|$)/gm,
|
|
126
|
+
/^\[Subagent Context\][^\n]*(?:\n|$)/gm,
|
|
127
|
+
/^Every subagent in this batch[^\n]*(?:\n|$)/gm,
|
|
128
|
+
// Only the tag goes; the reminder text after it stays.
|
|
129
|
+
/^\[cron:[0-9a-f-]{8,} [^\]\n]*\][ \t]*/gm,
|
|
130
|
+
new RegExp(`^${BOILERPLATE_TS}OpenClaw resumed this CLI session[^\\n]*(?:\\n|$)`, "gm"),
|
|
131
|
+
new RegExp(`^${BOILERPLATE_TS}Automation repair request from the scheduler, not a user message\\.[^\\n]*(?:\\n|$)`, "gm"),
|
|
132
|
+
];
|
|
133
|
+
/** Removes host-generated boilerplate lines that carry no user intent. */
|
|
134
|
+
export function stripRecallBoilerplate(text) {
|
|
135
|
+
let out = text;
|
|
136
|
+
for (const pattern of BOILERPLATE_PATTERNS) {
|
|
137
|
+
out = out.replace(pattern, "");
|
|
138
|
+
}
|
|
139
|
+
return out.trim();
|
|
140
|
+
}
|
|
141
|
+
/** Cleaned recall text for one message, or null when too short or not user text. */
|
|
142
|
+
function cleanRecallCandidate(candidate) {
|
|
143
|
+
let cleaned = stripRecallBoilerplate(cleanMessageText(candidate));
|
|
144
|
+
cleaned = cleaned.replace(/^(?:System:.*\n)+\n?/, "");
|
|
145
|
+
cleaned = cleaned.replace(/\n\[from:[^\]]*\]\s*$/, "").trim();
|
|
146
|
+
if (cleaned.length >= MIN_QUERY_CHARS && !/^\s*system:/i.test(cleaned)) {
|
|
147
|
+
return cleaned.slice(0, RECALL_MAX_QUERY_CHARS);
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
119
151
|
/** The user's current request with host envelopes removed and capped, or null when too short. */
|
|
120
152
|
export function extractRecallQuery(currentUserMessage, prompt) {
|
|
121
|
-
const
|
|
122
|
-
for (const candidate of candidates) {
|
|
153
|
+
for (const candidate of [currentUserMessage, prompt]) {
|
|
123
154
|
if (!candidate) {
|
|
124
155
|
continue;
|
|
125
156
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (cleaned.length >= MIN_QUERY_CHARS && !/^\s*system:/i.test(cleaned)) {
|
|
130
|
-
return cleaned.slice(0, RECALL_MAX_QUERY_CHARS);
|
|
157
|
+
const cleaned = cleanRecallCandidate(candidate);
|
|
158
|
+
if (cleaned) {
|
|
159
|
+
return cleaned;
|
|
131
160
|
}
|
|
132
161
|
}
|
|
133
162
|
return null;
|
|
134
163
|
}
|
|
164
|
+
/** True for a short message with no URL or path: it leans on the turn before it. */
|
|
165
|
+
export function isShortFollowup(query, maxWords) {
|
|
166
|
+
if (maxWords < 1 || /\/|~\/|http/i.test(query)) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
return query.split(/\s+/).filter(Boolean).length <= maxWords;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The user message before the current one, cleaned like the query. The host may or may not list
|
|
173
|
+
* the current message last, so a trailing user message equal to the query is skipped.
|
|
174
|
+
*/
|
|
175
|
+
export function previousUserQuery(messages, query) {
|
|
176
|
+
if (!Array.isArray(messages)) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
let skippedCurrent = false;
|
|
180
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
181
|
+
const message = messages[i];
|
|
182
|
+
if (asMessage(message)?.role !== "user") {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const cleaned = cleanRecallCandidate(messageText(message));
|
|
186
|
+
if (!cleaned) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!skippedCurrent && cleaned === query) {
|
|
190
|
+
skippedCurrent = true;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
return isEphemeralOperationalText(cleaned) ? null : cleaned;
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
/** Previous user message plus the follow-up, capped with the follow-up kept whole. */
|
|
198
|
+
export function expandFollowupQuery(previous, followup) {
|
|
199
|
+
const room = RECALL_MAX_QUERY_CHARS - followup.length - 1;
|
|
200
|
+
return room > 0 ? `${previous.slice(0, room)}\n${followup}` : followup;
|
|
201
|
+
}
|
|
135
202
|
function formatOccurred(start, end) {
|
|
136
203
|
if (start && end) {
|
|
137
204
|
return start === end ? ` [occurred: ${start}]` : ` [occurred: ${start} -> ${end}]`;
|
package/openclaw.plugin.json
CHANGED
|
@@ -72,6 +72,16 @@
|
|
|
72
72
|
"help": "Queued retains older than this are dropped (default 7 days)",
|
|
73
73
|
"advanced": true
|
|
74
74
|
},
|
|
75
|
+
"recallCacheTtlMs": {
|
|
76
|
+
"label": "Recall Cache TTL (ms)",
|
|
77
|
+
"help": "Serve a repeated auto-recall query from a per-session cache for this long; 0 turns the cache off",
|
|
78
|
+
"advanced": true
|
|
79
|
+
},
|
|
80
|
+
"recallFollowupMaxWords": {
|
|
81
|
+
"label": "Recall Follow-up Max Words",
|
|
82
|
+
"help": "Messages this short (no URL or path) skip auto-recall while the session has a recent non-empty recall, else recall with the previous user message added; 0 turns it off",
|
|
83
|
+
"advanced": true
|
|
84
|
+
},
|
|
75
85
|
"retainMission": {
|
|
76
86
|
"label": "Retain Mission",
|
|
77
87
|
"help": "Applied to each bank before its first write: what Hindsight should extract",
|
|
@@ -152,6 +162,22 @@
|
|
|
152
162
|
"skipOperationalSessions": {
|
|
153
163
|
"type": "boolean"
|
|
154
164
|
},
|
|
165
|
+
"ignoreSessionPatterns": {
|
|
166
|
+
"type": "array",
|
|
167
|
+
"items": {
|
|
168
|
+
"type": "string"
|
|
169
|
+
},
|
|
170
|
+
"default": [],
|
|
171
|
+
"description": "Session-key globs that get no auto-recall, no auto-retain and no hindsight_* tools. `*` matches any run of characters except ':', `**` matches anything including ':'. Checked after excludeProviders, temp: and incognito, and before recallOnlySessionPatterns. Never selects a bank"
|
|
172
|
+
},
|
|
173
|
+
"recallOnlySessionPatterns": {
|
|
174
|
+
"type": "array",
|
|
175
|
+
"items": {
|
|
176
|
+
"type": "string"
|
|
177
|
+
},
|
|
178
|
+
"default": [],
|
|
179
|
+
"description": "Session-key globs that get auto-recall and the read tools (hindsight_recall, hindsight_reflect, knowledge-base read tools) even when skipOperationalSessions would skip them, but never auto-retain and no hindsight_retain tool. Same glob syntax as ignoreSessionPatterns. Never selects a bank"
|
|
180
|
+
},
|
|
155
181
|
"retainQueueEnabled": {
|
|
156
182
|
"type": "boolean",
|
|
157
183
|
"default": true,
|
|
@@ -170,11 +196,30 @@
|
|
|
170
196
|
"default": 604800000,
|
|
171
197
|
"description": "Queued retains older than this many milliseconds are dropped (default 7 days)"
|
|
172
198
|
},
|
|
199
|
+
"recallCacheTtlMs": {
|
|
200
|
+
"type": "integer",
|
|
201
|
+
"minimum": 0,
|
|
202
|
+
"maximum": 3600000,
|
|
203
|
+
"default": 0,
|
|
204
|
+
"description": "Auto-recall cache lifetime in milliseconds, per session and bank (same query within this time is served without an API call); 0 = off. Also bounds how long a recall counts for recallFollowupMaxWords"
|
|
205
|
+
},
|
|
206
|
+
"recallFollowupMaxWords": {
|
|
207
|
+
"type": "integer",
|
|
208
|
+
"minimum": 0,
|
|
209
|
+
"maximum": 100,
|
|
210
|
+
"default": 0,
|
|
211
|
+
"description": "A message with at most this many words and no URL or path is a short follow-up. Within recallCacheTtlMs of a recall that returned memories it skips auto-recall; otherwise the query is the previous user message plus the follow-up. Needs recallCacheTtlMs > 0 to skip; 0 = off"
|
|
212
|
+
},
|
|
173
213
|
"knowledgeReadTools": {
|
|
174
214
|
"type": "boolean",
|
|
175
215
|
"default": false,
|
|
176
216
|
"description": "Offer the read-only knowledge-base tools (tree, search, page) on the session's own memory bank"
|
|
177
217
|
},
|
|
218
|
+
"listToolsWithoutAudience": {
|
|
219
|
+
"type": "boolean",
|
|
220
|
+
"default": false,
|
|
221
|
+
"description": "Deprecated since 0.6.0 and ignored. The host now passes the memory audience when it lists claude-cli tools (openclaw/openclaw#197274). Remove this key; a later major release rejects it"
|
|
222
|
+
},
|
|
178
223
|
"agentBankMap": {
|
|
179
224
|
"type": "object",
|
|
180
225
|
"additionalProperties": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@etzelm/openclaw-memory-hindsight",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Native Hindsight memory engine for OpenClaw: memory slot provider runtime, auto-recall, auto-retain, pre-compaction flush and hindsight_* agent tools.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"hindsight",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
38
38
|
"test": "vitest run",
|
|
39
39
|
"test:coverage": "vitest run --coverage",
|
|
40
|
-
"lint": "oxlint src",
|
|
40
|
+
"lint": "oxlint --deny-warnings src",
|
|
41
41
|
"format": "oxfmt --write .",
|
|
42
42
|
"format:check": "oxfmt --check .",
|
|
43
43
|
"prepack": "npm run build"
|