@etzelm/openclaw-memory-hindsight 0.5.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,67 @@
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
+
48
+ ## 0.6.0 (2026-11-11)
49
+
50
+ ### Upgrade notes
51
+
52
+ - Needs an OpenClaw build with openclaw/openclaw#197274 (merged 2026-11-11) for `hindsight_*`
53
+ tools on claude-cli sessions. That host change passes the turn's memory audience when it lists
54
+ claude-cli MCP tools, so the tools list through the normal audience route. On an older
55
+ 2026.11.x host, claude-cli sessions keep auto-recall but lose the `hindsight_*` tools; stay on
56
+ 0.5.0 there.
57
+ - `listToolsWithoutAudience` is deprecated and ignored. The key still validates, so an existing
58
+ config keeps loading; remove it. A later major release rejects it.
59
+
60
+ ### Removed
61
+
62
+ - The `listToolsWithoutAudience` workaround from 0.5.0: a tool context without a memory audience
63
+ gets no `hindsight_*` tools again, whatever the config says.
64
+
3
65
  ## 0.5.0 (2026-11-10)
4
66
 
5
67
  ### Upgrade notes
package/README.md CHANGED
@@ -129,7 +129,6 @@ tool conflict.
129
129
  | `recallCacheTtlMs` | `0` (off) | Auto-recall cache per session and bank, 0 to 3600000 ms; a repeated query skips the API call |
130
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 |
131
131
  | `knowledgeReadTools` | `false` | Offer the read-only knowledge-base tools (see below); turning it on needs a gateway restart |
132
- | `listToolsWithoutAudience` | `false` | List the `hindsight_*` tools when the host builds the tool list without an audience (see below) |
133
132
 
134
133
  Session patterns: `*` matches any run of characters except `:`, `**` matches anything including
135
134
  `:`, everything else is literal. Patterns match the trimmed session key. `ignoreSessionPatterns`
@@ -149,14 +148,10 @@ prompt, so the cached prefix stays stable). Knowledge-base tool caps: a response
149
148
  the tree shows 500 entries, then `(truncated, N more entries)`; search shows at most 50 hits
150
149
  with snippets cut at 500 characters and names and ids at 200.
151
150
 
152
- `listToolsWithoutAudience`: the claude-cli runtime on OpenClaw 2026.11.x builds a turn's MCP tool
153
- list before it attaches the turn's memory audience, so every tool factory sees no audience, returns
154
- null, and `hindsight_*` never reach claude-cli sessions (auto-recall still works; it reads the
155
- audience from the hook context). With this key on, an agent session with a session key gets the
156
- tools listed anyway. The host then builds the callable copy with the audience from the run's grant,
157
- and that copy alone reaches Hindsight; a call that still has no audience fails with an error before
158
- any request. The session filters (`memoryMode`, incognito, `excludeProviders`,
159
- `skipOperationalSessions`) apply as before, and an audience for another agent is still refused.
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.
160
155
 
161
156
  Config changes apply without a restart: the plugin re-reads its entry from the host's current
162
157
  config snapshot (`api.runtime.config.current()`), falling back to the register-time
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/config.js CHANGED
@@ -40,6 +40,7 @@ const CONFIG_KEYS = [
40
40
  "retainQueueMaxItems",
41
41
  "retainQueueMaxAgeMs",
42
42
  "knowledgeReadTools",
43
+ // Deprecated in 0.6.0 and ignored: accepted so a config that still sets it keeps validating.
43
44
  "listToolsWithoutAudience",
44
45
  "recallCacheTtlMs",
45
46
  "recallFollowupMaxWords",
@@ -227,7 +228,6 @@ export const hindsightConfigSchema = {
227
228
  retainQueueMaxItems: readPositiveInt(value, "retainQueueMaxItems", DEFAULT_RETAIN_QUEUE_MAX_ITEMS, MAX_RETAIN_QUEUE_MAX_ITEMS),
228
229
  retainQueueMaxAgeMs: readPositiveInt(value, "retainQueueMaxAgeMs", DEFAULT_RETAIN_QUEUE_MAX_AGE_MS),
229
230
  knowledgeReadTools: readBool(value, "knowledgeReadTools", false),
230
- listToolsWithoutAudience: readBool(value, "listToolsWithoutAudience", false),
231
231
  recallCacheTtlMs: readNonNegativeInt(value, "recallCacheTtlMs", 0, MAX_RECALL_CACHE_TTL_MS),
232
232
  recallFollowupMaxWords: readNonNegativeInt(value, "recallFollowupMaxWords", 0, MAX_RECALL_FOLLOWUP_WORDS),
233
233
  };
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
@@ -24,19 +24,6 @@ function requiredString(params, name) {
24
24
  }
25
25
  return value.trim();
26
26
  }
