@etzelm/openclaw-memory-hindsight 0.6.0 → 0.7.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 +45 -0
- package/dist/client.js +18 -4
- package/dist/index.js +17 -3
- package/dist/retain-queue.js +17 -0
- package/dist/tools.js +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 (2026-11-11)
|
|
4
|
+
|
|
5
|
+
### Upgrade notes
|
|
6
|
+
|
|
7
|
+
- No config change is needed, and no key is added or removed.
|
|
8
|
+
- A 404 whose body cannot be read (request aborted, connection lost) is now an error, not "no
|
|
9
|
+
bank yet". Auto-recall logs it at warn and injects nothing, as before. The retain queue is
|
|
10
|
+
unchanged: it drops a queued retain only on an explicit `Bank '<id>' not found` body, so such a
|
|
11
|
+
retain stayed queued before and still does.
|
|
12
|
+
- With `recallCacheTtlMs` set, a `hindsight_retain` call now drops that session's cached recall,
|
|
13
|
+
so the next turn of the session recalls fresh. Other sessions keep their cache.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Retain queue: a copy that replay delivered but whose batched save failed stays in the file until
|
|
18
|
+
the next save. When a newer copy of the same document then could not be queued (too large, or
|
|
19
|
+
the file could not be written) and went out live, a restart replayed the old copy over it. The
|
|
20
|
+
queue now writes a tombstone for that copy.
|
|
21
|
+
- Auto-retain: the host does not await `agent_end`, so run N's handler can run after run N+1
|
|
22
|
+
started. It used and deleted run N+1's start marker, so run N retained the wrong slice and run
|
|
23
|
+
N+1 lost its start. A marker of another run is now left alone.
|
|
24
|
+
- A 404 whose body read failed counted as a missing bank, so recall reported "no memories"
|
|
25
|
+
instead of an error. The read error is now thrown. Bodies of non-404 errors are cancelled
|
|
26
|
+
instead of left unread.
|
|
27
|
+
- Auto-recall cache (`recallCacheTtlMs`): a `hindsight_retain` call drops the session's cached
|
|
28
|
+
recall. Auto-retain leaves the cache alone; that turn is already in the transcript.
|
|
29
|
+
- Compatibility proof (not in the package): the owner fact is a boat name instead of a "launch
|
|
30
|
+
code word", which Hindsight's retain model sometimes stored without its value. B1 and B2 judge
|
|
31
|
+
the chat.send session, so a host heartbeat turn in the same window no longer fails them, and
|
|
32
|
+
B2 still fails on a code hit from any session. The probe imports the memory host SDK while it
|
|
33
|
+
registers, because upstream main now closes a plugin's invocation scope after the run.
|
|
34
|
+
- Compatibility matrix (not in the package): G2 counts with the audience checks for the
|
|
35
|
+
expected `claude-cli` failure. With `CLI_AUDIENCE_FIXED_IN` set, a host built from source must
|
|
36
|
+
pass, and `CLI_AUDIENCE_FIXED_IN=main` covers a fix that is only on upstream main.
|
|
37
|
+
- Compatibility nightly (not in the package): an unwritable `WORK_ROOT` exits 4 with "cannot
|
|
38
|
+
write WORK_ROOT" instead of "another nightly run took the lock". The example plist starts bash
|
|
39
|
+
through a node binary with Full Disk Access, because a LaunchAgent bootstrapped from cron after
|
|
40
|
+
a macOS reboot gets no access to an external disk.
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- Compatibility nightly (not in the package): `HS_COMPAT_REF=latest-release` tests the highest
|
|
45
|
+
`vX.Y.Z` tag, and `HS_COMPAT_STATUS_FILE` writes a Prometheus textfile (last attempt, last run,
|
|
46
|
+
last success, exit code) for a missed-nightly alert.
|
|
47
|
+
|
|
3
48
|
## 0.6.0 (2026-11-11)
|
|
4
49
|
|
|
5
50
|
### Upgrade notes
|
package/dist/client.js
CHANGED
|
@@ -120,10 +120,24 @@ export class HindsightClient {
|
|
|
120
120
|
...(init.signal ? { signal: init.signal } : {}),
|
|
121
121
|
});
|
|
122
122
|
if (!response.ok) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
let body = "";
|
|
124
|
+
if (response.status === 404) {
|
|
125
|
+
try {
|
|
126
|
+
body = await readBoundedText(response, ERROR_BODY_MAX_BYTES);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
// An oversized body is not "empty", so it never reads as a missing bank. A read that
|
|
130
|
+
// failed (aborted, connection lost) says nothing about the bank: throw that error.
|
|
131
|
+
if (!(error instanceof BodyTooLargeError)) {
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
body = "<oversized>";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
// Never read, so release the connection instead of leaving the body to the GC.
|
|
139
|
+
await response.body?.cancel().catch(() => undefined);
|
|
140
|
+
}
|
|
127
141
|
const bankMissing = response.status === 404 && isBankMissingBody(body);
|
|
128
142
|
const bankMissingExplicit = response.status === 404 && isExplicitBankMissingBody(body);
|
|
129
143
|
const pageMissing = response.status === 404 && isExplicitPageMissingBody(body);
|
package/dist/index.js
CHANGED
|
@@ -134,7 +134,13 @@ export default definePluginEntry({
|
|
|
134
134
|
return body ? { name, ...body } : null;
|
|
135
135
|
}, { name });
|
|
136
136
|
};
|
|
137
|
-
|
|
137
|
+
// A hindsight_retain makes the session's cached recall out of date. Auto-retain does not: its
|
|
138
|
+
// turn is already in the transcript, and dropping the entry every turn would void the cache.
|
|
139
|
+
registerTool(RETAIN_TOOL, (ctx) => createRetainTool(service, flushDeduper, ctx, (key) => {
|
|
140
|
+
if (key) {
|
|
141
|
+
recallCache.delete(key);
|
|
142
|
+
}
|
|
143
|
+
}));
|
|
138
144
|
registerTool(RECALL_TOOL, (ctx) => createRecallTool(service, ctx));
|
|
139
145
|
registerTool(REFLECT_TOOL, (ctx) => createReflectTool(service, ctx));
|
|
140
146
|
// Opt-in: registered only when knowledgeReadTools is on at startup, so the default tool set is
|
|
@@ -281,10 +287,18 @@ export default definePluginEntry({
|
|
|
281
287
|
trigger: ctx.trigger,
|
|
282
288
|
sessionKey,
|
|
283
289
|
});
|
|
284
|
-
const
|
|
290
|
+
const stored = sessionKey && !maintenance ? runStartBySession.get(sessionKey) : undefined;
|
|
291
|
+
// The host does not await agent_end, so run N's handler can run after run N+1's
|
|
292
|
+
// before_prompt_build replaced the marker. A marker of another run is neither used nor
|
|
293
|
+
// deleted; this run then falls back to its last user message.
|
|
294
|
+
const otherRun = stored !== undefined &&
|
|
295
|
+
runId !== undefined &&
|
|
296
|
+
stored.runId !== undefined &&
|
|
297
|
+
stored.runId !== runId;
|
|
298
|
+
const start = otherRun ? undefined : stored;
|
|
285
299
|
const runStart = start?.marker;
|
|
286
300
|
// Taken before any skip. The marker stays while the run can still continue; without a
|
|
287
|
-
// runId it is dropped so the next run marks its own start.
|
|
301
|
+
// runId on either side it is dropped so the next run marks its own start.
|
|
288
302
|
if (sessionKey && start && (runId === undefined || start.runId !== runId)) {
|
|
289
303
|
runStartBySession.delete(sessionKey);
|
|
290
304
|
}
|
package/dist/retain-queue.js
CHANGED
|
@@ -127,6 +127,12 @@ export class RetainQueue {
|
|
|
127
127
|
dropWarned = false;
|
|
128
128
|
/** Removals not yet written (replay batches its writes). */
|
|
129
129
|
dirty = false;
|
|
130
|
+
/**
|
|
131
|
+
* Copies removed from memory but still in the file until the next successful save, by bank and
|
|
132
|
+
* document id. A newer copy that cannot be queued gets a tombstone for it, so a restart before
|
|
133
|
+
* that save cannot replay the old copy over the newer one the caller sent live.
|
|
134
|
+
*/
|
|
135
|
+
unsaved = new Map();
|
|
130
136
|
/** Bytes in the file as this queue last read or wrote it; appends grow it, rewrites reset it. */
|
|
131
137
|
fileBytes = 0;
|
|
132
138
|
limits;
|
|
@@ -158,6 +164,8 @@ export class RetainQueue {
|
|
|
158
164
|
* skipped with one warning. Any other read error throws, so the file is never overwritten.
|
|
159
165
|
*/
|
|
160
166
|
load() {
|
|
167
|
+
// The file is the source of truth again; any copy it still holds is loaded below.
|
|
168
|
+
this.unsaved.clear();
|
|
161
169
|
let text;
|
|
162
170
|
try {
|
|
163
171
|
text = readFileSync(this.filePath, "utf8");
|
|
@@ -258,6 +266,7 @@ export class RetainQueue {
|
|
|
258
266
|
queuedAt: this.now(),
|
|
259
267
|
});
|
|
260
268
|
const index = this.stored.findIndex((s) => s.entry.bankId === entry.bankId && s.entry.documentId === entry.documentId);
|
|
269
|
+
const unsavedCopy = index < 0 ? this.unsaved.get(queueKey(entry)) : undefined;
|
|
261
270
|
if (next.size > this.maxBytes()) {
|
|
262
271
|
this.logger.warn?.(`memory-hindsight: retain too large to queue (${next.size} bytes, bound ${this.maxBytes()}); not queued`);
|
|
263
272
|
// An older copy of the same document is out of date now; do not replay it.
|
|
@@ -270,6 +279,9 @@ export class RetainQueue {
|
|
|
270
279
|
this.markStale(stale.entry);
|
|
271
280
|
}
|
|
272
281
|
}
|
|
282
|
+
else if (unsavedCopy) {
|
|
283
|
+
this.markStale(unsavedCopy);
|
|
284
|
+
}
|
|
273
285
|
return false;
|
|
274
286
|
}
|
|
275
287
|
// A failed write never leaves memory holding an item the file lacks; see the catch below.
|
|
@@ -338,6 +350,9 @@ export class RetainQueue {
|
|
|
338
350
|
if (index >= 0) {
|
|
339
351
|
this.markStale(before[index].entry);
|
|
340
352
|
}
|
|
353
|
+
else if (unsavedCopy) {
|
|
354
|
+
this.markStale(unsavedCopy);
|
|
355
|
+
}
|
|
341
356
|
throw rewriteError;
|
|
342
357
|
}
|
|
343
358
|
/**
|
|
@@ -353,6 +368,7 @@ export class RetainQueue {
|
|
|
353
368
|
this.stored.splice(index, 1);
|
|
354
369
|
if (options.persist === false) {
|
|
355
370
|
this.dirty = true;
|
|
371
|
+
this.unsaved.set(queueKey(entry), entry);
|
|
356
372
|
}
|
|
357
373
|
else {
|
|
358
374
|
this.persist();
|
|
@@ -439,6 +455,7 @@ export class RetainQueue {
|
|
|
439
455
|
this.rewrite();
|
|
440
456
|
// Cleared only once written, so a failed replay save is retried by the next save().
|
|
441
457
|
this.dirty = false;
|
|
458
|
+
this.unsaved.clear();
|
|
442
459
|
}
|
|
443
460
|
rewrite() {
|
|
444
461
|
if (this.stored.length === 0) {
|
package/dist/tools.js
CHANGED
|
@@ -72,7 +72,9 @@ export function flushDocumentId(flushId, content) {
|
|
|
72
72
|
const digest = createHash("sha256").update(`${flushId}\u0000${content}`).digest("hex");
|
|
73
73
|
return `openclaw-flush:${digest.slice(0, 32)}`;
|
|
74
74
|
}
|
|
75
|
-
export function createRetainTool(service, deduper, ctx
|
|
75
|
+
export function createRetainTool(service, deduper, ctx,
|
|
76
|
+
/** Called after a retain Hindsight accepted, so the session's cached recall is dropped. */
|
|
77
|
+
onRetained) {
|
|
76
78
|
const bank = resolveToolBank(service, ctx, "retain");
|
|
77
79
|
if (!bank) {
|
|
78
80
|
return null;
|
|
@@ -126,6 +128,7 @@ export function createRetainTool(service, deduper, ctx) {
|
|
|
126
128
|
...(flushId ? { retention_scope: "pre_compaction_flush" } : {}),
|
|
127
129
|
},
|
|
128
130
|
}, { async: true, signal: bounded });
|
|
131
|
+
onRetained?.(ctx.sessionKey);
|
|
129
132
|
}
|
|
130
133
|
catch (error) {
|
|
131
134
|
if (flushId) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@etzelm/openclaw-memory-hindsight",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|