@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.
@@ -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
- if (!text && raw.fromBot !== true) return null;
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. Writes are serialized
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
- this.#value = { rooms };
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 snapshot = JSON.stringify({ rooms }, null, 2);
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;