@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 +17 -0
- package/README.md +34 -0
- package/dist/index.js +40 -9
- package/dist/metrics.js +197 -0
- package/dist/retain-queue.js +48 -12
- package/dist/service.js +22 -1
- package/package.json +1 -1
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
:
|
|
176
|
-
|
|
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
|
});
|
package/dist/metrics.js
ADDED
|
@@ -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
|
+
}
|
package/dist/retain-queue.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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), {
|
|
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
|
+
"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",
|