@xmanrui/dsh-im 4.26.0 → 4.27.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.26.0",
3
+ "version": "4.27.0",
4
4
  "description": "把十二种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect twelve IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -111,6 +111,7 @@ const EN = Object.freeze({
111
111
  'DSH 版本兼容说明': 'DSH version compatibility',
112
112
  'dsh-im 4.25.0 支持到 DSH v0.1.6-alpha.2。': 'dsh-im 4.25.0 supports DSH up to v0.1.6-alpha.2.',
113
113
  'dsh-im 4.26.0 仅支持 DSH v0.1.7-alpha.1。': 'dsh-im 4.26.0 only supports DSH v0.1.7-alpha.1.',
114
+ 'dsh-im 4.27.0 仅支持 DSH v0.1.7-alpha.1 及更新版本。': 'dsh-im 4.27.0 only supports DSH v0.1.7-alpha.1 and later.',
114
115
  '升级到 dsh-im 4.26.0 前,请先将 DSH 升级到 v0.1.7-alpha.1;使用旧版 DSH 请保留 dsh-im 4.25.0。': 'Before updating to dsh-im 4.26.0, upgrade DSH to v0.1.7-alpha.1. If you use an older DSH version, stay on dsh-im 4.25.0.',
115
116
  '重新检查': 'Check again',
116
117
  '刷新状态': 'Refresh status',
@@ -471,6 +471,7 @@ export function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
471
471
  h('strong', null, 'DSH 版本兼容说明'),
472
472
  h('p', { className: 'dim-updateManualHint' }, 'dsh-im 4.25.0 支持到 DSH v0.1.6-alpha.2。'),
473
473
  h('p', { className: 'dim-updateManualHint' }, 'dsh-im 4.26.0 仅支持 DSH v0.1.7-alpha.1。'),
474
+ h('p', { className: 'dim-updateManualHint' }, 'dsh-im 4.27.0 仅支持 DSH v0.1.7-alpha.1 及更新版本。'),
474
475
  h('p', { className: 'dim-updateManualHint' },
475
476
  '升级到 dsh-im 4.26.0 前,请先将 DSH 升级到 v0.1.7-alpha.1;使用旧版 DSH 请保留 dsh-im 4.25.0。')),
