@etzelm/openclaw-memory-hindsight 0.1.2 → 0.3.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,560 @@
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
+ /** 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
+ }
87
+ /** The key of a tombstone line, written when a rewrite failed to drop an out-of-date copy. */
88
+ function tombstoneKey(value) {
89
+ const t = value?.tombstone;
90
+ return typeof t?.bankId === "string" && typeof t.documentId === "string"
91
+ ? { bankId: t.bankId, documentId: t.documentId }
92
+ : undefined;
93
+ }
94
+ function tombstoneLine(key) {
95
+ return JSON.stringify({ tombstone: { bankId: key.bankId, documentId: key.documentId } });
96
+ }
97
+ /** True when the file is empty or its last byte is a newline. */
98
+ function endsWithNewline(filePath) {
99
+ const fd = openSync(filePath, "r");
100
+ try {
101
+ const { size } = fstatSync(fd);
102
+ if (size === 0) {
103
+ return true;
104
+ }
105
+ const last = Buffer.alloc(1);
106
+ readSync(fd, last, 0, 1, size - 1);
107
+ return last[0] === 0x0a;
108
+ }
109
+ finally {
110
+ closeSync(fd);
111
+ }
112
+ }
113
+ function stored(entry, line = JSON.stringify(entry)) {
114
+ return { entry, line, size: Buffer.byteLength(line, "utf8") + 1 };
115
+ }
116
+ /**
117
+ * Bounded, file-backed FIFO of failed auto-retains, one JSON line per item. One entry per bank and
118
+ * document id: a newer retain of the same document replaces the queued one in place. A new item is
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.
123
+ */
124
+ export class RetainQueue {
125
+ options;
126
+ stored = [];
127
+ dropWarned = false;
128
+ /** Removals not yet written (replay batches its writes). */
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;
132
+ limits;
133
+ logger;
134
+ constructor(options) {
135
+ this.options = options;
136
+ this.limits = options.limits;
137
+ this.logger = options.logger;
138
+ }
139
+ get filePath() {
140
+ return this.options.filePath;
141
+ }
142
+ /** A restarted service reuses this queue with its own config and logger. */
143
+ configure(params) {
144
+ this.limits = params.limits;
145
+ this.logger = params.logger;
146
+ }
147
+ now() {
148
+ return this.options.now?.() ?? Date.now();
149
+ }
150
+ maxBytes() {
151
+ return this.options.maxBytes ?? RETAIN_QUEUE_MAX_BYTES;
152
+ }
153
+ /**
154
+ * Reads the file. A missing file is an empty queue. Unreadable lines (a torn append) are
155
+ * skipped with one warning. Any other read error throws, so the file is never overwritten.
156
+ */
157
+ load() {
158
+ let text;
159
+ try {
160
+ text = readFileSync(this.filePath, "utf8");
161
+ }
162
+ catch (error) {
163
+ if (error.code === "ENOENT") {
164
+ this.stored = [];
165
+ this.fileBytes = 0;
166
+ return;
167
+ }
168
+ throw error;
169
+ }
170
+ this.fileBytes = Buffer.byteLength(text, "utf8");
171
+ // Keyed by bank and document id in file order. A later copy wins and takes the later place:
172
+ // a crash between replay's batched saves leaves a delivered copy before a newer append.
173
+ const loaded = new Map();
174
+ let skipped = 0;
175
+ let tombstones = 0;
176
+ let duplicates = 0;
177
+ for (const line of text.split("\n")) {
178
+ if (!line.trim()) {
179
+ continue;
180
+ }
181
+ try {
182
+ const entry = JSON.parse(line);
183
+ if (isQueuedRetain(entry)) {
184
+ const key = queueKey(entry);
185
+ if (loaded.delete(key)) {
186
+ duplicates += 1;
187
+ }
188
+ loaded.set(key, stored(entry, line));
189
+ continue;
190
+ }
191
+ const dead = tombstoneKey(entry);
192
+ if (dead) {
193
+ // Drops the earlier copy the tombstone was written for.
194
+ loaded.delete(queueKey(dead));
195
+ tombstones += 1;
196
+ continue;
197
+ }
198
+ }
199
+ catch {
200
+ // counted below
201
+ }
202
+ skipped += 1;
203
+ }
204
+ this.stored = [...loaded.values()];
205
+ if (skipped > 0) {
206
+ this.logger.warn?.(`memory-hindsight: retain queue skipped ${skipped} unreadable line(s)`);
207
+ }
208
+ if (this.prune(false) > 0 || skipped > 0 || tombstones > 0 || duplicates > 0) {
209
+ try {
210
+ this.persist();
211
+ }
212
+ catch (error) {
213
+ // Compaction is optional: the file loads the same way next time, so keep the queue on.
214
+ this.logger.warn?.(`memory-hindsight: retain queue file not compacted (${formatErrorMessage(error)})`);
215
+ }
216
+ }
217
+ }
218
+ size() {
219
+ return this.stored.length;
220
+ }
221
+ hasBank(bankId) {
222
+ return this.stored.some((s) => s.entry.bankId === bankId);
223
+ }
224
+ peek() {
225
+ return this.stored[0]?.entry;
226
+ }
227
+ /** Bank and document id of every queued item, oldest first. */
228
+ list() {
229
+ return this.stored.map(({ entry }) => ({ bankId: entry.bankId, documentId: entry.documentId }));
230
+ }
231
+ /**
232
+ * Adds or replaces (same bank and document id) and writes the file before returning. Returns
233
+ * false, and queues nothing, when the item alone is larger than the size bound.
234
+ */
235
+ enqueue(entry) {
236
+ const next = stored({
237
+ bankId: entry.bankId,
238
+ documentId: entry.documentId,
239
+ item: entry.item,
240
+ queuedAt: this.now(),
241
+ });
242
+ const index = this.stored.findIndex((s) => s.entry.bankId === entry.bankId && s.entry.documentId === entry.documentId);
243
+ if (next.size > this.maxBytes()) {
244
+ this.logger.warn?.(`memory-hindsight: retain too large to queue (${next.size} bytes, bound ${this.maxBytes()}); not queued`);
245
+ // An older copy of the same document is out of date now; do not replay it.
246
+ if (index >= 0) {
247
+ const [stale] = this.stored.splice(index, 1);
248
+ try {
249
+ this.persist();
250
+ }
251
+ catch {
252
+ this.markStale(stale.entry);
253
+ }
254
+ }
255
+ return false;
256
+ }
257
+ // A failed write never leaves memory holding an item the file lacks; see the catch below.
258
+ const before = [...this.stored];
259
+ if (index >= 0) {
260
+ this.stored[index] = next;
261
+ }
262
+ else {
263
+ this.stored.push(next);
264
+ }
265
+ let rewriteError;
266
+ try {
267
+ if (this.prune(false) === 0 &&
268
+ index < 0 &&
269
+ existsSync(this.filePath) &&
270
+ this.canAppend(next.size)) {
271
+ this.append(next.line);
272
+ return true;
273
+ }
274
+ this.persist();
275
+ // False when the bound dropped the item just added or replaced.
276
+ return this.stored.includes(next);
277
+ }
278
+ catch (error) {
279
+ rewriteError = error;
280
+ }
281
+ // The rewrite failed. load() keeps the newest copy per bank and document id, so appending the
282
+ // new copy also replaces an older one in the file; the byte check keeps the file bounded.
283
+ const kept = index >= 0 ? before.filter((_, i) => i !== index) : before;
284
+ // Memory becomes what load() gives for the file plus the appended text: the new copy after the
285
+ // older one, and a tombstone for every item the bounds drop, so no dropped line can come back
286
+ // on a restart (say, after a later tombstone brings the count under maxItems).
287
+ this.stored = [...kept, next];
288
+ this.prune(false);
289
+ const keepsNext = this.stored.includes(next);
290
+ const dead = kept.filter((s) => !this.stored.includes(s)).map((s) => s.entry);
291
+ if (!keepsNext && index >= 0) {
292
+ dead.push(before[index].entry);
293
+ }
294
+ const lines = dead.map((e) => tombstoneLine(e));
295
+ if (keepsNext) {
296
+ lines.push(next.line);
297
+ }
298
+ const text = lines.join("\n");
299
+ // One more byte for the trailing newline, as Stored.size counts it.
300
+ if (existsSync(this.filePath) && this.canAppend(Buffer.byteLength(text, "utf8") + 1)) {
301
+ try {
302
+ if (text) {
303
+ this.append(text);
304
+ }
305
+ return keepsNext;
306
+ }
307
+ catch {
308
+ // Neither write worked; handled below.
309
+ }
310
+ }
311
+ // A queued copy of the same document is out of date now (the caller sends the newer one live),
312
+ // so it must not be replayed over it: not from memory, and not after a restart.
313
+ this.stored = kept;
314
+ if (index >= 0) {
315
+ this.markStale(before[index].entry);
316
+ }
317
+ throw rewriteError;
318
+ }
319
+ /**
320
+ * Removes the entry when it is still the queued copy (a newer enqueue replaced it otherwise).
321
+ * Replay passes `persist: false` and calls `save()` in batches: a crash before the save only
322
+ * re-sends items that replace themselves on the server (same document id, update_mode replace).
323
+ */
324
+ remove(entry, options = {}) {
325
+ const index = this.stored.findIndex((s) => s.entry === entry);
326
+ if (index < 0) {
327
+ return;
328
+ }
329
+ this.stored.splice(index, 1);
330
+ if (options.persist === false) {
331
+ this.dirty = true;
332
+ }
333
+ else {
334
+ this.persist();
335
+ }
336
+ }
337
+ /** Writes batched removals, if any. */
338
+ save() {
339
+ if (this.dirty) {
340
+ this.persist();
341
+ }
342
+ }
343
+ /**
344
+ * Drops expired entries, then the oldest beyond maxItems or the byte bound. Warns once, then
345
+ * stays quiet until the queue is empty again.
346
+ */
347
+ prune(persist = true) {
348
+ const { maxItems, maxAgeMs } = this.limits();
349
+ const cutoff = this.now() - maxAgeMs;
350
+ const before = this.stored.length;
351
+ let kept = this.stored.filter((s) => s.entry.queuedAt >= cutoff);
352
+ if (kept.length > maxItems) {
353
+ kept = kept.slice(kept.length - maxItems);
354
+ }
355
+ let bytes = kept.reduce((sum, s) => sum + s.size, 0);
356
+ while (kept.length > 0 && bytes > this.maxBytes()) {
357
+ bytes -= kept.shift().size;
358
+ }
359
+ 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
+ }
369
+ }
370
+ return dropped;
371
+ }
372
+ ensureDir() {
373
+ const dir = dirname(this.filePath);
374
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
375
+ chmodSync(dir, 0o700);
376
+ }
377
+ /** No append that would take the file past the size bound (one byte for a torn line's newline). */
378
+ canAppend(size) {
379
+ return this.fileBytes + size + 1 <= this.maxBytes();
380
+ }
381
+ append(line) {
382
+ // A torn last line (crash mid-append) must not swallow this item: start on a fresh line.
383
+ const text = `${endsWithNewline(this.filePath) ? "" : "\n"}${line}\n`;
384
+ appendFileSync(this.filePath, text, { mode: 0o600 });
385
+ this.fileBytes += Buffer.byteLength(text, "utf8");
386
+ try {
387
+ chmodSync(this.filePath, 0o600);
388
+ }
389
+ catch {
390
+ // The line is written; a chmod failure must not make the caller think it was not.
391
+ }
392
+ }
393
+ /**
394
+ * Best effort when the rewrite failed: append a tombstone for an out-of-date copy that is still
395
+ * in the file, so `load()` drops it after a restart instead of replaying it.
396
+ */
397
+ markStale(entry) {
398
+ try {
399
+ this.append(tombstoneLine(entry));
400
+ }
401
+ catch (error) {
402
+ 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`);
403
+ }
404
+ }
405
+ persist() {
406
+ this.rewrite();
407
+ // Cleared only once written, so a failed replay save is retried by the next save().
408
+ this.dirty = false;
409
+ }
410
+ rewrite() {
411
+ if (this.stored.length === 0) {
412
+ rmSync(this.filePath, { force: true });
413
+ this.fileBytes = 0;
414
+ this.dropWarned = false;
415
+ return;
416
+ }
417
+ this.ensureDir();
418
+ const tmp = `${this.filePath}.tmp`;
419
+ const text = `${this.stored.map((s) => s.line).join("\n")}\n`;
420
+ writeFileSync(tmp, text, { mode: 0o600 });
421
+ // writeFileSync keeps the mode of an existing file; force it.
422
+ chmodSync(tmp, 0o600);
423
+ renameSync(tmp, this.filePath);
424
+ this.fileBytes = Buffer.byteLength(text, "utf8");
425
+ }
426
+ }
427
+ /**
428
+ * One queue per file per process, shared across service restarts, so a stopped instance can
429
+ * never write a stale copy over the file the current instance owns.
430
+ */
431
+ const sharedQueues = new Map();
432
+ export function sharedRetainQueue(filePath, params) {
433
+ const existing = sharedQueues.get(filePath);
434
+ if (existing) {
435
+ existing.configure(params);
436
+ return existing;
437
+ }
438
+ const queue = new RetainQueue({ filePath, ...params });
439
+ queue.load();
440
+ sharedQueues.set(filePath, queue);
441
+ return queue;
442
+ }
443
+ /** Test seam: forget every shared queue, as a new process would (the files stay). */
444
+ export function resetSharedRetainQueues() {
445
+ sharedQueues.clear();
446
+ }
447
+ export const RETAIN_QUEUE_RETRY_BASE_MS = 30_000;
448
+ export const RETAIN_QUEUE_RETRY_MAX_MS = 15 * 60_000;
449
+ // Replay writes the file after this many deliveries and at the end of each pass.
450
+ const SAVE_EVERY = 25;
451
+ /** Replays the queue in order on a backoff timer. Stops at the first transport failure. */
452
+ export class RetainQueueRunner {
453
+ queue;
454
+ options;
455
+ timer;
456
+ delayMs;
457
+ running;
458
+ rejectionWarned = false;
459
+ controller = new AbortController();
460
+ constructor(queue, options) {
461
+ this.queue = queue;
462
+ this.options = options;
463
+ this.delayMs = this.baseDelay();
464
+ }
465
+ baseDelay() {
466
+ return this.options.baseDelayMs ?? RETAIN_QUEUE_RETRY_BASE_MS;
467
+ }
468
+ stopped() {
469
+ return this.controller.signal.aborted;
470
+ }
471
+ /** Runs one replay pass now (or joins the one in progress). */
472
+ flush() {
473
+ if (this.stopped()) {
474
+ return Promise.resolve();
475
+ }
476
+ this.running ??= this.replay()
477
+ .catch((error) => {
478
+ this.options.logger.warn?.(`memory-hindsight: retain queue replay stopped (${formatErrorMessage(error)})`);
479
+ })
480
+ .finally(() => {
481
+ this.running = undefined;
482
+ });
483
+ return this.running;
484
+ }
485
+ /** Schedules a pass after the current backoff unless one is already scheduled. */
486
+ schedule(delayMs = this.delayMs) {
487
+ if (this.stopped() || this.timer || this.queue.size() === 0) {
488
+ return;
489
+ }
490
+ this.timer = setTimeout(() => {
491
+ this.timer = undefined;
492
+ void this.flush();
493
+ }, delayMs);
494
+ this.timer.unref?.();
495
+ }
496
+ stop() {
497
+ if (this.timer) {
498
+ clearTimeout(this.timer);
499
+ this.timer = undefined;
500
+ }
501
+ this.controller.abort();
502
+ }
503
+ async replay() {
504
+ let delivered = 0;
505
+ try {
506
+ delivered = await this.replayItems();
507
+ }
508
+ finally {
509
+ // Removals during the pass are batched; write the final state once.
510
+ this.queue.save();
511
+ }
512
+ if (delivered > 0) {
513
+ this.options.logger.info?.(`memory-hindsight: retain queue delivered ${delivered}, ${this.queue.size() > 0 ? `${this.queue.size()} pending` : "queue empty"}`);
514
+ }
515
+ }
516
+ async replayItems() {
517
+ const { logger, send } = this.options;
518
+ let delivered = 0;
519
+ this.queue.prune();
520
+ for (let entry = this.queue.peek(); entry; entry = this.queue.peek()) {
521
+ try {
522
+ await send(entry, this.controller.signal);
523
+ }
524
+ catch (error) {
525
+ if (this.stopped()) {
526
+ return delivered;
527
+ }
528
+ if (!isDroppingRejection(error)) {
529
+ // Not an answer about this item (outage, auth, rate limit, ambiguous 404, refused
530
+ // redirect, or an unexpected error): keep it and back off.
531
+ if (!isTransportFailure(error) && !this.rejectionWarned) {
532
+ this.rejectionWarned = true;
533
+ 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`);
534
+ }
535
+ logger.debug?.(`memory-hindsight: retain queue replay deferred (${this.queue.size()} pending): ${formatErrorMessage(error)}`);
536
+ // The pass that just failed waited the current delay; the next one waits twice as long.
537
+ this.schedule();
538
+ this.delayMs = Math.min(this.delayMs * 2, this.options.maxDelayMs ?? RETAIN_QUEUE_RETRY_MAX_MS);
539
+ return delivered;
540
+ }
541
+ // Hindsight answered 4xx about this item: retrying cannot help, so it leaves the queue.
542
+ this.queue.remove(entry, { persist: false });
543
+ logger.warn?.(`memory-hindsight: queued retain rejected and dropped: ${formatErrorMessage(error)}`);
544
+ continue;
545
+ }
546
+ // A runner stopped mid-send leaves the item for the next one; a resend replaces itself.
547
+ if (this.stopped()) {
548
+ return delivered;
549
+ }
550
+ this.queue.remove(entry, { persist: false });
551
+ this.rejectionWarned = false;
552
+ delivered += 1;
553
+ if (delivered % SAVE_EVERY === 0) {
554
+ this.queue.save();
555
+ }
556
+ }
557
+ this.delayMs = this.baseDelay();
558
+ return delivered;
559
+ }
560
+ }
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
+ }