polygram 0.8.0-rc.23 → 0.8.0-rc.25
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/approvals.js +9 -1
- package/lib/async-lock.js +11 -3
- package/lib/autosteered-refs.js +100 -0
- package/lib/context-format.js +79 -0
- package/lib/status-reactions.js +28 -19
- package/lib/telegram-prompt.js +119 -0
- package/package.json +1 -1
- package/polygram.js +36 -64
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.8.0-rc.
|
|
4
|
+
"version": "0.8.0-rc.25",
|
|
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/lib/approvals.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const crypto = require('crypto');
|
|
14
|
+
const { canonicalizeToolInput } = require('./canonical-json');
|
|
14
15
|
|
|
15
16
|
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
16
17
|
// 16 random bytes → 22 base64url chars ≈ 128 bits of entropy. Prevents
|
|
@@ -19,7 +20,14 @@ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
|
19
20
|
const TOKEN_BYTES = 16;
|
|
20
21
|
|
|
21
22
|
function digestInput(input) {
|
|
22
|
-
|
|
23
|
+
// Canonicalise object inputs so key-order doesn't change the digest.
|
|
24
|
+
// Pre-fix `JSON.stringify({a:1,b:2})` and `JSON.stringify({b:2,a:1})`
|
|
25
|
+
// produced different hashes — the dedup contract assumed logical
|
|
26
|
+
// equivalence but the impl was order-sensitive, so an SDK that
|
|
27
|
+
// re-serialised the input between turns would dedup-miss.
|
|
28
|
+
const json = typeof input === 'string'
|
|
29
|
+
? input
|
|
30
|
+
: JSON.stringify(canonicalizeToolInput(input));
|
|
23
31
|
return crypto.createHash('sha256').update(json).digest('hex').slice(0, 16);
|
|
24
32
|
}
|
|
25
33
|
|
package/lib/async-lock.js
CHANGED
|
@@ -22,13 +22,21 @@ function createAsyncLock() {
|
|
|
22
22
|
const prev = chains.get(key) || Promise.resolve();
|
|
23
23
|
let release;
|
|
24
24
|
const next = new Promise((resolve) => { release = resolve; });
|
|
25
|
-
|
|
25
|
+
// Save the chain-entry promise so the cleanup branch can compare
|
|
26
|
+
// against the SAME reference. Pre-fix this re-evaluated
|
|
27
|
+
// `prev.then(() => next)` (a fresh promise each call), so the
|
|
28
|
+
// === compare was always false and the Map leaked one entry per
|
|
29
|
+
// unique key.
|
|
30
|
+
const myEntry = prev.then(() => next);
|
|
31
|
+
chains.set(key, myEntry);
|
|
26
32
|
await prev;
|
|
27
33
|
// Return a wrapper that also clears the chain entry when this is
|
|
28
34
|
// the last holder — avoids the Map growing unbounded across the
|
|
29
|
-
// lifetime of the process.
|
|
35
|
+
// lifetime of the process. Idempotent: a double-release call is
|
|
36
|
+
// harmless (release() is a Promise resolver; calling resolve
|
|
37
|
+
// twice is a no-op).
|
|
30
38
|
return () => {
|
|
31
|
-
if (chains.get(key) ===
|
|
39
|
+
if (chains.get(key) === myEntry) {
|
|
32
40
|
chains.delete(key);
|
|
33
41
|
}
|
|
34
42
|
release();
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session tracker for messages that received the ✍ AUTOSTEERED
|
|
3
|
+
* reaction, so they can be cleared at turn-end.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists (rc.14): each autosteer invocation runs inside its
|
|
6
|
+
* own `handleMessage` scope with its own `reactor`. When the original
|
|
7
|
+
* (trigger) message's reactor calls `.clear()` at turn-end, it can
|
|
8
|
+
* only clear *its own* message — not the follow-ups whose reactors
|
|
9
|
+
* already called `.stop()` after acking ✍. So we track the
|
|
10
|
+
* (chat_id, message_id) pairs centrally per session and the success-
|
|
11
|
+
* path handler in polygram.js calls `clear(sessionKey)` to drop the
|
|
12
|
+
* reactions in one go.
|
|
13
|
+
*
|
|
14
|
+
* Concurrency: this is a plain Map indexed by sessionKey. Single-
|
|
15
|
+
* thread Node, so add/get/clear race-free.
|
|
16
|
+
*
|
|
17
|
+
* The `applyClear` callback abstracts Telegram's setMessageReaction
|
|
18
|
+
* so tests can inject a fake without spinning up grammy/bot.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} MsgRef
|
|
25
|
+
* @property {number|string} chatId
|
|
26
|
+
* @property {number} msgId
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} AutosteeredRefs
|
|
31
|
+
* @property {(sessionKey: string, ref: MsgRef) => void} add
|
|
32
|
+
* @property {(sessionKey: string) => MsgRef[]} get
|
|
33
|
+
* @property {(sessionKey: string) => Promise<number>} clear
|
|
34
|
+
* resolves with the count of refs that were cleared.
|
|
35
|
+
* @property {(sessionKey: string) => number} size
|
|
36
|
+
* @property {(sessionKey: string) => void} dropSession
|
|
37
|
+
* discard all refs for a session WITHOUT calling applyClear (used
|
|
38
|
+
* when the chat is being torn down — Telegram side will be cleared
|
|
39
|
+
* by the parent reactor).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {object} opts
|
|
44
|
+
* @param {(ref: MsgRef) => Promise<void>} opts.applyClear
|
|
45
|
+
* invoked once per ref during clear(). Errors are caught and
|
|
46
|
+
* logged to opts.logger?.error — they never block clearing of
|
|
47
|
+
* subsequent refs.
|
|
48
|
+
* @param {{ error?: (msg: string) => void }} [opts.logger]
|
|
49
|
+
* @returns {AutosteeredRefs}
|
|
50
|
+
*/
|
|
51
|
+
function createAutosteeredRefs({ applyClear, logger = console } = {}) {
|
|
52
|
+
if (typeof applyClear !== 'function') {
|
|
53
|
+
throw new TypeError('applyClear function required');
|
|
54
|
+
}
|
|
55
|
+
/** @type {Map<string, MsgRef[]>} */
|
|
56
|
+
const refs = new Map();
|
|
57
|
+
|
|
58
|
+
function add(sessionKey, ref) {
|
|
59
|
+
if (!sessionKey || !ref || ref.msgId == null || ref.chatId == null) return;
|
|
60
|
+
let list = refs.get(sessionKey);
|
|
61
|
+
if (!list) { list = []; refs.set(sessionKey, list); }
|
|
62
|
+
list.push({ chatId: ref.chatId, msgId: ref.msgId });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function get(sessionKey) {
|
|
66
|
+
return refs.get(sessionKey)?.slice() || [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function size(sessionKey) {
|
|
70
|
+
return refs.get(sessionKey)?.length || 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function dropSession(sessionKey) {
|
|
74
|
+
refs.delete(sessionKey);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function clear(sessionKey) {
|
|
78
|
+
const list = refs.get(sessionKey);
|
|
79
|
+
if (!list || list.length === 0) return 0;
|
|
80
|
+
refs.delete(sessionKey);
|
|
81
|
+
let cleared = 0;
|
|
82
|
+
for (const ref of list) {
|
|
83
|
+
try {
|
|
84
|
+
await applyClear(ref);
|
|
85
|
+
cleared += 1;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
// Ack-clear failures are silent — the ✍ stays on screen but
|
|
88
|
+
// doesn't block the in-flight turn's reply UX.
|
|
89
|
+
logger?.error?.(
|
|
90
|
+
`autosteer-clear failed (chat=${ref.chatId} msg=${ref.msgId}): ${err?.message || err}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return cleared;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { add, get, clear, size, dropSession };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { createAutosteeredRefs };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure formatters for /context command output and the 85%-full hint.
|
|
3
|
+
*
|
|
4
|
+
* Lifted from polygram.js so the formatting can be unit-tested without
|
|
5
|
+
* spinning up the full handleMessage stack. Both functions are pure —
|
|
6
|
+
* no I/O, no Date.now, no module-level state.
|
|
7
|
+
*
|
|
8
|
+
* Background — rc.4 percentage scale:
|
|
9
|
+
* The SDK's `getContextUsage()` returns `percentage` already on a
|
|
10
|
+
* 0-100 scale (verified in rc.3 production: a 77%-used context
|
|
11
|
+
* reported `percentage: 77`). Pre-rc.4 polygram treated it as a
|
|
12
|
+
* 0-1 ratio and multiplied by 100, which displayed "7700% full" and
|
|
13
|
+
* skipped the 85% hint threshold. The formatters below assume the
|
|
14
|
+
* 0-100 scale; do not multiply or divide.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const HINT_THRESHOLD_PCT = 85;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Format a getContextUsage() result into a multi-line chat reply.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} usage — return value from `Query.getContextUsage()`.
|
|
25
|
+
* Expected fields (all optional, all from SDK):
|
|
26
|
+
* percentage: number (0-100)
|
|
27
|
+
* totalTokens: number
|
|
28
|
+
* maxTokens: number
|
|
29
|
+
* model: string
|
|
30
|
+
* isAutoCompactEnabled: boolean
|
|
31
|
+
* autoCompactThreshold: number (0-100)
|
|
32
|
+
* categories: Array<{ label?: string, name?: string, tokens: number }>
|
|
33
|
+
* @returns {string} pre-formatted text suitable for sendMessage
|
|
34
|
+
*/
|
|
35
|
+
function formatContextReply(usage) {
|
|
36
|
+
const u = usage || {};
|
|
37
|
+
const pct = (u.percentage ?? 0).toFixed(0);
|
|
38
|
+
const total = (u.totalTokens ?? 0).toLocaleString();
|
|
39
|
+
const max = (u.maxTokens ?? 0).toLocaleString();
|
|
40
|
+
const lines = [`📚 Context: ${total} / ${max} tokens (${pct}%)`];
|
|
41
|
+
if (u.model) lines.push(`Model: ${u.model}`);
|
|
42
|
+
if (u.isAutoCompactEnabled && u.autoCompactThreshold) {
|
|
43
|
+
const thrPct = u.autoCompactThreshold.toFixed(0);
|
|
44
|
+
lines.push(`Auto-compact at ${thrPct}%.`);
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(u.categories) && u.categories.length) {
|
|
47
|
+
const top = [...u.categories]
|
|
48
|
+
.filter((c) => Number.isFinite(c?.tokens) && c.tokens > 0)
|
|
49
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
50
|
+
.slice(0, 3)
|
|
51
|
+
.map((c) => ` • ${c.label || c.name || '?'}: ${c.tokens.toLocaleString()}`);
|
|
52
|
+
if (top.length) lines.push('Top categories:', ...top);
|
|
53
|
+
}
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Decide whether to send the 85% hint and return the hint text if so.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} usage — same shape as formatContextReply input.
|
|
61
|
+
* @returns {string|null} the hint text to send, or null when below threshold.
|
|
62
|
+
*/
|
|
63
|
+
function maybeContextFullHint(usage) {
|
|
64
|
+
const pct = usage?.percentage ?? 0;
|
|
65
|
+
if (pct < HINT_THRESHOLD_PCT) return null;
|
|
66
|
+
return [
|
|
67
|
+
`📚 Context window ${pct.toFixed(0)}% full. Three options:`,
|
|
68
|
+
'',
|
|
69
|
+
'• `/new` — start fresh; this conversation ends.',
|
|
70
|
+
'• `/compact` — summarise older messages. Add a hint after the command (e.g. `/compact keep the Q3 commission decisions`) and that becomes the compactor\'s guidance.',
|
|
71
|
+
'• Keep chatting — I\'ll auto-compact when needed; key context is preserved automatically.',
|
|
72
|
+
].join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
formatContextReply,
|
|
77
|
+
maybeContextFullHint,
|
|
78
|
+
HINT_THRESHOLD_PCT,
|
|
79
|
+
};
|
package/lib/status-reactions.js
CHANGED
|
@@ -57,12 +57,19 @@ const DEFAULT_THROTTLE_MS = 800;
|
|
|
57
57
|
// 0.7.4 (item A): after this long with no setState() call (Claude is
|
|
58
58
|
// silently chugging on a long tool / model latency), auto-flip to STALL
|
|
59
59
|
// (🥱) so the user has a visible cue that the bot is alive but slow.
|
|
60
|
-
// 10s
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
// rc.25: bumped from 10s → 45s. The original 10s matched OpenClaw, but
|
|
61
|
+
// SDK pm with effort=high reasoning routinely thinks for 15-30s before
|
|
62
|
+
// firing any tool or text chunk — under the old threshold the 🥱 was
|
|
63
|
+
// firing on EVERY substantive turn, training users to ignore it.
|
|
64
|
+
const DEFAULT_STALL_MS = 45_000;
|
|
65
|
+
// rc.25: bumped from 30s → 180s (3 min). The 😨 TIMEOUT was firing
|
|
66
|
+
// during ordinary multi-step agent runs (Ivan DM at 11:32 — bot was
|
|
67
|
+
// actively replying within 20s, but the trigger message stayed at
|
|
68
|
+
// 😨 because the OUTER turn ran for 100+ s across multiple replies
|
|
69
|
+
// and tool calls). Real "stuck" state would be 3+ min of nothing,
|
|
70
|
+
// which 180s captures while letting routine work breathe. Pm has its
|
|
71
|
+
// own 5-minute hard idle timeout that actually rejects stuck turns.
|
|
72
|
+
const DEFAULT_FREEZE_MS = 180_000;
|
|
66
73
|
|
|
67
74
|
// Tool name → state classifier. Case-insensitive substring match so we
|
|
68
75
|
// don't have to enumerate every existing or future tool. Order matters:
|
|
@@ -224,19 +231,21 @@ function createReactionManager({
|
|
|
224
231
|
// (no point arming over QUEUED/STALL/TIMEOUT itself).
|
|
225
232
|
armStallTimers();
|
|
226
233
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
234
|
+
// 0.8.0-rc.24: drop the 800ms throttle. Pre-rc.24, when a tool-
|
|
235
|
+
// using turn fired QUEUED → THINKING → TOOL within a few ms,
|
|
236
|
+
// the throttle squashed THINKING (pendingTimer flushed
|
|
237
|
+
// currentState which was already overwritten to TOOL by the
|
|
238
|
+
// time the timer fired). Users saw 👀 → ❰long pause❱ → 🔥 →
|
|
239
|
+
// 🥱, missing the 🤔 transition entirely.
|
|
240
|
+
//
|
|
241
|
+
// Why the throttle is now redundant: rc.11 added applyChain
|
|
242
|
+
// which serializes every apply() call to Telegram in
|
|
243
|
+
// setState() invocation order. So three rapid setStates in
|
|
244
|
+
// 30ms produce three sequential network calls, each ~200-300ms
|
|
245
|
+
// round-trip. User sees 👀 → 🤔 → 🔥 progress, smoothly
|
|
246
|
+
// paced by network latency.
|
|
247
|
+
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
|
|
248
|
+
return flush(stateName);
|
|
240
249
|
};
|
|
241
250
|
|
|
242
251
|
const clear = async () => {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polygram-side display constraints injected into every chat's system
|
|
3
|
+
* prompt. This is INFRASTRUCTURE knowledge — the agent's business
|
|
4
|
+
* logic shouldn't have to know that Telegram's `<pre>` block on a
|
|
5
|
+
* portrait iPhone wraps at ~36 monospace chars. The agent decides
|
|
6
|
+
* *what* to render; polygram tells it *how* the surface displays.
|
|
7
|
+
*
|
|
8
|
+
* Why a polygram concern, not an agent concern:
|
|
9
|
+
* - Same agent runs across surfaces (Telegram bot, CLI, future
|
|
10
|
+
* surfaces). Each has its own width / markdown / image support.
|
|
11
|
+
* - Mixing display rules into agent prompts means every agent doc
|
|
12
|
+
* has to be updated when Telegram's rendering changes (or when
|
|
13
|
+
* we onboard a new chat surface). Centralising here keeps
|
|
14
|
+
* `_shumabit-base.md` and friends focused on business logic.
|
|
15
|
+
* - Tested in isolation; no risk of agent drift breaking tables.
|
|
16
|
+
*
|
|
17
|
+
* Width budget — measured 2026-04-30 from production screenshots:
|
|
18
|
+
* - iPhone portrait, default Telegram font: ~36 monospace chars
|
|
19
|
+
* per line in a `<pre>` block before wrap.
|
|
20
|
+
* - iPhone landscape: ~70.
|
|
21
|
+
* - Desktop client (macOS, default): ~85+.
|
|
22
|
+
* Agents see the conservative number (40) so output stays clean on
|
|
23
|
+
* the smallest reasonable surface.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const TELEGRAM_TABLE_WIDTH_BUDGET = 40;
|
|
29
|
+
|
|
30
|
+
const POLYGRAM_DISPLAY_HINT = [
|
|
31
|
+
'## Telegram display constraints',
|
|
32
|
+
'',
|
|
33
|
+
'Your replies are sent to Telegram. The user reads them on phone or desktop.',
|
|
34
|
+
'',
|
|
35
|
+
'**Tables:** Telegram renders markdown tables as monospace `<pre>` blocks.',
|
|
36
|
+
`On mobile portrait, lines wrap after ~${TELEGRAM_TABLE_WIDTH_BUDGET} chars and look broken.`,
|
|
37
|
+
'',
|
|
38
|
+
'- Use a markdown table when **every** rendered row (including separators',
|
|
39
|
+
` and padding) fits in ${TELEGRAM_TABLE_WIDTH_BUDGET} chars or fewer.`,
|
|
40
|
+
'- If any row would exceed that budget, **drop the table** and switch to',
|
|
41
|
+
' vertical "row blocks": one entity per paragraph, **bold** headline,',
|
|
42
|
+
' then `Field: value` per data point. Example:',
|
|
43
|
+
'',
|
|
44
|
+
' ```',
|
|
45
|
+
' **Mini dress Keen → Black dress mini**',
|
|
46
|
+
' COGS: ฿546 → ฿1144 (2.1×)',
|
|
47
|
+
' Margin: 84.8% → 77% ↓',
|
|
48
|
+
'',
|
|
49
|
+
' **Tank top Sway → Top voluminous cotton**',
|
|
50
|
+
' COGS: ฿360 → ฿947 (2.6×)',
|
|
51
|
+
' Margin: 78.7% → 73% ↓',
|
|
52
|
+
' ```',
|
|
53
|
+
'',
|
|
54
|
+
'- Decide row-by-row before emitting; do not start a wide table assuming',
|
|
55
|
+
' the user can scroll.',
|
|
56
|
+
'',
|
|
57
|
+
'Other Telegram quirks:',
|
|
58
|
+
'- Headers `#`, `##`, `###` render as plain text — use **bold** for emphasis.',
|
|
59
|
+
'- Horizontal rules render as a thin divider line.',
|
|
60
|
+
'- Long replies stream in chunks, so prefer concise structure over walls of text.',
|
|
61
|
+
].join('\n');
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Append the polygram display hint to an existing systemPrompt option,
|
|
65
|
+
* preserving the original shape (string / preset object / undefined).
|
|
66
|
+
* Pure function — does not mutate input.
|
|
67
|
+
*
|
|
68
|
+
* Shapes handled (matches @anthropic-ai/claude-agent-sdk's Options.systemPrompt):
|
|
69
|
+
* - undefined / null → returns `{ type: 'preset', preset: 'claude_code', append: hint }`
|
|
70
|
+
* - string → returns `string + '\n\n' + hint`
|
|
71
|
+
* - { type: 'preset', append?: string }
|
|
72
|
+
* → merges hint into `append`
|
|
73
|
+
* - other (string[], etc.) → returns input unchanged (caller's responsibility)
|
|
74
|
+
*
|
|
75
|
+
* @param {*} systemPromptOpt — current SdkOptions.systemPrompt value
|
|
76
|
+
* @param {string} [hint] — override the default hint (used by tests)
|
|
77
|
+
* @returns {*} new systemPrompt option with the hint appended
|
|
78
|
+
*/
|
|
79
|
+
function appendDisplayHint(systemPromptOpt, hint = POLYGRAM_DISPLAY_HINT) {
|
|
80
|
+
if (!hint) return systemPromptOpt;
|
|
81
|
+
|
|
82
|
+
if (systemPromptOpt == null) {
|
|
83
|
+
return { type: 'preset', preset: 'claude_code', append: hint };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof systemPromptOpt === 'string') {
|
|
87
|
+
return `${systemPromptOpt}\n\n${hint}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (typeof systemPromptOpt === 'object' && systemPromptOpt.type === 'preset') {
|
|
91
|
+
const existingAppend = typeof systemPromptOpt.append === 'string' ? systemPromptOpt.append : '';
|
|
92
|
+
const newAppend = existingAppend ? `${existingAppend}\n\n${hint}` : hint;
|
|
93
|
+
return { ...systemPromptOpt, append: newAppend };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Unknown shape (e.g. string[]) — return as-is. Caller can opt in
|
|
97
|
+
// by passing a supported shape.
|
|
98
|
+
return systemPromptOpt;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* For the CLI pm (`claude -p ...`), the equivalent of an appended
|
|
103
|
+
* system prompt is the `--append-system-prompt <text>` flag. This
|
|
104
|
+
* helper returns the args the CLI pm should add to its argv.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} [hint] — override (tests)
|
|
107
|
+
* @returns {string[]} — argv tail, e.g. ['--append-system-prompt', '...']
|
|
108
|
+
*/
|
|
109
|
+
function appendDisplayHintCliArgs(hint = POLYGRAM_DISPLAY_HINT) {
|
|
110
|
+
if (!hint) return [];
|
|
111
|
+
return ['--append-system-prompt', hint];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
POLYGRAM_DISPLAY_HINT,
|
|
116
|
+
TELEGRAM_TABLE_WIDTH_BUDGET,
|
|
117
|
+
appendDisplayHint,
|
|
118
|
+
appendDisplayHintCliArgs,
|
|
119
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.8.0-rc.
|
|
3
|
+
"version": "0.8.0-rc.25",
|
|
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": {
|
package/polygram.js
CHANGED
|
@@ -32,6 +32,7 @@ const { ProcessManager } = require('./lib/process-manager');
|
|
|
32
32
|
// soak proves SDK stable. See docs/0.8.0-architecture-decisions.md.
|
|
33
33
|
const { ProcessManagerSdk } = require('./lib/process-manager-sdk');
|
|
34
34
|
const { createAutosteerBuffer, makePostToolBatchHook } = require('./lib/autosteer-buffer');
|
|
35
|
+
const { createAutosteeredRefs } = require('./lib/autosteered-refs');
|
|
35
36
|
const { makeRouterPolicy, createPmRouter } = require('./lib/pm-router');
|
|
36
37
|
const { canonicalizeToolInput } = require('./lib/canonical-json');
|
|
37
38
|
const {
|
|
@@ -41,6 +42,8 @@ const {
|
|
|
41
42
|
approvalCardText,
|
|
42
43
|
} = require('./lib/approval-ui');
|
|
43
44
|
const { makeSessionStartHook } = require('./lib/history-preload');
|
|
45
|
+
const { formatContextReply, maybeContextFullHint } = require('./lib/context-format');
|
|
46
|
+
const { appendDisplayHint, appendDisplayHintCliArgs } = require('./lib/telegram-prompt');
|
|
44
47
|
const agentLoader = require('./lib/agent-loader');
|
|
45
48
|
const USE_SDK = process.env.POLYGRAM_USE_SDK === '1';
|
|
46
49
|
const { createSender } = require('./lib/telegram');
|
|
@@ -723,23 +726,18 @@ const autosteerBuffer = createAutosteerBuffer();
|
|
|
723
726
|
// the TRIGGER message's reactor.clear() at turn-end couldn't reach
|
|
724
727
|
// across to other messages. Without this map, users see ✍ stuck on
|
|
725
728
|
// every follow-up and don't know whether the bot incorporated them.
|
|
726
|
-
const
|
|
729
|
+
const autosteeredRefs = createAutosteeredRefs({
|
|
730
|
+
applyClear: async ({ chatId, msgId }) => {
|
|
731
|
+
if (!bot) return;
|
|
732
|
+
await tg(bot, 'setMessageReaction', {
|
|
733
|
+
chat_id: chatId, message_id: msgId, reaction: [],
|
|
734
|
+
}, { source: 'autosteer-clear', botName: BOT_NAME });
|
|
735
|
+
},
|
|
736
|
+
logger: { error: (m) => console.error(`[${BOT_NAME}] ${m}`) },
|
|
737
|
+
});
|
|
727
738
|
|
|
728
739
|
async function clearAutosteeredReactions(sessionKey) {
|
|
729
|
-
|
|
730
|
-
if (!list || list.length === 0) return;
|
|
731
|
-
autosteeredMsgRefs.delete(sessionKey);
|
|
732
|
-
if (!bot) return;
|
|
733
|
-
for (const { chatId: cid, msgId } of list) {
|
|
734
|
-
try {
|
|
735
|
-
await tg(bot, 'setMessageReaction', {
|
|
736
|
-
chat_id: cid, message_id: msgId, reaction: [],
|
|
737
|
-
}, { source: 'autosteer-clear', botName: BOT_NAME });
|
|
738
|
-
} catch (err) {
|
|
739
|
-
// Ack-clear failures are silent — the ✍ stays on screen
|
|
740
|
-
// but doesn't block the in-flight turn's reply UX.
|
|
741
|
-
}
|
|
742
|
-
}
|
|
740
|
+
return autosteeredRefs.clear(sessionKey);
|
|
743
741
|
}
|
|
744
742
|
|
|
745
743
|
// 0.8.0-rc.14: tool-less-turn drain. PostToolBatch hook only fires
|
|
@@ -820,6 +818,10 @@ function spawnClaude(sessionKey, ctx) {
|
|
|
820
818
|
];
|
|
821
819
|
if (chatConfig.agent) args.push('--agent', chatConfig.agent);
|
|
822
820
|
if (existingSessionId) args.push('--resume', existingSessionId);
|
|
821
|
+
// Polygram-side display constraints — same hint the SDK pm appends
|
|
822
|
+
// via Options.systemPrompt. Keeps the table-width rule in
|
|
823
|
+
// infrastructure, not in agent docs.
|
|
824
|
+
args.push(...appendDisplayHintCliArgs());
|
|
823
825
|
|
|
824
826
|
console.log(`[${label}] Spawning process (${chatConfig.model}/${chatConfig.effort})`);
|
|
825
827
|
|
|
@@ -973,7 +975,7 @@ function buildSdkOptions(sessionKey, ctx) {
|
|
|
973
975
|
// precedence: chatConfig > agent > defaults. The chatConfig keys
|
|
974
976
|
// we care about for SDK options are model/effort/cwd/thinking;
|
|
975
977
|
// others (agent, chrome, isolateTopics) are polygram-only.
|
|
976
|
-
|
|
978
|
+
const composed = agentLoader.composeSdkOptions(
|
|
977
979
|
{
|
|
978
980
|
// chat-level overrides — only the keys SDK understands.
|
|
979
981
|
model: chatConfig.model,
|
|
@@ -984,6 +986,13 @@ function buildSdkOptions(sessionKey, ctx) {
|
|
|
984
986
|
agentBundle,
|
|
985
987
|
baseOpts,
|
|
986
988
|
);
|
|
989
|
+
|
|
990
|
+
// Append polygram's display constraints to the systemPrompt.
|
|
991
|
+
// Infrastructure-layer hint — the agent's own prompt covers
|
|
992
|
+
// business logic; polygram adds "your output renders in Telegram,
|
|
993
|
+
// here's the width budget for tables".
|
|
994
|
+
composed.systemPrompt = appendDisplayHint(composed.systemPrompt);
|
|
995
|
+
return composed;
|
|
987
996
|
}
|
|
988
997
|
|
|
989
998
|
function buildSpawnContext(sessionKey) {
|
|
@@ -1955,32 +1964,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1955
1964
|
}
|
|
1956
1965
|
try {
|
|
1957
1966
|
const u = await q.getContextUsage();
|
|
1958
|
-
|
|
1959
|
-
// — saw "77" for a 77%-used context). Display directly.
|
|
1960
|
-
const pct = (u?.percentage ?? 0).toFixed(0);
|
|
1961
|
-
const total = (u?.totalTokens ?? 0).toLocaleString();
|
|
1962
|
-
const max = (u?.maxTokens ?? 0).toLocaleString();
|
|
1963
|
-
const lines = [`📚 Context: ${total} / ${max} tokens (${pct}%)`];
|
|
1964
|
-
if (u?.model) lines.push(`Model: ${u.model}`);
|
|
1965
|
-
if (u?.isAutoCompactEnabled && u?.autoCompactThreshold) {
|
|
1966
|
-
// autoCompactThreshold scale is currently unverified; assume
|
|
1967
|
-
// matches percentage (0-100). If it turns out to be 0-1 we'll
|
|
1968
|
-
// see something like "Auto-compact at 0%" and can flip back.
|
|
1969
|
-
const thrPct = u.autoCompactThreshold.toFixed(0);
|
|
1970
|
-
lines.push(`Auto-compact at ${thrPct}%.`);
|
|
1971
|
-
}
|
|
1972
|
-
// Top-3 categories by token cost so the user knows where the
|
|
1973
|
-
// budget is going. SDK exposes a rich breakdown in
|
|
1974
|
-
// u.categories — we just summarise.
|
|
1975
|
-
if (Array.isArray(u?.categories) && u.categories.length) {
|
|
1976
|
-
const top = [...u.categories]
|
|
1977
|
-
.filter((c) => Number.isFinite(c?.tokens) && c.tokens > 0)
|
|
1978
|
-
.sort((a, b) => b.tokens - a.tokens)
|
|
1979
|
-
.slice(0, 3)
|
|
1980
|
-
.map((c) => ` • ${c.label || c.name || '?'}: ${c.tokens.toLocaleString()}`);
|
|
1981
|
-
if (top.length) lines.push('Top categories:', ...top);
|
|
1982
|
-
}
|
|
1983
|
-
await sendReply(lines.join('\n'));
|
|
1967
|
+
await sendReply(formatContextReply(u));
|
|
1984
1968
|
} catch (err) {
|
|
1985
1969
|
console.error(`[${label}] /context failed: ${err.message}`);
|
|
1986
1970
|
await sendReply(`📚 Couldn't fetch context info: ${err.message}`);
|
|
@@ -2509,9 +2493,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2509
2493
|
if (ok) {
|
|
2510
2494
|
// Track this msg_id so the in-flight turn's success / abort
|
|
2511
2495
|
// / error path can clear the ✍ reaction at turn-end.
|
|
2512
|
-
|
|
2513
|
-
refs.push({ chatId, msgId: msg.message_id });
|
|
2514
|
-
autosteeredMsgRefs.set(sessionKey, refs);
|
|
2496
|
+
autosteeredRefs.add(sessionKey, { chatId, msgId: msg.message_id });
|
|
2515
2497
|
logEvent('autosteer', {
|
|
2516
2498
|
chat_id: chatId, msg_id: msg.message_id,
|
|
2517
2499
|
text_len: prompt?.length ?? 0,
|
|
@@ -2642,25 +2624,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2642
2624
|
const q = entry?.query;
|
|
2643
2625
|
if (q && typeof q.getContextUsage === 'function') {
|
|
2644
2626
|
q.getContextUsage().then((usage) => {
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
// by 100, which displayed "7700% full" for a 77%-used
|
|
2648
|
-
// context (and fired below the intended 85% threshold).
|
|
2649
|
-
const pct = usage?.percentage ?? 0;
|
|
2650
|
-
if (pct < 85) return;
|
|
2651
|
-
// rc.22: three-choice hint. The original "send /new"
|
|
2652
|
-
// message implied the only path forward was a hard
|
|
2653
|
-
// reset. Now offer all three options the user actually
|
|
2654
|
-
// has — start fresh, compact with their preserve
|
|
2655
|
-
// instructions, or keep going (auto-compact eventually
|
|
2656
|
-
// fires).
|
|
2657
|
-
const text = [
|
|
2658
|
-
`📚 Context window ${pct.toFixed(0)}% full. Three options:`,
|
|
2659
|
-
'',
|
|
2660
|
-
'• `/new` — start fresh; this conversation ends.',
|
|
2661
|
-
'• `/compact` — summarise older messages. Add a hint after the command (e.g. `/compact keep the Q3 commission decisions`) and that becomes the compactor\'s guidance.',
|
|
2662
|
-
'• Keep chatting — I\'ll auto-compact when needed; key context is preserved automatically.',
|
|
2663
|
-
].join('\n');
|
|
2627
|
+
const text = maybeContextFullHint(usage);
|
|
2628
|
+
if (!text) return;
|
|
2664
2629
|
return tg(bot, 'sendMessage', {
|
|
2665
2630
|
chat_id: chatId,
|
|
2666
2631
|
text,
|
|
@@ -3624,6 +3589,13 @@ async function main() {
|
|
|
3624
3589
|
const head = entry.pendingQueue?.[0];
|
|
3625
3590
|
const s = head?.context?.streamer;
|
|
3626
3591
|
if (s) s.forceNewMessage();
|
|
3592
|
+
// rc.25: heartbeat at every assistant-message boundary too. A
|
|
3593
|
+
// long thinking phase (effort=high, 30+ s before first chunk)
|
|
3594
|
+
// doesn't fire onStreamChunk. Without this, the freeze timer
|
|
3595
|
+
// could expire while the model is "still thinking but about
|
|
3596
|
+
// to speak".
|
|
3597
|
+
const r = head?.context?.reactor;
|
|
3598
|
+
if (r && typeof r.heartbeat === 'function') r.heartbeat();
|
|
3627
3599
|
},
|
|
3628
3600
|
// 0.8.0 Phase 2 step 5: SDK auto-compaction observability. Fires
|
|
3629
3601
|
// when SDK emits SDKCompactBoundaryMessage (between turns or
|