@etzelm/openclaw-memory-hindsight 0.2.0 → 0.4.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,99 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 (2026-11-09)
4
+
5
+ ### Upgrade notes
6
+
7
+ - No config change is needed. The plugin now serves Prometheus metrics at
8
+ `GET /plugins/hindsight-native/metrics` on the Gateway port. To collect them, add a scrape job
9
+ for that path that sends a Gateway token with `operator.read`. Without a scrape job nothing
10
+ changes.
11
+
12
+ ### Added
13
+
14
+ - Gateway metrics: Prometheus text at `GET /plugins/hindsight-native/metrics` on the Gateway
15
+ port (Gateway auth, `operator.read`). Auto-recall and auto-retain counts by outcome with
16
+ latency histograms, and retain queue depth, replays and drops. Labels are `agent` and `bank`
17
+ plus fixed outcome and reason values; conversation banks share one label per owner bank.
18
+ See README, Gateway metrics.
19
+
20
+ ## 0.3.0 (2026-11-08)
21
+
22
+ ### Upgrade notes
23
+
24
+ - No config change is needed. The new `knowledgeReadTools` key is off by default, so the
25
+ registered tools are the same as in 0.2.0 unless you turn it on.
26
+ - The retain queue file format is unchanged. See Downgrade notes before going back to 0.2.0.
27
+ - Recalled memories that contain text shaped like the `<hindsight_memories>` wrapper or
28
+ OpenClaw's internal-context delimiters are now escaped before they reach the model.
29
+
30
+ ### Security
31
+
32
+ - Recall wrapper: `<hindsight_memories>` and `</hindsight_memories>` tags inside recalled text
33
+ are neutralized in more forms than the exact literal: any case, whitespace inside the tag,
34
+ attributes, self-closing, a missing `>`, `-` for `_`, invisible characters, accents, common
35
+ Cyrillic/Greek lookalike letters, fullwidth letters and `<` lookalikes (everything Unicode
36
+ confusables.txt maps to `<`, plus angle brackets).
37
+ - OpenClaw prompt delimiters in recalled text are escaped: `<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>`
38
+ and `<<<END_OPENCLAW_INTERNAL_CONTEXT>>>` (variants in case, spacing or invisible characters
39
+ too), `OpenClaw runtime context:`, `End OpenClaw runtime context.`,
40
+ `OpenClaw runtime context (internal):`, the runtime-context notice line and the legacy
41
+ carrier prefaces that precede it. Prose that mentions OpenClaw runtime context is unchanged.
42
+
43
+ ### Added
44
+
45
+ - `scripts/compat-matrix.sh`: host keyword `beta` (npm dist-tag `beta`), comma-separated
46
+ `--hosts` and `--runtimes`, and matrix rows in the requested host order.
47
+ - `knowledgeReadTools` (default off): opt-in read-only knowledge-base tools
48
+ `hindsight_get_knowledge_base_tree`, `hindsight_search_knowledge_base` and
49
+ `hindsight_get_knowledge_page`, with the vendor plugin's names. They read the same
50
+ audience-routed bank as `hindsight_recall`; a missing bank reads as empty. Off, the
51
+ registered tool set is unchanged; the manifest's static `contracts.tools` still lists the
52
+ three names. Output is capped (page 32000 characters, tree 500 entries, search 50 hits)
53
+ and a knowledge-base response body over 1 MiB fails.
54
+
55
+ ### Changed
56
+
57
+ - CI enforces a vitest coverage floor (`npm run test:coverage`, `@vitest/coverage-v8`) at
58
+ lines 94%, branches 86%, functions 95%, statements 93% (develop's measured coverage minus a
59
+ margin). GitLab shows the percentage on MRs and keeps the report as an artifact. Both pack
60
+ checks also reject `coverage/` (the `files` allowlist already kept it out of the pack).
61
+ - `scripts/launchd/hs-compat-nightly.plist.example`: `LimitLoadToSessionType` Aqua and
62
+ Background, so it also loads in the `user/<uid>` domain; the docs cover installing it for an
63
+ account without a GUI session.
64
+
65
+ ### Fixes
66
+
67
+ - `scripts/live-proof.sh`: X2 now searches every Claude Code project dir that appeared during
68
+ the run, and every file under the projects dir modified during it, for the code word, not
69
+ only names with `hs-live-proof`. Dirs it did not predict are counted and never deleted;
70
+ symlinks there are not followed.
71
+ - `scripts/compat-matrix.sh`: an empty `--hosts` or `--runtimes` list is refused (exit 2).
72
+ - `scripts/compat-nightly.sh`: one run at a time per `WORK_ROOT`; a lock is stale when its pid
73
+ does not run the same script path or it is older than 12 h.
74
+ - Config: `hindsightApiUrl` with a bare trailing `?` or `#` is rejected like a non-empty query
75
+ string or fragment.
76
+ - Retain queue: a retain is no longer lost when the file cannot be rewritten but can still be
77
+ appended to. The new copy is appended instead (load keeps the newest copy per document), and the
78
+ 32 MiB bound still applies to appends, so the file cannot grow without bound. A failed replay
79
+ save is retried by the next one.
80
+ - Retain queue: after a crash between replay's batched writes, load keeps only the newest copy
81
+ per bank and document id, and replay always sends `update_mode: replace` with the item's
82
+ document id.
83
+ - Auto-retain no longer loses a turn when the host continues a run after a transient model
84
+ retry. The second `agent_end` for the same run id now re-sends the whole run from its first
85
+ start instead of only the continuation, so the run's document is replaced with complete
86
+ content. The run start is kept per session and run id; a new run id, `session_end` or a stop
87
+ drops it. Host maintenance turns no longer touch it.
88
+
89
+ ### Downgrade notes
90
+
91
+ - The queue file format is unchanged. 0.2.0 keeps duplicate copies and replays them oldest first
92
+ (same document id, replace), so a re-retain of that document before replay finishes can leave
93
+ older text. A build that ignores tombstone lines (unreleased queue builds before 0.2.0) may
94
+ replay an out-of-date copy over a newer one that was sent live. Let the queue drain, or delete
95
+ `retain-queue.jsonl`, before downgrading.
96
+
3
97
  ## 0.2.0 (2026-11-07)
