@xmanrui/dsh-im 4.38.2 → 4.39.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/lib/client.js +4 -2
- package/lib/index.js +311 -306
- package/package.json +1 -1
- package/plugin-src/host/injected-context.mjs +13 -1
- package/src/channels/matrix/matrix-api.mjs +34 -0
- package/src/channels/matrix/matrix-config-store.mjs +50 -2
- package/src/channels/matrix/matrix-normalize.mjs +106 -14
- package/src/channels/matrix/matrix-room-history.mjs +240 -8
- package/src/channels/matrix/matrix-runtime.mjs +563 -3
- package/src/channels/shared/context-enhancement.mjs +2 -0
- package/src/channels/shared/conversation/ambient-provider.mjs +115 -0
- package/src/channels/shared/conversation/context-builder.mjs +148 -0
- package/src/channels/shared/conversation/reply-context.mjs +95 -0
- package/src/channels/shared/conversation/root-context.mjs +101 -0
- package/src/channels/shared/conversation/route.mjs +71 -0
- package/src/channels/shared/conversation/semantic-message.mjs +75 -0
- package/src/channels/shared/conversation/trigger-policy.mjs +80 -0
- package/src/channels/shared/conversation-state-store.mjs +1 -1
- package/src/channels/shared/harness-approval.mjs +19 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +2 -0
- package/src/channels/shared/injected-context.mjs +64 -2
- package/src/channels/shared/speaker-label.mjs +46 -0
- package/src/channels/shared/text-harness-bridge.mjs +348 -32
- package/src/channels/wecom/wecom-cards.mjs +1 -1
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
17
17
|
import { dirname } from 'node:path';
|
|
18
18
|
|
|
19
|
+
import { buildConversationRoute, encodeConversationRouteKey } from '../shared/conversation/route.mjs';
|
|
20
|
+
|
|
19
21
|
export const DEFAULT_ROOM_HISTORY_LIMIT = 2_000;
|
|
20
22
|
export const MIN_ROOM_HISTORY_LIMIT = 20;
|
|
21
23
|
export const MAX_ROOM_HISTORY_LIMIT = 50_000;
|
|
@@ -25,6 +27,8 @@ export const MIN_ROOM_CONTEXT_CHARS = 200;
|
|
|
25
27
|
export const MAX_ROOM_CONTEXT_CHARS = 200_000;
|
|
26
28
|
const ENTRY_TEXT_CHARS = 4_000;
|
|
27
29
|
const INJECTED_EVENT_MAX = 1_000;
|
|
30
|
+
const CONTEXT_LEDGER_ROUTE_MAX = 400;
|
|
31
|
+
const CONTEXT_LEDGER_ENTRY_MAX = 1_000;
|
|
28
32
|
|
|
29
33
|
export function matrixRoomHistoryPathFor(botDir) {
|
|
30
34
|
return `${botDir}/matrix-history.json`;
|
|
@@ -73,7 +77,9 @@ function normalizeEntry(raw) {
|
|
|
73
77
|
const eventId = cleanText(raw.eventId, 255);
|
|
74
78
|
const text = cleanText(raw.text, ENTRY_TEXT_CHARS);
|
|
75
79
|
if (!eventId) return null;
|
|
76
|
-
|
|
80
|
+
// Bot entries may arrive content-free: a pending-placeholder receipt keeps
|
|
81
|
+
// the authorship record until the first edit frame revises it to the body.
|
|
82
|
+
if (!text && raw.fromBot !== true && raw.kind !== 'bot') return null;
|
|
77
83
|
const kind = raw.kind === 'bot' || raw.fromBot === true ? 'bot'
|
|
78
84
|
: raw.kind === 'command' ? 'command' : 'human';
|
|
79
85
|
return {
|
|
@@ -82,23 +88,48 @@ function normalizeEntry(raw) {
|
|
|
82
88
|
name: cleanText(raw.name, 256) || localPart(raw.sender),
|
|
83
89
|
text,
|
|
84
90
|
ts: toTimestamp(raw.ts),
|
|
91
|
+
threadId: cleanText(raw.threadId, 255) || null,
|
|
85
92
|
kind,
|
|
86
93
|
injected: raw.injected === true || kind !== 'human',
|
|
94
|
+
// Provenance of a bubble one Session produced: the conversation key it was
|
|
95
|
+
// delivered under plus the session generation that stamped it. The runtime's
|
|
96
|
+
// room window retires such a bubble for that conversation while the stamped
|
|
97
|
+
// generation is current; other conversations and a fresh session still read it.
|
|
98
|
+
turnKey: cleanText(raw.turnKey, 512) || null,
|
|
99
|
+
turnEpoch: Number.isSafeInteger(raw.turnEpoch) && raw.turnEpoch >= 0 ? raw.turnEpoch : null,
|
|
87
100
|
};
|
|
88
101
|
}
|
|
89
102
|
|
|
103
|
+
// A recorded bot bubble missing its provenance stamp was delivered before that stamp
|
|
104
|
+
// existed; its own room and thread name the route it was delivered under. Repair the
|
|
105
|
+
// stamp at load so the runtime's self-answer guard retires the bubble from that
|
|
106
|
+
// conversation's windows again after a restart. The healed generation is the first one
|
|
107
|
+
// (0): a conversation that cleared its session still reads the bubble back once.
|
|
108
|
+
function healBotProvenance(entries, roomId) {
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
if (entry.kind !== 'bot' || entry.turnKey !== null || !entry.text) continue;
|
|
111
|
+
entry.turnKey = encodeConversationRouteKey(buildConversationRoute({
|
|
112
|
+
channel: 'matrix', chatId: roomId, threadId: entry.threadId, kind: 'group',
|
|
113
|
+
}));
|
|
114
|
+
entry.turnEpoch = 0;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
90
118
|
/**
|
|
91
119
|
* Durable, bounded, per-room message record. Entries are kept in arrival order
|
|
92
120
|
* (the runtime feeds the sync timeline, which is chronological per room). Each
|
|
93
121
|
* room is trimmed to `limit` most-recent entries and injected-event markers are
|
|
94
|
-
* pruned against the live entries so growth stays bounded.
|
|
122
|
+
* pruned against the live entries so growth stays bounded. A durable per-conversation
|
|
123
|
+
* collection ledger (`ledgers`) records which events each conversation has already been
|
|
124
|
+
* offered as context, together with the session generation that retired them, so the
|
|
125
|
+
* read-once retirement survives a restart. Writes are serialized
|
|
95
126
|
* through an in-memory queue with an atomic temp-file rename, mirroring the
|
|
96
127
|
* Matrix sidecar.
|
|
97
128
|
*/
|
|
98
129
|
export class MatrixRoomHistoryStore {
|
|
99
130
|
#path;
|
|
100
131
|
#limit;
|
|
101
|
-
#value = { rooms: Object.create(null) };
|
|
132
|
+
#value = { rooms: Object.create(null), ledgers: Object.create(null) };
|
|
102
133
|
#writeQueue = Promise.resolve();
|
|
103
134
|
|
|
104
135
|
constructor(path, { limit = DEFAULT_ROOM_HISTORY_LIMIT } = {}) {
|
|
@@ -124,9 +155,27 @@ export class MatrixRoomHistoryStore {
|
|
|
124
155
|
const injected = new Set((Array.isArray(room.injected) ? room.injected : [])
|
|
125
156
|
.filter((id) => typeof id === 'string' && id));
|
|
126
157
|
rooms[roomId] = { entries, injected };
|
|
158
|
+
healBotProvenance(entries, roomId);
|
|
127
159
|
}
|
|
128
160
|
}
|
|
129
|
-
|
|
161
|
+
const ledgers = Object.create(null);
|
|
162
|
+
if (parsed && typeof parsed === 'object' && parsed.ledgers && typeof parsed.ledgers === 'object') {
|
|
163
|
+
for (const [conversationKey, ledger] of Object.entries(parsed.ledgers)) {
|
|
164
|
+
if (!conversationKey || !ledger || typeof ledger !== 'object') continue;
|
|
165
|
+
const epoch = Number.isSafeInteger(ledger.epoch) && ledger.epoch >= 0 ? ledger.epoch : 0;
|
|
166
|
+
const seen = new Set();
|
|
167
|
+
const injected = [];
|
|
168
|
+
for (const eventId of Array.isArray(ledger.injected) ? ledger.injected : []) {
|
|
169
|
+
if (typeof eventId !== 'string' || !eventId || seen.has(eventId)) continue;
|
|
170
|
+
seen.add(eventId);
|
|
171
|
+
injected.push(eventId);
|
|
172
|
+
}
|
|
173
|
+
ledgers[conversationKey] = { epoch, injected: injected.slice(-CONTEXT_LEDGER_ENTRY_MAX) };
|
|
174
|
+
}
|
|
175
|
+
const keys = Object.keys(ledgers);
|
|
176
|
+
for (let index = 0; index < keys.length - CONTEXT_LEDGER_ROUTE_MAX; index += 1) delete ledgers[keys[index]];
|
|
177
|
+
}
|
|
178
|
+
this.#value = { rooms, ledgers };
|
|
130
179
|
} catch (error) {
|
|
131
180
|
if (error?.code !== 'ENOENT') throw error;
|
|
132
181
|
}
|
|
@@ -142,6 +191,20 @@ export class MatrixRoomHistoryStore {
|
|
|
142
191
|
return room;
|
|
143
192
|
}
|
|
144
193
|
|
|
194
|
+
#ledgerEntry(conversationKey) {
|
|
195
|
+
let ledger = this.#value.ledgers[conversationKey];
|
|
196
|
+
if (ledger) return ledger;
|
|
197
|
+
ledger = { epoch: 0, injected: [] };
|
|
198
|
+
this.#value.ledgers[conversationKey] = ledger;
|
|
199
|
+
const keys = Object.keys(this.#value.ledgers);
|
|
200
|
+
for (let index = 0; index < keys.length - CONTEXT_LEDGER_ROUTE_MAX; index += 1) {
|
|
201
|
+
const oldest = keys[index];
|
|
202
|
+
if (oldest === conversationKey && keys.length === 1) break;
|
|
203
|
+
delete this.#value.ledgers[oldest];
|
|
204
|
+
}
|
|
205
|
+
return ledger;
|
|
206
|
+
}
|
|
207
|
+
|
|
145
208
|
#persist() {
|
|
146
209
|
const rooms = Object.create(null);
|
|
147
210
|
for (const [roomId, room] of Object.entries(this.#value.rooms)) {
|
|
@@ -151,7 +214,12 @@ export class MatrixRoomHistoryStore {
|
|
|
151
214
|
injected: [...room.injected].slice(-INJECTED_EVENT_MAX),
|
|
152
215
|
};
|
|
153
216
|
}
|
|
154
|
-
const
|
|
217
|
+
const ledgers = Object.create(null);
|
|
218
|
+
for (const [conversationKey, ledger] of Object.entries(this.#value.ledgers)) {
|
|
219
|
+
if (ledger.epoch === 0 && ledger.injected.length === 0) continue;
|
|
220
|
+
ledgers[conversationKey] = { epoch: ledger.epoch, injected: ledger.injected.slice(-CONTEXT_LEDGER_ENTRY_MAX) };
|
|
221
|
+
}
|
|
222
|
+
const snapshot = JSON.stringify({ rooms, ledgers }, null, 2);
|
|
155
223
|
const operation = this.#writeQueue.then(async () => {
|
|
156
224
|
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
|
157
225
|
const temporary = `${this.#path}.tmp`;
|
|
@@ -166,11 +234,13 @@ export class MatrixRoomHistoryStore {
|
|
|
166
234
|
* Record one room message. The caller decides the kind ('human', 'command'
|
|
167
235
|
* or 'bot'); non-human and explicitly injected entries are marked as already
|
|
168
236
|
* consumed so they never surface as pending context. Duplicate event ids are
|
|
169
|
-
* ignored to keep a replayed sync from double-counting an entry.
|
|
237
|
+
* ignored to keep a replayed sync from double-counting an entry. A turn-bound
|
|
238
|
+
* send passes `turnKey`/`turnEpoch` — the conversation its Session produced
|
|
239
|
+
* the bubble for and the session generation that delivered it.
|
|
170
240
|
*/
|
|
171
|
-
append({ roomId, eventId, sender, name, text, ts, kind = 'human', injected } = {}) {
|
|
241
|
+
append({ roomId, eventId, sender, name, text, ts, threadId, kind = 'human', injected, turnKey = null, turnEpoch = null } = {}) {
|
|
172
242
|
if (!roomId || typeof eventId !== 'string' || !eventId.trim()) return null;
|
|
173
|
-
const entry = normalizeEntry({ eventId, sender, name, text, ts, kind, injected });
|
|
243
|
+
const entry = normalizeEntry({ eventId, sender, name, text, ts, threadId, kind, injected, turnKey, turnEpoch });
|
|
174
244
|
if (!entry) return null;
|
|
175
245
|
const room = this.#roomEntry(roomId);
|
|
176
246
|
if (room.entries.some((existing) => existing.eventId === entry.eventId)) return null;
|
|
@@ -184,6 +254,34 @@ export class MatrixRoomHistoryStore {
|
|
|
184
254
|
return entry;
|
|
185
255
|
}
|
|
186
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Bring one recorded entry to its latest edit frame — the shape a streamed bot
|
|
259
|
+
* answer takes while the client edits the placeholder event toward the final
|
|
260
|
+
* body. Revising an entry that is not stored yet records it, so the last frame
|
|
261
|
+
* always owns the entry regardless of which frame reaches here first.
|
|
262
|
+
*/
|
|
263
|
+
revise({ roomId, eventId, sender, name, text, ts, threadId, kind = 'bot', turnKey = null, turnEpoch = null } = {}) {
|
|
264
|
+
if (!roomId || typeof eventId !== 'string' || !eventId.trim()) return false;
|
|
265
|
+
const room = this.#roomEntry(roomId);
|
|
266
|
+
const existing = room.entries.find((entry) => entry.eventId === eventId);
|
|
267
|
+
if (existing) {
|
|
268
|
+
const nextText = cleanText(text, ENTRY_TEXT_CHARS);
|
|
269
|
+
if (nextText) existing.text = nextText;
|
|
270
|
+
const nextTs = Number.isSafeInteger(ts) && ts > 0 ? ts : null;
|
|
271
|
+
if (nextTs !== null && nextTs >= existing.ts) existing.ts = nextTs;
|
|
272
|
+
const nextName = cleanText(name, 256);
|
|
273
|
+
if (nextName) existing.name = nextName;
|
|
274
|
+
if (existing.threadId === null) existing.threadId = cleanText(threadId, 255) || null;
|
|
275
|
+
// An echo-ingested receipt may have created the entry before the send face
|
|
276
|
+
// knew its turn; the first frame that carries provenance owns the backfill.
|
|
277
|
+
if (existing.turnKey === null && typeof turnKey === 'string' && turnKey) existing.turnKey = turnKey;
|
|
278
|
+
if (existing.turnEpoch === null && Number.isSafeInteger(turnEpoch) && turnEpoch >= 0) existing.turnEpoch = turnEpoch;
|
|
279
|
+
this.#persist();
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
return this.append({ roomId, eventId, sender, name, text, ts, threadId, kind, turnKey, turnEpoch }) !== null;
|
|
283
|
+
}
|
|
284
|
+
|
|
187
285
|
/**
|
|
188
286
|
* Ambient human chatter that has not been injected yet, from the same civil
|
|
189
287
|
* day (entries at or after `sinceTs`), in chronological order. Pure -- does not
|
|
@@ -226,6 +324,17 @@ export class MatrixRoomHistoryStore {
|
|
|
226
324
|
return selected;
|
|
227
325
|
}
|
|
228
326
|
|
|
327
|
+
/**
|
|
328
|
+
* The recorded kind of one event ('bot', 'human' or 'command'), or null when
|
|
329
|
+
* the event is not stored. Lets callers reason about authorship without
|
|
330
|
+
* trusting an id the room record has never seen.
|
|
331
|
+
*/
|
|
332
|
+
kindOf({ roomId, eventId } = {}) {
|
|
333
|
+
const room = this.#value.rooms[roomId];
|
|
334
|
+
const entry = room?.entries.find((candidate) => candidate.eventId === eventId);
|
|
335
|
+
return entry ? entry.kind : null;
|
|
336
|
+
}
|
|
337
|
+
|
|
229
338
|
/**
|
|
230
339
|
* Search the recorded room history across the retained window. Substring match
|
|
231
340
|
* against text and speaker display name (case-insensitive), with optional
|
|
@@ -256,6 +365,129 @@ export class MatrixRoomHistoryStore {
|
|
|
256
365
|
return matches;
|
|
257
366
|
}
|
|
258
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Recent room-visible entries for a context window: every kind, whether or
|
|
370
|
+
* not already consumed, newest-bounded by a count, a character budget, and a
|
|
371
|
+
* freshness floor, with caller-named events skipped. The window stays inside
|
|
372
|
+
* one message list — the room's mainline (`threadId: null`) or one thread —
|
|
373
|
+
* so messages members cannot read in the current conversation stay out. A
|
|
374
|
+
* thread window also holds that thread's root bubble: the panel heads with the
|
|
375
|
+
* root message itself and an answer sent as the reply that opened a thread is
|
|
376
|
+
* stored on the mainline where it was sent — the root leads the window and is
|
|
377
|
+
* exempt from the freshness floor, being the panel's permanent head rather
|
|
378
|
+
* than a recent post. Pure read — consumption bookkeeping stays with the
|
|
379
|
+
* caller's per-conversation ledger.
|
|
380
|
+
*/
|
|
381
|
+
recent({ roomId, threadId = null, exclude = null, limit = 8, maxChars = 4_000, sinceTs = 0 } = {}) {
|
|
382
|
+
const room = this.#value.rooms[roomId];
|
|
383
|
+
if (!room) return [];
|
|
384
|
+
const cap = boundedInteger(limit, MIN_ROOM_CONTEXT_LIMIT, MAX_ROOM_CONTEXT_LIMIT, 8);
|
|
385
|
+
const charCap = boundedInteger(maxChars, MIN_ROOM_CONTEXT_CHARS, MAX_ROOM_CONTEXT_CHARS, 4_000);
|
|
386
|
+
const floor = Number.isSafeInteger(sinceTs) ? sinceTs : 0;
|
|
387
|
+
const wantedThread = cleanText(threadId, 255) || null;
|
|
388
|
+
const skip = exclude instanceof Set ? exclude : new Set(Array.isArray(exclude) ? exclude : []);
|
|
389
|
+
const selected = [];
|
|
390
|
+
let used = 0;
|
|
391
|
+
const rootEntry = wantedThread === null ? null
|
|
392
|
+
: room.entries.find((entry) => entry.eventId === wantedThread && entry.text && !skip.has(entry.eventId)) ?? null;
|
|
393
|
+
if (rootEntry) {
|
|
394
|
+
used += rootEntry.text.length + (rootEntry.name ? rootEntry.name.length + 2 : 1);
|
|
395
|
+
}
|
|
396
|
+
for (let index = room.entries.length - 1; index >= 0 && selected.length < (rootEntry ? cap - 1 : cap); index -= 1) {
|
|
397
|
+
const entry = room.entries[index];
|
|
398
|
+
if (rootEntry !== null && entry.eventId === rootEntry.eventId) continue;
|
|
399
|
+
if ((entry.threadId ?? null) !== wantedThread) continue;
|
|
400
|
+
if (!entry.text) continue;
|
|
401
|
+
if (entry.ts <= floor || skip.has(entry.eventId)) continue;
|
|
402
|
+
const cost = entry.text.length + (entry.name ? entry.name.length + 2 : 1);
|
|
403
|
+
if (used + cost > charCap && selected.length > 0) break;
|
|
404
|
+
selected.unshift(entry);
|
|
405
|
+
used += cost;
|
|
406
|
+
}
|
|
407
|
+
return rootEntry === null ? selected : [rootEntry, ...selected];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Mark recorded entries as already offered to a conversation so no later
|
|
412
|
+
* query re-offers them (the ambient provider's consumption step). Only ids of
|
|
413
|
+
* live entries count; the count of newly marked ids is returned so callers
|
|
414
|
+
* can log how much context one turn actually consumed.
|
|
415
|
+
*/
|
|
416
|
+
markInjected(roomId, eventIds) {
|
|
417
|
+
const room = this.#value.rooms[roomId];
|
|
418
|
+
if (!room || !Array.isArray(eventIds)) return 0;
|
|
419
|
+
const live = new Set(room.entries.map((entry) => entry.eventId));
|
|
420
|
+
let marked = 0;
|
|
421
|
+
for (const eventId of eventIds) {
|
|
422
|
+
if (typeof eventId !== 'string' || room.injected.has(eventId) || !live.has(eventId)) continue;
|
|
423
|
+
room.injected.add(eventId);
|
|
424
|
+
marked += 1;
|
|
425
|
+
}
|
|
426
|
+
if (marked > 0) this.#persist();
|
|
427
|
+
return marked;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Durable counterpart of the runtime's per-conversation injection ledger: within one
|
|
432
|
+
* conversation an event enters the model as collected context at most once, and that
|
|
433
|
+
* once must outlive the process. The session generation rides the same record, so an
|
|
434
|
+
* answer retired under a live generation is not read back merely because the process
|
|
435
|
+
* restarted; only a cleared session lifts the retirement.
|
|
436
|
+
*/
|
|
437
|
+
contextEpoch({ conversationKey } = {}) {
|
|
438
|
+
if (typeof conversationKey !== 'string' || !conversationKey) return 0;
|
|
439
|
+
return this.#value.ledgers[conversationKey]?.epoch ?? 0;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Advance one conversation's durable generation (its session was cleared); returns the new value. */
|
|
443
|
+
bumpContextEpoch({ conversationKey } = {}) {
|
|
444
|
+
if (typeof conversationKey !== 'string' || !conversationKey) return 0;
|
|
445
|
+
const ledger = this.#ledgerEntry(conversationKey);
|
|
446
|
+
ledger.epoch += 1;
|
|
447
|
+
this.#persist();
|
|
448
|
+
return ledger.epoch;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Whether one event was already injected into one conversation's collected context. */
|
|
452
|
+
contextInjected({ conversationKey, eventId } = {}) {
|
|
453
|
+
if (typeof conversationKey !== 'string' || !conversationKey || typeof eventId !== 'string' || !eventId) return false;
|
|
454
|
+
const ledger = this.#value.ledgers[conversationKey];
|
|
455
|
+
return ledger ? ledger.injected.includes(eventId) : false;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Record events as injected into one conversation; the most recent entries are kept when the bound binds. */
|
|
459
|
+
markContextInjected({ conversationKey, eventIds } = {}) {
|
|
460
|
+
if (typeof conversationKey !== 'string' || !conversationKey || !Array.isArray(eventIds)) return false;
|
|
461
|
+
const ledger = this.#ledgerEntry(conversationKey);
|
|
462
|
+
const seen = new Set(ledger.injected);
|
|
463
|
+
let marked = 0;
|
|
464
|
+
for (const eventId of eventIds) {
|
|
465
|
+
if (typeof eventId !== 'string' || !eventId || seen.has(eventId)) continue;
|
|
466
|
+
seen.add(eventId);
|
|
467
|
+
ledger.injected.push(eventId);
|
|
468
|
+
marked += 1;
|
|
469
|
+
}
|
|
470
|
+
if (marked === 0) return false;
|
|
471
|
+
if (ledger.injected.length > CONTEXT_LEDGER_ENTRY_MAX) {
|
|
472
|
+
ledger.injected.splice(0, ledger.injected.length - CONTEXT_LEDGER_ENTRY_MAX);
|
|
473
|
+
}
|
|
474
|
+
// Recency follows the last offer, so the route cap evicts the stalest conversation.
|
|
475
|
+
delete this.#value.ledgers[conversationKey];
|
|
476
|
+
this.#value.ledgers[conversationKey] = ledger;
|
|
477
|
+
this.#persist();
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Release one conversation's injected records: its cleared successor session reads each bubble back once. */
|
|
482
|
+
clearContextInjected({ conversationKey } = {}) {
|
|
483
|
+
if (typeof conversationKey !== 'string' || !conversationKey) return false;
|
|
484
|
+
const ledger = this.#value.ledgers[conversationKey];
|
|
485
|
+
if (!ledger || ledger.injected.length === 0) return false;
|
|
486
|
+
ledger.injected = [];
|
|
487
|
+
this.#persist();
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
|
|
259
491
|
/** Drop a room record entirely (used when a room is removed/reset). */
|
|
260
492
|
clear(roomId) {
|
|
261
493
|
if (!this.#value.rooms[roomId]) return false;
|