@etzelm/openclaw-memory-hindsight 0.1.1 → 0.2.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 +74 -0
- package/README.md +142 -31
- package/dist/client.js +37 -4
- package/dist/config.js +29 -1
- package/dist/index.js +37 -14
- package/dist/retain-queue.js +493 -0
- package/dist/routing.js +13 -0
- package/dist/service.js +123 -13
- package/dist/tools.js +5 -7
- package/openclaw.plugin.json +54 -0
- package/package.json +3 -3
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
4
|
+
import { HindsightHttpError } from "./client.js";
|
|
5
|
+
export const RETAIN_QUEUE_DIR = "hindsight-native";
|
|
6
|
+
export const RETAIN_QUEUE_FILE = "retain-queue.jsonl";
|
|
7
|
+
/** Total size bound for the queue file, on top of the item and age limits. */
|
|
8
|
+
export const RETAIN_QUEUE_MAX_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
/** `<state dir>/hindsight-native/retain-queue.jsonl`; the host passes the state dir to services. */
|
|
10
|
+
export function retainQueuePath(stateDir) {
|
|
11
|
+
return join(stateDir, RETAIN_QUEUE_DIR, RETAIN_QUEUE_FILE);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* True when Hindsight was not reached or failed on its side: network errors (fetch rejects with
|
|
15
|
+
* `TypeError: fetch failed`), timeouts and aborts, and HTTP 5xx. A 4xx is an answer about the
|
|
16
|
+
* request, so it is never queued. A refused redirect (`redirect: "error"`) is a configuration
|
|
17
|
+
* problem, not an outage, and any other TypeError is a bug, so neither is queued.
|
|
18
|
+
*/
|
|
19
|
+
export function isTransportFailure(error) {
|
|
20
|
+
if (error instanceof HindsightHttpError) {
|
|
21
|
+
return error.status >= 500;
|
|
22
|
+
}
|
|
23
|
+
if (error instanceof TypeError) {
|
|
24
|
+
const cause = error.cause;
|
|
25
|
+
const causeMessage = typeof cause?.message === "string" ? cause.message : "";
|
|
26
|
+
return error.message === "fetch failed" && !/redirect/i.test(causeMessage);
|
|
27
|
+
}
|
|
28
|
+
const name = error?.name;
|
|
29
|
+
return name === "AbortError" || name === "TimeoutError";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A 4xx from bank setup (applying `retainMission` before the write). It is about the bank's
|
|
33
|
+
* config, not the queued item, so replay keeps the item; a config fix lets it through.
|
|
34
|
+
*/
|
|
35
|
+
export class BankSetupError extends Error {
|
|
36
|
+
cause;
|
|
37
|
+
constructor(cause) {
|
|
38
|
+
super(`bank setup failed (${formatErrorMessage(cause)})`);
|
|
39
|
+
this.cause = cause;
|
|
40
|
+
this.name = "BankSetupError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The only replay failure that drops an item: a Hindsight HTTP 4xx about the item itself (not
|
|
45
|
+
* auth, rate limit or an ambiguous 404). Everything else keeps the item.
|
|
46
|
+
*/
|
|
47
|
+
export function isDroppingRejection(error) {
|
|
48
|
+
return (error instanceof HindsightHttpError &&
|
|
49
|
+
error.status >= 400 &&
|
|
50
|
+
error.status < 500 &&
|
|
51
|
+
!isRetryableRejection(error));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Rejections on replay that say nothing about the item itself: keep it and retry later. That is
|
|
55
|
+
* auth (401, 403), rate limits (429) and a 404 for a route that does not exist (a wrong
|
|
56
|
+
* `hindsightApiUrl` path) or with an empty body. Hindsight creates a bank on its first retain, so
|
|
57
|
+
* only an explicit `Bank '<id>' not found` 404 is an answer about this item and drops it like
|
|
58
|
+
* other 4xx; an ambiguous 404 keeps it, and the age bound clears it in the end.
|
|
59
|
+
*/
|
|
60
|
+
export function isRetryableRejection(error) {
|
|
61
|
+
if (!(error instanceof HindsightHttpError)) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (error.status === 404) {
|
|
65
|
+
return !error.bankMissingExplicit;
|
|
66
|
+
}
|
|
67
|
+
return error.status === 401 || error.status === 403 || error.status === 429;
|
|
68
|
+
}
|
|
69
|
+
function isQueuedRetain(value) {
|
|
70
|
+
const v = value;
|
|
71
|
+
return (typeof v === "object" &&
|
|
72
|
+
v !== null &&
|
|
73
|
+
typeof v.bankId === "string" &&
|
|
74
|
+
v.bankId.length > 0 &&
|
|
75
|
+
typeof v.documentId === "string" &&
|
|
76
|
+
v.documentId.length > 0 &&
|
|
77
|
+
typeof v.queuedAt === "number" &&
|
|
78
|
+
Number.isFinite(v.queuedAt) &&
|
|
79
|
+
typeof v.item === "object" &&
|
|
80
|
+
v.item !== null &&
|
|
81
|
+
typeof v.item.content === "string");
|
|
82
|
+
}
|
|
83
|
+
/** The key of a tombstone line, written when a rewrite failed to drop an out-of-date copy. */
|
|
84
|
+
function tombstoneKey(value) {
|
|
85
|
+
const t = value?.tombstone;
|
|
86
|
+
return typeof t?.bankId === "string" && typeof t.documentId === "string"
|
|
87
|
+
? { bankId: t.bankId, documentId: t.documentId }
|
|
88
|
+
: undefined;
|
|
89
|
+
}
|
|
90
|
+
/** True when the file is empty or its last byte is a newline. */
|
|
91
|
+
function endsWithNewline(filePath) {
|
|
92
|
+
const fd = openSync(filePath, "r");
|
|
93
|
+
try {
|
|
94
|
+
const { size } = fstatSync(fd);
|
|
95
|
+
if (size === 0) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
const last = Buffer.alloc(1);
|
|
99
|
+
readSync(fd, last, 0, 1, size - 1);
|
|
100
|
+
return last[0] === 0x0a;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
closeSync(fd);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function stored(entry, line = JSON.stringify(entry)) {
|
|
107
|
+
return { entry, line, size: Buffer.byteLength(line, "utf8") + 1 };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Bounded, file-backed FIFO of failed auto-retains, one JSON line per item. One entry per bank and
|
|
111
|
+
* 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).
|
|
113
|
+
*/
|
|
114
|
+
export class RetainQueue {
|
|
115
|
+
options;
|
|
116
|
+
stored = [];
|
|
117
|
+
dropWarned = false;
|
|
118
|
+
/** Removals not yet written (replay batches its writes). */
|
|
119
|
+
dirty = false;
|
|
120
|
+
limits;
|
|
121
|
+
logger;
|
|
122
|
+
constructor(options) {
|
|
123
|
+
this.options = options;
|
|
124
|
+
this.limits = options.limits;
|
|
125
|
+
this.logger = options.logger;
|
|
126
|
+
}
|
|
127
|
+
get filePath() {
|
|
128
|
+
return this.options.filePath;
|
|
129
|
+
}
|
|
130
|
+
/** A restarted service reuses this queue with its own config and logger. */
|
|
131
|
+
configure(params) {
|
|
132
|
+
this.limits = params.limits;
|
|
133
|
+
this.logger = params.logger;
|
|
134
|
+
}
|
|
135
|
+
now() {
|
|
136
|
+
return this.options.now?.() ?? Date.now();
|
|
137
|
+
}
|
|
138
|
+
maxBytes() {
|
|
139
|
+
return this.options.maxBytes ?? RETAIN_QUEUE_MAX_BYTES;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Reads the file. A missing file is an empty queue. Unreadable lines (a torn append) are
|
|
143
|
+
* skipped with one warning. Any other read error throws, so the file is never overwritten.
|
|
144
|
+
*/
|
|
145
|
+
load() {
|
|
146
|
+
let text;
|
|
147
|
+
try {
|
|
148
|
+
text = readFileSync(this.filePath, "utf8");
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (error.code === "ENOENT") {
|
|
152
|
+
this.stored = [];
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
let loaded = [];
|
|
158
|
+
let skipped = 0;
|
|
159
|
+
let tombstones = 0;
|
|
160
|
+
for (const line of text.split("\n")) {
|
|
161
|
+
if (!line.trim()) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const entry = JSON.parse(line);
|
|
166
|
+
if (isQueuedRetain(entry)) {
|
|
167
|
+
loaded.push(stored(entry, line));
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const dead = tombstoneKey(entry);
|
|
171
|
+
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);
|
|
174
|
+
tombstones += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// counted below
|
|
180
|
+
}
|
|
181
|
+
skipped += 1;
|
|
182
|
+
}
|
|
183
|
+
this.stored = loaded;
|
|
184
|
+
if (skipped > 0) {
|
|
185
|
+
this.logger.warn?.(`memory-hindsight: retain queue skipped ${skipped} unreadable line(s)`);
|
|
186
|
+
}
|
|
187
|
+
if (this.prune(false) > 0 || skipped > 0 || tombstones > 0) {
|
|
188
|
+
try {
|
|
189
|
+
this.persist();
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
// Compaction is optional: the file loads the same way next time, so keep the queue on.
|
|
193
|
+
this.logger.warn?.(`memory-hindsight: retain queue file not compacted (${formatErrorMessage(error)})`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
size() {
|
|
198
|
+
return this.stored.length;
|
|
199
|
+
}
|
|
200
|
+
hasBank(bankId) {
|
|
201
|
+
return this.stored.some((s) => s.entry.bankId === bankId);
|
|
202
|
+
}
|
|
203
|
+
peek() {
|
|
204
|
+
return this.stored[0]?.entry;
|
|
205
|
+
}
|
|
206
|
+
/** Bank and document id of every queued item, oldest first. */
|
|
207
|
+
list() {
|
|
208
|
+
return this.stored.map(({ entry }) => ({ bankId: entry.bankId, documentId: entry.documentId }));
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Adds or replaces (same bank and document id) and writes the file before returning. Returns
|
|
212
|
+
* false, and queues nothing, when the item alone is larger than the size bound.
|
|
213
|
+
*/
|
|
214
|
+
enqueue(entry) {
|
|
215
|
+
const next = stored({
|
|
216
|
+
bankId: entry.bankId,
|
|
217
|
+
documentId: entry.documentId,
|
|
218
|
+
item: entry.item,
|
|
219
|
+
queuedAt: this.now(),
|
|
220
|
+
});
|
|
221
|
+
const index = this.stored.findIndex((s) => s.entry.bankId === entry.bankId && s.entry.documentId === entry.documentId);
|
|
222
|
+
if (next.size > this.maxBytes()) {
|
|
223
|
+
this.logger.warn?.(`memory-hindsight: retain too large to queue (${next.size} bytes, bound ${this.maxBytes()}); not queued`);
|
|
224
|
+
// An older copy of the same document is out of date now; do not replay it.
|
|
225
|
+
if (index >= 0) {
|
|
226
|
+
const [stale] = this.stored.splice(index, 1);
|
|
227
|
+
try {
|
|
228
|
+
this.persist();
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
this.markStale(stale.entry);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
// A failed write never leaves memory holding an item the file lacks; see the catch below.
|
|
237
|
+
const before = [...this.stored];
|
|
238
|
+
if (index >= 0) {
|
|
239
|
+
this.stored[index] = next;
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
this.stored.push(next);
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
if (this.prune(false) === 0 && index < 0 && existsSync(this.filePath)) {
|
|
246
|
+
this.append(next.line);
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
this.persist();
|
|
250
|
+
}
|
|
251
|
+
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);
|
|
257
|
+
}
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
// False when the bound dropped the item just added or replaced.
|
|
261
|
+
return this.stored.includes(next);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Removes the entry when it is still the queued copy (a newer enqueue replaced it otherwise).
|
|
265
|
+
* Replay passes `persist: false` and calls `save()` in batches: a crash before the save only
|
|
266
|
+
* re-sends items that replace themselves on the server (same document id, update_mode replace).
|
|
267
|
+
*/
|
|
268
|
+
remove(entry, options = {}) {
|
|
269
|
+
const index = this.stored.findIndex((s) => s.entry === entry);
|
|
270
|
+
if (index < 0) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
this.stored.splice(index, 1);
|
|
274
|
+
if (options.persist === false) {
|
|
275
|
+
this.dirty = true;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
this.persist();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** Writes batched removals, if any. */
|
|
282
|
+
save() {
|
|
283
|
+
if (this.dirty) {
|
|
284
|
+
this.persist();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Drops expired entries, then the oldest beyond maxItems or the byte bound. Warns once, then
|
|
289
|
+
* stays quiet until the queue is empty again.
|
|
290
|
+
*/
|
|
291
|
+
prune(persist = true) {
|
|
292
|
+
const { maxItems, maxAgeMs } = this.limits();
|
|
293
|
+
const cutoff = this.now() - maxAgeMs;
|
|
294
|
+
const before = this.stored.length;
|
|
295
|
+
let kept = this.stored.filter((s) => s.entry.queuedAt >= cutoff);
|
|
296
|
+
if (kept.length > maxItems) {
|
|
297
|
+
kept = kept.slice(kept.length - maxItems);
|
|
298
|
+
}
|
|
299
|
+
let bytes = kept.reduce((sum, s) => sum + s.size, 0);
|
|
300
|
+
while (kept.length > 0 && bytes > this.maxBytes()) {
|
|
301
|
+
bytes -= kept.shift().size;
|
|
302
|
+
}
|
|
303
|
+
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
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return dropped;
|
|
315
|
+
}
|
|
316
|
+
ensureDir() {
|
|
317
|
+
const dir = dirname(this.filePath);
|
|
318
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
319
|
+
chmodSync(dir, 0o700);
|
|
320
|
+
}
|
|
321
|
+
append(line) {
|
|
322
|
+
// 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
|
+
});
|
|
326
|
+
try {
|
|
327
|
+
chmodSync(this.filePath, 0o600);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// The line is written; a chmod failure must not make the caller think it was not.
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Best effort when the rewrite failed: append a tombstone for an out-of-date copy that is still
|
|
335
|
+
* in the file, so `load()` drops it after a restart instead of replaying it.
|
|
336
|
+
*/
|
|
337
|
+
markStale(entry) {
|
|
338
|
+
try {
|
|
339
|
+
this.append(JSON.stringify({ tombstone: { bankId: entry.bankId, documentId: entry.documentId } }));
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
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
|
+
}
|
|
344
|
+
}
|
|
345
|
+
persist() {
|
|
346
|
+
this.dirty = false;
|
|
347
|
+
if (this.stored.length === 0) {
|
|
348
|
+
rmSync(this.filePath, { force: true });
|
|
349
|
+
this.dropWarned = false;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
this.ensureDir();
|
|
353
|
+
const tmp = `${this.filePath}.tmp`;
|
|
354
|
+
writeFileSync(tmp, `${this.stored.map((s) => s.line).join("\n")}\n`, { mode: 0o600 });
|
|
355
|
+
// writeFileSync keeps the mode of an existing file; force it.
|
|
356
|
+
chmodSync(tmp, 0o600);
|
|
357
|
+
renameSync(tmp, this.filePath);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* One queue per file per process, shared across service restarts, so a stopped instance can
|
|
362
|
+
* never write a stale copy over the file the current instance owns.
|
|
363
|
+
*/
|
|
364
|
+
const sharedQueues = new Map();
|
|
365
|
+
export function sharedRetainQueue(filePath, params) {
|
|
366
|
+
const existing = sharedQueues.get(filePath);
|
|
367
|
+
if (existing) {
|
|
368
|
+
existing.configure(params);
|
|
369
|
+
return existing;
|
|
370
|
+
}
|
|
371
|
+
const queue = new RetainQueue({ filePath, ...params });
|
|
372
|
+
queue.load();
|
|
373
|
+
sharedQueues.set(filePath, queue);
|
|
374
|
+
return queue;
|
|
375
|
+
}
|
|
376
|
+
/** Test seam: forget every shared queue, as a new process would (the files stay). */
|
|
377
|
+
export function resetSharedRetainQueues() {
|
|
378
|
+
sharedQueues.clear();
|
|
379
|
+
}
|
|
380
|
+
export const RETAIN_QUEUE_RETRY_BASE_MS = 30_000;
|
|
381
|
+
export const RETAIN_QUEUE_RETRY_MAX_MS = 15 * 60_000;
|
|
382
|
+
// Replay writes the file after this many deliveries and at the end of each pass.
|
|
383
|
+
const SAVE_EVERY = 25;
|
|
384
|
+
/** Replays the queue in order on a backoff timer. Stops at the first transport failure. */
|
|
385
|
+
export class RetainQueueRunner {
|
|
386
|
+
queue;
|
|
387
|
+
options;
|
|
388
|
+
timer;
|
|
389
|
+
delayMs;
|
|
390
|
+
running;
|
|
391
|
+
rejectionWarned = false;
|
|
392
|
+
controller = new AbortController();
|
|
393
|
+
constructor(queue, options) {
|
|
394
|
+
this.queue = queue;
|
|
395
|
+
this.options = options;
|
|
396
|
+
this.delayMs = this.baseDelay();
|
|
397
|
+
}
|
|
398
|
+
baseDelay() {
|
|
399
|
+
return this.options.baseDelayMs ?? RETAIN_QUEUE_RETRY_BASE_MS;
|
|
400
|
+
}
|
|
401
|
+
stopped() {
|
|
402
|
+
return this.controller.signal.aborted;
|
|
403
|
+
}
|
|
404
|
+
/** Runs one replay pass now (or joins the one in progress). */
|
|
405
|
+
flush() {
|
|
406
|
+
if (this.stopped()) {
|
|
407
|
+
return Promise.resolve();
|
|
408
|
+
}
|
|
409
|
+
this.running ??= this.replay()
|
|
410
|
+
.catch((error) => {
|
|
411
|
+
this.options.logger.warn?.(`memory-hindsight: retain queue replay stopped (${formatErrorMessage(error)})`);
|
|
412
|
+
})
|
|
413
|
+
.finally(() => {
|
|
414
|
+
this.running = undefined;
|
|
415
|
+
});
|
|
416
|
+
return this.running;
|
|
417
|
+
}
|
|
418
|
+
/** Schedules a pass after the current backoff unless one is already scheduled. */
|
|
419
|
+
schedule(delayMs = this.delayMs) {
|
|
420
|
+
if (this.stopped() || this.timer || this.queue.size() === 0) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
this.timer = setTimeout(() => {
|
|
424
|
+
this.timer = undefined;
|
|
425
|
+
void this.flush();
|
|
426
|
+
}, delayMs);
|
|
427
|
+
this.timer.unref?.();
|
|
428
|
+
}
|
|
429
|
+
stop() {
|
|
430
|
+
if (this.timer) {
|
|
431
|
+
clearTimeout(this.timer);
|
|
432
|
+
this.timer = undefined;
|
|
433
|
+
}
|
|
434
|
+
this.controller.abort();
|
|
435
|
+
}
|
|
436
|
+
async replay() {
|
|
437
|
+
let delivered = 0;
|
|
438
|
+
try {
|
|
439
|
+
delivered = await this.replayItems();
|
|
440
|
+
}
|
|
441
|
+
finally {
|
|
442
|
+
// Removals during the pass are batched; write the final state once.
|
|
443
|
+
this.queue.save();
|
|
444
|
+
}
|
|
445
|
+
if (delivered > 0) {
|
|
446
|
+
this.options.logger.info?.(`memory-hindsight: retain queue delivered ${delivered}, ${this.queue.size() > 0 ? `${this.queue.size()} pending` : "queue empty"}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async replayItems() {
|
|
450
|
+
const { logger, send } = this.options;
|
|
451
|
+
let delivered = 0;
|
|
452
|
+
this.queue.prune();
|
|
453
|
+
for (let entry = this.queue.peek(); entry; entry = this.queue.peek()) {
|
|
454
|
+
try {
|
|
455
|
+
await send(entry, this.controller.signal);
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
if (this.stopped()) {
|
|
459
|
+
return delivered;
|
|
460
|
+
}
|
|
461
|
+
if (!isDroppingRejection(error)) {
|
|
462
|
+
// Not an answer about this item (outage, auth, rate limit, ambiguous 404, refused
|
|
463
|
+
// redirect, or an unexpected error): keep it and back off.
|
|
464
|
+
if (!isTransportFailure(error) && !this.rejectionWarned) {
|
|
465
|
+
this.rejectionWarned = true;
|
|
466
|
+
logger.warn?.(`memory-hindsight: queued retain not delivered (${formatErrorMessage(error)}); ${this.queue.size()} pending, retrying with backoff; new retains for a bank with pending items queue behind it`);
|
|
467
|
+
}
|
|
468
|
+
logger.debug?.(`memory-hindsight: retain queue replay deferred (${this.queue.size()} pending): ${formatErrorMessage(error)}`);
|
|
469
|
+
// The pass that just failed waited the current delay; the next one waits twice as long.
|
|
470
|
+
this.schedule();
|
|
471
|
+
this.delayMs = Math.min(this.delayMs * 2, this.options.maxDelayMs ?? RETAIN_QUEUE_RETRY_MAX_MS);
|
|
472
|
+
return delivered;
|
|
473
|
+
}
|
|
474
|
+
// Hindsight answered 4xx about this item: retrying cannot help, so it leaves the queue.
|
|
475
|
+
this.queue.remove(entry, { persist: false });
|
|
476
|
+
logger.warn?.(`memory-hindsight: queued retain rejected and dropped: ${formatErrorMessage(error)}`);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
// A runner stopped mid-send leaves the item for the next one; a resend replaces itself.
|
|
480
|
+
if (this.stopped()) {
|
|
481
|
+
return delivered;
|
|
482
|
+
}
|
|
483
|
+
this.queue.remove(entry, { persist: false });
|
|
484
|
+
this.rejectionWarned = false;
|
|
485
|
+
delivered += 1;
|
|
486
|
+
if (delivered % SAVE_EVERY === 0) {
|
|
487
|
+
this.queue.save();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
this.delayMs = this.baseDelay();
|
|
491
|
+
return delivered;
|
|
492
|
+
}
|
|
493
|
+
}
|
package/dist/routing.js
CHANGED
|
@@ -60,3 +60,16 @@ export function sessionUsesMemory(config, params) {
|
|
|
60
60
|
}
|
|
61
61
|
return !(sessionKey && OPERATIONAL_SESSION_RE.test(sessionKey));
|
|
62
62
|
}
|
|
63
|
+
// Active Memory's blocking recall child: `<parent>:active-memory:<12 hex>` (as memory-core reads it).
|
|
64
|
+
const ACTIVE_MEMORY_RECALL_SESSION_RE = /:active-memory:[a-f0-9]{12}$/i;
|
|
65
|
+
/**
|
|
66
|
+
* Host maintenance turns that get no auto-recall and no auto-retain. The pre-compaction flush
|
|
67
|
+
* (trigger "memory") sends a fixed host prompt and saves through hindsight_retain; Active
|
|
68
|
+
* Memory's recall child sends a recall prompt and reads through hindsight_recall. Neither is a
|
|
69
|
+
* user turn, so recalling on its prompt or retaining its transcript only adds noise. The
|
|
70
|
+
* hindsight_* tools stay available to both.
|
|
71
|
+
*/
|
|
72
|
+
export function isMaintenanceTurn(params) {
|
|
73
|
+
return (params.trigger === "memory" ||
|
|
74
|
+
Boolean(params.sessionKey && ACTIVE_MEMORY_RECALL_SESSION_RE.test(params.sessionKey.trim())));
|
|
75
|
+
}
|
package/dist/service.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
2
|
+
import { boundedSignal, HindsightClient } from "./client.js";
|
|
3
|
+
import { RETAIN_TIMEOUT_MS } from "./config.js";
|
|
4
|
+
import { BankSetupError, isDroppingRejection, isTransportFailure, RetainQueueRunner, retainQueuePath, sharedRetainQueue, } from "./retain-queue.js";
|
|
2
5
|
export function createHindsightService(params) {
|
|
3
6
|
const preparedBanks = new Map();
|
|
4
7
|
let stopped = false;
|
|
8
|
+
let runner;
|
|
9
|
+
// Kept after stop, so a live retain that fails after a stop or restart still lands in the file.
|
|
10
|
+
let queue;
|
|
5
11
|
const client = new HindsightClient({
|
|
6
12
|
resolveEndpoint: () => {
|
|
7
13
|
const { apiUrl, apiToken } = params.config();
|
|
@@ -9,28 +15,132 @@ export function createHindsightService(params) {
|
|
|
9
15
|
},
|
|
10
16
|
...(params.fetch ? { fetch: params.fetch } : {}),
|
|
11
17
|
});
|
|
18
|
+
const ensureBank = async (bankId, signal) => {
|
|
19
|
+
let pending = preparedBanks.get(bankId);
|
|
20
|
+
if (!pending) {
|
|
21
|
+
const { retainMission } = params.config();
|
|
22
|
+
pending = client.applyBankDefaults(bankId, { retainMission }, signal);
|
|
23
|
+
preparedBanks.set(bankId, pending);
|
|
24
|
+
// A failed setup retries on the next write instead of caching the failure.
|
|
25
|
+
pending.catch(() => {
|
|
26
|
+
if (preparedBanks.get(bankId) === pending) {
|
|
27
|
+
preparedBanks.delete(bankId);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
await pending;
|
|
32
|
+
};
|
|
33
|
+
const write = async (bankId, item, signal) => {
|
|
34
|
+
await ensureBank(bankId, signal);
|
|
35
|
+
await client.retain(bankId, item, { signal });
|
|
36
|
+
};
|
|
37
|
+
const stopQueue = () => {
|
|
38
|
+
runner?.stop();
|
|
39
|
+
runner = undefined;
|
|
40
|
+
};
|
|
12
41
|
return {
|
|
13
42
|
config: params.config,
|
|
14
43
|
client,
|
|
15
44
|
logger: params.logger,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
45
|
+
ensureBank,
|
|
46
|
+
async retainOrQueue(bankId, item, signal) {
|
|
47
|
+
const entry = { bankId, documentId: item.document_id, item };
|
|
48
|
+
// Items already waiting for this bank go first, so the bank sees its writes in order.
|
|
49
|
+
let refused = false;
|
|
50
|
+
if (runner && queue?.hasBank(bankId)) {
|
|
51
|
+
let queued = false;
|
|
52
|
+
try {
|
|
53
|
+
queued = queue.enqueue(entry);
|
|
54
|
+
}
|
|
55
|
+
catch (queueError) {
|
|
56
|
+
// Disk full or not writable: warn and try a live write, as the failure path does.
|
|
57
|
+
params.logger.warn?.(`memory-hindsight: could not queue retain (${formatErrorMessage(queueError)}); sending it now`);
|
|
58
|
+
}
|
|
59
|
+
if (queued) {
|
|
60
|
+
void runner.flush();
|
|
61
|
+
return "queued";
|
|
62
|
+
}
|
|
63
|
+
// Not queued (too large, already warned, or the write failed): send it live without
|
|
64
|
+
// waiting for the bank's queue, and do not try to queue it again.
|
|
65
|
+
refused = true;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
await write(bankId, item, signal);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
// Read again after the await: the service may have stopped or restarted meanwhile.
|
|
72
|
+
const target = queue;
|
|
73
|
+
if (!target || refused || !isTransportFailure(error)) {
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
let queued;
|
|
77
|
+
try {
|
|
78
|
+
queued = target.enqueue(entry);
|
|
79
|
+
}
|
|
80
|
+
catch (queueError) {
|
|
81
|
+
params.logger.warn?.(`memory-hindsight: could not queue retain (${formatErrorMessage(queueError)})`);
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
if (!queued) {
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
params.logger.warn?.(`memory-hindsight: Hindsight unreachable, retain queued (${target.size()} pending): ${formatErrorMessage(error)}`);
|
|
88
|
+
runner?.schedule();
|
|
89
|
+
return "queued";
|
|
90
|
+
}
|
|
91
|
+
if (runner && queue && queue.size() > 0) {
|
|
92
|
+
void runner.flush();
|
|
93
|
+
}
|
|
94
|
+
return "sent";
|
|
95
|
+
},
|
|
96
|
+
startRetainQueue(stateDir) {
|
|
97
|
+
stopQueue();
|
|
98
|
+
queue = undefined;
|
|
99
|
+
if (!params.config().retainQueueEnabled) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// The service contract always passes stateDir; only a bare test harness omits it.
|
|
103
|
+
if (!stateDir) {
|
|
104
|
+
params.logger.debug?.("memory-hindsight: host gave no state dir; failed retains are not queued");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const limits = () => {
|
|
108
|
+
const config = params.config();
|
|
109
|
+
return { maxItems: config.retainQueueMaxItems, maxAgeMs: config.retainQueueMaxAgeMs };
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
queue = sharedRetainQueue(retainQueuePath(stateDir), { limits, logger: params.logger });
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
queue = undefined;
|
|
116
|
+
params.logger.warn?.(`memory-hindsight: retain queue unavailable, failed retains are not queued (${formatErrorMessage(error)})`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
runner = new RetainQueueRunner(queue, {
|
|
120
|
+
send: async (entry, stopSignal) => {
|
|
121
|
+
const signal = boundedSignal(RETAIN_TIMEOUT_MS, stopSignal);
|
|
122
|
+
try {
|
|
123
|
+
await ensureBank(entry.bankId, signal);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
// A setup 4xx is about the bank config, not this item: keep it (see BankSetupError).
|
|
127
|
+
throw isDroppingRejection(error) ? new BankSetupError(error) : error;
|
|
26
128
|
}
|
|
27
|
-
|
|
129
|
+
await client.retain(entry.bankId, entry.item, { signal });
|
|
130
|
+
},
|
|
131
|
+
logger: params.logger,
|
|
132
|
+
...params.retainQueueRetry,
|
|
133
|
+
});
|
|
134
|
+
if (queue.size() > 0) {
|
|
135
|
+
params.logger.info?.(`memory-hindsight: ${queue.size()} queued retain(s) from an earlier run, replaying`);
|
|
136
|
+
void runner.flush();
|
|
28
137
|
}
|
|
29
|
-
await pending;
|
|
30
138
|
},
|
|
139
|
+
retainQueue: () => runner,
|
|
31
140
|
stopped: () => stopped,
|
|
32
141
|
stop() {
|
|
33
142
|
stopped = true;
|
|
143
|
+
stopQueue();
|
|
34
144
|
preparedBanks.clear();
|
|
35
145
|
},
|
|
36
146
|
start() {
|
package/dist/tools.js
CHANGED
|
@@ -83,14 +83,12 @@ export function createRetainTool(service, deduper, ctx) {
|
|
|
83
83
|
async execute(_toolCallId, rawParams, signal) {
|
|
84
84
|
const params = asOptionalRecord(rawParams) ?? {};
|
|
85
85
|
const content = requiredString(params, "content");
|
|
86
|
-
const
|
|
87
|
-
?
|
|
88
|
-
...new Set(params.tags
|
|
89
|
-
.filter((tag) => typeof tag === "string")
|
|
90
|
-
.map((tag) => tag.trim())
|
|
91
|
-
.filter(Boolean)),
|
|
92
|
-
]
|
|
86
|
+
const callTags = Array.isArray(params.tags)
|
|
87
|
+
? params.tags.filter((tag) => typeof tag === "string")
|
|
93
88
|
: [];
|
|
89
|
+
const tags = [
|
|
90
|
+
...new Set([...service.config().retainTags, ...callTags].map((tag) => tag.trim()).filter(Boolean)),
|
|
91
|
+
];
|
|
94
92
|
const occurredAt = typeof params.occurred_at === "string" ? params.occurred_at.trim() : "";
|
|
95
93
|
const flushId = ctx.memoryFlush?.flushId;
|
|
96
94
|
if (flushId && !deduper.claim(flushId, bank.bankId, content)) {
|