polygram 0.8.0-rc.8 → 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.
@@ -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.8",
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.8",
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 Phase 2 step 1: /steer <text> mid-turn steering. Pushes
1970
- // a priority:'now' user message onto the active Query so Claude
1971
- // sees it without waiting for the in-flight turn to fully
1972
- // complete. SDK pm only CLI pm has no steer primitive (its
1973
- // stream-json transport is request-response, not interruptible
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
@@ -2424,13 +2443,22 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2424
2443
  const chatAutosteer = chatConfig.autosteer != null
2425
2444
  ? chatConfig.autosteer
2426
2445
  : config.bot?.autosteer;
2427
- const autosteerTarget = pm.pickFor(sessionKey);
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).
2428
2456
  const autosteerEnabled = chatAutosteer !== false
2429
- && typeof autosteerTarget.steer === 'function';
2457
+ && pm.isSdkFor(sessionKey);
2430
2458
  if (autosteerEnabled && pm.has(sessionKey)) {
2431
2459
  const entry = pm.get(sessionKey);
2432
2460
  if (entry?.inFlight) {
2433
- const ok = autosteerTarget.steer(sessionKey, prompt);
2461
+ const ok = autosteerBuffer.append(sessionKey, prompt);
2434
2462
  if (ok) {
2435
2463
  // Quiet ack — no chat-bubble reply, just a reaction so the
2436
2464
  // user sees their message was incorporated. The in-flight
@@ -2807,7 +2835,7 @@ function createBot(token) {
2807
2835
  // Cached once @botUsername is known — was recompiling per inbound msg.
2808
2836
  let mentionRe = null;
2809
2837
  // Hoisted admin-command matcher; was re-allocated per message.
2810
- const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context|steer)(\s|$)/;
2838
+ const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context)(\s|$)/;
2811
2839
  const PAIR_CLAIM_RE = /^\/pair\s+\S+/;
2812
2840
 
2813
2841
  // The filter in main() guarantees config.chats only contains chats owned