polygram 0.20.1 → 0.20.3
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/.claude-plugin/plugin.json +1 -1
- package/lib/db.js +42 -28
- package/lib/handlers/dispatcher.js +7 -1
- package/lib/ops/cgroup-oom-observer.js +139 -0
- package/lib/ops/shutdown-disposition.js +36 -0
- package/lib/prompt.js +21 -5
- package/package.json +2 -2
- package/polygram.js +27 -15
- package/skills/polygram-send/SKILL.md +30 -9
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.20.
|
|
4
|
+
"version": "0.20.3",
|
|
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 plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"telegram",
|
package/lib/db.js
CHANGED
|
@@ -239,6 +239,38 @@ function wrap(db) {
|
|
|
239
239
|
UPDATE messages SET status = 'failed', error = '${CRASHED_MID_SEND}'
|
|
240
240
|
WHERE status = 'pending' AND ts < ? AND bot_name = ?
|
|
241
241
|
`);
|
|
242
|
+
const markReplayPendingStmt = db.prepare(`
|
|
243
|
+
UPDATE messages SET handler_status = 'replay-pending'
|
|
244
|
+
WHERE direction = 'in'
|
|
245
|
+
AND handler_status IN ('dispatched', 'processing')
|
|
246
|
+
AND bot_name = ?
|
|
247
|
+
AND ts > ?
|
|
248
|
+
`);
|
|
249
|
+
const clearCleanShutdownMarkerStmt = db.prepare(`
|
|
250
|
+
UPDATE polling_state SET clean_shutdown_at = NULL
|
|
251
|
+
WHERE bot_name = ?
|
|
252
|
+
`);
|
|
253
|
+
|
|
254
|
+
function recordShutdown({ botName, now = Date.now(), since, clean }) {
|
|
255
|
+
const cutoff = since ?? now - 30 * 60 * 1000;
|
|
256
|
+
const txn = db.transaction(() => {
|
|
257
|
+
const marked = markReplayPendingStmt.run(botName, cutoff);
|
|
258
|
+
if (clean) {
|
|
259
|
+
db.prepare(`
|
|
260
|
+
INSERT INTO polling_state (bot_name, last_update_id, ts, clean_shutdown_at)
|
|
261
|
+
VALUES (?,
|
|
262
|
+
COALESCE((SELECT last_update_id FROM polling_state WHERE bot_name = ?), 0),
|
|
263
|
+
COALESCE((SELECT ts FROM polling_state WHERE bot_name = ?), ?),
|
|
264
|
+
?)
|
|
265
|
+
ON CONFLICT(bot_name) DO UPDATE SET clean_shutdown_at = excluded.clean_shutdown_at
|
|
266
|
+
`).run(botName, botName, botName, now, now);
|
|
267
|
+
} else {
|
|
268
|
+
clearCleanShutdownMarkerStmt.run(botName);
|
|
269
|
+
}
|
|
270
|
+
return marked.changes;
|
|
271
|
+
});
|
|
272
|
+
return { replayMarked: txn() };
|
|
273
|
+
}
|
|
242
274
|
|
|
243
275
|
return {
|
|
244
276
|
raw: db,
|
|
@@ -635,13 +667,7 @@ function wrap(db) {
|
|
|
635
667
|
// null to mark all dispatched/processing rows for a bot).
|
|
636
668
|
markReplayPending({ botName, since }) {
|
|
637
669
|
const cutoff = since ?? Date.now() - 30 * 60 * 1000;
|
|
638
|
-
return
|
|
639
|
-
UPDATE messages SET handler_status = 'replay-pending'
|
|
640
|
-
WHERE direction = 'in'
|
|
641
|
-
AND handler_status IN ('dispatched', 'processing')
|
|
642
|
-
AND bot_name = ?
|
|
643
|
-
AND ts > ?
|
|
644
|
-
`).run(botName, cutoff);
|
|
670
|
+
return markReplayPendingStmt.run(botName, cutoff);
|
|
645
671
|
},
|
|
646
672
|
|
|
647
673
|
// 0.14 boot-replay: record a DELIBERATE (clean) shutdown. Atomically, in ONE
|
|
@@ -657,26 +683,14 @@ function wrap(db) {
|
|
|
657
683
|
// only on a non-empty getUpdates batch): COALESCE the existing values, else
|
|
658
684
|
// seed (0, now).
|
|
659
685
|
recordCleanShutdown({ botName, now = Date.now(), since } = {}) {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
`).run(botName, cutoff);
|
|
669
|
-
db.prepare(`
|
|
670
|
-
INSERT INTO polling_state (bot_name, last_update_id, ts, clean_shutdown_at)
|
|
671
|
-
VALUES (?,
|
|
672
|
-
COALESCE((SELECT last_update_id FROM polling_state WHERE bot_name = ?), 0),
|
|
673
|
-
COALESCE((SELECT ts FROM polling_state WHERE bot_name = ?), ?),
|
|
674
|
-
?)
|
|
675
|
-
ON CONFLICT(bot_name) DO UPDATE SET clean_shutdown_at = excluded.clean_shutdown_at
|
|
676
|
-
`).run(botName, botName, botName, now, now);
|
|
677
|
-
return marked.changes;
|
|
678
|
-
});
|
|
679
|
-
return { replayMarked: txn() };
|
|
686
|
+
return recordShutdown({ botName, now, since, clean: true });
|
|
687
|
+
},
|
|
688
|
+
|
|
689
|
+
// A handled stop can still be crash-like when the supervisor is stopping
|
|
690
|
+
// the service after a child OOM. Mark current work replayable and clear any
|
|
691
|
+
// clean marker in one transaction so the next boot fails toward recovery.
|
|
692
|
+
recordCrashShutdown({ botName, now = Date.now(), since } = {}) {
|
|
693
|
+
return recordShutdown({ botName, now, since, clean: false });
|
|
680
694
|
},
|
|
681
695
|
|
|
682
696
|
// 0.14: read AND clear the clean-shutdown marker in one txn. "Clean" iff a
|
|
@@ -689,7 +703,7 @@ function wrap(db) {
|
|
|
689
703
|
const row = db.prepare('SELECT clean_shutdown_at FROM polling_state WHERE bot_name = ?').get(botName);
|
|
690
704
|
const at = row ? row.clean_shutdown_at : null;
|
|
691
705
|
if (row && at != null) {
|
|
692
|
-
|
|
706
|
+
clearCleanShutdownMarkerStmt.run(botName);
|
|
693
707
|
}
|
|
694
708
|
return at;
|
|
695
709
|
});
|
|
@@ -69,6 +69,7 @@ function createDispatcher({
|
|
|
69
69
|
authDisabledGate = createAuthDisabledGate(),
|
|
70
70
|
// State accessors (need late binding because polygram.js mutates):
|
|
71
71
|
getIsShuttingDown, // () → boolean
|
|
72
|
+
getOomObservation = () => null, // () → cgroup OOM observation
|
|
72
73
|
logger = console,
|
|
73
74
|
} = {}) {
|
|
74
75
|
// Per-session in-flight handler count.
|
|
@@ -188,7 +189,11 @@ function createDispatcher({
|
|
|
188
189
|
handleMessage(sessionKey, chatId, msg, bot).catch((err) => {
|
|
189
190
|
const wasAborted = abortGrace.isRecent(sessionKey);
|
|
190
191
|
const isReplay = msg._isReplay === true;
|
|
191
|
-
|
|
192
|
+
let oomShutdown = false;
|
|
193
|
+
try {
|
|
194
|
+
oomShutdown = getOomObservation()?.detected === true;
|
|
195
|
+
} catch {}
|
|
196
|
+
const isShuttingDown = getIsShuttingDown() || oomShutdown;
|
|
192
197
|
logger.error?.(`[${sessionKey}] Error: ${err.message}`);
|
|
193
198
|
// Mark the row terminal so the right thing happens on next
|
|
194
199
|
// boot:
|
|
@@ -212,6 +217,7 @@ function createDispatcher({
|
|
|
212
217
|
stack: err.stack?.split('\n').slice(0, 5).join('\n'),
|
|
213
218
|
aborted: wasAborted || undefined,
|
|
214
219
|
replay: isReplay || undefined,
|
|
220
|
+
oom_shutdown: oomShutdown || undefined,
|
|
215
221
|
});
|
|
216
222
|
// Startup-gate death (claude exited during spawn / the dialog gate timed
|
|
217
223
|
// out) of a likely-aged RESUMED session — the persisted claude_session_id
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const CGROUP_MEMBERSHIP_PATH = '/proc/self/cgroup';
|
|
7
|
+
const CGROUP_ROOT = '/sys/fs/cgroup';
|
|
8
|
+
|
|
9
|
+
function parseCgroupV2Path(contents) {
|
|
10
|
+
if (typeof contents !== 'string') throw new TypeError('cgroup membership must be text');
|
|
11
|
+
const matches = contents
|
|
12
|
+
.split(/\r?\n/)
|
|
13
|
+
.filter((line) => line.startsWith('0::'));
|
|
14
|
+
if (matches.length === 0) return null;
|
|
15
|
+
if (matches.length !== 1) throw new Error('multiple cgroup v2 memberships');
|
|
16
|
+
|
|
17
|
+
const cgroupPath = matches[0].slice(3);
|
|
18
|
+
if (!cgroupPath.startsWith('/')) throw new Error('cgroup v2 path is not absolute');
|
|
19
|
+
if (cgroupPath.includes('\0')) throw new Error('cgroup v2 path contains NUL');
|
|
20
|
+
if (/\s+\(deleted\)$/.test(cgroupPath)) throw new Error('cgroup v2 path is deleted');
|
|
21
|
+
|
|
22
|
+
const relative = cgroupPath.slice(1);
|
|
23
|
+
if (relative !== '') {
|
|
24
|
+
const components = relative.split('/');
|
|
25
|
+
if (components.some((component) => component === '' || component === '.' || component === '..')) {
|
|
26
|
+
throw new Error('cgroup v2 path contains an invalid component');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return cgroupPath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveMemoryEventsPath(cgroupPath, cgroupRoot = CGROUP_ROOT) {
|
|
33
|
+
if (typeof cgroupPath !== 'string' || !cgroupPath.startsWith('/')) {
|
|
34
|
+
throw new Error('cgroup v2 path is invalid');
|
|
35
|
+
}
|
|
36
|
+
const root = path.resolve(cgroupRoot);
|
|
37
|
+
const cgroupDir = path.resolve(root, cgroupPath.slice(1));
|
|
38
|
+
const relative = path.relative(root, cgroupDir);
|
|
39
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
40
|
+
throw new Error('cgroup v2 path escapes the cgroup root');
|
|
41
|
+
}
|
|
42
|
+
return path.join(cgroupDir, 'memory.events');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseOomKillCounter(contents) {
|
|
46
|
+
if (typeof contents !== 'string') throw new TypeError('memory.events must be text');
|
|
47
|
+
const values = [];
|
|
48
|
+
for (const line of contents.split(/\r?\n/)) {
|
|
49
|
+
const fields = line.trim().split(/\s+/);
|
|
50
|
+
if (fields[0] !== 'oom_kill') continue;
|
|
51
|
+
if (fields.length !== 2 || !/^\d+$/.test(fields[1])) {
|
|
52
|
+
throw new Error('memory.events oom_kill counter is invalid');
|
|
53
|
+
}
|
|
54
|
+
values.push(fields[1]);
|
|
55
|
+
}
|
|
56
|
+
if (values.length !== 1) {
|
|
57
|
+
throw new Error('memory.events oom_kill counter is missing or invalid');
|
|
58
|
+
}
|
|
59
|
+
return BigInt(values[0]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function observation(status, {
|
|
63
|
+
detected = false,
|
|
64
|
+
delta = null,
|
|
65
|
+
reason = null,
|
|
66
|
+
} = {}) {
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
status,
|
|
69
|
+
detected,
|
|
70
|
+
delta,
|
|
71
|
+
...(reason ? { reason } : {}),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function createCgroupOomObserver({
|
|
76
|
+
platform = process.platform,
|
|
77
|
+
readFileSync = fs.readFileSync,
|
|
78
|
+
membershipPath = CGROUP_MEMBERSHIP_PATH,
|
|
79
|
+
cgroupRoot = CGROUP_ROOT,
|
|
80
|
+
} = {}) {
|
|
81
|
+
if (platform !== 'linux') {
|
|
82
|
+
const startup = observation('unsupported', { reason: 'platform' });
|
|
83
|
+
return { startup, sample: () => startup };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let cgroupPath;
|
|
87
|
+
try {
|
|
88
|
+
cgroupPath = parseCgroupV2Path(readFileSync(membershipPath, 'utf8'));
|
|
89
|
+
} catch {
|
|
90
|
+
const startup = observation('unavailable', { reason: 'membership-invalid' });
|
|
91
|
+
return { startup, sample: () => startup };
|
|
92
|
+
}
|
|
93
|
+
if (cgroupPath == null) {
|
|
94
|
+
const startup = observation('unsupported', { reason: 'cgroup-v2-unavailable' });
|
|
95
|
+
return { startup, sample: () => startup };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let memoryEventsPath;
|
|
99
|
+
try {
|
|
100
|
+
memoryEventsPath = resolveMemoryEventsPath(cgroupPath, cgroupRoot);
|
|
101
|
+
} catch {
|
|
102
|
+
const startup = observation('unavailable', { reason: 'membership-invalid' });
|
|
103
|
+
return { startup, sample: () => startup };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let baseline;
|
|
107
|
+
try {
|
|
108
|
+
baseline = parseOomKillCounter(readFileSync(memoryEventsPath, 'utf8'));
|
|
109
|
+
} catch {
|
|
110
|
+
const startup = observation('unavailable', { reason: 'memory-events-unavailable' });
|
|
111
|
+
return { startup, sample: () => startup };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const startup = observation('unchanged', { delta: 0n });
|
|
115
|
+
return {
|
|
116
|
+
startup,
|
|
117
|
+
sample() {
|
|
118
|
+
let current;
|
|
119
|
+
try {
|
|
120
|
+
current = parseOomKillCounter(readFileSync(memoryEventsPath, 'utf8'));
|
|
121
|
+
} catch {
|
|
122
|
+
return observation('unavailable', { reason: 'memory-events-unavailable' });
|
|
123
|
+
}
|
|
124
|
+
if (current < baseline) {
|
|
125
|
+
return observation('unavailable', { reason: 'oom-kill-counter-decreased' });
|
|
126
|
+
}
|
|
127
|
+
const delta = current - baseline;
|
|
128
|
+
if (delta > 0n) return observation('detected', { detected: true, delta });
|
|
129
|
+
return observation('unchanged', { delta });
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
parseCgroupV2Path,
|
|
136
|
+
resolveMemoryEventsPath,
|
|
137
|
+
parseOomKillCounter,
|
|
138
|
+
createCgroupOomObserver,
|
|
139
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function shutdownReasonFor(observation) {
|
|
4
|
+
if (observation?.detected === true) return 'cgroup-oom-kill';
|
|
5
|
+
if (observation?.status === 'unchanged') return 'no-oom-delta';
|
|
6
|
+
if (observation?.status === 'unsupported') return 'oom-observer-unsupported';
|
|
7
|
+
return 'oom-observer-unavailable';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Persist the replay state associated with a handled shutdown signal.
|
|
12
|
+
*
|
|
13
|
+
* Keeping this decision outside the signal handler makes the shutdown
|
|
14
|
+
* classification independently testable while the caller retains ownership of
|
|
15
|
+
* draining processes and emitting telemetry.
|
|
16
|
+
*/
|
|
17
|
+
function persistShutdownDisposition({
|
|
18
|
+
db,
|
|
19
|
+
botName,
|
|
20
|
+
now = Date.now(),
|
|
21
|
+
since,
|
|
22
|
+
observation,
|
|
23
|
+
} = {}) {
|
|
24
|
+
const oomDetected = observation?.detected === true;
|
|
25
|
+
const result = oomDetected
|
|
26
|
+
? db.recordCrashShutdown({ botName, now, since })
|
|
27
|
+
: db.recordCleanShutdown({ botName, now, since });
|
|
28
|
+
return {
|
|
29
|
+
clean: !oomDetected,
|
|
30
|
+
shutdownReason: shutdownReasonFor(observation),
|
|
31
|
+
replayMarked: result?.replayMarked ?? 0,
|
|
32
|
+
...(oomDetected ? { oomKillDelta: String(observation.delta) } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { persistShutdownDisposition };
|
package/lib/prompt.js
CHANGED
|
@@ -5,23 +5,37 @@
|
|
|
5
5
|
* Telegram payload → polygram DB → unresolvable marker.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
const { pickBackend } = require('@shumkov/orchestra');
|
|
9
|
+
|
|
10
|
+
function resolvePromptBackend({ config, chatId, threadId = null }) {
|
|
11
|
+
return pickBackend({
|
|
12
|
+
config,
|
|
13
|
+
chatId: String(chatId),
|
|
14
|
+
threadId: threadId == null || threadId === '' ? null : String(threadId),
|
|
15
|
+
pmDefault: 'sdk',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
/**
|
|
9
20
|
* The <polygram-info> preamble. Sticker guidance is included ONLY when this bot
|
|
10
21
|
* has a sticker set loaded (`stickerEmojis` non-empty) — a bot with no stickers.json
|
|
11
22
|
* must not be told to emit stickers it doesn't have. The emoji list is the bot's OWN
|
|
12
23
|
* pack (its emojiToSticker keys), not a hard-coded set, so it always matches reality.
|
|
13
24
|
*
|
|
14
|
-
* @param {{ stickerEmojis?: string[] }} [opts]
|
|
25
|
+
* @param {{ stickerEmojis?: string[], backend?: 'sdk'|'cli' }} [opts]
|
|
15
26
|
*/
|
|
16
|
-
function polygramInfo({ stickerEmojis = [] } = {}) {
|
|
27
|
+
function polygramInfo({ stickerEmojis = [], backend = 'sdk' } = {}) {
|
|
17
28
|
const hasStickers = stickerEmojis.length > 0;
|
|
29
|
+
const deliveryContract = backend === 'cli'
|
|
30
|
+
? `You are connected via a Telegram daemon (polygram) in channels mode. Inline final text is invisible to the Telegram user. For every user-visible response, you MUST call \`mcp__polygram-bridge__reply\` within this turn. Only a successful reply-tool receipt proves delivery. Do not claim something was already sent or posted above based only on the transcript; transcript content is not a delivery receipt. If delivery is uncertain, send it now with the reply tool.`
|
|
31
|
+
: `You are connected via a Telegram daemon (polygram). Just reply with text — polygram delivers your response automatically. Do NOT use Telegram MCP tools.`;
|
|
18
32
|
const emojiLine = hasStickers
|
|
19
33
|
? `Single emoji reply = auto-converted: ${stickerEmojis.join('')} become your stickers, any other emoji (🔥👍💪❤️) becomes a reaction on the user's message.`
|
|
20
34
|
: `Single emoji reply = auto-converted to a reaction on the user's message (any Telegram emoji: 🔥👍💪❤️ …).`;
|
|
21
35
|
const stickerTag = hasStickers
|
|
22
36
|
? `\n- \`[sticker:NAME]\` anywhere in your reply sends that sticker after the text. NAME must match polygram's sticker map.`
|
|
23
37
|
: '';
|
|
24
|
-
return
|
|
38
|
+
return `${deliveryContract}
|
|
25
39
|
${emojiLine}
|
|
26
40
|
Inline tags (rc.63):${stickerTag}
|
|
27
41
|
- \`[react:EMOJI]\` anywhere in your reply adds that emoji as a reaction on the user's message. Use any Telegram-supported emoji (👍 🔥 ❤️ 🎉 😢 …). Only the FIRST [react:] tag in a reply is applied; additional ones are dropped.
|
|
@@ -182,8 +196,9 @@ function buildVoiceTags(attachments) {
|
|
|
182
196
|
* @param {string} params.sessionCtx - session context file contents (optional)
|
|
183
197
|
* @param {Array} params.attachments - downloaded attachments
|
|
184
198
|
* @param {Object} params.replyTo - input for buildReplyToBlock (optional)
|
|
199
|
+
* @param {'sdk'|'cli'} params.backend - delivery contract for the selected process backend
|
|
185
200
|
*/
|
|
186
|
-
function buildPrompt({ msg, topicName = '', sessionCtx = '', attachments = [], replyTo = null, polygramHistory = '', stickerEmojis = [] }) {
|
|
201
|
+
function buildPrompt({ msg, topicName = '', sessionCtx = '', attachments = [], replyTo = null, polygramHistory = '', stickerEmojis = [], backend = 'sdk' }) {
|
|
187
202
|
const chatId = msg.chat.id.toString();
|
|
188
203
|
const msgId = msg.message_id.toString();
|
|
189
204
|
const user = msg.from?.first_name || msg.from?.username || 'Unknown';
|
|
@@ -207,7 +222,7 @@ function buildPrompt({ msg, topicName = '', sessionCtx = '', attachments = [], r
|
|
|
207
222
|
if (polygramHistory) {
|
|
208
223
|
prompt += polygramHistory + '\n\n';
|
|
209
224
|
}
|
|
210
|
-
prompt += `<polygram-info>${polygramInfo({ stickerEmojis })}</polygram-info>\n\n`;
|
|
225
|
+
prompt += `<polygram-info>${polygramInfo({ stickerEmojis, backend })}</polygram-info>\n\n`;
|
|
211
226
|
|
|
212
227
|
const replyBlock = buildReplyToBlock(replyTo);
|
|
213
228
|
const attachmentTags = buildAttachmentTags(attachments);
|
|
@@ -225,6 +240,7 @@ function buildPrompt({ msg, topicName = '', sessionCtx = '', attachments = [], r
|
|
|
225
240
|
}
|
|
226
241
|
|
|
227
242
|
module.exports = {
|
|
243
|
+
resolvePromptBackend,
|
|
228
244
|
xmlEscape,
|
|
229
245
|
truncateReplyText,
|
|
230
246
|
buildReplyToBlock,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.3",
|
|
4
4
|
"description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
|
|
5
5
|
"main": "lib/ipc/client.js",
|
|
6
6
|
"bin": {
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"@anthropic-ai/claude-agent-sdk": "0.2.123",
|
|
65
65
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
66
|
-
"@shumkov/orchestra": "
|
|
66
|
+
"@shumkov/orchestra": "0.4.3",
|
|
67
67
|
"better-sqlite3": "^12.9.0",
|
|
68
68
|
"grammy": "^1.42.0",
|
|
69
69
|
"markdown-it": "^14.1.1",
|
package/polygram.js
CHANGED
|
@@ -27,7 +27,7 @@ const dbClient = require('./lib/db');
|
|
|
27
27
|
const {
|
|
28
28
|
migrateJsonToDb, getClaudeSessionId, resolveSessionForSpawn,
|
|
29
29
|
} = require('./lib/db/sessions');
|
|
30
|
-
const { buildPrompt } = require('./lib/prompt');
|
|
30
|
+
const { buildPrompt, resolvePromptBackend } = require('./lib/prompt');
|
|
31
31
|
const { filterAttachments, resolveFileCaps, resolveMaxFileOverride, MAX_TOTAL_BYTES } = require('./lib/attachments');
|
|
32
32
|
// 0.9.0: SDK ProcessManager is the only pm. CLI pm
|
|
33
33
|
// (lib/process-manager.js) deleted in commit 6.
|
|
@@ -129,6 +129,8 @@ const { classify: classifyError, classifyTurnEndError, detectWedgedSessionError,
|
|
|
129
129
|
const { createAutoResumeTracker, isAutoResumable } = require('./lib/db/auto-resume');
|
|
130
130
|
const { createAuthDisabledGate } = require('./lib/ops/auth-disabled-gate');
|
|
131
131
|
const { createHeartbeat } = require('./lib/ops/heartbeat');
|
|
132
|
+
const { createCgroupOomObserver } = require('./lib/ops/cgroup-oom-observer');
|
|
133
|
+
const { persistShutdownDisposition } = require('./lib/ops/shutdown-disposition');
|
|
132
134
|
const { resolveReplayWindowMs } = require('./lib/db/replay-window');
|
|
133
135
|
const { pruneEvents, resolveRetentionPolicy, validatePolicy } = require('./lib/db/events-retention');
|
|
134
136
|
const { sweepSecrets, resolveSecretSweepConfig } = require('./lib/db/secret-sweep');
|
|
@@ -430,6 +432,7 @@ function formatPrompt(msg, sessionCtx, attachments = [], { sessionKey = null } =
|
|
|
430
432
|
attachments,
|
|
431
433
|
replyTo: resolveReplyTo(msg),
|
|
432
434
|
polygramHistory,
|
|
435
|
+
backend: resolvePromptBackend({ config, chatId, threadId }),
|
|
433
436
|
// Only advertise stickers this bot actually has — empty for a bot with no
|
|
434
437
|
// sticker set, so its prompt never mentions stickers.
|
|
435
438
|
stickerEmojis: Object.keys(emojiToSticker),
|
|
@@ -2372,6 +2375,7 @@ let startPollWatchdog = null;
|
|
|
2372
2375
|
// ─── Main ───────────────────────────────────────────────────────────
|
|
2373
2376
|
|
|
2374
2377
|
async function main() {
|
|
2378
|
+
const cgroupOomObserver = createCgroupOomObserver();
|
|
2375
2379
|
loadConfig();
|
|
2376
2380
|
|
|
2377
2381
|
let dbOverride;
|
|
@@ -2468,6 +2472,10 @@ async function main() {
|
|
|
2468
2472
|
db.logEvent('inbox-swept', { files: swept.swept, bytes: swept.bytes, retention_days: inboxRetentionMs / 86_400_000 });
|
|
2469
2473
|
}
|
|
2470
2474
|
db.logEvent('polygram-start', { migration: migration.reason, imported: migration.imported });
|
|
2475
|
+
if (cgroupOomObserver.startup.status === 'unavailable') {
|
|
2476
|
+
console.warn(`[oom-observer] unavailable (${cgroupOomObserver.startup.reason}); handled shutdown signals will retain clean-restart behavior`);
|
|
2477
|
+
db.logEvent('oom-observer-unavailable', { reason: cgroupOomObserver.startup.reason });
|
|
2478
|
+
}
|
|
2471
2479
|
} catch (err) {
|
|
2472
2480
|
console.error(`[db] FATAL: ${err.message}`);
|
|
2473
2481
|
console.error('Bridge cannot run without a DB (Phase 2: DB is source of truth).');
|
|
@@ -2907,6 +2915,7 @@ async function main() {
|
|
|
2907
2915
|
chunkMarkdownText, deliverReplies,
|
|
2908
2916
|
chunkBudget: TG_CHUNK_BUDGET,
|
|
2909
2917
|
getIsShuttingDown: () => isShuttingDown,
|
|
2918
|
+
getOomObservation: () => cgroupOomObserver.sample(),
|
|
2910
2919
|
authDisabledGate,
|
|
2911
2920
|
logger: console,
|
|
2912
2921
|
}));
|
|
@@ -2985,6 +2994,7 @@ async function main() {
|
|
|
2985
2994
|
const SHUTDOWN_DRAIN_MS = 30_000;
|
|
2986
2995
|
const shutdown = async () => {
|
|
2987
2996
|
if (isShuttingDown) return;
|
|
2997
|
+
const shutdownObservation = cgroupOomObserver.sample();
|
|
2988
2998
|
isShuttingDown = true;
|
|
2989
2999
|
console.log('\nShutting down...');
|
|
2990
3000
|
// 1. Stop accepting new inbound first so nothing new queues behind the drain.
|
|
@@ -3071,28 +3081,30 @@ async function main() {
|
|
|
3071
3081
|
let remaining = 0;
|
|
3072
3082
|
for (const n of inFlightHandlers.values()) remaining += n;
|
|
3073
3083
|
|
|
3074
|
-
// 3.
|
|
3075
|
-
//
|
|
3076
|
-
//
|
|
3077
|
-
//
|
|
3078
|
-
//
|
|
3079
|
-
// recovering everything on a crash (0.14, §H1). markReplayPending alone
|
|
3080
|
-
// (the old behavior) couldn't distinguish the two, so every deploy
|
|
3081
|
-
// re-answered. A stale replay-pending row from a prior life would also be
|
|
3082
|
-
// crash-recovered on a deliberate restart without the unconditional marker.
|
|
3084
|
+
// 3. A handled signal is normally deliberate, but a Linux supervisor can
|
|
3085
|
+
// also stop the service after an OOM-killed child. Persist crash-like
|
|
3086
|
+
// state when the process cgroup recorded an OOM kill; otherwise retain
|
|
3087
|
+
// the clean-restart marker. Both paths mark in-flight work replayable in
|
|
3088
|
+
// the same transaction as the marker update.
|
|
3083
3089
|
if (db) {
|
|
3084
3090
|
try {
|
|
3085
|
-
const res =
|
|
3091
|
+
const res = persistShutdownDisposition({
|
|
3092
|
+
db,
|
|
3093
|
+
botName: BOT_NAME,
|
|
3094
|
+
observation: shutdownObservation,
|
|
3095
|
+
});
|
|
3086
3096
|
logEvent('shutdown-drain', {
|
|
3087
3097
|
bot: BOT_NAME,
|
|
3088
3098
|
in_flight: remaining,
|
|
3089
|
-
replay_marked: res
|
|
3099
|
+
replay_marked: res.replayMarked,
|
|
3090
3100
|
elapsed_ms: drainElapsed,
|
|
3091
|
-
clean:
|
|
3101
|
+
clean: res.clean,
|
|
3102
|
+
shutdown_reason: res.shutdownReason,
|
|
3103
|
+
...(res.oomKillDelta != null ? { oom_kill_delta: res.oomKillDelta } : {}),
|
|
3092
3104
|
});
|
|
3093
|
-
console.log(`[shutdown] clean shutdown recorded; drained ${drainElapsed}ms, ${remaining} in-flight, ${res
|
|
3105
|
+
console.log(`[shutdown] ${res.clean ? 'clean' : 'crash-like'} shutdown recorded (${res.shutdownReason}); drained ${drainElapsed}ms, ${remaining} in-flight, ${res.replayMarked} marked replay-pending`);
|
|
3094
3106
|
} catch (err) {
|
|
3095
|
-
console.error(`[shutdown]
|
|
3107
|
+
console.error(`[shutdown] persistence failed: ${err.message}`);
|
|
3096
3108
|
}
|
|
3097
3109
|
}
|
|
3098
3110
|
|
|
@@ -1,25 +1,45 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: polygram-send
|
|
3
|
-
description:
|
|
3
|
+
description: Choose the correct polygram delivery path for SDK turns, CLI/channels turns, and detached scripts. Use IPC for cron jobs or genuinely out-of-turn sends; use the reply MCP for interactive CLI text and files; return inline text for interactive SDK replies.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# polygram-send
|
|
7
7
|
|
|
8
8
|
Polygram exposes a per-bot Unix socket at `/tmp/polygram-<bot>.sock`. Authorized callers (same UID, with the per-bot secret) can send Telegram API methods through it. Polygram routes them to the live bot connection, logs them in the `messages` table, and applies markdown / chunking / dedup.
|
|
9
9
|
|
|
10
|
-
This is the **supported path** for
|
|
10
|
+
This is the **supported IPC path** for a detached script that needs to deliver a file or out-of-band message into a polygram-managed chat. Interactive turns have backend-specific delivery paths described below. Generic Telegram MCP is not wired up; CLI/channels turns use polygram's own reply MCP instead.
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## Choose exactly one delivery lane
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
### SDK interactive turn
|
|
15
|
+
|
|
16
|
+
Return the user-visible reply as inline assistant text. Polygram delivers it through the streamed SDK reply path. Do not also call the reply MCP or IPC, because that duplicates the message.
|
|
17
|
+
|
|
18
|
+
Conversation transcript text does not prove an earlier reply reached Telegram. Do not claim a report was "already sent" or "posted above" based only on transcript history. If delivery is uncertain, include the content in the current inline reply.
|
|
19
|
+
|
|
20
|
+
### CLI/channels interactive turn
|
|
21
|
+
|
|
22
|
+
Inline final text is not delivered to Telegram. Send every user-visible text reply through `mcp__polygram-bridge__reply` within the turn. Attach generated files through that tool's `files` array. Do not use IPC for an interactive text or file reply.
|
|
23
|
+
|
|
24
|
+
Only a successful reply-tool receipt proves delivery. Transcript text and an attempted call without a successful receipt do not. If delivery is uncertain, call the reply tool now instead of claiming the message was sent earlier.
|
|
25
|
+
|
|
26
|
+
### Cron or detached script
|
|
27
|
+
|
|
28
|
+
Use the `polygram-send` IPC client described below. This lane is for code running outside an interactive turn, including cron jobs, detached background scripts that generated files, and genuinely out-of-turn status updates. Check the IPC response and treat only a successful response as proof of delivery.
|
|
29
|
+
|
|
30
|
+
## When to use IPC
|
|
31
|
+
|
|
32
|
+
- A detached script generated a file (PDF, .docx, image, etc.) after the interactive turn ended
|
|
15
33
|
- Cron job needs to post a status update to the relevant chat
|
|
16
34
|
- Long-running work needs an out-of-turn nudge (e.g. "build finished, here's the artifact")
|
|
17
35
|
|
|
18
|
-
## When NOT to use
|
|
36
|
+
## When NOT to use IPC
|
|
19
37
|
|
|
20
|
-
-
|
|
38
|
+
- A normal SDK text reply — return inline text.
|
|
39
|
+
- A normal CLI/channels text reply — use `mcp__polygram-bridge__reply`.
|
|
40
|
+
- A CLI/channels file reply during an interactive turn — pass absolute paths in the reply tool's `files` array.
|
|
21
41
|
- Anything that needs the message attributed to "user" — IPC always sends as the bot.
|
|
22
|
-
-
|
|
42
|
+
- Generic Telegram MCP — it is not a polygram delivery path.
|
|
23
43
|
|
|
24
44
|
## Allowed methods
|
|
25
45
|
|
|
@@ -139,8 +159,9 @@ The IPC socket validates a per-bot secret (`/tmp/polygram-<bot>.secret`, mode 06
|
|
|
139
159
|
|
|
140
160
|
## Don't
|
|
141
161
|
|
|
142
|
-
- Don't
|
|
143
|
-
- Don't use Telegram MCP.
|
|
162
|
+
- Don't use IPC for a normal interactive text reply; follow the SDK or CLI lane above.
|
|
163
|
+
- Don't use generic Telegram MCP.
|
|
164
|
+
- Don't infer successful delivery from transcript text.
|
|
144
165
|
- Don't pass localhost URLs to `document`/`photo`.
|
|
145
166
|
- Don't hardcode `message_thread_id` from previous sessions.
|
|
146
167
|
- Don't omit `source` from agent-triggered sends.
|