@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/lib/client.js +3 -1
- package/lib/index.js +279 -273
- package/package.json +1 -1
- package/plugin-src/client/i18n.js +1 -0
- package/plugin-src/client/update-panel.js +1 -0
- package/plugin-src/host/channels/matrix/production.mjs +14 -0
- package/src/channels/feishu/state-store.mjs +3 -2
- package/src/channels/matrix/matrix-config-store.mjs +14 -2
- package/src/channels/matrix/matrix-room-history.mjs +316 -0
- package/src/channels/matrix/matrix-runtime.mjs +134 -20
- package/src/channels/shared/i18n-en/matrix.mjs +10 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
185
|
-
|
|
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
|
+
}
|