polygram 0.8.0-rc.24 → 0.8.0-rc.26
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/abort-grace.js +62 -0
- package/lib/agent-loader.js +16 -0
- package/lib/approval-waiters.js +7 -0
- package/lib/approvals.js +9 -1
- package/lib/async-lock.js +11 -3
- package/lib/autosteered-refs.js +100 -0
- package/lib/canonical-json.js +19 -1
- package/lib/context-format.js +79 -0
- package/lib/pm-router.js +40 -8
- package/lib/process-manager-sdk.js +15 -6
- package/lib/status-reactions.js +13 -6
- package/lib/telegram-prompt.js +119 -0
- package/package.json +1 -1
- package/polygram.js +46 -87
|
@@ -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.26",
|
|
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",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abort-grace tracker — per-session timestamps marking "user just
|
|
3
|
+
* /stop'd this session, suppress the next batch of generic error
|
|
4
|
+
* replies".
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: when the user types /stop (or natural-language
|
|
7
|
+
* "стоп"), polygram calls pm.kill(sessionKey). The kill SIGTERM's
|
|
8
|
+
* the in-flight process — every pending in the queue rejects with
|
|
9
|
+
* "Process killed" or INTERRUPTED. WITHOUT abort-grace, polygram
|
|
10
|
+
* would post "💥 Hit a snag" for each rejected pending, even though
|
|
11
|
+
* the user already saw the /stop ack and these errors are caused
|
|
12
|
+
* by their own action.
|
|
13
|
+
*
|
|
14
|
+
* Timestamp model (vs the earlier "delete after first read" Set):
|
|
15
|
+
* a single /stop can drain many pendings, so we mark a TS and let
|
|
16
|
+
* every error within ABORT_GRACE_MS see "yes, aborted, stay quiet".
|
|
17
|
+
*
|
|
18
|
+
* Closes v6 plan §7.1 G11 unit gate.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const DEFAULT_ABORT_GRACE_MS = 15_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} [opts]
|
|
27
|
+
* @param {number} [opts.windowMs] — grace window (default 15s)
|
|
28
|
+
* @param {() => number} [opts.now] — clock injection for tests
|
|
29
|
+
*/
|
|
30
|
+
function createAbortGrace({ windowMs = DEFAULT_ABORT_GRACE_MS, now = () => Date.now() } = {}) {
|
|
31
|
+
const aborted = new Map(); // sessionKey → ts of abort
|
|
32
|
+
|
|
33
|
+
function mark(sessionKey) {
|
|
34
|
+
if (!sessionKey) return;
|
|
35
|
+
const ts = now();
|
|
36
|
+
aborted.set(sessionKey, ts);
|
|
37
|
+
// Sweep old entries opportunistically. Use 2× window so a
|
|
38
|
+
// session that's marked-and-checked at the boundary doesn't
|
|
39
|
+
// disappear before the check completes.
|
|
40
|
+
for (const [k, t] of aborted) {
|
|
41
|
+
if (ts - t > windowMs * 2) aborted.delete(k);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isRecent(sessionKey) {
|
|
46
|
+
const ts = aborted.get(sessionKey);
|
|
47
|
+
return ts != null && (now() - ts) < windowMs;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clear(sessionKey) {
|
|
51
|
+
aborted.delete(sessionKey);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
mark,
|
|
56
|
+
isRecent,
|
|
57
|
+
clear,
|
|
58
|
+
get size() { return aborted.size; },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { createAbortGrace, DEFAULT_ABORT_GRACE_MS };
|
package/lib/agent-loader.js
CHANGED
|
@@ -43,7 +43,23 @@ const cache = new Map(); // cacheKey → AgentBundle
|
|
|
43
43
|
|
|
44
44
|
// Resolve agent file by checking each search path in order.
|
|
45
45
|
// Returns { kind: 'file'|'dir', path, dir | null } or null.
|
|
46
|
+
// Restrict agent names to a conservative charset so they can't
|
|
47
|
+
// path-traverse out of the `.claude/agents/` directory. Pre-fix, an
|
|
48
|
+
// agent name like `../../etc/passwd` silently resolved to whatever
|
|
49
|
+
// existed at that path, loading arbitrary file content as the
|
|
50
|
+
// system prompt. Chat configs are operator-controlled (not user
|
|
51
|
+
// input), so the practical threat is operator typos — but pinning
|
|
52
|
+
// the contract removes the foot-gun.
|
|
53
|
+
//
|
|
54
|
+
// Allowed: alphanumerics, hyphen, underscore, single dots inside
|
|
55
|
+
// (e.g. "shumabit-finance.v2"). Forbidden: leading/trailing dot,
|
|
56
|
+
// consecutive dots, slashes, NUL.
|
|
57
|
+
const AGENT_NAME_RE = /^[A-Za-z0-9_]+(?:[.-][A-Za-z0-9_]+)*$/;
|
|
58
|
+
|
|
46
59
|
function resolveAgentLocation(agentName, homeDir, cwd) {
|
|
60
|
+
if (typeof agentName !== 'string' || !AGENT_NAME_RE.test(agentName)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
47
63
|
const fileCandidates = [];
|
|
48
64
|
if (cwd) fileCandidates.push(path.join(cwd, '.claude', 'agents', agentName + '.md'));
|
|
49
65
|
fileCandidates.push(path.join(homeDir, '.claude', 'agents', agentName + '.md'));
|
package/lib/approval-waiters.js
CHANGED
|
@@ -110,6 +110,13 @@ function createApprovalWaiters({
|
|
|
110
110
|
parkedAt: Date.now(),
|
|
111
111
|
sessionKey,
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
// If the signal was ALREADY aborted before we attached the
|
|
115
|
+
// listener, addEventListener never fires — the waiter would
|
|
116
|
+
// sit in the map until timeout-sweep / shutdown picked it up.
|
|
117
|
+
// Trigger the cleanup manually so the parked promise rejects
|
|
118
|
+
// immediately (matches "abort fired during park" semantics).
|
|
119
|
+
if (signal && signal.aborted) sigCleanup();
|
|
113
120
|
});
|
|
114
121
|
}
|
|
115
122
|
|
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 };
|
package/lib/canonical-json.js
CHANGED
|
@@ -31,11 +31,29 @@ function canonicalizeToolInput(input) {
|
|
|
31
31
|
if (input == null || typeof input !== 'object') {
|
|
32
32
|
return JSON.stringify(input);
|
|
33
33
|
}
|
|
34
|
+
// Track in-flight (currently-on-stack) nodes to detect circular
|
|
35
|
+
// references. WeakSet membership marks "we are still inside this
|
|
36
|
+
// node"; we drop the entry after finishing recursion so DAG
|
|
37
|
+
// shapes (shared subtrees that aren't cycles) round-trip fine.
|
|
38
|
+
// Pre-fix sortRec recursed forever on `{a: 1, self: <self>}`
|
|
39
|
+
// and crashed the daemon — DoS path if any tool ever produces
|
|
40
|
+
// self-referencing input. Now throws a clean TypeError matching
|
|
41
|
+
// JSON.stringify's own "Converting circular structure to JSON".
|
|
42
|
+
const onStack = new WeakSet();
|
|
34
43
|
const sortRec = (v) => {
|
|
35
|
-
if (Array.isArray(v))
|
|
44
|
+
if (Array.isArray(v)) {
|
|
45
|
+
if (onStack.has(v)) throw new TypeError('Converting circular structure to JSON');
|
|
46
|
+
onStack.add(v);
|
|
47
|
+
const result = v.map(sortRec);
|
|
48
|
+
onStack.delete(v);
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
36
51
|
if (v == null || typeof v !== 'object') return v;
|
|
52
|
+
if (onStack.has(v)) throw new TypeError('Converting circular structure to JSON');
|
|
53
|
+
onStack.add(v);
|
|
37
54
|
const out = {};
|
|
38
55
|
for (const k of Object.keys(v).sort()) out[k] = sortRec(v[k]);
|
|
56
|
+
onStack.delete(v);
|
|
39
57
|
return out;
|
|
40
58
|
};
|
|
41
59
|
return JSON.stringify(sortRec(input));
|
|
@@ -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/pm-router.js
CHANGED
|
@@ -73,6 +73,33 @@ function makeRouterPolicy({ useSdkAll = false, sdkChats = [], getChatIdFromKey }
|
|
|
73
73
|
* @param {object|null} opts.sdkPm
|
|
74
74
|
* @param {(sessionKey: string) => 'sdk'|'cli'} opts.pickPmKindFor
|
|
75
75
|
*/
|
|
76
|
+
/**
|
|
77
|
+
* Broadcast helper for killChat / shutdown. Awaits every task to
|
|
78
|
+
* settlement (success OR rejection), then throws an aggregate error
|
|
79
|
+
* if any task rejected. Single rejections re-throw the original
|
|
80
|
+
* error untouched (no AggregateError noise); multiple rejections
|
|
81
|
+
* surface as `AggregateError` with all causes preserved.
|
|
82
|
+
*
|
|
83
|
+
* Each task entry is `[label, () => Promise]`; the label appears in
|
|
84
|
+
* AggregateError messages so a debugger can tell which pm failed.
|
|
85
|
+
*/
|
|
86
|
+
async function broadcastSettle(method, tasks) {
|
|
87
|
+
const results = await Promise.allSettled(tasks.map(([, fn]) => fn()));
|
|
88
|
+
const errors = [];
|
|
89
|
+
results.forEach((r, i) => {
|
|
90
|
+
if (r.status === 'rejected') {
|
|
91
|
+
const tag = tasks[i][0];
|
|
92
|
+
const err = r.reason instanceof Error ? r.reason : new Error(String(r.reason));
|
|
93
|
+
err.pmTag = tag;
|
|
94
|
+
errors.push(err);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
if (errors.length === 1) throw errors[0];
|
|
98
|
+
if (errors.length > 1) {
|
|
99
|
+
throw new AggregateError(errors, `${method} failed in ${errors.length} pms`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
76
103
|
function createPmRouter({ cliPm, sdkPm = null, pickPmKindFor } = {}) {
|
|
77
104
|
if (!cliPm) throw new TypeError('cliPm required');
|
|
78
105
|
if (typeof pickPmKindFor !== 'function') {
|
|
@@ -98,15 +125,20 @@ function createPmRouter({ cliPm, sdkPm = null, pickPmKindFor } = {}) {
|
|
|
98
125
|
|
|
99
126
|
// Lifecycle methods broadcast to both pms because a chat may
|
|
100
127
|
// have spawned sessions on either side at different times.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
128
|
+
// Promise.allSettled (NOT Promise.all) so a rejection from one
|
|
129
|
+
// pm doesn't abandon the other mid-tear-down. Both must always
|
|
130
|
+
// complete; we then surface aggregated errors. Pre-fix, a cliPm
|
|
131
|
+
// rejection let sdkPm's Query.close() get GC'd with handles
|
|
132
|
+
// still open.
|
|
133
|
+
killChat(chatId) {
|
|
134
|
+
const tasks = [['cli', () => cliPm.killChat(chatId)]];
|
|
135
|
+
if (sdkPm) tasks.push(['sdk', () => sdkPm.killChat(chatId)]);
|
|
136
|
+
return broadcastSettle('killChat', tasks);
|
|
105
137
|
},
|
|
106
|
-
|
|
107
|
-
const tasks = [cliPm.shutdown()];
|
|
108
|
-
if (sdkPm) tasks.push(sdkPm.shutdown());
|
|
109
|
-
|
|
138
|
+
shutdown() {
|
|
139
|
+
const tasks = [['cli', () => cliPm.shutdown()]];
|
|
140
|
+
if (sdkPm) tasks.push(['sdk', () => sdkPm.shutdown()]);
|
|
141
|
+
return broadcastSettle('shutdown', tasks);
|
|
110
142
|
},
|
|
111
143
|
|
|
112
144
|
// Optional methods — forward when the routed pm implements
|
|
@@ -224,6 +224,9 @@ class ProcessManagerSdk {
|
|
|
224
224
|
// ─── Spawn / pool ────────────────────────────────────────────────
|
|
225
225
|
|
|
226
226
|
async getOrSpawn(sessionKey, spawnContext) {
|
|
227
|
+
if (this._shuttingDown) {
|
|
228
|
+
throw new Error('shutdown');
|
|
229
|
+
}
|
|
227
230
|
const existing = this.procs.get(sessionKey);
|
|
228
231
|
if (existing && !existing.closed) return existing;
|
|
229
232
|
|
|
@@ -232,6 +235,7 @@ class ProcessManagerSdk {
|
|
|
232
235
|
if (!evicted) {
|
|
233
236
|
// All entries in-flight — park.
|
|
234
237
|
await this._awaitLruSlot();
|
|
238
|
+
if (this._shuttingDown) throw new Error('shutdown');
|
|
235
239
|
return this.getOrSpawn(sessionKey, spawnContext);
|
|
236
240
|
}
|
|
237
241
|
}
|
|
@@ -899,18 +903,23 @@ class ProcessManagerSdk {
|
|
|
899
903
|
}
|
|
900
904
|
|
|
901
905
|
async shutdown() {
|
|
906
|
+
// Set flag FIRST so any LRU-waiter unparked by _closeEntry's
|
|
907
|
+
// iteration-finally doesn't recurse into a fresh spawn (which
|
|
908
|
+
// would leave an orphaned entry after `procs.clear()` below).
|
|
909
|
+
// Reject parked waiters immediately so their getOrSpawn callers
|
|
910
|
+
// unwind cleanly rather than racing the shutdown.
|
|
911
|
+
this._shuttingDown = true;
|
|
912
|
+
while (this._lruWaiters.length) {
|
|
913
|
+
const w = this._lruWaiters.shift();
|
|
914
|
+
clearTimeout(w.timer);
|
|
915
|
+
w.reject(new Error('shutdown'));
|
|
916
|
+
}
|
|
902
917
|
const entries = [...this.procs.values()];
|
|
903
918
|
await Promise.allSettled(entries.map((e) => {
|
|
904
919
|
this.drainQueue(e.sessionKey, 'SHUTDOWN');
|
|
905
920
|
return this._closeEntry(e, 'shutdown');
|
|
906
921
|
}));
|
|
907
922
|
this.procs.clear();
|
|
908
|
-
// Reject any remaining LRU waiters.
|
|
909
|
-
while (this._lruWaiters.length) {
|
|
910
|
-
const w = this._lruWaiters.shift();
|
|
911
|
-
clearTimeout(w.timer);
|
|
912
|
-
w.reject(new Error('shutdown'));
|
|
913
|
-
}
|
|
914
923
|
}
|
|
915
924
|
|
|
916
925
|
// ─── Helpers ────────────────────────────────────────────────────
|
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:
|
|
@@ -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.26",
|
|
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,9 @@ 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');
|
|
47
|
+
const { createAbortGrace } = require('./lib/abort-grace');
|
|
44
48
|
const agentLoader = require('./lib/agent-loader');
|
|
45
49
|
const USE_SDK = process.env.POLYGRAM_USE_SDK === '1';
|
|
46
50
|
const { createSender } = require('./lib/telegram');
|
|
@@ -723,23 +727,18 @@ const autosteerBuffer = createAutosteerBuffer();
|
|
|
723
727
|
// the TRIGGER message's reactor.clear() at turn-end couldn't reach
|
|
724
728
|
// across to other messages. Without this map, users see ✍ stuck on
|
|
725
729
|
// every follow-up and don't know whether the bot incorporated them.
|
|
726
|
-
const
|
|
730
|
+
const autosteeredRefs = createAutosteeredRefs({
|
|
731
|
+
applyClear: async ({ chatId, msgId }) => {
|
|
732
|
+
if (!bot) return;
|
|
733
|
+
await tg(bot, 'setMessageReaction', {
|
|
734
|
+
chat_id: chatId, message_id: msgId, reaction: [],
|
|
735
|
+
}, { source: 'autosteer-clear', botName: BOT_NAME });
|
|
736
|
+
},
|
|
737
|
+
logger: { error: (m) => console.error(`[${BOT_NAME}] ${m}`) },
|
|
738
|
+
});
|
|
727
739
|
|
|
728
740
|
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
|
-
}
|
|
741
|
+
return autosteeredRefs.clear(sessionKey);
|
|
743
742
|
}
|
|
744
743
|
|
|
745
744
|
// 0.8.0-rc.14: tool-less-turn drain. PostToolBatch hook only fires
|
|
@@ -820,6 +819,10 @@ function spawnClaude(sessionKey, ctx) {
|
|
|
820
819
|
];
|
|
821
820
|
if (chatConfig.agent) args.push('--agent', chatConfig.agent);
|
|
822
821
|
if (existingSessionId) args.push('--resume', existingSessionId);
|
|
822
|
+
// Polygram-side display constraints — same hint the SDK pm appends
|
|
823
|
+
// via Options.systemPrompt. Keeps the table-width rule in
|
|
824
|
+
// infrastructure, not in agent docs.
|
|
825
|
+
args.push(...appendDisplayHintCliArgs());
|
|
823
826
|
|
|
824
827
|
console.log(`[${label}] Spawning process (${chatConfig.model}/${chatConfig.effort})`);
|
|
825
828
|
|
|
@@ -973,7 +976,7 @@ function buildSdkOptions(sessionKey, ctx) {
|
|
|
973
976
|
// precedence: chatConfig > agent > defaults. The chatConfig keys
|
|
974
977
|
// we care about for SDK options are model/effort/cwd/thinking;
|
|
975
978
|
// others (agent, chrome, isolateTopics) are polygram-only.
|
|
976
|
-
|
|
979
|
+
const composed = agentLoader.composeSdkOptions(
|
|
977
980
|
{
|
|
978
981
|
// chat-level overrides — only the keys SDK understands.
|
|
979
982
|
model: chatConfig.model,
|
|
@@ -984,6 +987,13 @@ function buildSdkOptions(sessionKey, ctx) {
|
|
|
984
987
|
agentBundle,
|
|
985
988
|
baseOpts,
|
|
986
989
|
);
|
|
990
|
+
|
|
991
|
+
// Append polygram's display constraints to the systemPrompt.
|
|
992
|
+
// Infrastructure-layer hint — the agent's own prompt covers
|
|
993
|
+
// business logic; polygram adds "your output renders in Telegram,
|
|
994
|
+
// here's the width budget for tables".
|
|
995
|
+
composed.systemPrompt = appendDisplayHint(composed.systemPrompt);
|
|
996
|
+
return composed;
|
|
987
997
|
}
|
|
988
998
|
|
|
989
999
|
function buildSpawnContext(sessionKey) {
|
|
@@ -1092,30 +1102,16 @@ function errorReplyText(err) {
|
|
|
1092
1102
|
return userMessage; // may be null — caller must handle
|
|
1093
1103
|
}
|
|
1094
1104
|
|
|
1095
|
-
// Sessions the operator just /stop'd (or natural-language "стоп").
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
|
|
1102
|
-
// them reject with "Process killed", all of them should be silent, not
|
|
1103
|
-
// just the first one.
|
|
1104
|
-
const ABORT_GRACE_MS = 15_000;
|
|
1105
|
-
const abortedSessions = new Map();
|
|
1106
|
-
|
|
1107
|
-
function markSessionAborted(sessionKey) {
|
|
1108
|
-
abortedSessions.set(sessionKey, Date.now());
|
|
1109
|
-
// Sweep old entries opportunistically.
|
|
1110
|
-
for (const [k, ts] of abortedSessions) {
|
|
1111
|
-
if (Date.now() - ts > ABORT_GRACE_MS * 2) abortedSessions.delete(k);
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1105
|
+
// Sessions the operator just /stop'd (or natural-language "стоп").
|
|
1106
|
+
// rc.25: extracted to lib/abort-grace.js so the timestamp/window
|
|
1107
|
+
// logic has its own unit tests. Behaviour identical: any pending
|
|
1108
|
+
// rejected within the grace window is considered abort-caused —
|
|
1109
|
+
// its generic error reply is suppressed and the streamer warning
|
|
1110
|
+
// is skipped.
|
|
1111
|
+
const abortGrace = createAbortGrace();
|
|
1114
1112
|
|
|
1115
|
-
function
|
|
1116
|
-
|
|
1117
|
-
return ts != null && (Date.now() - ts) < ABORT_GRACE_MS;
|
|
1118
|
-
}
|
|
1113
|
+
function markSessionAborted(sessionKey) { abortGrace.mark(sessionKey); }
|
|
1114
|
+
function isSessionRecentlyAborted(sessionKey) { return abortGrace.isRecent(sessionKey); }
|
|
1119
1115
|
|
|
1120
1116
|
// Called by bot.on('message') for every regular (non-admin, non-pair)
|
|
1121
1117
|
// message. Runs handleMessage in a fire-and-forget manner with centralised
|
|
@@ -1955,32 +1951,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1955
1951
|
}
|
|
1956
1952
|
try {
|
|
1957
1953
|
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'));
|
|
1954
|
+
await sendReply(formatContextReply(u));
|
|
1984
1955
|
} catch (err) {
|
|
1985
1956
|
console.error(`[${label}] /context failed: ${err.message}`);
|
|
1986
1957
|
await sendReply(`📚 Couldn't fetch context info: ${err.message}`);
|
|
@@ -2509,9 +2480,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2509
2480
|
if (ok) {
|
|
2510
2481
|
// Track this msg_id so the in-flight turn's success / abort
|
|
2511
2482
|
// / error path can clear the ✍ reaction at turn-end.
|
|
2512
|
-
|
|
2513
|
-
refs.push({ chatId, msgId: msg.message_id });
|
|
2514
|
-
autosteeredMsgRefs.set(sessionKey, refs);
|
|
2483
|
+
autosteeredRefs.add(sessionKey, { chatId, msgId: msg.message_id });
|
|
2515
2484
|
logEvent('autosteer', {
|
|
2516
2485
|
chat_id: chatId, msg_id: msg.message_id,
|
|
2517
2486
|
text_len: prompt?.length ?? 0,
|
|
@@ -2642,25 +2611,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2642
2611
|
const q = entry?.query;
|
|
2643
2612
|
if (q && typeof q.getContextUsage === 'function') {
|
|
2644
2613
|
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');
|
|
2614
|
+
const text = maybeContextFullHint(usage);
|
|
2615
|
+
if (!text) return;
|
|
2664
2616
|
return tg(bot, 'sendMessage', {
|
|
2665
2617
|
chat_id: chatId,
|
|
2666
2618
|
text,
|
|
@@ -3624,6 +3576,13 @@ async function main() {
|
|
|
3624
3576
|
const head = entry.pendingQueue?.[0];
|
|
3625
3577
|
const s = head?.context?.streamer;
|
|
3626
3578
|
if (s) s.forceNewMessage();
|
|
3579
|
+
// rc.25: heartbeat at every assistant-message boundary too. A
|
|
3580
|
+
// long thinking phase (effort=high, 30+ s before first chunk)
|
|
3581
|
+
// doesn't fire onStreamChunk. Without this, the freeze timer
|
|
3582
|
+
// could expire while the model is "still thinking but about
|
|
3583
|
+
// to speak".
|
|
3584
|
+
const r = head?.context?.reactor;
|
|
3585
|
+
if (r && typeof r.heartbeat === 'function') r.heartbeat();
|
|
3627
3586
|
},
|
|
3628
3587
|
// 0.8.0 Phase 2 step 5: SDK auto-compaction observability. Fires
|
|
3629
3588
|
// when SDK emits SDKCompactBoundaryMessage (between turns or
|