27
- export const NO_AUDIENCE_AT_CALL = "This session has no host memory audience for this call, so Hindsight memory is unavailable.";
28
- /**
29
- * A tool listed for a session whose tool list the host built without a memory audience. Reading
30
- * `bankId` throws, so `execute` fails before any request; the host builds the callable tool again
31
- * with the session's audience, and that copy gets a real bank.
32
- */
33
- function deferredBank() {
34
- return {
35
- get bankId() {
36
- throw new Error(NO_AUDIENCE_AT_CALL);
37
- },
38
- };
39
- }
40
27
  /**
41
28
  * Resolves the bank for one tool context, or null when the session must not get the tool.
42
29
  * The bank comes only from the host-minted audience; the model never names a bank.
@@ -52,15 +39,6 @@ function resolveToolBank(service, ctx, purpose) {
52
39
  })) {
53
40
  return null;
54
41
  }
55
- // Some hosts list a session's tools before they attach its audience (claude-cli MCP tool
56
- // projection on OpenClaw 2026.11.x). Opt-in: list the tool for an agent session with a key, and
57
- // let only the audience-bearing copy built for the call reach Hindsight.
58
- if (config.listToolsWithoutAudience &&
59
- !ctx.memoryAudience &&
60
- ctx.agentId?.trim() &&
61
- ctx.sessionKey?.trim()) {
62
- return deferredBank();
63
- }
64
42
  const route = routeAudience(ctx.agentId, ctx.memoryAudience, config.agentBankMap);
65
43
  return route.ok ? { bankId: route.bankId } : null;
66
44
  }
@@ -94,7 +72,9 @@ export function flushDocumentId(flushId, content) {
94
72
  const digest = createHash("sha256").update(`${flushId}\u0000${content}`).digest("hex");
95
73
  return `openclaw-flush:${digest.slice(0, 32)}`;
96
74
  }
97
- 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) {
98
78
  const bank = resolveToolBank(service, ctx, "retain");
99
79
  if (!bank) {
100
80
  return null;
@@ -148,6 +128,7 @@ export function createRetainTool(service, deduper, ctx) {
148
128
  ...(flushId ? { retention_scope: "pre_compaction_flush" } : {}),
149
129
  },
150
130
  }, { async: true, signal: bounded });
131
+ onRetained?.(ctx.sessionKey);
151
132
  }
152
133
  catch (error) {
153
134
  if (flushId) {
@@ -96,11 +96,6 @@
96
96
  "label": "Knowledge Base Read Tools",
97
97
  "help": "Offer hindsight_get_knowledge_base_tree, hindsight_search_knowledge_base and hindsight_get_knowledge_page on the same bank as hindsight_recall",
98
98
  "advanced": true
99
- },
100
- "listToolsWithoutAudience": {
101
- "label": "List Tools Without Audience",
102
- "help": "List the hindsight_* tools when the host builds a session's tool list before it attaches the memory audience (claude-cli sessions on OpenClaw 2026.11.x). Calls still need the audience",
103
- "advanced": true
104
99
  }
105
100
  },
106
101
  "configSchema": {
@@ -223,7 +218,7 @@
223
218
  "listToolsWithoutAudience": {
224
219
  "type": "boolean",
225
220
  "default": false,
226
- "description": "List the hindsight_* tools for an agent session whose tool list the host builds without its memory audience (claude-cli MCP tool projection on OpenClaw 2026.11.x). The bank still comes only from the audience: a call without one fails before any request"
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"
227
222
  },
228
223
  "agentBankMap": {
229
224
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@etzelm/openclaw-memory-hindsight",
3
- "version": "0.5.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",
@@ -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"