476
477
  h(ManualUpdateCommand, {
@@ -5,6 +5,7 @@ import { MatrixConfigStore, MatrixSidecarStore } from '../../../../src/channels/
5
5
  import { MatrixController } from '../../../../src/channels/matrix/matrix-controller.mjs';
6
6
  import { MatrixCryptoStore, matrixCryptoPathFor } from '../../../../src/channels/matrix/matrix-crypto-store.mjs';
7
7
  import { MatrixHarnessClient } from '../../../../src/channels/matrix/matrix-harness-client.mjs';
8
+ import { MatrixRoomHistoryStore, matrixRoomHistoryPathFor } from '../../../../src/channels/matrix/matrix-room-history.mjs';
8
9
  import { MatrixRuntime } from '../../../../src/channels/matrix/matrix-runtime.mjs';
9
10
  import { ConversationStateStore } from '../../../../src/channels/shared/conversation-state-store.mjs';
10
11
  import {
@@ -63,10 +64,13 @@ export async function createProductionController(ctx, config = {}, internals = {
63
64
  const stateStores = new Map();
64
65
  const sidecarStores = new Map();
65
66
  const cryptoStores = new Map();
67
+ const historyStores = new Map();
68
+ const ResolvedRoomHistory = internals.RoomHistoryStore ?? MatrixRoomHistoryStore;
66
69
  const ResolvedCryptoStore = internals.CryptoStore ?? MatrixCryptoStore;
67
70
  const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
68
71
  const sidecarPath = (botId) => resolve(paths.bots, botId, 'matrix.json');
69
72
  const cryptoPath = (botId) => matrixCryptoPathFor(resolve(paths.bots, botId));
73
+ const historyPath = (botId) => matrixRoomHistoryPathFor(resolve(paths.bots, botId));
70
74
  const stateFor = async (botId) => {
71
75
  let state = stateStores.get(botId);
72
76
  if (!state) {
@@ -91,6 +95,14 @@ export async function createProductionController(ctx, config = {}, internals = {
91
95
  }
92
96
  return crypto;
93
97
  };
98
+ const historyFor = async (botId) => {
99
+ let history = historyStores.get(botId);
100
+ if (!history) {
101
+ history = await new ResolvedRoomHistory(historyPath(botId)).load();
102
+ historyStores.set(botId, history);
103
+ }
104
+ return history;
105
+ };
94
106
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
95
107
  const inboundTtl = internals.inboundTtl ?? getInboundTtlRuntime(ctx, config);
96
108
  const inboundTtlService = inboundTtl?.service ?? inboundTtl;
@@ -137,6 +149,7 @@ export async function createProductionController(ctx, config = {}, internals = {
137
149
  });
138
150
  const e2eeMode = String(botConfig?.e2eeMode ?? 'optional').trim().toLowerCase();
139
151
  const cryptoStore = e2eeMode === 'off' ? null : await cryptoFor(botId);
152
+ const roomHistory = await historyFor(botId);
140
153
  return new ResolvedRuntime({
141
154
  config: botConfig,
142
155
  ...(accessToken ? { accessToken } : {}),
@@ -145,6 +158,7 @@ export async function createProductionController(ctx, config = {}, internals = {
145
158
  harness: workspaceScope.harness,
146
159
  state: workspaceScope.state,
147
160
  sidecar,
161
+ roomHistory,
148
162
  ...(cryptoStore ? { cryptoStore } : {}),
149
163
  contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
150
164
  accessPolicy: accessPolicyProvider(workspaces, botId, {
@@ -202,12 +202,13 @@ export class StateStore {
202
202
 
203
203
  async #persist() {
204
204
  const snapshot = JSON.stringify(this.#state, null, 2) + '\n';
205
- this.#writeQueue = this.#writeQueue.then(async () => {
205
+ const operation = this.#writeQueue.then(async () => {
206
206
  await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
207
207
  const temporary = `${this.#path}.tmp`;
208
208
  await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
209
209
  await rename(temporary, this.#path);
210
210
  });
211
- await this.#writeQueue;
211
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
212
+ await operation;
212
213
  }
213
214
  }
@@ -13,6 +13,9 @@ const E2EE_MODES = new Set(['off', 'optional', 'required']);
13
13
  const AUTO_JOIN_MODES = new Set(['authorized', 'all']);
14
14
  const MAX_MESSAGE_LENGTH_BOUNDS = Object.freeze({ min: 500, max: 65_535 });
15
15
  const MAX_MEDIA_BYTES_CEILING = 104_857_600;
16
+ const ROOM_CONTEXT_LIMIT_BOUNDS = Object.freeze({ min: 1, max: 500 });
17
+ const ROOM_CONTEXT_CHARS_BOUNDS = Object.freeze({ min: 200, max: 200_000 });
18
+ const ROOM_CONTEXT_TZ_BOUNDS = Object.freeze({ min: -840, max: 840 });
16
19
 
17
20
  function cleanString(value) {
18
21
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -179,10 +182,15 @@ export class MatrixConfigStore {
179
182
  const allowedRooms = roomIdList(value.allowedRooms);
180
183
  const freeResponseRooms = roomIdList(value.freeResponseRooms);
181
184
  const ignoreUserPatterns = patternList(value.ignoreUserPatterns);
185
+ const roomContextLimit = boundedInteger(value.roomContextLimit, ROOM_CONTEXT_LIMIT_BOUNDS);
186
+ const roomContextMaxChars = boundedInteger(value.roomContextMaxChars, ROOM_CONTEXT_CHARS_BOUNDS);
187
+ const roomContextTzOffsetMinutes = boundedInteger(value.roomContextTzOffsetMinutes, ROOM_CONTEXT_TZ_BOUNDS);
182
188
  if (!E2EE_MODES.has(e2eeMode) || !AUTO_JOIN_MODES.has(autoJoinInvites)
183
189
  || maxMessageLength === null || maxMediaBytes === null
184
- || allowedRooms === null || freeResponseRooms === null || ignoreUserPatterns === null) return null;
185
- for (const key of ['requireMention', 'processNotices', 'allowRoomMentions', 'reactions']) {
190
+ || allowedRooms === null || freeResponseRooms === null || ignoreUserPatterns === null
191
+ || roomContextLimit === null || roomContextMaxChars === null
192
+ || roomContextTzOffsetMinutes === null) return null;
193
+ for (const key of ['requireMention', 'processNotices', 'allowRoomMentions', 'reactions', 'roomContextEnabled']) {
186
194
  if (value?.[key] !== undefined && typeof value[key] !== 'boolean') return null;
187
195
  }
188
196
  return Object.freeze({
@@ -208,6 +216,10 @@ export class MatrixConfigStore {
208
216
  ...(allowedRooms !== undefined ? { allowedRooms: Object.freeze(allowedRooms) } : {}),
209
217
  ...(freeResponseRooms !== undefined ? { freeResponseRooms: Object.freeze(freeResponseRooms) } : {}),
210
218
  ...(ignoreUserPatterns !== undefined ? { ignoreUserPatterns: Object.freeze(ignoreUserPatterns) } : {}),
219
+ ...(typeof value.roomContextEnabled === 'boolean' ? { roomContextEnabled: value.roomContextEnabled } : {}),
220
+ ...(roomContextLimit !== undefined ? { roomContextLimit } : {}),
221
+ ...(roomContextMaxChars !== undefined ? { roomContextMaxChars } : {}),
222
+ ...(roomContextTzOffsetMinutes !== undefined ? { roomContextTzOffsetMinutes } : {}),
211
223
  });
212
224
  }
213
225
 
@@ -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
+ }