polygram 0.8.0-rc.7 → 0.8.0-rc.9
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/autosteer-buffer.js +80 -0
- package/package.json +1 -1
- package/polygram.js +79 -39
|
@@ -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.9",
|
|
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,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session buffer for mid-turn user follow-ups (autosteer + /steer).
|
|
3
|
+
*
|
|
4
|
+
* 0.8.0-rc.9: lands the steer mechanism that survived production. Earlier
|
|
5
|
+
* rcs pushed `priority:'now'` SDKUserMessages onto the SDK input
|
|
6
|
+
* iterable mid-tool-use; the CLI binary's `m87` gate rejected them with
|
|
7
|
+
* `result.subtype = error_during_execution` because the transcript shape
|
|
8
|
+
* (assistant ending with tool_use → next user message NOT being a
|
|
9
|
+
* tool_result) is malformed per Anthropic's API contract.
|
|
10
|
+
*
|
|
11
|
+
* The mechanism we landed on: append the follow-up to a per-session
|
|
12
|
+
* buffer; on every PostToolBatch hook fire, drain the buffer into the
|
|
13
|
+
* hook's `additionalContext` field wrapped in a `<channel
|
|
14
|
+
* source="user-followup">…</channel>` tag — the same framing Channels
|
|
15
|
+
* MCP uses, which Claude is trained to trust as legitimate
|
|
16
|
+
* out-of-band user context (vs. prompt-injection inside tool output,
|
|
17
|
+
* which the model defends against by refusing to follow).
|
|
18
|
+
*
|
|
19
|
+
* Spike result (post-tool-batch-spike-v2.mjs): with this framing, the
|
|
20
|
+
* marker "spike-marker-9d3e" injected via additionalContext was
|
|
21
|
+
* incorporated verbatim into the assistant's final answer. With the
|
|
22
|
+
* earlier `<user_message_during_turn>` framing, the model recognised
|
|
23
|
+
* it as prompt-injection-shaped and refused.
|
|
24
|
+
*
|
|
25
|
+
* Why a buffer module instead of inlining: per-sessionKey state lives
|
|
26
|
+
* outside the pm and outside polygram.js's handleMessage so both
|
|
27
|
+
* autosteer (handleMessage line ~2418) and /steer (line ~1975) can
|
|
28
|
+
* share it. pm-sdk binds a hook callback per spawn that closes over
|
|
29
|
+
* its sessionKey and drains this buffer.
|
|
30
|
+
*
|
|
31
|
+
* Edge: tool-less turns (Claude answers without firing a tool). The
|
|
32
|
+
* hook never fires, so a queued message would be lost. pm-sdk's
|
|
33
|
+
* onResult handler MUST drain the buffer at turn-end and push the
|
|
34
|
+
* remainder via `inputController.push(..., { shouldQuery: false })`
|
|
35
|
+
* for next-turn injection — no m87 risk because the previous turn
|
|
36
|
+
* ended cleanly with text/end_turn before the push lands.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
'use strict';
|
|
40
|
+
|
|
41
|
+
function createAutosteerBuffer() {
|
|
42
|
+
// sessionKey → array of strings (in order of arrival)
|
|
43
|
+
const queues = new Map();
|
|
44
|
+
|
|
45
|
+
function append(sessionKey, text) {
|
|
46
|
+
if (!sessionKey || typeof text !== 'string' || text.length === 0) return false;
|
|
47
|
+
let q = queues.get(sessionKey);
|
|
48
|
+
if (!q) { q = []; queues.set(sessionKey, q); }
|
|
49
|
+
q.push(text);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function drain(sessionKey) {
|
|
54
|
+
const q = queues.get(sessionKey);
|
|
55
|
+
if (!q || q.length === 0) return [];
|
|
56
|
+
queues.delete(sessionKey);
|
|
57
|
+
return q;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function size(sessionKey) {
|
|
61
|
+
return queues.get(sessionKey)?.length ?? 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clear(sessionKey) {
|
|
65
|
+
queues.delete(sessionKey);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Format the drained messages as the additionalContext payload that
|
|
69
|
+
// Claude trusts. Multiple messages are joined with a blank line so
|
|
70
|
+
// the model sees them as a sequence within a single channel tag.
|
|
71
|
+
function formatForHook(messages) {
|
|
72
|
+
if (!messages || messages.length === 0) return null;
|
|
73
|
+
const body = messages.join('\n\n');
|
|
74
|
+
return `<channel source="user-followup">\n${body}\n</channel>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { append, drain, size, clear, formatForHook };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { createAutosteerBuffer };
|
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.9",
|
|
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
|
@@ -31,6 +31,7 @@ const { ProcessManager } = require('./lib/process-manager');
|
|
|
31
31
|
// pick-at-startup. Phase 4 deletes the CLI version after Phase 5
|
|
32
32
|
// soak proves SDK stable. See docs/0.8.0-architecture-decisions.md.
|
|
33
33
|
const { ProcessManagerSdk } = require('./lib/process-manager-sdk');
|
|
34
|
+
const { createAutosteerBuffer } = require('./lib/autosteer-buffer');
|
|
34
35
|
const agentLoader = require('./lib/agent-loader');
|
|
35
36
|
const USE_SDK = process.env.POLYGRAM_USE_SDK === '1';
|
|
36
37
|
const { createSender } = require('./lib/telegram');
|
|
@@ -698,6 +699,14 @@ function formatPrompt(msg, sessionCtx, attachments = []) {
|
|
|
698
699
|
|
|
699
700
|
let pm = null; // ProcessManager, created in main()
|
|
700
701
|
|
|
702
|
+
// 0.8.0-rc.9: per-session autosteer buffer. Holds user follow-ups
|
|
703
|
+
// that arrive mid-turn so the SDK pm's PostToolBatch hook can drain
|
|
704
|
+
// them into `additionalContext` on each tool boundary. Replaces the
|
|
705
|
+
// rc.6/rc.7 approach of pushing priority:'now' SDKUserMessages
|
|
706
|
+
// directly (which violated the SDK's m87 transcript-shape gate when
|
|
707
|
+
// the assistant was mid-tool-use).
|
|
708
|
+
const autosteerBuffer = createAutosteerBuffer();
|
|
709
|
+
|
|
701
710
|
function spawnClaude(sessionKey, ctx) {
|
|
702
711
|
const { chatConfig, existingSessionId, label, chatId } = ctx;
|
|
703
712
|
// 0.7.3: Claude Code's Chrome-extension integration (browser
|
|
@@ -817,6 +826,40 @@ function buildSdkOptions(sessionKey, ctx) {
|
|
|
817
826
|
const useCanUseTool = apprCfg && apprCfg.adminChatId
|
|
818
827
|
&& Array.isArray(apprCfg.gatedTools) && apprCfg.gatedTools.length > 0;
|
|
819
828
|
|
|
829
|
+
// 0.8.0-rc.9: PostToolBatch hook drains the autosteer buffer for
|
|
830
|
+
// this session and injects queued user follow-ups as
|
|
831
|
+
// `additionalContext` on each tool boundary. Framing matters:
|
|
832
|
+
// wrapping in `<channel source="user-followup">…</channel>` is
|
|
833
|
+
// what Claude is trained to trust as legitimate out-of-band user
|
|
834
|
+
// context (verified live via post-tool-batch-spike-v2.mjs); the
|
|
835
|
+
// earlier `<user_message_during_turn>` framing tripped the
|
|
836
|
+
// model's prompt-injection defense and got refused.
|
|
837
|
+
const postToolBatchHook = async () => {
|
|
838
|
+
try {
|
|
839
|
+
const drained = autosteerBuffer.drain(sessionKey);
|
|
840
|
+
if (drained.length === 0) return { continue: true };
|
|
841
|
+
const additionalContext = autosteerBuffer.formatForHook(drained);
|
|
842
|
+
logEvent('autosteer-hook-drained', {
|
|
843
|
+
chat_id: ctx?.chatId ?? null,
|
|
844
|
+
session_key: sessionKey,
|
|
845
|
+
message_count: drained.length,
|
|
846
|
+
});
|
|
847
|
+
return {
|
|
848
|
+
continue: true,
|
|
849
|
+
hookSpecificOutput: {
|
|
850
|
+
hookEventName: 'PostToolBatch',
|
|
851
|
+
additionalContext,
|
|
852
|
+
},
|
|
853
|
+
};
|
|
854
|
+
} catch (err) {
|
|
855
|
+
console.error(`[${sessionKey}] PostToolBatch hook error: ${err.message}`);
|
|
856
|
+
// Never throw out of a hook — the SDK may treat it as a hard
|
|
857
|
+
// fail (`stop_hook_prevented` result subtype). Drop the
|
|
858
|
+
// queued messages on the floor; the user can re-send.
|
|
859
|
+
return { continue: true };
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
|
|
820
863
|
const baseOpts = {
|
|
821
864
|
model: chatConfig.model || config.defaults.model,
|
|
822
865
|
effort: chatConfig.effort || config.defaults.effort,
|
|
@@ -828,6 +871,9 @@ function buildSdkOptions(sessionKey, ctx) {
|
|
|
828
871
|
permissionMode: useCanUseTool ? 'default' : 'bypassPermissions',
|
|
829
872
|
allowDangerouslySkipPermissions: !useCanUseTool,
|
|
830
873
|
...(useCanUseTool && { canUseTool: makeCanUseTool(sessionKey) }),
|
|
874
|
+
hooks: {
|
|
875
|
+
PostToolBatch: [{ hooks: [postToolBatchHook] }],
|
|
876
|
+
},
|
|
831
877
|
executable: 'node',
|
|
832
878
|
...(existingSessionId && { resume: existingSessionId }),
|
|
833
879
|
...(process.env.POLYGRAM_CLAUDE_BIN && {
|
|
@@ -1966,38 +2012,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1966
2012
|
await sendReply('✨ Started a fresh session.');
|
|
1967
2013
|
return;
|
|
1968
2014
|
}
|
|
1969
|
-
// 0.8.0
|
|
1970
|
-
//
|
|
1971
|
-
//
|
|
1972
|
-
//
|
|
1973
|
-
//
|
|
1974
|
-
// mid-turn). Falls back to /new under CLI pm.
|
|
1975
|
-
if (botAllowsCommands && text.startsWith('/steer ')) {
|
|
1976
|
-
const steerText = text.slice(7).trim();
|
|
1977
|
-
if (!steerText) { await sendReply('Usage: /steer <text>'); return; }
|
|
1978
|
-
const target = pm.pickFor(sessionKey);
|
|
1979
|
-
if (typeof target.steer !== 'function') {
|
|
1980
|
-
await sendReply('🛞 /steer requires the SDK pm. This chat is on the CLI pm path.');
|
|
1981
|
-
return;
|
|
1982
|
-
}
|
|
1983
|
-
if (!pm.has(sessionKey)) {
|
|
1984
|
-
await sendReply('🛞 No active session — /steer only works mid-turn. Send a message first, then /steer while it\'s thinking.');
|
|
1985
|
-
return;
|
|
1986
|
-
}
|
|
1987
|
-
const ok = target.steer(sessionKey, steerText);
|
|
1988
|
-
if (ok) {
|
|
1989
|
-
logEvent('steer-command', {
|
|
1990
|
-
chat_id: chatId, text_len: steerText.length,
|
|
1991
|
-
user: cmdUser, user_id: cmdUserId,
|
|
1992
|
-
});
|
|
1993
|
-
// Quiet ack so user knows it landed; the actual response will
|
|
1994
|
-
// arrive as the in-flight turn's continuation.
|
|
1995
|
-
await sendReply('🛞 Steering applied. Watching for the response.');
|
|
1996
|
-
} else {
|
|
1997
|
-
await sendReply('🛞 Couldn\'t apply steer — session may have just closed.');
|
|
1998
|
-
}
|
|
1999
|
-
return;
|
|
2000
|
-
}
|
|
2015
|
+
// 0.8.0-rc.9: /steer command removed. Mid-turn user input is
|
|
2016
|
+
// handled implicitly by autosteer — any follow-up message during
|
|
2017
|
+
// an in-flight SDK turn flows through autosteerBuffer +
|
|
2018
|
+
// PostToolBatch hook. No explicit command needed; matches Claude
|
|
2019
|
+
// Code interactive UX where you just keep typing.
|
|
2001
2020
|
// Graceful application of a model/effort change to the user's CURRENT
|
|
2002
2021
|
// session only. With isolateTopics=false the sessionKey is just the
|
|
2003
2022
|
// chat (one shared session for the whole chat — every topic
|
|
@@ -2412,19 +2431,34 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2412
2431
|
// chatConfig.autosteer === false). CLI pm always falls through
|
|
2413
2432
|
// to the queue-FIFO path (no steer primitive on stream-json).
|
|
2414
2433
|
//
|
|
2415
|
-
// The steered message gets a
|
|
2434
|
+
// The steered message gets a ✍ reaction so the user knows it
|
|
2416
2435
|
// landed; no separate reply is generated (the in-flight turn's
|
|
2417
2436
|
// response covers both messages, OpenClaw-style).
|
|
2437
|
+
//
|
|
2438
|
+
// Reaction emoji must be from Telegram's curated allowlist
|
|
2439
|
+
// (~60 standard emoji per core.telegram.org/bots/api#availablereactions).
|
|
2440
|
+
// 🛞 (steering wheel) is NOT on it — Telegram returns
|
|
2441
|
+
// 400: REACTION_INVALID. ✍ ("writing/noting") is on the list and
|
|
2442
|
+
// conveys "incorporating this".
|
|
2418
2443
|
const chatAutosteer = chatConfig.autosteer != null
|
|
2419
2444
|
? chatConfig.autosteer
|
|
2420
2445
|
: config.bot?.autosteer;
|
|
2421
|
-
|
|
2446
|
+
// 0.8.0-rc.9: autosteer now drives through autosteerBuffer +
|
|
2447
|
+
// PostToolBatch hook (in buildSdkOptions), not pm.steer's direct
|
|
2448
|
+
// inputController push. The hook fires on every tool boundary
|
|
2449
|
+
// and injects queued follow-ups as <channel source="user-followup">
|
|
2450
|
+
// additionalContext — the SDK-trusted framing that survives the
|
|
2451
|
+
// m87 transcript-shape gate.
|
|
2452
|
+
//
|
|
2453
|
+
// We still gate on the SDK pm path: under CLI pm there's no
|
|
2454
|
+
// PostToolBatch hook surface, so autosteer falls through to the
|
|
2455
|
+
// regular FIFO send (same UX as 0.7.x).
|
|
2422
2456
|
const autosteerEnabled = chatAutosteer !== false
|
|
2423
|
-
&&
|
|
2457
|
+
&& pm.isSdkFor(sessionKey);
|
|
2424
2458
|
if (autosteerEnabled && pm.has(sessionKey)) {
|
|
2425
2459
|
const entry = pm.get(sessionKey);
|
|
2426
2460
|
if (entry?.inFlight) {
|
|
2427
|
-
const ok =
|
|
2461
|
+
const ok = autosteerBuffer.append(sessionKey, prompt);
|
|
2428
2462
|
if (ok) {
|
|
2429
2463
|
// Quiet ack — no chat-bubble reply, just a reaction so the
|
|
2430
2464
|
// user sees their message was incorporated. The in-flight
|
|
@@ -2432,7 +2466,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2432
2466
|
tg(bot, 'setMessageReaction', {
|
|
2433
2467
|
chat_id: chatId,
|
|
2434
2468
|
message_id: msg.message_id,
|
|
2435
|
-
reaction: [{ type: 'emoji', emoji: '
|
|
2469
|
+
reaction: [{ type: 'emoji', emoji: '✍' }],
|
|
2436
2470
|
}, { source: 'autosteer-ack', botName: BOT_NAME }).catch((err) => {
|
|
2437
2471
|
console.error(`[${label}] autosteer reaction: ${err.message}`);
|
|
2438
2472
|
});
|
|
@@ -2441,7 +2475,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2441
2475
|
text_len: prompt?.length ?? 0,
|
|
2442
2476
|
});
|
|
2443
2477
|
stopTyping();
|
|
2444
|
-
|
|
2478
|
+
// 0.8.0-rc.8: clear() instead of stop() so the THINKING/QUEUED
|
|
2479
|
+
// 👀 reaction set by the reactor at QUEUED-state actually
|
|
2480
|
+
// disappears from the user's message. reactor.stop() only
|
|
2481
|
+
// cancels timers; the visible emoji persists indefinitely
|
|
2482
|
+
// without an explicit clear() — that's why production showed
|
|
2483
|
+
// 👀 stuck on every steered follow-up under rc.6/rc.7.
|
|
2484
|
+
await reactor.clear().catch(() => {});
|
|
2445
2485
|
markReplied();
|
|
2446
2486
|
return;
|
|
2447
2487
|
}
|
|
@@ -2795,7 +2835,7 @@ function createBot(token) {
|
|
|
2795
2835
|
// Cached once @botUsername is known — was recompiling per inbound msg.
|
|
2796
2836
|
let mentionRe = null;
|
|
2797
2837
|
// Hoisted admin-command matcher; was re-allocated per message.
|
|
2798
|
-
const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context
|
|
2838
|
+
const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context)(\s|$)/;
|
|
2799
2839
|
const PAIR_CLAIM_RE = /^\/pair\s+\S+/;
|
|
2800
2840
|
|
|
2801
2841
|
// The filter in main() guarantees config.chats only contains chats owned
|