4
98
 
5
99
  ### Upgrade notes
package/README.md CHANGED
@@ -11,7 +11,8 @@ contracts instead of only hooking prompts:
11
11
  run, replaced on re-delivery). Host maintenance turns (the pre-compaction flush and Active
12
12
  Memory's recall child) get neither; they use the tools. A bank nothing was retained to yet
13
13
  reads as "no memories", without a warning.
14
- - **Agent tools** `hindsight_retain`, `hindsight_recall`, `hindsight_reflect`.
14
+ - **Agent tools** `hindsight_retain`, `hindsight_recall`, `hindsight_reflect`, plus opt-in
15
+ read-only knowledge-base tools (see [Knowledge-base read tools](#knowledge-base-read-tools)).
15
16
  - **Recall tool declaration** (`recallToolNames`): Active Memory uses `hindsight_recall` and
16
17
  `hindsight_reflect` without a `toolsAllow` list.
17
18
  - **Pre-compaction flush, tools arm**: before compaction the host runs a silent turn that saves
@@ -58,7 +59,7 @@ tarball and install that:
58
59
  git clone https://github.com/etzelm/openclaw-memory-hindsight.git
59
60
  cd openclaw-memory-hindsight
60
61
  npm ci && npm pack
61
- openclaw plugins install npm-pack:./etzelm-openclaw-memory-hindsight-0.2.0.tgz --force --accept-capabilities
62
+ openclaw plugins install npm-pack:./etzelm-openclaw-memory-hindsight-0.3.0.tgz --force --accept-capabilities
62
63
  ```
63
64
 
64
65
  `--force` confirms the "outside ClawHub review" warning for a local archive. Every install asks for
@@ -123,15 +124,21 @@ tool conflict.
123
124
  | `retainQueueEnabled` | `true` | Queue auto-retains that fail because Hindsight is unreachable and send them later (see below) |
124
125
  | `retainQueueMaxItems` | `500` | Most queued retains kept (1 to 10000); the oldest are dropped first |
125
126
  | `retainQueueMaxAgeMs` | `604800000` (7 days) | Queued retains older than this are dropped |
127
+ | `knowledgeReadTools` | `false` | Offer the read-only knowledge-base tools (see below); turning it on needs a gateway restart |
126
128
 
127
129
  Fixed values (not configurable): recall max tokens 1024, query cap 800 characters, recall
128
- timeout 10 s, retain and reflect timeout 30 s, memory get timeout 10 s (each combined with the
129
- host's abort signal when there is one), retained roles user and assistant, JSON transcript format, recall injected before
130
- the user message (outside the system prompt, so the cached prefix stays stable).
130
+ timeout 10 s, retain and reflect timeout 30 s, memory get and knowledge-base tool timeout 10 s
131
+ (each combined with the host's abort signal when there is one), retained roles user and
132
+ assistant, JSON transcript format, recall injected before the user message (outside the system
133
+ prompt, so the cached prefix stays stable). Knowledge-base tool caps: a response body over
134
+ 1 MiB fails; a page shows its first 32000 characters, then `(truncated, N more characters)`;
135
+ the tree shows 500 entries, then `(truncated, N more entries)`; search shows at most 50 hits
136
+ with snippets cut at 500 characters and names and ids at 200.
131
137
 
132
138
  Config changes apply without a restart: the plugin re-reads its entry from the host's current
133
139
  config snapshot (`api.runtime.config.current()`), falling back to the register-time
134
- `api.pluginConfig`.
140
+ `api.pluginConfig`. One exception: `knowledgeReadTools` registers its tools at startup, so turning
141
+ it on takes a gateway restart (turning it off applies at once).
135
142
 
136
143
  ### Offline retain queue
137
144
 
@@ -174,17 +181,95 @@ session transcripts.
174
181
  for its bank's queue, and its failure is logged as before.
175
182
  - **Disk writes.** A new item is appended, on a new line even after a torn last line. Replacing,
176
183
  pruning and replay rewrite the file (atomic rename); replay writes once per 25 delivered items and
177
- at the end of each pass. If a rewrite that should drop an out-of-date copy fails, a tombstone line
178
- is appended so a restart does not replay that copy. One queue per file per process, shared across
179
- service restarts. If a bank already has queued items and the queue file cannot be written (disk
180
- full, permissions), the retain is sent live instead. If the live write already failed, the queue
181
- error is logged and the retain is not kept.
184
+ at the end of each pass, and a failed write is retried by the next one. If a rewrite fails, the
185
+ new or newer copy is appended instead, with a tombstone for any item the limits drop; the older
186
+ copy stays in the file until the next successful rewrite, and load keeps only the newest line per
187
+ bank and document id. An append that would take
188
+ the file past 32 MiB rewrites it instead. Only when that rewrite fails too is the item not queued:
189
+ an out-of-date copy it replaced gets a tombstone line so a restart does not replay it, and the
190
+ retain is sent live, or, when its live write already failed, logged and not kept. Until the next
191
+ successful rewrite, the file stays within 32 MiB plus at most one short tombstone line per item
192
+ removed from the queue meanwhile. A crash between replay's writes can also leave an already delivered copy before
193
+ a newer one; load keeps the newer one, and replay always sends `update_mode: replace` with the
194
+ item's document id, so a repeat is harmless. One queue per file per process, shared across
195
+ service restarts.
196
+ - **Downgrade.** The file format is unchanged. 0.2.0 reads tombstones but keeps every copy of a
197
+ document (left by a crash during replay or by a replace that could not rewrite the file) and
198
+ sends them oldest first, same document id with replace, so the newest text wins unless the same
199
+ document is retained again before that replay finishes. 0.1.x has no queue and ignores the file.
200
+ A build that ignores tombstone lines (only unreleased queue builds before 0.2.0) can replay an
201
+ out-of-date copy a tombstone hid; if the newer copy was then sent live and never queued, the stale
202
+ text overwrites it on the server. Let the queue drain (the file is deleted when it is empty), or
203
+ delete the file, before downgrading.
182
204
  - Only auto-retain is queued. `hindsight_retain` and the pre-compaction flush return the error to
183
205
  the agent, as before. With `retainQueueEnabled: false` the plugin neither reads nor writes the
184
206
  file; delete it, or turn the queue back on to drain it. `retainQueueEnabled` is read at service
185
207
  start (restart the gateway after changing it); the two limits apply live. One gateway process per
186
208
  state dir: the file is not locked.
187
209
 
210
+ ### Knowledge-base read tools
211
+
212
+ With `knowledgeReadTools: true` the agent also gets three read-only tools over Hindsight's
213
+ knowledge pages (documents Hindsight writes and refreshes from memories), under the same names as
214
+ the vendor plugin:
215
+
216
+ - `hindsight_get_knowledge_base_tree`: folders and pages with page ids and a stale mark
217
+ (`GET .../knowledge-base/tree`).
218
+ - `hindsight_search_knowledge_base`: pages matching a query, with a snippet (`query`, optional
219
+ `limit` clamped to 1-50; `GET .../knowledge-base/search`).
220
+ - `hindsight_get_knowledge_page`: one page as markdown by `page_id`
221
+ (`GET .../knowledge-base/pages/<id>`).
222
+
223
+ They follow the recall tools' rules: same bank from the host-minted memory audience (an
224
+ owner-private session reads the owner bank, a conversation reads only its own
225
+ `<bank>::conv::<hash>` bank), same session filters, none in `context` mode or without an
226
+ audience. Hindsight scopes page ids to the bank in the URL, so a page id from the owner bank is
227
+ "not found" through a conversation bank. A bank nothing was written to reads as "no knowledge
228
+ pages yet"; as with recall, a 404 with an empty body counts as a missing bank too. A route 404
229
+ (`{"detail":"Not Found"}`: wrong URL or a Hindsight version without these endpoints) stays an
230
+ error. `page_id` must look like a Hindsight id (letters, digits, `_ . : -`, starting with a
231
+ letter or digit, at most 200 characters) and is refused otherwise, before any request. Output
232
+ is marked as untrusted data, like recall, and capped (see Fixed values). The manifest's
233
+ `contracts.tools` always lists the three names (it is static), so they appear in plugin status
234
+ and doctor tool-owner output even with the key off; they are registered only when it is on.
235
+ Host tool listings can therefore show them while the key is off: the manifest's `configSignals`
236
+ only test that a key is present, not that it is `true`, so they cannot express this key. The
237
+ write tools (create folder, create page, update, delete) are not ported.
238
+
239
+ ### Gateway metrics
240
+
241
+ The plugin serves Prometheus text at `GET /plugins/hindsight-native/metrics` on the Gateway
242
+ port. The route uses Gateway auth and needs `operator.read` (or `operator.write` /
243
+ `operator.admin`), the same as `diagnostics-prometheus`, so scrape it with the token you use
244
+ for `/api/diagnostics/prometheus`. `diagnostics-prometheus` renders only core diagnostic
245
+ events and has no plugin metric surface, which is why this is a separate path. There is no
246
+ config key; counters live in memory and reset when the Gateway restarts.
247
+
248
+ | Series (`openclaw_hindsight_` prefix) | Type | Labels |
249
+ | ------------------------------------- | --------- | -------------------------- |
250
+ | `recall_total` | counter | `agent`, `bank`, `outcome` |
251
+ | `recall_duration_seconds` | histogram | `agent`, `bank` |
252
+ | `retain_total` | counter | `agent`, `bank`, `outcome` |
253
+ | `retain_duration_seconds` | histogram | `agent`, `bank` |
254
+ | `retain_queue_depth` | gauge | `bank` |
255
+ | `retain_queue_replays_total` | counter | `bank`, `outcome` |
256
+ | `retain_queue_drops_total` | counter | `bank`, `reason` |
257
+
258
+ - Recall counts the auto-recall request only: `injected`, `empty` (no results or no bank yet),
259
+ `timeout`, `error`. A recall the host cancels (audience changed, hook invocation stale) is
260
+ not counted. Retain counts auto-retain only: `sent`, `queued`, `timeout`, `error`. Tool
261
+ calls are not counted.
262
+ - The latency histograms cover every outcome, so timeouts and fast `queued` retains are in the
263
+ same quantiles as successful calls.
264
+ - Replays: `delivered`, or `deferred` (Hindsight unreachable, item kept). Drops count retains
265
+ that are lost: `limit` (`retainQueueMaxItems`, `retainQueueMaxAgeMs` or the size bound),
266
+ `too_large` (the live write failed and the item is over the queue's size bound), `rejected`
267
+ (a 4xx on replay).
268
+ - `agent` is the normalized agent id. `bank` is the owner bank; every conversation bank of
269
+ that owner shares one `<bank>::conv` label. Values that are not label-safe read `unknown`.
270
+ Past 200 label sets per metric family, new `agent` and `bank` values fold into `other`;
271
+ `outcome` and `reason` keep their value. No session ids, document ids or text.
272
+
188
273
  ### Mapping from `@vectorize-io/hindsight-openclaw`
189
274
 
190
275
  | Vendor key | Here |
@@ -251,8 +336,8 @@ and 2026-11-07):
251
336
  - 2026.10.10 is the first release that records a dashboard session created by
252
337
  `sessions.create` as a direct chat (openclaw#181359). On 2026.10.1 through 2026.10.9 the host
253
338
  gives turns in such a session no memory audience ("root session ... has no durable chat
254
- type"), so the plugin neither recalls nor retains there. Nothing leaks; memory just does not
255
- work for those sessions, including `openclaw agent --session-key` runs in them.
339
+ type"), so the plugin neither recalls nor retains there: no memory is read or written in those
340
+ sessions, including `openclaw agent --session-key` runs in them.
256
341
  - 2026.9.36 has no tools-arm flush plan (`persistenceToolNames`) and no
257
342
  `assertMemoryAudienceCurrent`. 2026.10.1 is the first release with `recallToolNames`, the
258
343
  tools-arm `providerFlushPlanResolver` result and `memoryAudience` /
@@ -308,15 +393,16 @@ deprecated on npm once the fix ships, as 0.1.1 is.
308
393
  - **`memory.search` through the provider runtime skips the plugin filters.** It is gated by the
309
394
  caller's audience (owner-private or conversation), not by `memoryMode`, `excludeProviders` or
310
395
  incognito session keys. Those filters apply to auto-recall, auto-retain and the tools only.
311
- - `hindsightApiUrl` must not contain credentials, a query string or a fragment (the parser
312
- rejects it). An unresolved SecretRef token, or a token sent over plain `http` to a
313
- non-loopback host, logs one warning at service start.
396
+ - `hindsightApiUrl` must not contain credentials, a query string or a fragment, even an empty
397
+ one such as a trailing `?` or `#` (the parser rejects it). An unresolved SecretRef token, or a
398
+ token sent over plain `http` to a non-loopback host, logs one warning at service start.
314
399
  - Flush dedupe is per process; a restart mid-cycle can repeat a flush write, which the stable
315
400
  document id plus `update_mode: replace` turns into an overwrite.
316
401
  - Conversation banks are never pruned by the plugin, and `openclaw memory forget` does not reach
317
402
  slot owners (no host seam yet).
318
403
 
319
- Not ported from the vendor plugin: knowledge-base tools, tool-call retention
404
+ Not ported from the vendor plugin: knowledge-base write tools (create, update, delete; the
405
+ read tools are opt-in, see above), tool-call retention
320
406
  (`retainToolCalls`), `retainEveryNTurns` windows, the local Hindsight daemon, and
321
407
  per-user/per-channel bank granularity (replaced by audience routing).
322
408
 
package/dist/client.js CHANGED
@@ -3,15 +3,19 @@ export class HindsightHttpError extends Error {
3
3
  status;
4
4
  bankMissing;
5
5
  bankMissingExplicit;
6
+ pageMissing;
6
7
  constructor(message, status,
7
8
  /** True only for Hindsight's "Bank '<id>' not found" 404, not for a route that does not exist. */
8
9
  bankMissing = false,
9
10
  /** True only when the body said so; an empty 404 body sets `bankMissing` but not this. */
10
- bankMissingExplicit = false) {
11
+ bankMissingExplicit = false,
12
+ /** True only for Hindsight's "Knowledge page '<id>' not found" 404. */
13
+ pageMissing = false) {
11
14
  super(message);
12
15
  this.status = status;
13
16
  this.bankMissing = bankMissing;
14
17
  this.bankMissingExplicit = bankMissingExplicit;
18
+ this.pageMissing = pageMissing;
15
19
  this.name = "HindsightHttpError";
16
20
  }
17
21
  }
@@ -37,6 +41,49 @@ export function isExplicitBankMissingBody(text) {
37
41
  return false;
38
42
  }
39
43
  }
44
+ /** Only Hindsight's `Knowledge page '<id>' not found` detail; a route "Not Found" does not count. */
45
+ export function isExplicitPageMissingBody(text) {
46
+ try {
47
+ const detail = JSON.parse(text)?.detail;
48
+ return typeof detail === "string" && /\bknowledge page\b.*\bnot found\b/i.test(detail);
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ /** Largest knowledge-base response body read; a bigger one fails instead of filling memory. */
55
+ export const KB_MAX_RESPONSE_BYTES = 1024 * 1024;
56
+ // Error bodies are only classified, never copied, so a small prefix is enough.
57
+ const ERROR_BODY_MAX_BYTES = 64 * 1024;
58
+ class BodyTooLargeError extends Error {
59
+ }
60
+ /** Reads at most `maxBytes` of the body; throws BodyTooLargeError past it and stops reading. */
61
+ async function readBoundedText(response, maxBytes) {
62
+ const declared = Number(response.headers.get("content-length"));
63
+ if (Number.isFinite(declared) && declared > maxBytes) {
64
+ await response.body?.cancel().catch(() => undefined);
65
+ throw new BodyTooLargeError();
66
+ }
67
+ if (!response.body) {
68
+ return "";
69
+ }
70
+ const reader = response.body.getReader();
71
+ const chunks = [];
72
+ let total = 0;
73
+ for (;;) {
74
+ const { done, value } = await reader.read();
75
+ if (done) {
76
+ break;
77
+ }
78
+ total += value.byteLength;
79
+ if (total > maxBytes) {
80
+ await reader.cancel().catch(() => undefined);
81
+ throw new BodyTooLargeError();
82
+ }
83
+ chunks.push(value);
84
+ }
85
+ return new TextDecoder().decode(Buffer.concat(chunks));
86
+ }
40
87
  const HEALTH_TIMEOUT_MS = 5_000;
41
88
  /** A fixed timeout, combined with the host signal when there is one (host abort still wins). */
42
89
  export function boundedSignal(timeoutMs, hostSignal) {
@@ -73,13 +120,31 @@ export class HindsightClient {
73
120
  ...(init.signal ? { signal: init.signal } : {}),
74
121
  });
75
122
  if (!response.ok) {
76
- const body = response.status === 404 ? await response.text().catch(() => "") : "";
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
+ : "";
77
127
  const bankMissing = response.status === 404 && isBankMissingBody(body);
78
128
  const bankMissingExplicit = response.status === 404 && isExplicitBankMissingBody(body);
129
+ const pageMissing = response.status === 404 && isExplicitPageMissingBody(body);
79
130
  // Server error bodies can echo request content; keep only the status in the message.
80
- throw new HindsightHttpError(`Hindsight ${operation} failed: HTTP ${response.status}`, response.status, bankMissing, bankMissingExplicit);
131
+ throw new HindsightHttpError(`Hindsight ${operation} failed: HTTP ${response.status}`, response.status, bankMissing, bankMissingExplicit, pageMissing);
132
+ }
133
+ let text;
134
+ if (init.maxBodyBytes === undefined) {
135
+ text = await response.text();
136
+ }
137
+ else {
138
+ try {
139
+ text = await readBoundedText(response, init.maxBodyBytes);
140
+ }
141
+ catch (error) {
142
+ if (error instanceof BodyTooLargeError) {
143
+ throw new Error(`Hindsight ${operation} failed: response larger than ${init.maxBodyBytes} bytes`, { cause: error });
144
+ }
145
+ throw error;
146
+ }
81
147
  }
82
- const text = await response.text();
83
148
  // SAFETY: T only names optional fields; every caller type-checks each field before use.
84
149
  return (text ? JSON.parse(text) : {});
85
150
  }
@@ -125,6 +190,41 @@ export class HindsightClient {
125
190
  throw error;
126
191
  }
127
192
  }
193
+ async getKnowledgeBaseTree(bankId, signal) {
194
+ const response = await this.request("knowledge tree", this.bankUrl(bankId, "/knowledge-base/tree"), { method: "GET", signal, maxBodyBytes: KB_MAX_RESPONSE_BYTES });
195
+ return Array.isArray(response.roots) ? response.roots : [];
196
+ }
197
+ async searchKnowledgeBase(bankId, query, options = {}) {
198
+ const params = new URLSearchParams({ q: query });
199
+ if (options.limit !== undefined) {
200
+ params.set("limit", String(options.limit));
201
+ }
202
+ const response = await this.request("knowledge search", this.bankUrl(bankId, `/knowledge-base/search?${params.toString()}`), { method: "GET", signal: options.signal, maxBodyBytes: KB_MAX_RESPONSE_BYTES });
203
+ return Array.isArray(response.results) ? response.results : [];
204
+ }
205
+ /**
206
+ * Returns null when the bank or page does not exist. Hindsight scopes page ids to the bank in
207
+ * the URL, so a page id from another bank is also null. A route 404 stays an error.
208
+ */
209
+ async getKnowledgePage(bankId, pageId, signal) {
210
+ try {
211
+ const body = await this.request("knowledge page", this.bankUrl(bankId, `/knowledge-base/pages/${encodeURIComponent(pageId)}`), { method: "GET", signal, maxBodyBytes: KB_MAX_RESPONSE_BYTES });
212
+ if (typeof body.markdown !== "string") {
213
+ throw new Error("Hindsight knowledge page failed: unexpected response");
214
+ }
215
+ return {
216
+ id: pageId,
217
+ name: typeof body.name === "string" ? body.name : "",
218
+ markdown: body.markdown,
219
+ };
220
+ }
221
+ catch (error) {
222
+ if (error instanceof HindsightHttpError && (error.bankMissing || error.pageMissing)) {
223
+ return null;
224
+ }
225
+ throw error;
226
+ }
227
+ }
128
228
  /** Creates the bank or updates its missions; idempotent on the server. */
129
229
  async applyBankDefaults(bankId, defaults, signal) {
130
230
  const body = {};
package/dist/config.js CHANGED
@@ -35,6 +35,7 @@ const CONFIG_KEYS = [
35
35
  "retainQueueEnabled",
36
36
  "retainQueueMaxItems",
37
37
  "retainQueueMaxAgeMs",
38
+ "knowledgeReadTools",
38
39
  ];
39
40
  function isRecord(value) {
40
41
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -126,7 +127,9 @@ function normalizeApiUrl(raw) {
126
127
  if (url.username || url.password) {
127
128
  throw new Error("hindsightApiUrl must not contain credentials (user:password@); use hindsightApiToken");
128
129
  }
129
- if (url.search || url.hash) {
130
+ // An empty query or fragment (a bare trailing `?` or `#`) leaves search/hash empty but stays
131
+ // in href; `?` and `#` cannot appear in an http(s) href except as those delimiters.
132
+ if (/[?#]/.test(url.href)) {
130
133
  throw new Error("hindsightApiUrl must not contain a query string or fragment");
131
134
  }
132
135
  return raw.replace(/\/+$/, "");
@@ -203,6 +206,7 @@ export const hindsightConfigSchema = {
203
206
  retainQueueEnabled: readBool(value, "retainQueueEnabled", true),
204
207
  retainQueueMaxItems: readPositiveInt(value, "retainQueueMaxItems", DEFAULT_RETAIN_QUEUE_MAX_ITEMS, MAX_RETAIN_QUEUE_MAX_ITEMS),
205
208
  retainQueueMaxAgeMs: readPositiveInt(value, "retainQueueMaxAgeMs", DEFAULT_RETAIN_QUEUE_MAX_AGE_MS),
209
+ knowledgeReadTools: readBool(value, "knowledgeReadTools", false),
206
210
  };
207
211
  },
208
212
  };
package/dist/index.js CHANGED
@@ -6,11 +6,12 @@ import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
6
6
  import { isBankNotFound } from "./client.js";
7
7
  import { hasUnresolvedToken, hindsightConfigSchema, isPlainHttpRemote, RECALL_MAX_TOKENS, RECALL_TIMEOUT_MS, RETAIN_SOURCE, RETAIN_TIMEOUT_MS, } from "./config.js";
8
8
  import { createLearnContribution, createLegacyLearnFields, createProviderFlushPlanResolver, RECALL_TOOL_NAMES, } from "./memory-capability.js";
9
+ import { createMetricsRouteHandler, HindsightMetrics, isTimeoutError, METRICS_ROUTE_PATH, } from "./metrics.js";
9
10
  import { createHindsightProviderRuntime } from "./provider-runtime.js";
10
11
  import { isMaintenanceTurn, routeAudience, sessionUsesMemory } from "./routing.js";
11
12
  import { resolveLearnRegistrar } from "./sdk-types.js";
12
13
  import { createHindsightService } from "./service.js";
13
- import { createFlushDeduper, createRecallTool, createReflectTool, createRetainTool, RECALL_TOOL, REFLECT_TOOL, RETAIN_TOOL, } from "./tools.js";
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";
14
15
  import { buildRecallContext, extractRecallQuery, isEphemeralOperationalText, prepareRetentionTranscript, retainStartIndex, runStartMarker, } from "./transcript.js";
15
16
  export const PLUGIN_ID = "hindsight-native";
16
17
  // Bounds per-session run markers; one entry per active session is enough.
@@ -80,11 +81,28 @@ export default definePluginEntry({
80
81
  return startupConfig;
81
82
  }
82
83
  };
84
+ const metrics = new HindsightMetrics();
83
85
  const service = createHindsightService({
84
86
  config: currentConfig,
85
87
  logger: api.logger,
88
+ metrics,
89
+ });
90
+ // Gateway-side metrics on the gateway port, behind gateway auth (see metrics.ts for why this
91
+ // is a plugin route and not diagnostics-prometheus). The floor host has the method; the check
92
+ // only keeps a host or test double without it from failing registration.
93
+ if (typeof api.registerHttpRoute !== "function") {
94
+ api.logger.debug?.("memory-hindsight: host has no registerHttpRoute; metrics route not served");
95
+ }
96
+ api.registerHttpRoute?.({
97
+ path: METRICS_ROUTE_PATH,
98
+ auth: "gateway",
99
+ match: "exact",
100
+ gatewayRuntimeScopeSurface: "trusted-operator",
101
+ handler: createMetricsRouteHandler(metrics, () => service.retainQueueDepth()),
86
102
  });
87
103
  const flushDeduper = createFlushDeduper();
104
+ // Per session: where the current run started, kept for the whole run (by runId) so a second
105
+ // agent_end for the run (host continuation after a retry, or a re-delivery) re-sends all of it.
88
106
  const runStartBySession = new Map();
89
107
  const capability = {
90
108
  providerRuntime: createHindsightProviderRuntime({
@@ -112,12 +130,32 @@ export default definePluginEntry({
112
130
  registerTool(RETAIN_TOOL, (ctx) => createRetainTool(service, flushDeduper, ctx));
113
131
  registerTool(RECALL_TOOL, (ctx) => createRecallTool(service, ctx));
114
132
  registerTool(REFLECT_TOOL, (ctx) => createReflectTool(service, ctx));
133
+ // Opt-in: registered only when knowledgeReadTools is on at startup, so the default tool set is
134
+ // unchanged. Each factory also reads the live key and returns null once it is turned off.
135
+ if (startupConfig.knowledgeReadTools) {
136
+ registerTool(KB_TREE_TOOL, (ctx) => createKnowledgeTreeTool(service, ctx));
137
+ registerTool(KB_SEARCH_TOOL, (ctx) => createKnowledgeSearchTool(service, ctx));
138
+ registerTool(KB_PAGE_TOOL, (ctx) => createKnowledgePageTool(service, ctx));
139
+ }
115
140
  api.on("before_prompt_build", async (event, ctx) => {
116
141
  const config = currentConfig();
117
142
  const sessionKey = ctx.sessionKey;
118
- // Marks the run start for agent_end even when recall is skipped.
119
- if (sessionKey && !runStartBySession.has(sessionKey) && Array.isArray(event.messages)) {
120
- runStartBySession.set(sessionKey, runStartMarker(event.messages, event.currentUserMessage));
143
+ const maintenance = isMaintenanceTurn({
144
+ trigger: ctx.trigger,
145
+ sessionKey,
146
+ });
147
+ // Marks the run start for agent_end even when recall is skipped. A rebuild or continuation
148
+ // of the same run keeps the first start; maintenance turns never touch the markers.
149
+ const prior = sessionKey ? runStartBySession.get(sessionKey) : undefined;
150
+ if (sessionKey &&
151
+ !maintenance &&
152
+ Array.isArray(event.messages) &&
153
+ (!prior || prior.runId !== ctx.runId)) {
154
+ runStartBySession.delete(sessionKey);
155
+ runStartBySession.set(sessionKey, {
156
+ runId: ctx.runId,
157
+ marker: runStartMarker(event.messages, event.currentUserMessage),
158
+ });
121
159
  if (runStartBySession.size > MAX_RUN_MARKERS) {
122
160
  runStartBySession.delete(runStartBySession.keys().next().value);
123
161
  }
@@ -125,7 +163,7 @@ export default definePluginEntry({
125
163
  if (service.stopped() ||
126
164
  !config.autoRecall ||
127
165
  config.memoryMode === "tools" ||
128
- isMaintenanceTurn({ trigger: ctx.trigger, sessionKey }) ||
166
+ maintenance ||
129
167
  isIncognitoSessionKey(sessionKey) ||
130
168
  !sessionUsesMemory(config, {
131
169
  sessionKey,
@@ -144,17 +182,29 @@ export default definePluginEntry({
144
182
  }
145
183
  try {
146
184
  ctx.assertMemoryAudienceCurrent?.();
147
- const results = await service.client.recall(route.bankId, query, {
148
- budget: config.recallBudget,
149
- maxTokens: RECALL_MAX_TOKENS,
150
- ...(config.recallTypes ? { types: config.recallTypes } : {}),
151
- ...(config.preferObservations === undefined
152
- ? {}
153
- : { preferObservations: config.preferObservations }),
154
- signal: AbortSignal.timeout(RECALL_TIMEOUT_MS),
155
- });
185
+ // Metrics count the Hindsight request only. A host assert that throws (audience changed,
186
+ // hook invocation stale) is host lifecycle, not a recall outcome, so it records nothing.
187
+ const recallStarted = performance.now();
188
+ let results;
189
+ try {
190
+ results = await service.client.recall(route.bankId, query, {
191
+ budget: config.recallBudget,
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
+ });
199
+ }
200
+ catch (error) {
201
+ metrics.recordRecall(ctx.agentId, route.bankId, isBankNotFound(error) ? "empty" : isTimeoutError(error) ? "timeout" : "error", performance.now() - recallStarted);
202
+ throw error;
203
+ }
204
+ const recallMs = performance.now() - recallStarted;
156
205
  ctx.assertMemoryAudienceCurrent?.();
157
206
  ctx.hookInvocation?.assertActive();
207
+ metrics.recordRecall(ctx.agentId, route.bankId, results.length === 0 ? "empty" : "injected", recallMs);
158
208
  if (results.length === 0) {
159
209
  return undefined;
160
210
  }
@@ -176,9 +226,16 @@ export default definePluginEntry({
176
226
  });
177
227
  api.on("agent_end", async (event, ctx) => {
178
228
  const sessionKey = ctx.sessionKey;
179
- // Taken before any skip so the next run marks its own start.
180
- const runStart = sessionKey ? runStartBySession.get(sessionKey) : undefined;
181
- if (sessionKey) {
229
+ const runId = event.runId ?? ctx.runId;
230
+ const maintenance = isMaintenanceTurn({
231
+ trigger: ctx.trigger,
232
+ sessionKey,
233
+ });
234
+ const start = sessionKey && !maintenance ? runStartBySession.get(sessionKey) : undefined;
235
+ const runStart = start?.marker;
236
+ // Taken before any skip. The marker stays while the run can still continue; without a
237
+ // runId it is dropped so the next run marks its own start.
238
+ if (sessionKey && start && (runId === undefined || start.runId !== runId)) {
182
239
  runStartBySession.delete(sessionKey);
183
240
  }
184
241
  const config = currentConfig();
@@ -187,7 +244,7 @@ export default definePluginEntry({
187
244
  !event.success ||
188
245
  !Array.isArray(event.messages) ||
189
246
  event.messages.length === 0 ||
190
- isMaintenanceTurn({ trigger: ctx.trigger, sessionKey }) ||
247
+ maintenance ||
191
248
  isIncognitoSessionKey(sessionKey) ||
192
249
  !sessionUsesMemory(config, {
193
250
  sessionKey,
@@ -205,9 +262,11 @@ export default definePluginEntry({
205
262
  return;
206
263
  }
207
264
  const tags = [...new Set([...config.retainTags, ...retention.tags])];
208
- // One document per run: a re-delivered agent_end for the same run replaces, not duplicates.
209
- const runPart = sanitizeDocumentIdPart(event.runId ?? ctx.runId, `${Date.now()}`);
265
+ // One document per run. Every agent_end for the run carries the run from its first start,
266
+ // so a continuation or a re-delivery replaces the document with the whole run.
267
+ const runPart = sanitizeDocumentIdPart(runId, `${Date.now()}`);
210
268
  const documentId = `openclaw:${sanitizeDocumentIdPart(sessionKey, "session")}-${sessionKeyDigest(sessionKey)}:run:${runPart}`;
269
+ const retainStarted = performance.now();
211
270
  try {
212
271
  // No ctx.assertMemoryAudienceCurrent() here. The host starts agent_end handlers without
213
272
  // awaiting them (runAgentEndSideEffectsAsync -> void runAgentEnd, after awaiting the
@@ -234,9 +293,11 @@ export default definePluginEntry({
234
293
  ...(ctx.channelId ? { channel_id: ctx.channelId } : {}),
235
294
  },
236
295
  }, signal);
296
+ metrics.recordRetain(ctx.agentId, route.bankId, outcome, performance.now() - retainStarted);
237
297
  api.logger.debug?.(`memory-hindsight: ${outcome === "sent" ? "retained" : "queued"} ${retention.messageCount} messages (${route.audience.kind})`);
238
298
  }
239
299
  catch (error) {
300
+ metrics.recordRetain(ctx.agentId, route.bankId, isTimeoutError(error) ? "timeout" : "error", performance.now() - retainStarted);
240
301
  api.logger.warn?.(`memory-hindsight: auto-retain failed: ${formatErrorMessage(error)}`);
241
302
  }
242
303
  });