@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 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
- const body = response.status === 404
124
- ? // An oversized body is not "empty", so it never reads as a missing bank.
125
- await readBoundedText(response, ERROR_BODY_MAX_BYTES).catch((error) => error instanceof BodyTooLargeError ? "<oversized>" : "")
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
- registerTool(RETAIN_TOOL, (ctx) => createRetainTool(service, flushDeduper, ctx));
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 start = sessionKey && !maintenance ? runStartBySession.get(sessionKey) : undefined;
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
  }
@@ -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.6.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",