@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 +94 -0
- package/README.md +103 -17
- package/dist/client.js +104 -4
- package/dist/config.js +5 -1
- package/dist/index.js +81 -20
- package/dist/metrics.js +197 -0
- package/dist/provider-runtime.js +1 -1
- package/dist/retain-queue.js +134 -31
- package/dist/service.js +25 -2
- package/dist/tools.js +174 -1
- package/dist/transcript.js +244 -2
- package/openclaw.plugin.json +18 -1
- package/package.json +3 -1
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.
|
|
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
|
|
129
|
-
host's abort signal when there is one), retained roles user and
|
|
130
|
-
the user message (outside the system
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
255
|
-
|
|
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
|
|
312
|
-
rejects it). An unresolved SecretRef token, or 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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
:
|
|
154
|
-
|
|
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
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
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
|
-
|
|
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
|
|
209
|
-
|
|
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
|
});
|