@xmanrui/dsh-im 0.16.0 → 0.17.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.
@@ -1,7 +1,24 @@
1
1
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
3
 
4
- const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] });
4
+ const EMPTY_STATE = Object.freeze({
5
+ version: 1,
6
+ sessions: {},
7
+ seenMessageIds: [],
8
+ watches: {},
9
+ includeArchivedSessions: false,
10
+ });
11
+
12
+ /** One conversation key may watch at most this many sessions. */
13
+ export const MAX_WATCHES_PER_KEY = 20;
14
+
15
+ /** A persisted watch entry: the watched session plus its delivery target. */
16
+ function validWatchEntry(value) {
17
+ return value
18
+ && typeof value === 'object'
19
+ && typeof value.sessionId === 'string' && value.sessionId.length > 0
20
+ && typeof value.chatId === 'string' && value.chatId.length > 0;
21
+ }
5
22
 
6
23
  export class StateStore {
7
24
  #path;
@@ -19,6 +36,10 @@ export class StateStore {
19
36
  version: 1,
20
37
  sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
21
38
  seenMessageIds: Array.isArray(parsed.seenMessageIds) ? parsed.seenMessageIds.slice(-1000) : [],
39
+ watches: parsed.watches && typeof parsed.watches === 'object' ? parsed.watches : {},
40
+ includeArchivedSessions: typeof parsed.includeArchivedSessions === 'boolean'
41
+ ? parsed.includeArchivedSessions
42
+ : false,
22
43
  };
23
44
  } catch (error) {
24
45
  if (error?.code !== 'ENOENT') throw error;
@@ -63,6 +84,69 @@ export class StateStore {
63
84
  return structuredClone(this.#state);
64
85
  }
65
86
 
87
+ // ── Watches (persisted: surviving restarts) ─────────────────────────────
88
+
89
+ watchEntries(key) {
90
+ const list = this.#state.watches[key];
91
+ return Array.isArray(list) ? list.filter(validWatchEntry) : [];
92
+ }
93
+
94
+ watchEntry(key, sessionId) {
95
+ return this.watchEntries(key).find((entry) => entry.sessionId === sessionId) ?? null;
96
+ }
97
+
98
+ async setWatch(key, entry) {
99
+ const list = this.#state.watches[key] ?? [];
100
+ const index = list.findIndex((existing) => existing.sessionId === entry.sessionId);
101
+ if (index === -1) {
102
+ if (list.length >= MAX_WATCHES_PER_KEY) list.shift();
103
+ list.push(entry);
104
+ } else {
105
+ list[index] = entry;
106
+ }
107
+ this.#state.watches[key] = list;
108
+ await this.#persist();
109
+ }
110
+
111
+ async removeWatch(key, sessionId) {
112
+ const list = this.#state.watches[key] ?? [];
113
+ this.#state.watches[key] = list.filter((entry) => entry.sessionId !== sessionId);
114
+ await this.#persist();
115
+ }
116
+
117
+ async clearWatches(key) {
118
+ delete this.#state.watches[key];
119
+ await this.#persist();
120
+ }
121
+
122
+ /** Every conversation key currently watching the given session. */
123
+ keysWatching(sessionId) {
124
+ return Object.entries(this.#state.watches)
125
+ .filter(([, list]) => Array.isArray(list) && list.some((entry) => validWatchEntry(entry) && entry.sessionId === sessionId))
126
+ .map(([key]) => key);
127
+ }
128
+
129
+ /** Unique watched session ids across all keys (restart compensation). */
130
+ watchedSessionIds() {
131
+ const ids = new Set();
132
+ for (const list of Object.values(this.#state.watches)) {
133
+ if (!Array.isArray(list)) continue;
134
+ for (const entry of list) if (validWatchEntry(entry)) ids.add(entry.sessionId);
135
+ }
136
+ return [...ids];
137
+ }
138
+
139
+ // ── Session-list archived policy (per bot) ───────────────────
140
+
141
+ includesArchivedSessions() {
142
+ return this.#state.includeArchivedSessions === true;
143
+ }
144
+
145
+ async setIncludeArchivedSessions(include) {
146
+ this.#state.includeArchivedSessions = include === true;
147
+ await this.#persist();
148
+ }
149
+
66
150
  async #persist() {
67
151
  const snapshot = JSON.stringify(this.#state, null, 2) + '\n';
68
152
  this.#writeQueue = this.#writeQueue.then(async () => {
@@ -1223,6 +1223,125 @@ export class HarnessClient {
1223
1223
  });
1224
1224
  }
1225
1225
 
1226
+ /**
1227
+ * Watch the global Harness event mux (all sessions) until `signal`
1228
+ * aborts, reconnecting on drop. The Desktop host serves the mux as a
1229
+ * WebSocket downlink; frames are `server-request` envelopes whose payload
1230
+ * is a `session/event` — only those are forwarded. `onReconnect` (when
1231
+ * provided) fires after every (re)connection so callers can compensate
1232
+ * for events missed while offline.
1233
+ */
1234
+ async watchHarnessEvents({ signal, onSessionEvent, onReconnect } = {}) {
1235
+ if (typeof onSessionEvent !== 'function') {
1236
+ throw new TypeError('watchHarnessEvents requires onSessionEvent');
1237
+ }
1238
+ if (!signal || typeof signal.addEventListener !== 'function') {
1239
+ throw new TypeError('watchHarnessEvents requires an AbortSignal');
1240
+ }
1241
+ if (onReconnect !== undefined && typeof onReconnect !== 'function') {
1242
+ throw new TypeError('onReconnect must be a function');
1243
+ }
1244
+ const url = new URL('/api/events.mux', this.#baseUrl);
1245
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
1246
+ while (!signal.aborted) {
1247
+ try {
1248
+ await this.#watchHarnessEventSocket(url.toString(), {
1249
+ signal,
1250
+ onSessionEvent,
1251
+ onReconnect,
1252
+ });
1253
+ } catch (error) {
1254
+ if (signal.aborted) return;
1255
+ console.warn(`[${this.#logPrefix}] Harness event mux disconnected:`, error.message);
1256
+ }
1257
+ if (signal.aborted) return;
1258
+ try {
1259
+ await sleep(this.#interactionReconnectDelayMs, signal);
1260
+ } catch {
1261
+ if (signal.aborted) return;
1262
+ throw new Error('Harness event mux reconnect wait failed');
1263
+ }
1264
+ }
1265
+ }
1266
+
1267
+ #watchHarnessEventSocket(url, { signal, onSessionEvent, onReconnect }) {
1268
+ return new Promise((resolve, reject) => {
1269
+ let socket;
1270
+ try {
1271
+ socket = this.#createWebSocket(url);
1272
+ } catch (error) {
1273
+ reject(error);
1274
+ return;
1275
+ }
1276
+ let opened = false;
1277
+ let finished = false;
1278
+ const close = () => {
1279
+ try {
1280
+ socket.close();
1281
+ } catch {
1282
+ // Already closed.
1283
+ }
1284
+ };
1285
+ const finish = (error) => {
1286
+ if (finished) return;
1287
+ finished = true;
1288
+ socket.removeEventListener('open', handleOpen);
1289
+ socket.removeEventListener('message', handleMessage);
1290
+ socket.removeEventListener('close', handleClose);
1291
+ socket.removeEventListener('error', handleError);
1292
+ signal.removeEventListener('abort', handleAbort);
1293
+ if (error) reject(error);
1294
+ else resolve();
1295
+ };
1296
+ const handleOpen = () => {
1297
+ opened = true;
1298
+ try {
1299
+ onReconnect?.();
1300
+ } catch (error) {
1301
+ console.warn(`[${this.#logPrefix}] mux reconnect hook failed:`, error.message);
1302
+ }
1303
+ };
1304
+ const handleMessage = (event) => {
1305
+ try {
1306
+ if (typeof event.data !== 'string') return;
1307
+ const envelope = JSON.parse(event.data);
1308
+ const payload = envelope?.payload;
1309
+ if (envelope?.type !== 'server-request'
1310
+ || !payload
1311
+ || typeof payload !== 'object'
1312
+ || envelope.method !== payload.type
1313
+ || payload.type !== 'session/event'
1314
+ || typeof payload.sessionId !== 'string'
1315
+ || !payload.event
1316
+ || typeof payload.event !== 'object') return;
1317
+ onSessionEvent({ sessionId: payload.sessionId, event: payload.event });
1318
+ } catch (error) {
1319
+ console.warn(`[${this.#logPrefix}] ignored a malformed global mux frame:`, error.message);
1320
+ }
1321
+ };
1322
+ const handleClose = () => finish(opened ? null : new Error(
1323
+ 'Harness event mux WebSocket closed before opening',
1324
+ ));
1325
+ const handleError = () => {
1326
+ finish(new Error(opened
1327
+ ? 'Harness event mux WebSocket failed'
1328
+ : 'Harness event mux WebSocket failed before opening'));
1329
+ close();
1330
+ };
1331
+ const handleAbort = () => {
1332
+ close();
1333
+ finish();
1334
+ };
1335
+
1336
+ socket.addEventListener('open', handleOpen);
1337
+ socket.addEventListener('message', handleMessage);
1338
+ socket.addEventListener('close', handleClose, { once: true });
1339
+ socket.addEventListener('error', handleError, { once: true });
1340
+ signal.addEventListener('abort', handleAbort, { once: true });
1341
+ if (signal.aborted) handleAbort();
1342
+ });
1343
+ }
1344
+
1226
1345
  stopManagedProcess() {
1227
1346
  if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill('SIGTERM');
1228
1347
  }
@@ -184,7 +184,7 @@ function authenticatedHeaders(token) {
184
184
  function baseInfo() {
185
185
  return {
186
186
  channel_version: WEIXIN_PROTOCOL_VERSION,
187
- bot_agent: 'DeepSeekHarness/0.16.0',
187
+ bot_agent: 'DeepSeekHarness/0.17.0',
188
188
  };
189
189
  }
190
190