@etzelm/openclaw-memory-hindsight 0.3.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,22 @@
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
+
3
20
  ## 0.3.0 (2026-11-08)
4
21
 
5
22
  ### Upgrade notes
package/README.md CHANGED
@@ -236,6 +236,40 @@ Host tool listings can therefore show them while the key is off: the manifest's
236
236
  only test that a key is present, not that it is `true`, so they cannot express this key. The
237
237
  write tools (create folder, create page, update, delete) are not ported.
238
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
+
239
273
  ### Mapping from `@vectorize-io/hindsight-openclaw`
240
274
 
241
275
  | Vendor key | Here |
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ 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";
@@ -80,9 +81,24 @@ 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();
88
104
  // Per session: where the current run started, kept for the whole run (by runId) so a second
@@ -166,17 +182,29 @@ export default definePluginEntry({
166
182
  }
167
183
  try {
168
184
  ctx.assertMemoryAudienceCurrent?.();
169
- const results = await service.client.recall(route.bankId, query, {
170
- budget: config.recallBudget,
171
- maxTokens: RECALL_MAX_TOKENS,
172
- ...(config.recallTypes ? { types: config.recallTypes } : {}),
173
- ...(config.preferObservations === undefined
174
- ? {}
175
- : { preferObservations: config.preferObservations }),
176
- signal: AbortSignal.timeout(RECALL_TIMEOUT_MS),
177
- });
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;
178
205
  ctx.assertMemoryAudienceCurrent?.();
179
206
  ctx.hookInvocation?.assertActive();
207
+ metrics.recordRecall(ctx.agentId, route.bankId, results.length === 0 ? "empty" : "injected", recallMs);
180
208
  if (results.length === 0) {
181
209
  return undefined;
182
210
  }
@@ -238,6 +266,7 @@ export default definePluginEntry({
238
266
  // so a continuation or a re-delivery replaces the document with the whole run.
239
267
  const runPart = sanitizeDocumentIdPart(runId, `${Date.now()}`);
240
268
  const documentId = `openclaw:${sanitizeDocumentIdPart(sessionKey, "session")}-${sessionKeyDigest(sessionKey)}:run:${runPart}`;
269
+ const retainStarted = performance.now();
241
270
  try {
242
271
  // No ctx.assertMemoryAudienceCurrent() here. The host starts agent_end handlers without
243
272
  // awaiting them (runAgentEndSideEffectsAsync -> void runAgentEnd, after awaiting the
@@ -264,9 +293,11 @@ export default definePluginEntry({
264
293
  ...(ctx.channelId ? { channel_id: ctx.channelId } : {}),
265
294
  },
266
295
  }, signal);
296
+ metrics.recordRetain(ctx.agentId, route.bankId, outcome, performance.now() - retainStarted);
267
297
  api.logger.debug?.(`memory-hindsight: ${outcome === "sent" ? "retained" : "queued"} ${retention.messageCount} messages (${route.audience.kind})`);
268
298
  }
269
299
  catch (error) {
300
+ metrics.recordRetain(ctx.agentId, route.bankId, isTimeoutError(error) ? "timeout" : "error", performance.now() - retainStarted);
270
301
  api.logger.warn?.(`memory-hindsight: auto-retain failed: ${formatErrorMessage(error)}`);
271
302
  }
272
303
  });
@@ -0,0 +1,197 @@
1
+ import { normalizeDiagnosticValue } from "openclaw/plugin-sdk/diagnostic-runtime";
2
+ import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
3
+ import { normalizeAgentIdStrict } from "openclaw/plugin-sdk/routing";
4
+ /**
5
+ * Gateway-side metrics for auto-recall, auto-retain and the retain queue, rendered as Prometheus
6
+ * text on a gateway-auth plugin route. diagnostics-prometheus renders only core diagnostic events
7
+ * and has no plugin metric surface, so the plugin serves its own series on the gateway port.
8
+ * Labels are agent and bank plus fixed enums; never session ids, document ids or text.
9
+ */
10
+ export const METRICS_ROUTE_PATH = "/plugins/hindsight-native/metrics";
11
+ const PREFIX = "openclaw_hindsight_";
12
+ // Seconds. Recall times out at 10 s, retain at 30 s.
13
+ export const LATENCY_BUCKETS = [0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30];
14
+ // Label sets per family before new agent and bank values fold into "other"; agents and banks come
15
+ // from config. Outcome and reason keep their value, so the bound grows by the enum size at most.
16
+ export const MAX_SERIES_PER_FAMILY = 200;
17
+ const OTHER = "other";
18
+ const FOLDED_LABELS = new Set(["agent", "bank"]);
19
+ /** Agent label: the normalized agent id, or "unknown" when it is missing or not label-safe. */
20
+ export function agentLabel(agentId) {
21
+ // The guard runs first: normalizing would turn a session-shaped value into a safe-looking id.
22
+ // The strict form never falls back to the default agent, so an odd id reads unknown, not main.
23
+ const safe = normalizeDiagnosticValue(agentId);
24
+ if (safe === "unknown") {
25
+ return safe;
26
+ }
27
+ const normalized = normalizeAgentIdStrict(safe);
28
+ return normalized.ok ? normalized.value : "unknown";
29
+ }
30
+ /**
31
+ * Bank label. A conversation bank (`<owner>::conv::<digest>`, one per root session) folds into
32
+ * `<owner>::conv`, so the label set stays as small as the configured agents and bank map.
33
+ */
34
+ export function bankLabel(bankId) {
35
+ if (!bankId) {
36
+ return "unknown";
37
+ }
38
+ const conv = bankId.indexOf("::conv::");
39
+ return normalizeDiagnosticValue(conv >= 0 ? `${bankId.slice(0, conv)}::conv` : bankId);
40
+ }
41
+ /** True for the error an `AbortSignal.timeout` fetch rejects with. */
42
+ export function isTimeoutError(error) {
43
+ return error?.name === "TimeoutError";
44
+ }
45
+ function labelKey(labels) {
46
+ return JSON.stringify(Object.entries(labels));
47
+ }
48
+ function escapeLabelValue(value) {
49
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
50
+ }
51
+ function formatLabels(labels, extra) {
52
+ const pairs = Object.entries(labels);
53
+ if (extra) {
54
+ pairs.push(extra);
55
+ }
56
+ if (pairs.length === 0) {
57
+ return "";
58
+ }
59
+ return `{${pairs.map(([k, v]) => `${k}="${escapeLabelValue(v)}"`).join(",")}}`;
60
+ }
61
+ /** Bounded map of label set to state; past the bound every new set lands on the "other" set. */
62
+ class Series {
63
+ create;
64
+ entries = new Map();
65
+ constructor(create) {
66
+ this.create = create;
67
+ }
68
+ get(labels) {
69
+ let key = labelKey(labels);
70
+ let entry = this.entries.get(key);
71
+ if (!entry) {
72
+ if (this.entries.size >= MAX_SERIES_PER_FAMILY) {
73
+ labels = Object.fromEntries(Object.entries(labels).map(([k, v]) => [k, FOLDED_LABELS.has(k) ? OTHER : v]));
74
+ key = labelKey(labels);
75
+ entry = this.entries.get(key);
76
+ }
77
+ if (!entry) {
78
+ entry = { labels, value: this.create() };
79
+ this.entries.set(key, entry);
80
+ }
81
+ }
82
+ return entry.value;
83
+ }
84
+ values() {
85
+ return [...this.entries.values()];
86
+ }
87
+ }
88
+ function newHistogram() {
89
+ return { buckets: LATENCY_BUCKETS.map(() => 0), sum: 0, count: 0 };
90
+ }
91
+ function formatNumber(value) {
92
+ return Number.isFinite(value) ? String(value) : value > 0 ? "+Inf" : "-Inf";
93
+ }
94
+ export class HindsightMetrics {
95
+ recalls = new Series(() => ({ n: 0 }));
96
+ recallLatency = new Series(newHistogram);
97
+ retains = new Series(() => ({ n: 0 }));
98
+ retainLatency = new Series(newHistogram);
99
+ replays = new Series(() => ({ n: 0 }));
100
+ drops = new Series(() => ({ n: 0 }));
101
+ // Kept across scrapes, so a bank whose queue drained reads 0 instead of disappearing.
102
+ depth = new Series(() => ({ n: 0 }));
103
+ recordRecall(agentId, bankId, outcome, ms) {
104
+ const base = { agent: agentLabel(agentId), bank: bankLabel(bankId) };
105
+ this.recalls.get({ ...base, outcome }).n += 1;
106
+ observe(this.recallLatency.get(base), ms);
107
+ }
108
+ recordRetain(agentId, bankId, outcome, ms) {
109
+ const base = { agent: agentLabel(agentId), bank: bankLabel(bankId) };
110
+ this.retains.get({ ...base, outcome }).n += 1;
111
+ observe(this.retainLatency.get(base), ms);
112
+ }
113
+ recordReplay(bankId, outcome) {
114
+ this.replays.get({ bank: bankLabel(bankId), outcome }).n += 1;
115
+ }
116
+ recordDrop(bankId, reason, count = 1) {
117
+ if (count > 0) {
118
+ this.drops.get({ bank: bankLabel(bankId), reason }).n += count;
119
+ }
120
+ }
121
+ /** Prometheus text exposition (version 0.0.4). Families with no samples print only HELP/TYPE. */
122
+ render(queueDepth) {
123
+ const lines = [];
124
+ const counter = (name, help, series) => {
125
+ lines.push(`# HELP ${PREFIX}${name} ${help}`, `# TYPE ${PREFIX}${name} counter`);
126
+ for (const { labels, value } of series.values()) {
127
+ lines.push(`${PREFIX}${name}${formatLabels(labels)} ${value.n}`);
128
+ }
129
+ };
130
+ const histogram = (name, help, series) => {
131
+ lines.push(`# HELP ${PREFIX}${name} ${help}`, `# TYPE ${PREFIX}${name} histogram`);
132
+ for (const { labels, value } of series.values()) {
133
+ LATENCY_BUCKETS.forEach((le, i) => {
134
+ lines.push(`${PREFIX}${name}_bucket${formatLabels(labels, ["le", String(le)])} ${value.buckets[i]}`);
135
+ });
136
+ lines.push(`${PREFIX}${name}_bucket${formatLabels(labels, ["le", "+Inf"])} ${value.count}`, `${PREFIX}${name}_sum${formatLabels(labels)} ${formatNumber(value.sum)}`, `${PREFIX}${name}_count${formatLabels(labels)} ${value.count}`);
137
+ }
138
+ };
139
+ counter("recall_total", "Auto-recall attempts by outcome.", this.recalls);
140
+ histogram("recall_duration_seconds", "Auto-recall latency.", this.recallLatency);
141
+ counter("retain_total", "Auto-retain attempts by outcome.", this.retains);
142
+ histogram("retain_duration_seconds", "Auto-retain latency.", this.retainLatency);
143
+ lines.push(`# HELP ${PREFIX}retain_queue_depth Retains waiting in the offline queue.`, `# TYPE ${PREFIX}retain_queue_depth gauge`);
144
+ for (const { value } of this.depth.values()) {
145
+ value.n = 0;
146
+ }
147
+ for (const [bankId, n] of queueDepth?.() ?? []) {
148
+ this.depth.get({ bank: bankLabel(bankId) }).n += n;
149
+ }
150
+ for (const { labels, value } of this.depth.values()) {
151
+ lines.push(`${PREFIX}retain_queue_depth${formatLabels(labels)} ${value.n}`);
152
+ }
153
+ counter("retain_queue_replays_total", "Queued retain replay attempts by outcome.", this.replays);
154
+ counter("retain_queue_drops_total", "Retains dropped from the queue by reason.", this.drops);
155
+ return `${lines.join("\n")}\n`;
156
+ }
157
+ }
158
+ function observe(h, ms) {
159
+ const seconds = Math.max(0, ms) / 1000;
160
+ LATENCY_BUCKETS.forEach((le, i) => {
161
+ if (seconds <= le) {
162
+ h.buckets[i] += 1;
163
+ }
164
+ });
165
+ h.sum += seconds;
166
+ h.count += 1;
167
+ }
168
+ // Mirrors diagnostics-prometheus: the route is gateway-auth, and read access is checked here so a
169
+ // scope-narrowed operator identity cannot read it.
170
+ const READ_SCOPES = ["operator.read", "operator.write", "operator.admin"];
171
+ function hasReadScope() {
172
+ const scopes = getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes;
173
+ return Array.isArray(scopes) && READ_SCOPES.some((scope) => scopes.includes(scope));
174
+ }
175
+ export function createMetricsRouteHandler(metrics, queueDepth, readScope = hasReadScope) {
176
+ return (req, res) => {
177
+ res.setHeader("Cache-Control", "no-store");
178
+ if (!readScope()) {
179
+ res.statusCode = 403;
180
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
181
+ res.end("missing scope: operator.read");
182
+ return true;
183
+ }
184
+ if (req.method !== "GET" && req.method !== "HEAD") {
185
+ res.statusCode = 405;
186
+ res.setHeader("Allow", "GET, HEAD");
187
+ res.end("Method Not Allowed");
188
+ return true;
189
+ }
190
+ const body = metrics.render(queueDepth);
191
+ res.statusCode = 200;
192
+ res.setHeader("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
193
+ res.setHeader("Content-Length", String(Buffer.byteLength(body, "utf8")));
194
+ res.end(req.method === "HEAD" ? undefined : body);
195
+ return true;
196
+ };
197
+ }
@@ -131,10 +131,12 @@ export class RetainQueue {
131
131
  fileBytes = 0;
132
132
  limits;
133
133
  logger;
134
+ onDrop;
134
135
  constructor(options) {
135
136
  this.options = options;
136
137
  this.limits = options.limits;
137
138
  this.logger = options.logger;
139
+ this.onDrop = options.onDrop;
138
140
  }
139
141
  get filePath() {
140
142
  return this.options.filePath;
@@ -143,6 +145,7 @@ export class RetainQueue {
143
145
  configure(params) {
144
146
  this.limits = params.limits;
145
147
  this.logger = params.logger;
148
+ this.onDrop = params.onDrop;
146
149
  }
147
150
  now() {
148
151
  return this.options.now?.() ?? Date.now();
@@ -224,6 +227,21 @@ export class RetainQueue {
224
227
  peek() {
225
228
  return this.stored[0]?.entry;
226
229
  }
230
+ /**
231
+ * True when the item alone is over the size bound, so `enqueue` refuses it. Not reported as a
232
+ * drop here: the caller may still send it live, and reports it only when it is lost.
233
+ */
234
+ isTooLarge(entry) {
235
+ return stored({ ...entry, queuedAt: this.now() }).size > this.maxBytes();
236
+ }
237
+ /** Queued items per bank id. */
238
+ depthByBank() {
239
+ const depth = new Map();
240
+ for (const { entry } of this.stored) {
241
+ depth.set(entry.bankId, (depth.get(entry.bankId) ?? 0) + 1);
242
+ }
243
+ return depth;
244
+ }
227
245
  /** Bank and document id of every queued item, oldest first. */
228
246
  list() {
229
247
  return this.stored.map(({ entry }) => ({ bankId: entry.bankId, documentId: entry.documentId }));
@@ -264,7 +282,10 @@ export class RetainQueue {
264
282
  }
265
283
  let rewriteError;
266
284
  try {
267
- if (this.prune(false) === 0 &&
285
+ // Drops are reported only once the write that makes them stick succeeds; the fallback below
286
+ // restores memory and prunes again, and reports then.
287
+ const pruned = this.pruneStored();
288
+ if (pruned.length === 0 &&
268
289
  index < 0 &&
269
290
  existsSync(this.filePath) &&
270
291
  this.canAppend(next.size)) {
@@ -272,6 +293,7 @@ export class RetainQueue {
272
293
  return true;
273
294
  }
274
295
  this.persist();
296
+ this.reportDrops(pruned);
275
297
  // False when the bound dropped the item just added or replaced.
276
298
  return this.stored.includes(next);
277
299
  }
@@ -285,7 +307,8 @@ export class RetainQueue {
285
307
  // older one, and a tombstone for every item the bounds drop, so no dropped line can come back
286
308
  // on a restart (say, after a later tombstone brings the count under maxItems).
287
309
  this.stored = [...kept, next];
288
- this.prune(false);
310
+ // Reported only if the append below sticks; otherwise memory goes back to `kept`.
311
+ const prunedAgain = this.pruneStored();
289
312
  const keepsNext = this.stored.includes(next);
290
313
  const dead = kept.filter((s) => !this.stored.includes(s)).map((s) => s.entry);
291
314
  if (!keepsNext && index >= 0) {
@@ -302,6 +325,7 @@ export class RetainQueue {
302
325
  if (text) {
303
326
  this.append(text);
304
327
  }
328
+ this.reportDrops(prunedAgain);
305
329
  return keepsNext;
306
330
  }
307
331
  catch {
@@ -345,9 +369,17 @@ export class RetainQueue {
345
369
  * stays quiet until the queue is empty again.
346
370
  */
347
371
  prune(persist = true) {
372
+ const dropped = this.pruneStored();
373
+ this.reportDrops(dropped);
374
+ if (dropped.length > 0 && persist) {
375
+ this.persist();
376
+ }
377
+ return dropped.length;
378
+ }
379
+ /** The bounds step of `prune()`: updates memory and warns, but neither reports nor writes. */
380
+ pruneStored() {
348
381
  const { maxItems, maxAgeMs } = this.limits();
349
382
  const cutoff = this.now() - maxAgeMs;
350
- const before = this.stored.length;
351
383
  let kept = this.stored.filter((s) => s.entry.queuedAt >= cutoff);
352
384
  if (kept.length > maxItems) {
353
385
  kept = kept.slice(kept.length - maxItems);
@@ -356,19 +388,20 @@ export class RetainQueue {
356
388
  while (kept.length > 0 && bytes > this.maxBytes()) {
357
389
  bytes -= kept.shift().size;
358
390
  }
391
+ const keptSet = new Set(kept);
392
+ const dropped = this.stored.filter((s) => !keptSet.has(s));
359
393
  this.stored = kept;
360
- const dropped = before - kept.length;
361
- if (dropped > 0) {
362
- if (!this.dropWarned) {
363
- this.dropWarned = true;
364
- this.logger.warn?.(`memory-hindsight: retain queue dropped ${dropped} item(s) over retainQueueMaxItems (${maxItems}), retainQueueMaxAgeMs (${maxAgeMs}) or the ${this.maxBytes()}-byte size bound; further drops are not logged until the queue is empty`);
365
- }
366
- if (persist) {
367
- this.persist();
368
- }
394
+ if (dropped.length > 0 && !this.dropWarned) {
395
+ this.dropWarned = true;
396
+ this.logger.warn?.(`memory-hindsight: retain queue dropped ${dropped.length} item(s) over retainQueueMaxItems (${maxItems}), retainQueueMaxAgeMs (${maxAgeMs}) or the ${this.maxBytes()}-byte size bound; further drops are not logged until the queue is empty`);
369
397
  }
370
398
  return dropped;
371
399
  }
400
+ reportDrops(dropped) {
401
+ for (const s of dropped) {
402
+ this.onDrop?.(s.entry.bankId, "limit");
403
+ }
404
+ }
372
405
  ensureDir() {
373
406
  const dir = dirname(this.filePath);
374
407
  mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -526,6 +559,7 @@ export class RetainQueueRunner {
526
559
  return delivered;
527
560
  }
528
561
  if (!isDroppingRejection(error)) {
562
+ this.options.onReplay?.(entry.bankId, "deferred");
529
563
  // Not an answer about this item (outage, auth, rate limit, ambiguous 404, refused
530
564
  // redirect, or an unexpected error): keep it and back off.
531
565
  if (!isTransportFailure(error) && !this.rejectionWarned) {
@@ -540,6 +574,7 @@ export class RetainQueueRunner {
540
574
  }
541
575
  // Hindsight answered 4xx about this item: retrying cannot help, so it leaves the queue.
542
576
  this.queue.remove(entry, { persist: false });
577
+ this.options.onDrop?.(entry.bankId, "rejected");
543
578
  logger.warn?.(`memory-hindsight: queued retain rejected and dropped: ${formatErrorMessage(error)}`);
544
579
  continue;
545
580
  }
@@ -548,6 +583,7 @@ export class RetainQueueRunner {
548
583
  return delivered;
549
584
  }
550
585
  this.queue.remove(entry, { persist: false });
586
+ this.options.onReplay?.(entry.bankId, "delivered");
551
587
  this.rejectionWarned = false;
552
588
  delivered += 1;
553
589
  if (delivered % SAVE_EVERY === 0) {
package/dist/service.js CHANGED
@@ -3,6 +3,12 @@ import { boundedSignal, HindsightClient } from "./client.js";
3
3
  import { RETAIN_TIMEOUT_MS } from "./config.js";
4
4
  import { BankSetupError, isDroppingRejection, isTransportFailure, RetainQueueRunner, retainQueuePath, sharedRetainQueue, } from "./retain-queue.js";
5
5
  export function createHindsightService(params) {
6
+ const { metrics } = params;
7
+ // Queue and replay observers feed the gateway metrics; none when the caller passes no metrics.
8
+ const queueObserver = metrics ? { onDrop: metrics.recordDrop.bind(metrics) } : {};
9
+ const runnerObserver = metrics
10
+ ? { onDrop: metrics.recordDrop.bind(metrics), onReplay: metrics.recordReplay.bind(metrics) }
11
+ : {};
6
12
  const preparedBanks = new Map();
7
13
  let stopped = false;
8
14
  let runner;
@@ -71,6 +77,10 @@ export function createHindsightService(params) {
71
77
  // Read again after the await: the service may have stopped or restarted meanwhile.
72
78
  const target = queue;
73
79
  if (!target || refused || !isTransportFailure(error)) {
80
+ // Queue-first path: the queue refused the item and the live write failed, so it is lost.
81
+ if (target && refused && isTransportFailure(error) && target.isTooLarge(entry)) {
82
+ metrics?.recordDrop(bankId, "too_large");
83
+ }
74
84
  throw error;
75
85
  }
76
86
  let queued;
@@ -82,6 +92,11 @@ export function createHindsightService(params) {
82
92
  throw error;
83
93
  }
84
94
  if (!queued) {
95
+ // Lost: the live write failed and the queue refused it. A bound that dropped the new item
96
+ // is already counted as `limit` by the queue.
97
+ if (target.isTooLarge(entry)) {
98
+ metrics?.recordDrop(bankId, "too_large");
99
+ }
85
100
  throw error;
86
101
  }
87
102
  params.logger.warn?.(`memory-hindsight: Hindsight unreachable, retain queued (${target.size()} pending): ${formatErrorMessage(error)}`);
@@ -109,7 +124,11 @@ export function createHindsightService(params) {
109
124
  return { maxItems: config.retainQueueMaxItems, maxAgeMs: config.retainQueueMaxAgeMs };
110
125
  };
111
126
  try {
112
- queue = sharedRetainQueue(retainQueuePath(stateDir), { limits, logger: params.logger });
127
+ queue = sharedRetainQueue(retainQueuePath(stateDir), {
128
+ limits,
129
+ logger: params.logger,
130
+ ...queueObserver,
131
+ });
113
132
  }
114
133
  catch (error) {
115
134
  queue = undefined;
@@ -131,6 +150,7 @@ export function createHindsightService(params) {
131
150
  await client.retain(entry.bankId, { ...entry.item, document_id: entry.documentId, update_mode: "replace" }, { signal });
132
151
  },
133
152
  logger: params.logger,
153
+ ...runnerObserver,
134
154
  ...params.retainQueueRetry,
135
155
  });
136
156
  if (queue.size() > 0) {
@@ -139,6 +159,7 @@ export function createHindsightService(params) {
139
159
  }
140
160
  },
141
161
  retainQueue: () => runner,
162
+ retainQueueDepth: () => queue?.depthByBank(),
142
163
  stopped: () => stopped,
143
164
  stop() {
144
165
  stopped = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@etzelm/openclaw-memory-hindsight",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",