polygram 0.6.16 → 0.7.1

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,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.6.16",
4
+ "version": "0.7.1",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
package/README.md CHANGED
@@ -364,7 +364,7 @@ foreign-chat clicks are rejected. Default-deny on IPC error.
364
364
  ## Development
365
365
 
366
366
  ```bash
367
- npm test # 500 tests, 115 suites, node:test, no external services
367
+ npm test # 638 tests, 158 suites, node:test, no external services
368
368
  npm run coverage # native test coverage (Node 22+, no devDeps)
369
369
  npm start -- --bot my-bot
370
370
  npm run split-db -- --config config.json --dry-run
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Subagent / informational announces — a thin OpenClaw-style helper for
3
+ * sending small "I'm doing X" messages to a chat without mixing into
4
+ * the main reply flow.
5
+ *
6
+ * Polygram's user-facing surface for "shumabit is working" is the
7
+ * status reaction on the user's message (👀 → 🤔 → tool icons →
8
+ * 👍/💥). For tool-heavy turns where the user wants more visibility
9
+ * — e.g. shumabit delegating to a subagent via Claude Code's Task
10
+ * tool — an opt-in announce can post a brief informational message
11
+ * to the chat. Off by default (`config.bots.<bot>.announceSubagents`
12
+ * or `config.chats.<id>.announceSubagents`), so existing chats see
13
+ * no behavior change.
14
+ *
15
+ * 0.7.1 redesign: factory-based with split read/write predicates
16
+ * (canAnnounce / markAnnounced) and lazy GC. Pre-0.7.1 had a
17
+ * module-scoped Map and a mutate-on-check `shouldAnnounce` predicate
18
+ * — both anti-patterns flagged in design review. The free-function
19
+ * API (`shouldAnnounce`, `announce`) is preserved for back-compat,
20
+ * delegating to a default singleton.
21
+ */
22
+
23
+ const SUBAGENT_DEBOUNCE_MS = 30_000;
24
+
25
+ /**
26
+ * Per-chat debounce tracker. Returns:
27
+ * - canAnnounce(chatId): true if this chat hasn't announced within
28
+ * the debounce window. Pure read, NO mutation — safe for
29
+ * speculative checks.
30
+ * - markAnnounced(chatId): records `now` as the last announce time
31
+ * for this chat. Caller invokes after a successful send.
32
+ * - sweep(): drops entries older than `2 * debounceMs`. Called lazily
33
+ * on every canAnnounce check past a soft threshold.
34
+ * - size(): for tests / diagnostics.
35
+ * - clear(): for test isolation.
36
+ */
37
+ function createAnnouncer({
38
+ debounceMs = SUBAGENT_DEBOUNCE_MS,
39
+ clock = Date.now,
40
+ sweepThreshold = 1000,
41
+ } = {}) {
42
+ const lastAnnounceByChat = new Map();
43
+
44
+ function key(chatId) { return String(chatId); }
45
+
46
+ function sweep() {
47
+ const cutoff = clock() - 2 * debounceMs;
48
+ for (const [k, ts] of lastAnnounceByChat) {
49
+ if (ts < cutoff) lastAnnounceByChat.delete(k);
50
+ }
51
+ }
52
+
53
+ function canAnnounce(chatId) {
54
+ if (lastAnnounceByChat.size > sweepThreshold) sweep();
55
+ const prev = lastAnnounceByChat.get(key(chatId));
56
+ return prev == null || (clock() - prev) >= debounceMs;
57
+ }
58
+
59
+ function markAnnounced(chatId) {
60
+ lastAnnounceByChat.set(key(chatId), clock());
61
+ }
62
+
63
+ return {
64
+ canAnnounce, markAnnounced, sweep,
65
+ get size() { return lastAnnounceByChat.size; },
66
+ clear() { lastAnnounceByChat.clear(); },
67
+ };
68
+ }
69
+
70
+ // Default per-process state for the back-compat free-function API.
71
+ // Pre-0.7.1, this was the only API. Long-running daemons should still
72
+ // prefer createAnnouncer() for tests / multi-bot isolation, but
73
+ // polygram.js's single-bot-per-process model means the singleton works
74
+ // fine for the production path. The Map is pruned lazily inside
75
+ // shouldAnnounce when it grows past the sweep threshold.
76
+ const _defaultLastAnnouncements = new Map();
77
+ const _DEFAULT_SWEEP_THRESHOLD = 1000;
78
+
79
+ /**
80
+ * Back-compat: pre-0.7.1 callers used `shouldAnnounce(chatId, now?,
81
+ * debounceMs?)` which is a "predicate that mutates" — call site is
82
+ * `if (shouldAnnounce(id)) await sendAnnounce()`. The mutation happens
83
+ * eagerly. Preserved verbatim for callers; new code should use
84
+ * `createAnnouncer()` and the explicit canAnnounce/markAnnounced split.
85
+ *
86
+ * 0.7.1: added lazy sweep so the Map doesn't grow unbounded over a
87
+ * multi-week-uptime daemon.
88
+ */
89
+ function shouldAnnounce(chatId, now = Date.now(), debounceMs = SUBAGENT_DEBOUNCE_MS) {
90
+ if (_defaultLastAnnouncements.size > _DEFAULT_SWEEP_THRESHOLD) {
91
+ const cutoff = now - 2 * debounceMs;
92
+ for (const [k, ts] of _defaultLastAnnouncements) {
93
+ if (ts < cutoff) _defaultLastAnnouncements.delete(k);
94
+ }
95
+ }
96
+ const key = String(chatId);
97
+ const prev = _defaultLastAnnouncements.get(key);
98
+ if (prev != null && now - prev < debounceMs) return false;
99
+ _defaultLastAnnouncements.set(key, now);
100
+ return true;
101
+ }
102
+
103
+ /**
104
+ * Reset the default singleton state (for tests). Not exported in
105
+ * production docs.
106
+ */
107
+ function _resetDefaultAnnouncerForTests() {
108
+ _defaultLastAnnouncements.clear();
109
+ }
110
+
111
+ /**
112
+ * Send a plain-text announce (no markdown processing, no reply linkage).
113
+ * Caller passes `tg(bot, method, params, meta)` as `send` so we don't
114
+ * have to import the full lib/telegram.js dependency tree here.
115
+ */
116
+ async function announce({
117
+ send,
118
+ bot,
119
+ chatId,
120
+ threadId = null,
121
+ text,
122
+ meta = {},
123
+ logger = console,
124
+ }) {
125
+ if (!text) return null;
126
+ const params = {
127
+ chat_id: chatId,
128
+ text,
129
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
130
+ };
131
+ try {
132
+ return await send(bot, 'sendMessage', params, {
133
+ ...meta,
134
+ source: meta.source || 'announce',
135
+ plainText: true, // skip markdown→HTML
136
+ linkPreview: false, // never preview-card for announces
137
+ });
138
+ } catch (err) {
139
+ logger.error?.(`[announce] failed: ${err.message}`);
140
+ return null;
141
+ }
142
+ }
143
+
144
+ module.exports = {
145
+ announce,
146
+ shouldAnnounce,
147
+ createAnnouncer,
148
+ SUBAGENT_DEBOUNCE_MS,
149
+ _resetDefaultAnnouncerForTests,
150
+ };
package/lib/deliver.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Chunked reply delivery primitive.
3
+ *
4
+ * `deliverReplies` is the polygram analog of OpenClaw's `deliverReplies` —
5
+ * a small loop over already-chunked text that sends each chunk as its own
6
+ * Telegram message via the `send()` wrapper from lib/telegram.js (which
7
+ * does write-before-send, HTML→plain fallback, and MESSAGE_NOT_MODIFIED
8
+ * swallowing).
9
+ *
10
+ * Polygram's old code path inlined a `for (chunk of chunkText(rest))` loop
11
+ * inside polygram.js with an ad-hoc `tg()` call. That worked, but mixed
12
+ * delivery concerns into the streaming-finalize logic, and made testing
13
+ * the multi-message path painful. With this primitive, the new
14
+ * "preview-becomes-final" flow in handleMessage (Phase 5) just calls:
15
+ *
16
+ * await deliverReplies({ bot, chatId, threadId, chunks, replyToMessageId, ... })
17
+ *
18
+ * — and gets back `{ sent, failed }` arrays of message_ids per chunk.
19
+ *
20
+ * Behavior:
21
+ * - Sends `chunks[0]` first with `reply_parameters` (so the answer
22
+ * visually anchors to the user's question). Subsequent chunks omit
23
+ * `reply_parameters` — chaining replies would clutter the chat.
24
+ * - On chunk failure, logs and continues to the next chunk. We'd
25
+ * rather deliver partial content than abort the whole reply.
26
+ * - Empty input returns `{ sent: [], failed: [] }` immediately.
27
+ */
28
+
29
+ async function deliverReplies({
30
+ bot,
31
+ send, // (bot, method, params, meta) → res — usually createSender(db, logger)(...) or tg
32
+ chatId,
33
+ threadId = null,
34
+ chunks,
35
+ replyToMessageId = null,
36
+ meta = {},
37
+ logger = console,
38
+ }) {
39
+ if (!Array.isArray(chunks) || chunks.length === 0) {
40
+ return { sent: [], failed: [], results: [] };
41
+ }
42
+ // 0.7.1: results[] preserves correspondence with chunks[] — results[i]
43
+ // describes what happened to chunks[i]. sent[]/failed[] are projections
44
+ // for back-compat with callers that already use them; they no longer
45
+ // ambiguously mean "the i-th success/failure" vs "chunk i's outcome".
46
+ const results = [];
47
+ const sent = [];
48
+ const failed = [];
49
+ for (let i = 0; i < chunks.length; i++) {
50
+ const params = {
51
+ chat_id: chatId,
52
+ text: chunks[i],
53
+ };
54
+ if (threadId != null) params.message_thread_id = threadId;
55
+ if (i === 0 && replyToMessageId != null) {
56
+ // allow_sending_without_reply: long turns give the user time to
57
+ // delete their original message; without this flag Telegram
58
+ // rejects with MESSAGE_NOT_FOUND and the whole reply is lost.
59
+ params.reply_parameters = { message_id: replyToMessageId, allow_sending_without_reply: true };
60
+ }
61
+ try {
62
+ const res = await send(bot, 'sendMessage', params, meta);
63
+ const msgId = res?.message_id ?? null;
64
+ results.push({ index: i, status: 'ok', messageId: msgId });
65
+ sent.push(msgId);
66
+ } catch (err) {
67
+ logger.error?.(`[deliver] chunk ${i + 1}/${chunks.length} failed: ${err.message}`);
68
+ results.push({ index: i, status: 'fail', error: err.message });
69
+ failed.push({ index: i, error: err.message });
70
+ // Keep going — partial delivery is better than total loss. Caller
71
+ // should inspect failed.length and surface a warning to the user
72
+ // (see polygram.js handleMessage's stream-redeliver event log).
73
+ }
74
+ }
75
+ return { sent, failed, results };
76
+ }
77
+
78
+ module.exports = { deliverReplies };
package/lib/net-errors.js CHANGED
@@ -30,20 +30,55 @@ const PRE_CONNECT_ERROR_CODES = new Set([
30
30
  // Transient errors that are recoverable but may have made it partway. DO
31
31
  // NOT auto-retry these — the risk of double-delivery outweighs the gain.
32
32
  // Surface them to the caller and let humans decide.
33
+ //
34
+ // 0.7.0 added the UND_ERR_* family + ECONNABORTED / ERR_NETWORK to match
35
+ // OpenClaw's set (extensions/telegram/src/network-errors.ts). Node 22+
36
+ // uses undici as its default fetch impl, so these surface in real
37
+ // production traffic — pre-0.7.0 we'd silently misclassify them as
38
+ // non-network errors.
33
39
  const RECOVERABLE_ERROR_CODES = new Set([
34
- 'ETIMEDOUT', // TCP timeout after connect (message may have landed)
35
- 'EPIPE', // write after close — outcome indeterminate
36
- 'EAGAIN', // socket would block — reader should retry
40
+ 'ETIMEDOUT', // TCP timeout after connect (message may have landed)
41
+ 'EPIPE', // write after close — outcome indeterminate
42
+ 'EAGAIN', // socket would block — reader should retry
43
+ 'ESOCKETTIMEDOUT', // socket-level timeout (axios/legacy node)
44
+ 'ECONNABORTED', // connection aborted by client (timeout-induced)
45
+ 'ERR_NETWORK', // generic network error code
46
+ 'UND_ERR_CONNECT_TIMEOUT', // undici: connection timeout
47
+ 'UND_ERR_HEADERS_TIMEOUT', // undici: response headers timeout
48
+ 'UND_ERR_BODY_TIMEOUT', // undici: response body timeout
49
+ 'UND_ERR_SOCKET', // undici: socket error
50
+ 'UND_ERR_ABORTED', // undici: request aborted
37
51
  ]);
38
52
 
39
53
  // Error.name values emitted by undici/node for transient conditions.
54
+ // 0.7.0 added the undici-specific timeout error names; the new fetch
55
+ // impl in Node 22+ surfaces these as `err.name` rather than `err.code`
56
+ // in some shapes.
40
57
  const RECOVERABLE_ERROR_NAMES = new Set([
41
58
  'AbortError',
42
59
  'TimeoutError',
43
60
  'FetchError',
44
61
  'SocketError',
62
+ 'ConnectTimeoutError',
63
+ 'HeadersTimeoutError',
64
+ 'BodyTimeoutError',
45
65
  ]);
46
66
 
67
+ // Message-substring matchers for transient errors. undici sometimes
68
+ // wraps a network failure in a generic "fetch failed" without setting
69
+ // .code or .name — only the message tells us it's a network error.
70
+ //
71
+ // These are matched ONLY when the error doesn't already have a code or
72
+ // name we recognise, to avoid double-counting and to keep the broad
73
+ // matcher from catching unrelated errors that happen to include the
74
+ // substring.
75
+ const RECOVERABLE_MESSAGE_SNIPPETS = [
76
+ 'fetch failed',
77
+ 'undici',
78
+ 'network error',
79
+ 'network request',
80
+ ];
81
+
47
82
  function extractCode(err) {
48
83
  if (!err) return null;
49
84
  return err.code
@@ -67,6 +102,11 @@ function isSafeToRetry(err) {
67
102
  return code != null && PRE_CONNECT_ERROR_CODES.has(code);
68
103
  }
69
104
 
105
+ function extractMessage(err) {
106
+ if (!err) return '';
107
+ return String(err.message || err.cause?.message || err.description || '').toLowerCase();
108
+ }
109
+
70
110
  /**
71
111
  * Is this a transient network error — recoverable in the sense that the
72
112
  * connection may work next time, but NOT safe to auto-retry because the
@@ -80,6 +120,13 @@ function isTransientNetworkError(err) {
80
120
  }
81
121
  const name = extractName(err);
82
122
  if (name && RECOVERABLE_ERROR_NAMES.has(name)) return true;
123
+ // 0.7.0: only fall through to message-snippet matching when the
124
+ // error has no recognised code/name — avoids false-positive matches
125
+ // on unrelated errors that happen to mention "network".
126
+ const message = extractMessage(err);
127
+ if (message && RECOVERABLE_MESSAGE_SNIPPETS.some((s) => message.includes(s))) {
128
+ return true;
129
+ }
83
130
  return false;
84
131
  }
85
132
 
@@ -113,9 +160,11 @@ module.exports = {
113
160
  PRE_CONNECT_ERROR_CODES,
114
161
  RECOVERABLE_ERROR_CODES,
115
162
  RECOVERABLE_ERROR_NAMES,
163
+ RECOVERABLE_MESSAGE_SNIPPETS,
116
164
  isSafeToRetry,
117
165
  isTransientNetworkError,
118
166
  extractCode,
119
167
  extractName,
168
+ extractMessage,
120
169
  redactBotToken,
121
170
  };
@@ -59,6 +59,7 @@ class ProcessManager {
59
59
  onClose = null, // (sessionKey, code, entry) → void
60
60
  onStreamChunk = null, // (sessionKey, partialText, entry) → void — routes to pendingQueue[0]
61
61
  onToolUse = null, // (sessionKey, toolName, entry) → void — routes to pendingQueue[0]
62
+ onAssistantMessageStart = null, // (sessionKey, entry) → void — fires when a NEW top-level assistant message begins (after a previous one ended). Used by polygram.js to call streamer.forceNewMessage() so each assistant message gets its own bubble.
62
63
  onRespawn = null, // (sessionKey, reason, entry) → void — fires after graceful drain-and-kill
63
64
  } = {}) {
64
65
  if (!spawnFn) throw new Error('spawnFn required');
@@ -72,6 +73,7 @@ class ProcessManager {
72
73
  this.onClose = onClose;
73
74
  this.onStreamChunk = onStreamChunk;
74
75
  this.onToolUse = onToolUse;
76
+ this.onAssistantMessageStart = onAssistantMessageStart;
75
77
  this.onRespawn = onRespawn;
76
78
  this.procs = new Map();
77
79
  }
@@ -275,12 +277,34 @@ class ProcessManager {
275
277
  }
276
278
 
277
279
  if (event.type === 'assistant' && head) {
278
- if (this.onStreamChunk) {
279
- const added = extractAssistantText(event);
280
- if (added) {
281
- head.streamText = head.streamText
282
- ? `${head.streamText}\n\n${added}`
283
- : added;
280
+ // 0.7.0 (Phase F): detect message_id transitions to split bubbles
281
+ // per top-level assistant message. Each Anthropic stream-json
282
+ // 'assistant' event carries event.message.id; the same id across
283
+ // events means cumulative updates to the same message, a new
284
+ // id means a new message (typically after a tool-result cycle).
285
+ const messageId = event.message?.id;
286
+ const added = extractAssistantText(event);
287
+ if (added) {
288
+ // Pre-0.7.0 we did `streamText = streamText + '\n\n' + added`,
289
+ // which DUPLICATED text on every update because `added` is
290
+ // the cumulative full text-so-far of the current assistant
291
+ // message (not a delta). 0.7.0 REPLACES instead — the new
292
+ // text is already cumulative — and uses messageId boundaries
293
+ // to fire onAssistantMessageStart for each new top-level
294
+ // assistant message. The streamer responds by force-creating
295
+ // a fresh bubble, so each assistant message gets its own.
296
+ const isNewMessage = head.lastAssistantMessageId != null
297
+ && messageId != null
298
+ && head.lastAssistantMessageId !== messageId
299
+ && head.streamText
300
+ && head.streamText.length > 0;
301
+ if (isNewMessage && this.onAssistantMessageStart) {
302
+ try { this.onAssistantMessageStart(sessionKey, entry); }
303
+ catch (err) { this.logger.error(`[${entry.label}] onAssistantMessageStart: ${err.message}`); }
304
+ }
305
+ if (messageId != null) head.lastAssistantMessageId = messageId;
306
+ head.streamText = added;
307
+ if (this.onStreamChunk) {
284
308
  try { this.onStreamChunk(sessionKey, head.streamText, entry); }
285
309
  catch (err) { this.logger.error(`[${entry.label}] onStreamChunk: ${err.message}`); }
286
310
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * In-memory cache of message IDs the bot has sent, per chat.
3
+ *
4
+ * Port of OpenClaw's `sent-message-cache` (send-DVX_zY9w.js:1014-1041).
5
+ * Use case: filter the bot's own messages out of activation logic in
6
+ * group chats — a bot reply with a URL would otherwise auto-trigger
7
+ * a self-reply if the chat's activation rule includes "any message
8
+ * with a URL". Polygram's existing `messages` table can answer the
9
+ * same question via SQL (`direction = 'out' AND chat_id AND msg_id`),
10
+ * but the in-memory cache is O(1) for the high-frequency callers
11
+ * (every inbound message reaction handler).
12
+ *
13
+ * 24-hour TTL: Telegram messages older than 48h can't be reacted to
14
+ * anyway, so 24h is a comfortable working set. Per-chat cleanup runs
15
+ * lazily when the chat exceeds 100 entries.
16
+ */
17
+
18
+ const TTL_MS = 24 * 60 * 60 * 1000;
19
+ const CLEANUP_THRESHOLD = 100;
20
+ // 0.7.1: hard cap on per-chat Map size. CLEANUP_THRESHOLD only drops
21
+ // EXPIRED entries — if a busy chat sends >100 fresh messages within
22
+ // 24h, GC finds nothing to drop and the inner Map grows unbounded.
23
+ // Cap evicts oldest entries past this point regardless of TTL.
24
+ const MAX_PER_CHAT = 500;
25
+ // 0.7.1: outer Map sweep — drop chats whose inner Map has been empty
26
+ // long enough that we're sure no live caller still references it.
27
+ const OUTER_SWEEP_THRESHOLD = 1000;
28
+
29
+ function createSentCache({
30
+ ttlMs = TTL_MS,
31
+ cleanupThreshold = CLEANUP_THRESHOLD,
32
+ maxPerChat = MAX_PER_CHAT,
33
+ outerSweepThreshold = OUTER_SWEEP_THRESHOLD,
34
+ clock = Date.now,
35
+ } = {}) {
36
+ // chatKey → Map<msgId, ts>
37
+ const sentMessages = new Map();
38
+
39
+ function chatKey(chatId) { return String(chatId); }
40
+
41
+ function gcInner(entry) {
42
+ const cutoff = clock() - ttlMs;
43
+ for (const [id, ts] of entry) if (ts < cutoff) entry.delete(id);
44
+ // After TTL prune, if still over the hard cap, drop oldest entries
45
+ // (insertion order in Map iteration). This handles the busy-chat
46
+ // case where 1000 messages all sent within 24h would otherwise
47
+ // leak.
48
+ if (entry.size > maxPerChat) {
49
+ const dropCount = entry.size - maxPerChat;
50
+ let i = 0;
51
+ for (const id of entry.keys()) {
52
+ if (i >= dropCount) break;
53
+ entry.delete(id);
54
+ i += 1;
55
+ }
56
+ }
57
+ }
58
+
59
+ function gcOuter() {
60
+ // Drop chat entries that are entirely empty (their inner Map was
61
+ // drained by gcInner). Without this the outer Map's chatId set
62
+ // grows by one per ever-active-then-idle chat, slowly leaking.
63
+ for (const [k, entry] of sentMessages) {
64
+ if (entry.size === 0) sentMessages.delete(k);
65
+ }
66
+ }
67
+
68
+ function record(chatId, messageId) {
69
+ if (chatId == null || messageId == null) return;
70
+ const key = chatKey(chatId);
71
+ let entry = sentMessages.get(key);
72
+ if (!entry) {
73
+ entry = new Map();
74
+ sentMessages.set(key, entry);
75
+ }
76
+ entry.set(messageId, clock());
77
+ if (entry.size > cleanupThreshold) gcInner(entry);
78
+ // Periodic outer sweep — runs only when the outer Map gets crowded.
79
+ if (sentMessages.size > outerSweepThreshold) gcOuter();
80
+ }
81
+
82
+ function wasSent(chatId, messageId) {
83
+ if (chatId == null || messageId == null) return false;
84
+ const key = chatKey(chatId);
85
+ const entry = sentMessages.get(key);
86
+ if (!entry) return false;
87
+ const ts = entry.get(messageId);
88
+ if (ts == null) return false;
89
+ if (clock() - ts > ttlMs) {
90
+ entry.delete(messageId);
91
+ // If we just emptied the inner Map, drop the outer entry too.
92
+ if (entry.size === 0) sentMessages.delete(key);
93
+ return false;
94
+ }
95
+ return true;
96
+ }
97
+
98
+ function size() {
99
+ let total = 0;
100
+ for (const entry of sentMessages.values()) total += entry.size;
101
+ return total;
102
+ }
103
+
104
+ function chatCount() { return sentMessages.size; }
105
+
106
+ function clear() {
107
+ sentMessages.clear();
108
+ }
109
+
110
+ return { record, wasSent, size, chatCount, clear };
111
+ }
112
+
113
+ module.exports = {
114
+ createSentCache,
115
+ TTL_MS,
116
+ CLEANUP_THRESHOLD,
117
+ MAX_PER_CHAT,
118
+ OUTER_SWEEP_THRESHOLD,
119
+ };