@xmanrui/dsh-im 4.26.0 → 4.28.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,316 @@
1
+ // Shared, per-room conversation record for the Matrix channel.
2
+ //
3
+ // A Matrix room is one shared context: every participant's messages belong to
4
+ // the same record, regardless of who is addressed. The channel bot replies only
5
+ // when it is mentioned, but it conditions on the whole room. This store keeps a
6
+ // bounded, durable timeline of human and bot messages per room so that:
7
+ // - unmentioned chatter is not lost -- it is recorded and later injected as
8
+ // context the next time the bot is addressed, and
9
+ // - older messages remain searchable across days so a session can pull prior
10
+ // room history on demand.
11
+ //
12
+ // The store owns only the record and its queries; the runtime decides what is
13
+ // recorded and when pending context is consumed, so the trigger boundary stays
14
+ // in the inbound pipeline.
15
+
16
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
17
+ import { dirname } from 'node:path';
18
+
19
+ export const DEFAULT_ROOM_HISTORY_LIMIT = 2_000;
20
+ export const MIN_ROOM_HISTORY_LIMIT = 20;
21
+ export const MAX_ROOM_HISTORY_LIMIT = 50_000;
22
+ export const MIN_ROOM_CONTEXT_LIMIT = 1;
23
+ export const MAX_ROOM_CONTEXT_LIMIT = 500;
24
+ export const MIN_ROOM_CONTEXT_CHARS = 200;
25
+ export const MAX_ROOM_CONTEXT_CHARS = 200_000;
26
+ const ENTRY_TEXT_CHARS = 4_000;
27
+ const INJECTED_EVENT_MAX = 1_000;
28
+
29
+ export function matrixRoomHistoryPathFor(botDir) {
30
+ return `${botDir}/matrix-history.json`;
31
+ }
32
+
33
+ function boundedInteger(value, min, max, fallback) {
34
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
35
+ const rounded = Math.trunc(value);
36
+ if (!Number.isSafeInteger(rounded)) return fallback;
37
+ return Math.min(Math.max(rounded, min), max);
38
+ }
39
+
40
+ function cleanText(value, max) {
41
+ if (typeof value !== 'string') return '';
42
+ const trimmed = value.trim();
43
+ if (!trimmed) return '';
44
+ return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
45
+ }
46
+
47
+ function localPart(matrixId) {
48
+ if (typeof matrixId !== 'string') return '';
49
+ const at = matrixId.indexOf(':');
50
+ const local = at >= 0 ? matrixId.slice(1, at) : matrixId.replace(/^@/, '');
51
+ return local.trim();
52
+ }
53
+
54
+ // Start of the civil day that `nowMs` falls in, at the configured UTC offset.
55
+ // Returned as an absolute epoch-ms threshold so an entry's own origin_serverTs
56
+ // (ms) can be compared against it directly.
57
+ export function roomContextDayStartTs(nowMs, tzOffsetMinutes) {
58
+ const now = Number.isSafeInteger(nowMs) ? nowMs : Date.now();
59
+ const offset = Number.isSafeInteger(tzOffsetMinutes) ? tzOffsetMinutes : 0;
60
+ const shifted = now + offset * 60_000;
61
+ const dayStartShifted = Math.floor(shifted / 86_400_000) * 86_400_000;
62
+ return dayStartShifted - offset * 60_000;
63
+ }
64
+
65
+ function toTimestamp(value) {
66
+ if (Number.isSafeInteger(value) && value > 0) return value;
67
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) return Math.trunc(value);
68
+ return Date.now();
69
+ }
70
+
71
+ function normalizeEntry(raw) {
72
+ if (!raw || typeof raw !== 'object') return null;
73
+ const eventId = cleanText(raw.eventId, 255);
74
+ const text = cleanText(raw.text, ENTRY_TEXT_CHARS);
75
+ if (!eventId) return null;
76
+ if (!text && raw.fromBot !== true) return null;
77
+ const kind = raw.kind === 'bot' || raw.fromBot === true ? 'bot'
78
+ : raw.kind === 'command' ? 'command' : 'human';
79
+ return {
80
+ eventId,
81
+ sender: cleanText(raw.sender, 512),
82
+ name: cleanText(raw.name, 256) || localPart(raw.sender),
83
+ text,
84
+ ts: toTimestamp(raw.ts),
85
+ kind,
86
+ injected: raw.injected === true || kind !== 'human',
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Durable, bounded, per-room message record. Entries are kept in arrival order
92
+ * (the runtime feeds the sync timeline, which is chronological per room). Each
93
+ * 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
95
+ * through an in-memory queue with an atomic temp-file rename, mirroring the
96
+ * Matrix sidecar.
97
+ */
98
+ export class MatrixRoomHistoryStore {
99
+ #path;
100
+ #limit;
101
+ #value = { rooms: Object.create(null) };
102
+ #writeQueue = Promise.resolve();
103
+
104
+ constructor(path, { limit = DEFAULT_ROOM_HISTORY_LIMIT } = {}) {
105
+ if (!path) throw new TypeError('Matrix room history requires a file path');
106
+ this.#path = path;
107
+ this.#limit = boundedInteger(limit, MIN_ROOM_HISTORY_LIMIT, MAX_ROOM_HISTORY_LIMIT, DEFAULT_ROOM_HISTORY_LIMIT);
108
+ }
109
+
110
+ get limit() {
111
+ return this.#limit;
112
+ }
113
+
114
+ async load() {
115
+ try {
116
+ const parsed = JSON.parse(await readFile(this.#path, 'utf8'));
117
+ const rooms = Object.create(null);
118
+ if (parsed && typeof parsed === 'object' && parsed.rooms && typeof parsed.rooms === 'object') {
119
+ for (const [roomId, room] of Object.entries(parsed.rooms)) {
120
+ if (!roomId || !room || typeof room !== 'object') continue;
121
+ const entries = Array.isArray(room.entries)
122
+ ? room.entries.map(normalizeEntry).filter(Boolean)
123
+ : [];
124
+ const injected = new Set((Array.isArray(room.injected) ? room.injected : [])
125
+ .filter((id) => typeof id === 'string' && id));
126
+ rooms[roomId] = { entries, injected };
127
+ }
128
+ }
129
+ this.#value = { rooms };
130
+ } catch (error) {
131
+ if (error?.code !== 'ENOENT') throw error;
132
+ }
133
+ return this;
134
+ }
135
+
136
+ #roomEntry(roomId) {
137
+ let room = this.#value.rooms[roomId];
138
+ if (!room) {
139
+ room = { entries: [], injected: new Set() };
140
+ this.#value.rooms[roomId] = room;
141
+ }
142
+ return room;
143
+ }
144
+
145
+ #persist() {
146
+ const rooms = Object.create(null);
147
+ for (const [roomId, room] of Object.entries(this.#value.rooms)) {
148
+ if (room.entries.length === 0) continue;
149
+ rooms[roomId] = {
150
+ entries: room.entries,
151
+ injected: [...room.injected].slice(-INJECTED_EVENT_MAX),
152
+ };
153
+ }
154
+ const snapshot = JSON.stringify({ rooms }, null, 2);
155
+ const operation = this.#writeQueue.then(async () => {
156
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
157
+ const temporary = `${this.#path}.tmp`;
158
+ await writeFile(temporary, `${snapshot}\n`, { encoding: 'utf8', mode: 0o600 });
159
+ await rename(temporary, this.#path);
160
+ });
161
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
162
+ void operation.catch(() => undefined);
163
+ }
164
+
165
+ /**
166
+ * Record one room message. The caller decides the kind ('human', 'command'
167
+ * or 'bot'); non-human and explicitly injected entries are marked as already
168
+ * consumed so they never surface as pending context. Duplicate event ids are
169
+ * ignored to keep a replayed sync from double-counting an entry.
170
+ */
171
+ append({ roomId, eventId, sender, name, text, ts, kind = 'human', injected } = {}) {
172
+ if (!roomId || typeof eventId !== 'string' || !eventId.trim()) return null;
173
+ const entry = normalizeEntry({ eventId, sender, name, text, ts, kind, injected });
174
+ if (!entry) return null;
175
+ const room = this.#roomEntry(roomId);
176
+ if (room.entries.some((existing) => existing.eventId === entry.eventId)) return null;
177
+ room.entries.push(entry);
178
+ if (entry.injected) room.injected.add(entry.eventId);
179
+ if (room.entries.length > this.#limit) {
180
+ const dropped = room.entries.splice(0, room.entries.length - this.#limit);
181
+ for (const removed of dropped) room.injected.delete(removed.eventId);
182
+ }
183
+ this.#persist();
184
+ return entry;
185
+ }
186
+
187
+ /**
188
+ * Ambient human chatter that has not been injected yet, from the same civil
189
+ * day (entries at or after `sinceTs`), in chronological order. Pure -- does not
190
+ * mark anything -- so the runtime can inspect what would be injected.
191
+ */
192
+ pending({ roomId, sinceTs = 0, limit = MAX_ROOM_CONTEXT_LIMIT } = {}) {
193
+ const room = this.#value.rooms[roomId];
194
+ if (!room) return [];
195
+ const cap = boundedInteger(limit, MIN_ROOM_CONTEXT_LIMIT, MAX_ROOM_CONTEXT_LIMIT, MAX_ROOM_CONTEXT_LIMIT);
196
+ return room.entries
197
+ .filter((entry) => entry.kind === 'human' && !room.injected.has(entry.eventId) && entry.ts > sinceTs)
198
+ .slice(-cap);
199
+ }
200
+
201
+ /**
202
+ * Select the pending same-day chatter to inject now and mark those entries as
203
+ * consumed, bounded by both a count and a character budget (the most recent
204
+ * entries are preferred so the freshest context wins when the budget binds).
205
+ */
206
+ consumePending({ roomId, sinceTs = 0, limit, maxChars, now = Date.now() } = {}) {
207
+ const room = this.#value.rooms[roomId];
208
+ if (!room) return [];
209
+ const cap = boundedInteger(limit, MIN_ROOM_CONTEXT_LIMIT, MAX_ROOM_CONTEXT_LIMIT, 50);
210
+ const charCap = boundedInteger(maxChars, MIN_ROOM_CONTEXT_CHARS, MAX_ROOM_CONTEXT_CHARS, 8_000);
211
+ const dayStart = roomContextDayStartTs(now, 0);
212
+ const floor = Math.max(Number.isSafeInteger(sinceTs) ? sinceTs : 0, dayStart);
213
+ const candidates = room.entries
214
+ .filter((entry) => entry.kind === 'human' && !room.injected.has(entry.eventId) && entry.ts > floor);
215
+ const selected = [];
216
+ let used = 0;
217
+ for (let index = candidates.length - 1; index >= 0 && selected.length < cap; index -= 1) {
218
+ const entry = candidates[index];
219
+ const cost = entry.text.length + (entry.name ? entry.name.length + 2 : 1);
220
+ if (selected.length > 0 && used + cost > charCap) break;
221
+ selected.unshift(entry);
222
+ used += cost;
223
+ }
224
+ for (const entry of selected) room.injected.add(entry.eventId);
225
+ if (selected.length > 0) this.#persist();
226
+ return selected;
227
+ }
228
+
229
+ /**
230
+ * Search the recorded room history across the retained window. Substring match
231
+ * against text and speaker display name (case-insensitive), with optional
232
+ * inclusive time bounds and a recency-first limit. Used to surface older room
233
+ * history a session can pull in after the current-day auto-context has expired.
234
+ */
235
+ search({ roomId, query = '', sender = null, from = null, to = null, limit = 20 } = {}) {
236
+ const room = this.#value.rooms[roomId];
237
+ if (!room) return [];
238
+ const needle = cleanText(query, 512).toLowerCase();
239
+ const wantSender = cleanText(sender, 512).toLowerCase();
240
+ const cap = boundedInteger(limit, 1, MAX_ROOM_CONTEXT_LIMIT, 20);
241
+ const matches = [];
242
+ for (let index = room.entries.length - 1; index >= 0 && matches.length < cap; index -= 1) {
243
+ const entry = room.entries[index];
244
+ if (Number.isSafeInteger(from) && from > 0 && entry.ts < from) continue;
245
+ if (Number.isSafeInteger(to) && to > 0 && entry.ts > to) continue;
246
+ if (wantSender
247
+ && entry.sender.toLowerCase() !== wantSender
248
+ && localPart(entry.sender).toLowerCase() !== wantSender
249
+ && entry.name.toLowerCase() !== wantSender) continue;
250
+ if (needle) {
251
+ const haystack = `${entry.text}\n${entry.name}`.toLowerCase();
252
+ if (!haystack.includes(needle)) continue;
253
+ }
254
+ matches.push(entry);
255
+ }
256
+ return matches;
257
+ }
258
+
259
+ /** Drop a room record entirely (used when a room is removed/reset). */
260
+ clear(roomId) {
261
+ if (!this.#value.rooms[roomId]) return false;
262
+ delete this.#value.rooms[roomId];
263
+ this.#persist();
264
+ return true;
265
+ }
266
+
267
+ /** Await the serialized write queue so all queued changes reach disk. */
268
+ async flush() {
269
+ await this.#writeQueue;
270
+ }
271
+
272
+ async remove() {
273
+ const operation = this.#writeQueue.then(async () => {
274
+ const { unlink } = await import('node:fs/promises');
275
+ await unlink(this.#path).catch((error) => {
276
+ if (error?.code !== 'ENOENT') throw error;
277
+ });
278
+ });
279
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
280
+ await operation;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Format pending entries as a context block prepended to the prompt. Entries are
286
+ * attributed by display name so the model can tell participants apart; the block
287
+ * states it is room chatter the bot was not directly addressed in.
288
+ */
289
+ // Each line carries a clock stamp and a bracketed speaker, and the whole run sits between a labelled
290
+ // opening and a closing fence: a bare list of speaker-colon-text lines reads to the model as questions
291
+ // that are waiting to be answered one by one.
292
+ function roomContextStamp(ts, offsetMinutes) {
293
+ const stamp = Number.isSafeInteger(ts) ? ts : 0;
294
+ const offset = Number.isSafeInteger(offsetMinutes) ? offsetMinutes : 0;
295
+ const shifted = new Date(stamp + offset * 60_000);
296
+ const hours = String(shifted.getUTCHours()).padStart(2, '0');
297
+ const minutes = String(shifted.getUTCMinutes()).padStart(2, '0');
298
+ return `[${hours}:${minutes}]`;
299
+ }
300
+
301
+ export function formatRoomContextBlock(entries, {
302
+ header = '', instruction = '', begin = '', end = '', tzOffsetMinutes = 0,
303
+ } = {}) {
304
+ if (!Array.isArray(entries) || entries.length === 0) return '';
305
+ const lines = entries.map((entry) => {
306
+ const speaker = entry.name || entry.sender || '?';
307
+ return `${roomContextStamp(entry.ts, tzOffsetMinutes)} [${speaker}] ${entry.text}`;
308
+ });
309
+ const parts = [];
310
+ if (header) parts.push(header);
311
+ if (instruction) parts.push(instruction);
312
+ if (begin) parts.push(begin);
313
+ parts.push(...lines);
314
+ if (end) parts.push(end);
315
+ return parts.join('\n');
316
+ }
@@ -18,10 +18,16 @@ import {
18
18
  ClockSkewGuard,
19
19
  compileIgnorePatterns,
20
20
  EventDedupeRing,
21
+ isMatrixCommandLike,
21
22
  normalizeMatrixDeliveryTarget,
22
23
  normalizeMatrixTimelineEvent,
23
24
  resolveBangMatrixCommand,
24
25
  } from './matrix-normalize.mjs';
26
+ import {
27
+ DEFAULT_ROOM_HISTORY_LIMIT,
28
+ formatRoomContextBlock,
29
+ roomContextDayStartTs,
30
+ } from './matrix-room-history.mjs';
25
31
  import {
26
32
  applyMatrixRelations,
27
33
  buildMatrixEditContent,
@@ -38,6 +44,13 @@ const INVITE_JOIN_TIMEOUT_MS = 45_000;
38
44
  const DEFAULT_MAX_MESSAGE_LENGTH = 16_000;
39
45
  const DEFAULT_MAX_MEDIA_BYTES = 104_857_600;
40
46
  const EDIT_STREAM_INTERVAL_MS = 350;
47
+
48
+ function safeInt(value, min, max, fallback) {
49
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
50
+ const rounded = Math.trunc(value);
51
+ if (!Number.isSafeInteger(rounded)) return fallback;
52
+ return Math.min(Math.max(rounded, min), max);
53
+ }
41
54
  const DEAD_ROOM_MARKERS = Object.freeze(['no servers', 'room not found']);
42
55
  const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
43
56
 
@@ -106,6 +119,16 @@ function inviteSenderOf(inviteRoom) {
106
119
  return null;
107
120
  }
108
121
 
122
+ // A room is a direct chat only when the homeserver itself says so: the invite's own membership event
123
+ // carries is_direct, or the account's m.direct listing names the room. A two-member room is an
124
+ // ordinary room, where the mention gate must keep applying, so the joined-member count is never a signal.
125
+ function inviteMarksDirect(inviteRoom) {
126
+ const events = Array.isArray(inviteRoom?.invite_state?.events) ? inviteRoom.invite_state.events : [];
127
+ return events.some((event) => event?.type === 'm.room.member'
128
+ && event?.content?.membership === 'invite'
129
+ && event?.content?.is_direct === true);
130
+ }
131
+
109
132
  function timelineEventsOf(joinRoom) {
110
133
  const events = Array.isArray(joinRoom?.timeline?.events) ? joinRoom.timeline.events : [];
111
134
  return events.filter((event) => event && typeof event === 'object' && event.state_key === undefined);
@@ -186,6 +209,7 @@ export class MatrixRuntime {
186
209
  createApi = (options) => new MatrixApi(options),
187
210
  cryptoStore = null,
188
211
  createCrypto = null,
212
+ roomHistory = null,
189
213
  isKnownCommand,
190
214
  } = {}) {
191
215
  const homeserver = validateMatrixHomeserver(config.homeserver);
@@ -233,6 +257,17 @@ export class MatrixRuntime {
233
257
  ? createCrypto
234
258
  : (options) => new MatrixCryptoEngine(options);
235
259
  this.#isKnownCommand = typeof isKnownCommand === 'function' ? isKnownCommand : () => false;
260
+ this.#roomHistory = roomHistory
261
+ && typeof roomHistory.append === 'function'
262
+ && typeof roomHistory.consumePending === 'function'
263
+ && typeof roomHistory.pending === 'function'
264
+ && typeof roomHistory.search === 'function'
265
+ ? roomHistory
266
+ : null;
267
+ this.#roomContextLimit = safeInt(config.roomContextLimit, 1, 500, 50);
268
+ this.#roomContextMaxChars = safeInt(config.roomContextMaxChars, 200, 200_000, 8_000);
269
+ this.#roomContextTzOffsetMinutes = safeInt(config.roomContextTzOffsetMinutes, -840, 840, 0);
270
+ this.#roomContextEnabled = config.roomContextEnabled !== false;
236
271
  this.#replyTimeoutMs = replyTimeoutMs;
237
272
  this.#patterns = compileIgnorePatterns(config.ignoreUserPatterns);
238
273
  for (const roomId of toRoomSet(config.freeResponseRooms)) this.#freeRooms.add(roomId);
@@ -241,6 +276,11 @@ export class MatrixRuntime {
241
276
 
242
277
  #freeRooms = new Set();
243
278
  #allowedRooms = new Set();
279
+ #roomHistory = null;
280
+ #roomContextLimit = 50;
281
+ #roomContextMaxChars = 8_000;
282
+ #roomContextTzOffsetMinutes = 0;
283
+ #roomContextEnabled = true;
244
284
 
245
285
  get status() {
246
286
  return this.#status;
@@ -437,12 +477,13 @@ export class MatrixRuntime {
437
477
  if (!Array.isArray(list)) continue;
438
478
  for (const roomId of list) if (isMatrixRoomId(roomId)) this.#dmRooms.add(roomId);
439
479
  }
440
- for (const roomId of this.#sidecar.dmRooms()) this.#dmRooms.add(roomId);
480
+ // A persisted direct chat survives only where the bot really routes a person to it, or where the
481
+ // account data above re-registers it: residue from the retired member-count rule is forgotten here.
482
+ const routedTo = new Set(Object.values(this.#sidecar.dmRoomByUser()).filter((roomId) => isMatrixRoomId(roomId)));
483
+ for (const roomId of this.#sidecar.dmRooms()) if (routedTo.has(roomId)) this.#dmRooms.add(roomId);
441
484
  for (const roomId of this.#sidecar.joinedRooms()) this.#joinedRooms.add(roomId);
442
- await this.#classifyUnknownRooms();
443
- for (const roomId of Object.keys(rooms.invite ?? {})) {
444
- const inviter = inviteSenderOf(rooms.invite?.[roomId]);
445
- this.#scheduleInviteJoin(roomId, inviter);
485
+ for (const [roomId, room] of Object.entries(rooms.invite ?? {})) {
486
+ this.#scheduleInviteJoin(roomId, inviteSenderOf(room), inviteMarksDirect(room));
446
487
  }
447
488
  // Queued to-device room keys land before the offline timeline replay so queued ciphertext can decrypt on first sight.
448
489
  await this.#dispatchToDeviceEvents(initial?.to_device?.events);
@@ -461,14 +502,6 @@ export class MatrixRuntime {
461
502
  this.#status.encryptedRoomsSeen = this.#encryptedRooms.size;
462
503
  }
463
504
 
464
- async #classifyUnknownRooms() {
465
- for (const roomId of this.#joinedRooms) {
466
- if (this.#dmRooms.has(roomId)) continue;
467
- const count = await this.#api.getJoinedMemberCount(roomId).catch(() => null);
468
- if (count !== null && count <= 2) this.#dmRooms.add(roomId);
469
- }
470
- }
471
-
472
505
  async #syncLoop(generation) {
473
506
  while (!this.#stopped && generation === this.#generation && this.#api) {
474
507
  try {
@@ -537,7 +570,7 @@ export class MatrixRuntime {
537
570
  }
538
571
  for (const [roomId, room] of Object.entries(rooms.invite ?? {})) {
539
572
  if (!isMatrixRoomId(roomId)) continue;
540
- this.#scheduleInviteJoin(roomId, inviteSenderOf(room));
573
+ this.#scheduleInviteJoin(roomId, inviteSenderOf(room), inviteMarksDirect(room));
541
574
  }
542
575
  if (typeof data?.next_batch === 'string' && data.next_batch) {
543
576
  this.#lastBatch = data.next_batch;
@@ -588,6 +621,7 @@ export class MatrixRuntime {
588
621
  this.#status.lastClockSkewAt = new Date().toISOString();
589
622
  this.#logger.warn?.(t('Matrix 收到的时间戳持续远落后于本机时间,检测到本机时钟超前,请校准系统时间后重启机器人。'));
590
623
  }
624
+ if (outcome.drop === 'mention-required') this.#recordRoomAmbient(roomId, event);
591
625
  return;
592
626
  }
593
627
  const message = { ...outcome.message };
@@ -598,6 +632,22 @@ export class MatrixRuntime {
598
632
  message.addressed = true;
599
633
  }
600
634
  }
635
+ if (this.#roomHistory && this.#roomContextEnabled && message.kind === 'group') {
636
+ if (message.addressed) {
637
+ const commandLike = isMatrixCommandLike(message.content);
638
+ const directAddress = message.mentioned === true || Boolean(message.replyToEventId);
639
+ this.#recordRoomHuman(message, { kind: commandLike ? 'command' : 'human', injected: true });
640
+ if (!commandLike && directAddress) {
641
+ const block = this.#consumeRoomContext(message.roomId);
642
+ if (block) {
643
+ const label = t('【下面这条才是对你的提问,请只回答它】');
644
+ message.content = `${block}\n\n${label}\n${message.content}`;
645
+ }
646
+ }
647
+ } else {
648
+ this.#recordRoomHuman(message, { kind: 'human', injected: false });
649
+ }
650
+ }
601
651
  void Promise.resolve(this.#bridge?.accept(message)).catch((error) => {
602
652
  this.#logger.warn?.('[dsh-im:matrix] inbound message handling failed:', error?.message ?? error);
603
653
  });
@@ -606,6 +656,68 @@ export class MatrixRuntime {
606
656
  }
607
657
  }
608
658
 
659
+ #recordRoomAmbient(roomId, event) {
660
+ if (!this.#roomHistory || !this.#roomContextEnabled) return;
661
+ if (this.#dmRooms.has(roomId)) return;
662
+ const eventId = typeof event?.event_id === 'string' ? event.event_id : '';
663
+ const sender = typeof event?.sender === 'string' ? event.sender : '';
664
+ const body = typeof event?.content?.body === 'string' ? event.content.body.trim() : '';
665
+ if (!eventId || !sender || !body) return;
666
+ const self = this.#botUserId?.toLowerCase() ?? '';
667
+ if (self !== '' && sender.toLowerCase() === self) return;
668
+ this.#roomHistory.append({
669
+ roomId,
670
+ eventId,
671
+ sender,
672
+ text: body,
673
+ ts: Number.isSafeInteger(event?.origin_server_ts) ? event.origin_server_ts : Date.now(),
674
+ kind: 'human',
675
+ injected: false,
676
+ });
677
+ }
678
+
679
+ #recordRoomHuman(message, { kind = 'human', injected = false } = {}) {
680
+ if (!this.#roomHistory || !this.#roomContextEnabled) return;
681
+ const text = typeof message.content === 'string' ? message.content.trim() : '';
682
+ if (!text || !message.messageId || !message.senderId) return;
683
+ const self = this.#botUserId?.toLowerCase() ?? '';
684
+ if (self !== '' && message.senderId.toLowerCase() === self) return;
685
+ this.#roomHistory.append({
686
+ roomId: message.roomId,
687
+ eventId: message.messageId,
688
+ sender: message.senderId,
689
+ text,
690
+ ts: Date.now(),
691
+ kind,
692
+ injected,
693
+ });
694
+ }
695
+
696
+ #consumeRoomContext(roomId) {
697
+ if (!this.#roomHistory) return '';
698
+ const now = Date.now();
699
+ const selected = this.#roomHistory.consumePending({
700
+ roomId,
701
+ sinceTs: roomContextDayStartTs(now, this.#roomContextTzOffsetMinutes),
702
+ limit: this.#roomContextLimit,
703
+ maxChars: this.#roomContextMaxChars,
704
+ now,
705
+ });
706
+ if (selected.length === 0) return '';
707
+ // The room timeline echoes the bot's own messages too; showing them as other members' speech makes
708
+ // the model quote itself and answer its own earlier reply, so only third-party lines are presented.
709
+ const self = this.#botUserId?.toLowerCase() ?? '';
710
+ const others = selected.filter((entry) => self === '' || String(entry?.sender ?? '').toLowerCase() !== self);
711
+ if (others.length === 0) return '';
712
+ return formatRoomContextBlock(others, {
713
+ header: t('【群聊背景】以下各条是群里其他成员之间的发言,均未指向你。'),
714
+ instruction: t('这些内容只用来了解现场发生过什么。请勿逐条回应、复述、翻译或总结它们,也不要因为它们而改变下面那条提问的回答。'),
715
+ begin: t('—— 背景开始 ——'),
716
+ end: t('—— 背景结束(以上无需回应) ——'),
717
+ tzOffsetMinutes: this.#roomContextTzOffsetMinutes,
718
+ });
719
+ }
720
+
609
721
  #noteEncryptedRoom(roomId) {
610
722
  this.#encryptedRooms.add(roomId);
611
723
  this.#status.encryptedRoomsSeen = this.#encryptedRooms.size;
@@ -619,7 +731,7 @@ export class MatrixRuntime {
619
731
 
620
732
  // ---- 邀请 join ----
621
733
 
622
- #scheduleInviteJoin(roomId, inviter) {
734
+ #scheduleInviteJoin(roomId, inviter, marksDirect = false) {
623
735
  if (this.#stopped || !isMatrixRoomId(roomId) || this.#joinedRooms.has(roomId)) return;
624
736
  if (this.#sidecar.isDeclined(roomId)) return;
625
737
  const allowed = this.#config.autoJoinInvites === 'all' || inviterAllowed(this.#accessPolicy, inviter);
@@ -633,12 +745,12 @@ export class MatrixRuntime {
633
745
  if (this.#inviteTasks.has(roomId)) return;
634
746
  const controller = new AbortController();
635
747
  this.#inviteTasks.set(roomId, controller);
636
- void this.#joinInvitedRoom(roomId, inviter, controller).finally(() => {
748
+ void this.#joinInvitedRoom(roomId, inviter, controller, marksDirect).finally(() => {
637
749
  this.#inviteTasks.delete(roomId);
638
750
  });
639
751
  }
640
752
 
641
- async #joinInvitedRoom(roomId, inviter, controller) {
753
+ async #joinInvitedRoom(roomId, inviter, controller, marksDirect = false) {
642
754
  const timeout = AbortSignal.timeout(INVITE_JOIN_TIMEOUT_MS);
643
755
  const signal = AbortSignal.any([controller.signal, timeout]);
644
756
  try {
@@ -646,7 +758,7 @@ export class MatrixRuntime {
646
758
  this.#joinedRooms.add(roomId);
647
759
  this.#status.joinedRooms = this.#joinedRooms.size;
648
760
  await this.#sidecar.apply({ joinedRooms: [...this.#joinedRooms] });
649
- if (inviter) await this.#recordDmRoom(roomId, inviter);
761
+ if (inviter) await this.#recordDmRoom(roomId, inviter, marksDirect);
650
762
  this.#logger.info?.(t('Matrix 已按授权邀请加入房间 {room}。'), { room: roomId });
651
763
  } catch (error) {
652
764
  const text = String(error?.message ?? '').toLowerCase();
@@ -664,14 +776,16 @@ export class MatrixRuntime {
664
776
  }
665
777
  }
666
778
 
667
- async #recordDmRoom(roomId, inviter) {
779
+ // The inviter's cached room stays usable for outbound routing either way; only an authoritative
780
+ // is_direct invite registers the room as a direct chat for inbound handling.
781
+ async #recordDmRoom(roomId, inviter, marksDirect = false) {
668
782
  const direct = await this.#api.getAccountData('m.direct').catch(() => null);
669
783
  const map = { ...(direct && typeof direct === 'object' && !Array.isArray(direct) ? direct : {}) };
670
784
  const list = Array.isArray(map[inviter]) ? [...map[inviter]] : [];
671
785
  if (!list.includes(roomId)) list.push(roomId);
672
786
  map[inviter] = list;
673
787
  await this.#api.setAccountData('m.direct', map).catch(() => undefined);
674
- this.#dmRooms.add(roomId);
788
+ if (marksDirect) this.#dmRooms.add(roomId);
675
789
  const dmRoomByUser = { ...this.#sidecar.dmRoomByUser(), [inviter.trim().toLowerCase()]: roomId };
676
790
  await this.#sidecar.apply({ dmRooms: [...this.#dmRooms], dmRoomByUser });
677
791
  }
@@ -1,5 +1,6 @@
1
1
  // English translations (feishu area). Keys are exact Chinese literals passed to t().
2
2
  export default {
3
+ '内容已合并至后续消息。': 'Content has been consolidated into the following messages.',
3
4
  // feishu/bridge.mjs — welcome / help
4
5
  '北汇星河 AIOS 已连接 DeepSeek Harness。':
5
6
  'BeiHui XingHe AIOS is connected to DeepSeek Harness.',
@@ -11,6 +11,16 @@ export default {
11
11
  'Matrix long polling and Harness replies are all working.',
12
12
  'Matrix 连接当前离线。':
13
13
  'The Matrix connection is currently offline.',
14
+ '【群聊背景】以下各条是群里其他成员之间的发言,均未指向你。':
15
+ '[Room background] The lines below are messages other members exchanged among themselves; none of them was addressed to you.',
16
+ '这些内容只用来了解现场发生过什么。请勿逐条回应、复述、翻译或总结它们,也不要因为它们而改变下面那条提问的回答。':
17
+ 'This only tells you what happened in the room. Do not answer, restate, translate or summarise these lines one by one, and do not let them change how you answer the question below.',
18
+ '—— 背景开始 ——':
19
+ '--- background begins ---',
20
+ '—— 背景结束(以上无需回应) ——':
21
+ '--- background ends (nothing above needs a reply) ---',
22
+ '【下面这条才是对你的提问,请只回答它】':
23
+ '[The line below is the question put to you: answer only this]',
14
24
  'Matrix 长轮询尚未建立,请检查 homeserver 与凭据。':
15
25
  'The Matrix long poll has not been established. Check the homeserver URL and the credentials.',
16
26
  'Matrix 正在处理消息;当前存在未恢复的连接。':