@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.
@@ -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
+ }
@@ -36,6 +36,7 @@ function sliceLines(text, from, lines) {
36
36
  }
37
37
  export function createHindsightProviderRuntime(params) {
38
38
  const { providerId, service } = params;
39
+ const reference = (id) => ({ providerId, id });
39
40
  return {
40
41
  async open({ agentId: rawAgentId, context }) {
41
42
  if (!rawAgentId?.trim()) {
@@ -57,7 +58,6 @@ export function createHindsightProviderRuntime(params) {
57
58
  assertCurrent();
58
59
  return grant.bankId;
59
60
  };
60
- const reference = (id) => ({ providerId, id });
61
61
  const provider = {
62
62
  capabilities: {
63
63
  sources: ["memory"],
@@ -80,6 +80,10 @@ function isQueuedRetain(value) {
80
80
  v.item !== null &&
81
81
  typeof v.item.content === "string");
82
82
  }
83
+ /** One queued copy per bank and document id; JSON keeps ids with any characters apart. */
84
+ function queueKey(key) {
85
+ return JSON.stringify([key.bankId, key.documentId]);
86
+ }
83
87
  /** The key of a tombstone line, written when a rewrite failed to drop an out-of-date copy. */
84
88
  function tombstoneKey(value) {
85
89
  const t = value?.tombstone;
@@ -87,6 +91,9 @@ function tombstoneKey(value) {
87
91
  ? { bankId: t.bankId, documentId: t.documentId }
88
92
  : undefined;
89
93
  }
94
+ function tombstoneLine(key) {
95
+ return JSON.stringify({ tombstone: { bankId: key.bankId, documentId: key.documentId } });
96
+ }
90
97
  /** True when the file is empty or its last byte is a newline. */
91
98
  function endsWithNewline(filePath) {
92
99
  const fd = openSync(filePath, "r");
@@ -109,7 +116,10 @@ function stored(entry, line = JSON.stringify(entry)) {
109
116
  /**
110
117
  * Bounded, file-backed FIFO of failed auto-retains, one JSON line per item. One entry per bank and
111
118
  * document id: a newer retain of the same document replaces the queued one in place. A new item is
112
- * appended to the file; replacing, pruning and replay rewrite it (mode 600, atomic rename).
119
+ * appended to the file; replacing, pruning and replay rewrite it (mode 600, atomic rename). When a
120
+ * rewrite fails, the item is appended instead (`load()` keeps the newest copy of each document). An
121
+ * append that would push the file past the size bound goes through a rewrite; when both fail, the
122
+ * caller sends the item live.
113
123
  */
114
124
  export class RetainQueue {
115
125
  options;
@@ -117,12 +127,16 @@ export class RetainQueue {
117
127
  dropWarned = false;
118
128
  /** Removals not yet written (replay batches its writes). */
119
129
  dirty = false;
130
+ /** Bytes in the file as this queue last read or wrote it; appends grow it, rewrites reset it. */
131
+ fileBytes = 0;
120
132
  limits;
121
133
  logger;
134
+ onDrop;
122
135
  constructor(options) {
123
136
  this.options = options;
124
137
  this.limits = options.limits;
125
138
  this.logger = options.logger;
139
+ this.onDrop = options.onDrop;
126
140
  }
127
141
  get filePath() {
128
142
  return this.options.filePath;
@@ -131,6 +145,7 @@ export class RetainQueue {
131
145
  configure(params) {
132
146
  this.limits = params.limits;
133
147
  this.logger = params.logger;
148
+ this.onDrop = params.onDrop;
134
149
  }
135
150
  now() {
136
151
  return this.options.now?.() ?? Date.now();
@@ -150,13 +165,18 @@ export class RetainQueue {
150
165
  catch (error) {
151
166
  if (error.code === "ENOENT") {
152
167
  this.stored = [];
168
+ this.fileBytes = 0;
153
169
  return;
154
170
  }
155
171
  throw error;
156
172
  }
157
- let loaded = [];
173
+ this.fileBytes = Buffer.byteLength(text, "utf8");
174
+ // Keyed by bank and document id in file order. A later copy wins and takes the later place:
175
+ // a crash between replay's batched saves leaves a delivered copy before a newer append.
176
+ const loaded = new Map();
158
177
  let skipped = 0;
159
178
  let tombstones = 0;
179
+ let duplicates = 0;
160
180
  for (const line of text.split("\n")) {
161
181
  if (!line.trim()) {
162
182
  continue;
@@ -164,13 +184,17 @@ export class RetainQueue {
164
184
  try {
165
185
  const entry = JSON.parse(line);
166
186
  if (isQueuedRetain(entry)) {
167
- loaded.push(stored(entry, line));
187
+ const key = queueKey(entry);
188
+ if (loaded.delete(key)) {
189
+ duplicates += 1;
190
+ }
191
+ loaded.set(key, stored(entry, line));
168
192
  continue;
169
193
  }
170
194
  const dead = tombstoneKey(entry);
171
195
  if (dead) {
172
- // Drops the earlier copies the tombstone was written for.
173
- loaded = loaded.filter((s) => s.entry.bankId !== dead.bankId || s.entry.documentId !== dead.documentId);
196
+ // Drops the earlier copy the tombstone was written for.
197
+ loaded.delete(queueKey(dead));
174
198
  tombstones += 1;
175
199
  continue;
176
200
  }
@@ -180,11 +204,11 @@ export class RetainQueue {
180
204
  }
181
205
  skipped += 1;
182
206
  }
183
- this.stored = loaded;
207
+ this.stored = [...loaded.values()];
184
208
  if (skipped > 0) {
185
209
  this.logger.warn?.(`memory-hindsight: retain queue skipped ${skipped} unreadable line(s)`);
186
210
  }
187
- if (this.prune(false) > 0 || skipped > 0 || tombstones > 0) {
211
+ if (this.prune(false) > 0 || skipped > 0 || tombstones > 0 || duplicates > 0) {
188
212
  try {
189
213
  this.persist();
190
214
  }
@@ -203,6 +227,21 @@ export class RetainQueue {
203
227
  peek() {
204
228
  return this.stored[0]?.entry;
205
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
+ }
206
245
  /** Bank and document id of every queued item, oldest first. */
207
246
  list() {
208
247
  return this.stored.map(({ entry }) => ({ bankId: entry.bankId, documentId: entry.documentId }));
@@ -241,24 +280,65 @@ export class RetainQueue {
241
280
  else {
242
281
  this.stored.push(next);
243
282
  }
283
+ let rewriteError;
244
284
  try {
245
- if (this.prune(false) === 0 && index < 0 && existsSync(this.filePath)) {
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 &&
289
+ index < 0 &&
290
+ existsSync(this.filePath) &&
291
+ this.canAppend(next.size)) {
246
292
  this.append(next.line);
247
293
  return true;
248
294
  }
249
295
  this.persist();
296
+ this.reportDrops(pruned);
297
+ // False when the bound dropped the item just added or replaced.
298
+ return this.stored.includes(next);
250
299
  }
251
300
  catch (error) {
252
- // A queued copy of the same document is out of date now (the caller sends the newer one
253
- // live), so it must not be replayed over it: not from memory, and not after a restart.
254
- this.stored = index >= 0 ? before.filter((_, i) => i !== index) : before;
255
- if (index >= 0) {
256
- this.markStale(before[index].entry);
301
+ rewriteError = error;
302
+ }
303
+ // The rewrite failed. load() keeps the newest copy per bank and document id, so appending the
304
+ // new copy also replaces an older one in the file; the byte check keeps the file bounded.
305
+ const kept = index >= 0 ? before.filter((_, i) => i !== index) : before;
306
+ // Memory becomes what load() gives for the file plus the appended text: the new copy after the
307
+ // older one, and a tombstone for every item the bounds drop, so no dropped line can come back
308
+ // on a restart (say, after a later tombstone brings the count under maxItems).
309
+ this.stored = [...kept, next];
310
+ // Reported only if the append below sticks; otherwise memory goes back to `kept`.
311
+ const prunedAgain = this.pruneStored();
312
+ const keepsNext = this.stored.includes(next);
313
+ const dead = kept.filter((s) => !this.stored.includes(s)).map((s) => s.entry);
314
+ if (!keepsNext && index >= 0) {
315
+ dead.push(before[index].entry);
316
+ }
317
+ const lines = dead.map((e) => tombstoneLine(e));
318
+ if (keepsNext) {
319
+ lines.push(next.line);
320
+ }
321
+ const text = lines.join("\n");
322
+ // One more byte for the trailing newline, as Stored.size counts it.
323
+ if (existsSync(this.filePath) && this.canAppend(Buffer.byteLength(text, "utf8") + 1)) {
324
+ try {
325
+ if (text) {
326
+ this.append(text);
327
+ }
328
+ this.reportDrops(prunedAgain);
329
+ return keepsNext;
257
330
  }
258
- throw error;
331
+ catch {
332
+ // Neither write worked; handled below.
333
+ }
334
+ }
335
+ // A queued copy of the same document is out of date now (the caller sends the newer one live),
336
+ // so it must not be replayed over it: not from memory, and not after a restart.
337
+ this.stored = kept;
338
+ if (index >= 0) {
339
+ this.markStale(before[index].entry);
259
340
  }
260
- // False when the bound dropped the item just added or replaced.
261
- return this.stored.includes(next);
341
+ throw rewriteError;
262
342
  }
263
343
  /**
264
344
  * Removes the entry when it is still the queued copy (a newer enqueue replaced it otherwise).
@@ -289,9 +369,17 @@ export class RetainQueue {
289
369
  * stays quiet until the queue is empty again.
290
370
  */
291
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() {
292
381
  const { maxItems, maxAgeMs } = this.limits();
293
382
  const cutoff = this.now() - maxAgeMs;
294
- const before = this.stored.length;
295
383
  let kept = this.stored.filter((s) => s.entry.queuedAt >= cutoff);
296
384
  if (kept.length > maxItems) {
297
385
  kept = kept.slice(kept.length - maxItems);
@@ -300,29 +388,34 @@ export class RetainQueue {
300
388
  while (kept.length > 0 && bytes > this.maxBytes()) {
301
389
  bytes -= kept.shift().size;
302
390
  }
391
+ const keptSet = new Set(kept);
392
+ const dropped = this.stored.filter((s) => !keptSet.has(s));
303
393
  this.stored = kept;
304
- const dropped = before - kept.length;
305
- if (dropped > 0) {
306
- if (!this.dropWarned) {
307
- this.dropWarned = true;
308
- 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`);
309
- }
310
- if (persist) {
311
- this.persist();
312
- }
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`);
313
397
  }
314
398
  return dropped;
315
399
  }
400
+ reportDrops(dropped) {
401
+ for (const s of dropped) {
402
+ this.onDrop?.(s.entry.bankId, "limit");
403
+ }
404
+ }
316
405
  ensureDir() {
317
406
  const dir = dirname(this.filePath);
318
407
  mkdirSync(dir, { recursive: true, mode: 0o700 });
319
408
  chmodSync(dir, 0o700);
320
409
  }
410
+ /** No append that would take the file past the size bound (one byte for a torn line's newline). */
411
+ canAppend(size) {
412
+ return this.fileBytes + size + 1 <= this.maxBytes();
413
+ }
321
414
  append(line) {
322
415
  // A torn last line (crash mid-append) must not swallow this item: start on a fresh line.
323
- appendFileSync(this.filePath, `${endsWithNewline(this.filePath) ? "" : "\n"}${line}\n`, {
324
- mode: 0o600,
325
- });
416
+ const text = `${endsWithNewline(this.filePath) ? "" : "\n"}${line}\n`;
417
+ appendFileSync(this.filePath, text, { mode: 0o600 });
418
+ this.fileBytes += Buffer.byteLength(text, "utf8");
326
419
  try {
327
420
  chmodSync(this.filePath, 0o600);
328
421
  }
@@ -336,25 +429,32 @@ export class RetainQueue {
336
429
  */
337
430
  markStale(entry) {
338
431
  try {
339
- this.append(JSON.stringify({ tombstone: { bankId: entry.bankId, documentId: entry.documentId } }));
432
+ this.append(tombstoneLine(entry));
340
433
  }
341
434
  catch (error) {
342
435
  this.logger.warn?.(`memory-hindsight: could not mark an out-of-date queued retain (${formatErrorMessage(error)}); a restart may replay it before the newer copy is written again`);
343
436
  }
344
437
  }
345
438
  persist() {
439
+ this.rewrite();
440
+ // Cleared only once written, so a failed replay save is retried by the next save().
346
441
  this.dirty = false;
442
+ }
443
+ rewrite() {
347
444
  if (this.stored.length === 0) {
348
445
  rmSync(this.filePath, { force: true });
446
+ this.fileBytes = 0;
349
447
  this.dropWarned = false;
350
448
  return;
351
449
  }
352
450
  this.ensureDir();
353
451
  const tmp = `${this.filePath}.tmp`;
354
- writeFileSync(tmp, `${this.stored.map((s) => s.line).join("\n")}\n`, { mode: 0o600 });
452
+ const text = `${this.stored.map((s) => s.line).join("\n")}\n`;
453
+ writeFileSync(tmp, text, { mode: 0o600 });
355
454
  // writeFileSync keeps the mode of an existing file; force it.
356
455
  chmodSync(tmp, 0o600);
357
456
  renameSync(tmp, this.filePath);
457
+ this.fileBytes = Buffer.byteLength(text, "utf8");
358
458
  }
359
459
  }
360
460
  /**
@@ -459,6 +559,7 @@ export class RetainQueueRunner {
459
559
  return delivered;
460
560
  }
461
561
  if (!isDroppingRejection(error)) {
562
+ this.options.onReplay?.(entry.bankId, "deferred");
462
563
  // Not an answer about this item (outage, auth, rate limit, ambiguous 404, refused
463
564
  // redirect, or an unexpected error): keep it and back off.
464
565
  if (!isTransportFailure(error) && !this.rejectionWarned) {
@@ -473,6 +574,7 @@ export class RetainQueueRunner {
473
574
  }
474
575
  // Hindsight answered 4xx about this item: retrying cannot help, so it leaves the queue.
475
576
  this.queue.remove(entry, { persist: false });
577
+ this.options.onDrop?.(entry.bankId, "rejected");
476
578
  logger.warn?.(`memory-hindsight: queued retain rejected and dropped: ${formatErrorMessage(error)}`);
477
579
  continue;
478
580
  }
@@ -481,6 +583,7 @@ export class RetainQueueRunner {
481
583
  return delivered;
482
584
  }
483
585
  this.queue.remove(entry, { persist: false });
586
+ this.options.onReplay?.(entry.bankId, "delivered");
484
587
  this.rejectionWarned = false;
485
588
  delivered += 1;
486
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;
@@ -126,9 +145,12 @@ export function createHindsightService(params) {
126
145
  // A setup 4xx is about the bank config, not this item: keep it (see BankSetupError).
127
146
  throw isDroppingRejection(error) ? new BankSetupError(error) : error;
128
147
  }
129
- await client.retain(entry.bankId, entry.item, { signal });
148
+ // Replay may repeat a delivered item (a crash before the batched save); a replace of
149
+ // the same document id makes the repeat a no-op on the server.
150
+ await client.retain(entry.bankId, { ...entry.item, document_id: entry.documentId, update_mode: "replace" }, { signal });
130
151
  },
131
152
  logger: params.logger,
153
+ ...runnerObserver,
132
154
  ...params.retainQueueRetry,
133
155
  });
134
156
  if (queue.size() > 0) {
@@ -137,6 +159,7 @@ export function createHindsightService(params) {
137
159
  }
138
160
  },
139
161
  retainQueue: () => runner,
162
+ retainQueueDepth: () => queue?.depthByBank(),
140
163
  stopped: () => stopped,
141
164
  stop() {
142
165
  stopped = true;