polygram 0.12.0-rc.9 → 0.12.0
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/config.example.json +3 -1
- package/lib/claude-bin.js +14 -1
- package/lib/compaction-warn.js +59 -0
- package/lib/context-usage.js +93 -0
- package/lib/db.js +1 -1
- package/lib/error/classify.js +33 -10
- package/lib/feedback/session-feedback.js +91 -0
- package/lib/handlers/abort.js +87 -40
- package/lib/handlers/autosteer.js +4 -0
- package/lib/handlers/config-callback.js +25 -6
- package/lib/handlers/config-ui.js +39 -10
- package/lib/handlers/dispatcher.js +83 -0
- package/lib/handlers/download.js +101 -58
- package/lib/handlers/drop-redeliver.js +69 -0
- package/lib/handlers/edit-correction.js +2 -0
- package/lib/handlers/edit-redelivery.js +136 -0
- package/lib/handlers/gate-inbound.js +188 -0
- package/lib/handlers/questions.js +289 -0
- package/lib/handlers/redeliver.js +122 -0
- package/lib/handlers/slash-commands.js +43 -30
- package/lib/history-preload.js +6 -0
- package/lib/history.js +7 -1
- package/lib/model-costs.js +4 -0
- package/lib/process/channels-bridge-protocol.js +22 -1
- package/lib/process/channels-bridge.mjs +128 -7
- package/lib/process/channels-tool-dispatcher.js +105 -12
- package/lib/process/cli-process.js +1277 -70
- package/lib/process/hook-event-tail.js +7 -0
- package/lib/process/hook-settings.js +7 -0
- package/lib/process/process.js +22 -0
- package/lib/process-guard.js +57 -1
- package/lib/process-manager.js +120 -35
- package/lib/questions/questions.js +187 -0
- package/lib/questions/store.js +105 -0
- package/lib/rewind/execute.js +89 -0
- package/lib/rewind/fork.js +112 -0
- package/lib/rewind/rewind.js +174 -0
- package/lib/sdk/callbacks.js +165 -167
- package/lib/session-key.js +29 -0
- package/lib/telegram/album-reactions.js +50 -0
- package/lib/telegram/parse.js +9 -2
- package/lib/telegram/typing.js +17 -2
- package/lib/tmux/startup-gate.js +44 -14
- package/migrations/012-pending-questions.sql +30 -0
- package/package.json +1 -1
- package/polygram.js +224 -78
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- 0.12 interactive questions: AskUserQuestion → Telegram inline keyboards.
|
|
2
|
+
-- Mirrors pending_approvals (per-row 128-bit callback token, status lifecycle,
|
|
3
|
+
-- audit-kept rows). One OPEN question per session at a time (idx_pq_open).
|
|
4
|
+
-- The answer routes back to claude on tool_call_id via a question_answer message.
|
|
5
|
+
|
|
6
|
+
CREATE TABLE pending_questions (
|
|
7
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
8
|
+
bot_name TEXT NOT NULL,
|
|
9
|
+
session_key TEXT NOT NULL, -- chat:thread that asked (claude session)
|
|
10
|
+
chat_id TEXT NOT NULL,
|
|
11
|
+
thread_id TEXT,
|
|
12
|
+
turn_id TEXT, -- echoed for routing
|
|
13
|
+
tool_call_id TEXT NOT NULL, -- the question_answer routes back on this
|
|
14
|
+
from_id INTEGER, -- Telegram user_id allowed to answer; claimed on first tap
|
|
15
|
+
callback_token TEXT NOT NULL, -- 128-bit; defeats forged/guessed callback_data
|
|
16
|
+
questions_json TEXT NOT NULL, -- the ask call's questions array
|
|
17
|
+
state_json TEXT NOT NULL, -- the question-state machine state
|
|
18
|
+
message_ids_json TEXT, -- Telegram msg_id(s) of the keyboard message to edit/strip
|
|
19
|
+
awaiting_other INTEGER NOT NULL DEFAULT 0, -- 1 while capturing a free-text "Other"
|
|
20
|
+
status TEXT NOT NULL
|
|
21
|
+
CHECK(status IN ('pending','answered','cancelled','timeout','expired'))
|
|
22
|
+
DEFAULT 'pending',
|
|
23
|
+
created_ts INTEGER NOT NULL,
|
|
24
|
+
timeout_ts INTEGER NOT NULL,
|
|
25
|
+
answered_ts INTEGER
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE INDEX idx_pq_open ON pending_questions(session_key, status) WHERE status = 'pending';
|
|
29
|
+
CREATE INDEX idx_pq_timeout ON pending_questions(status, timeout_ts) WHERE status = 'pending';
|
|
30
|
+
CREATE INDEX idx_pq_tool_call ON pending_questions(tool_call_id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.12.0
|
|
3
|
+
"version": "0.12.0",
|
|
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
|
@@ -56,12 +56,21 @@ const { sweepTmuxOrphans } = require('./lib/tmux/orphan-sweep');
|
|
|
56
56
|
const { createAutosteeredRefs } = require('./lib/autosteered-refs');
|
|
57
57
|
const { createBuildSdkOptions } = require('./lib/sdk/build-options');
|
|
58
58
|
const { createSdkCallbacks } = require('./lib/sdk/callbacks');
|
|
59
|
+
const { createQuestionStore } = require('./lib/questions/store');
|
|
60
|
+
const { createQuestionHandlers } = require('./lib/handlers/questions');
|
|
61
|
+
const { isRewindCommand, createRewindHandler } = require('./lib/rewind/rewind');
|
|
62
|
+
const { createRewindExecutor } = require('./lib/rewind/execute');
|
|
59
63
|
const { createTranscribeVoiceAttachments } = require('./lib/handlers/voice');
|
|
60
64
|
const { createDownloadAttachments } = require('./lib/handlers/download');
|
|
61
65
|
const { createHandleConfigCallback } = require('./lib/handlers/config-callback');
|
|
62
66
|
const { createHandleAbort } = require('./lib/handlers/abort');
|
|
63
67
|
const { createAutosteerHandlers } = require('./lib/handlers/autosteer');
|
|
64
68
|
const { createEditCorrectionInjector } = require('./lib/handlers/edit-correction');
|
|
69
|
+
const { createEditRedelivery } = require('./lib/handlers/edit-redelivery');
|
|
70
|
+
const { createGateInbound } = require('./lib/handlers/gate-inbound');
|
|
71
|
+
const { createRedeliver } = require('./lib/handlers/redeliver');
|
|
72
|
+
const { createDropRedeliverer } = require('./lib/handlers/drop-redeliver');
|
|
73
|
+
const { createSessionFeedback } = require('./lib/feedback/session-feedback');
|
|
65
74
|
const { createSlashCommands } = require('./lib/handlers/slash-commands');
|
|
66
75
|
const { createApprovals } = require('./lib/handlers/approvals');
|
|
67
76
|
const { canonicalizeToolInput } = require('./lib/canonical-json');
|
|
@@ -97,6 +106,7 @@ const { startTyping } = require('./lib/telegram/typing');
|
|
|
97
106
|
// consumer is lib/handlers/download.js.
|
|
98
107
|
const { createReactionManager, classifyToolName } = require('./lib/telegram/reactions');
|
|
99
108
|
const { createMediaGroupBuffer } = require('./lib/media-group-buffer');
|
|
109
|
+
const { applyReactionToMessages } = require('./lib/telegram/album-reactions');
|
|
100
110
|
const { classify: classifyError, detectWedgedSessionError, isTransientHttpError } = require('./lib/error/classify');
|
|
101
111
|
const { createAutoResumeTracker, isAutoResumable } = require('./lib/db/auto-resume');
|
|
102
112
|
const { resolveReplayWindowMs } = require('./lib/db/replay-window');
|
|
@@ -334,6 +344,9 @@ function formatPrompt(msg, sessionCtx, attachments = [], { sessionKey = null } =
|
|
|
334
344
|
db,
|
|
335
345
|
chatId,
|
|
336
346
|
threadId: threadId || null,
|
|
347
|
+
// Per-topic sessions must only preload their OWN thread — else a message
|
|
348
|
+
// in one topic gets fed other topics' history (2026-06-09 cross-topic bleed).
|
|
349
|
+
isolateTopics: chatConfig?.isolateTopics === true,
|
|
337
350
|
excludeMsgId: msg.message_id,
|
|
338
351
|
logger: console,
|
|
339
352
|
});
|
|
@@ -635,6 +648,13 @@ let handleAbortIfRequested = null;
|
|
|
635
648
|
let autosteer = null;
|
|
636
649
|
let dispatchSlashCommand = null;
|
|
637
650
|
let maybeInjectEditCorrection = null;
|
|
651
|
+
let maybePostTurnEdit = null;
|
|
652
|
+
// 0.13 D5/D4: the ONE intake gate (assigned in createBot, where botUsername/
|
|
653
|
+
// mentionRe live) + the ONE redelivery tail (assigned in main once the
|
|
654
|
+
// dispatcher exists). See lib/handlers/gate-inbound.js / redeliver.js.
|
|
655
|
+
let gateInbound = null;
|
|
656
|
+
let redeliverAsFreshTurn = null;
|
|
657
|
+
let dropRedeliverer = null; // 0.13 D2→D4 glue; assigned once redeliver exists
|
|
638
658
|
|
|
639
659
|
// rc.20: approvalCardText + safeParse moved to lib/approvals/ui.js.
|
|
640
660
|
// 0.9.0 commit 29: makeCanUseTool / handleApprovalCallback /
|
|
@@ -643,6 +663,10 @@ let maybeInjectEditCorrection = null;
|
|
|
643
663
|
// approvals store are available.
|
|
644
664
|
let makeCanUseTool = null;
|
|
645
665
|
let handleApprovalCallback = null;
|
|
666
|
+
// 0.12 interactive questions — assigned in main() once db.raw + pm exist; the
|
|
667
|
+
// createSdkCallbacks onQuestionAsked closure + the callback router read it late.
|
|
668
|
+
let questionHandlers = null;
|
|
669
|
+
let rewindHandler = null; // 0.13 /rewind (P1); late-bound, assigned in main() after pm exists
|
|
646
670
|
let resolveApprovalWaiter = null;
|
|
647
671
|
let startApprovalSweeper = null;
|
|
648
672
|
let cancelAllWaiters = null;
|
|
@@ -739,8 +763,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
739
763
|
|
|
740
764
|
if (botAllowsCommands && (text === '/model' || text === '/config' || text === '/effort')) {
|
|
741
765
|
const show = text === '/effort' ? 'effort' : text === '/model' ? 'model' : 'all';
|
|
742
|
-
|
|
743
|
-
|
|
766
|
+
// Resolve per-topic overrides so a topic's card shows its REAL
|
|
767
|
+
// agent/model/effort, not the chat-level default — Music topic (thread 3)
|
|
768
|
+
// showed "Agent: shumabit" instead of music-curation:music-curator
|
|
769
|
+
// (2026-06-03). getTopicConfig returns {} when there's no active topic.
|
|
770
|
+
const _cardTopicCfg = getTopicConfig(chatConfig, threadIdStr || null);
|
|
771
|
+
const info = formatConfigInfoText(chatConfig, show, sessionKey, _cardTopicCfg);
|
|
772
|
+
const reply_markup = buildConfigKeyboard(chatConfig, show, _cardTopicCfg);
|
|
744
773
|
await sendReply(info, { params: { reply_markup } });
|
|
745
774
|
return;
|
|
746
775
|
}
|
|
@@ -998,13 +1027,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
998
1027
|
const availableEmojis = await getReactionAllowlist(bot, chatId);
|
|
999
1028
|
const reactor = createReactionManager({
|
|
1000
1029
|
apply: async (emoji) => {
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
await
|
|
1007
|
-
|
|
1030
|
+
// rc.16: mirror the reaction onto album siblings too, so a multi-file
|
|
1031
|
+
// send shows the same status emoji on EVERY item, not just the anchor.
|
|
1032
|
+
// For a normal single message, _albumSiblingMsgIds is undefined and this
|
|
1033
|
+
// is exactly the prior single setMessageReaction. Anchor is awaited
|
|
1034
|
+
// (failure surfaces to the reactor); siblings are best-effort.
|
|
1035
|
+
await applyReactionToMessages({
|
|
1036
|
+
tg, bot, chatId,
|
|
1037
|
+
msgIds: [msg.message_id, ...(msg._albumSiblingMsgIds || [])],
|
|
1038
|
+
emoji,
|
|
1039
|
+
botName: BOT_NAME,
|
|
1040
|
+
});
|
|
1008
1041
|
},
|
|
1009
1042
|
availableEmojis,
|
|
1010
1043
|
logError: (m) => console.error(`[${label}] ${m}`),
|
|
@@ -1097,7 +1130,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1097
1130
|
// pick them off entry.pendingQueue[0].context.
|
|
1098
1131
|
await new Promise((dispatched) => {
|
|
1099
1132
|
sendPromise = sendToProcess(sessionKey, prompt, {
|
|
1100
|
-
|
|
1133
|
+
// 0.13 D1 (S8): the typing controller rides the per-turn context so
|
|
1134
|
+
// the question lifecycle (callbacks.js onQuestionAsked/-Resumed) can
|
|
1135
|
+
// pause it while the bot waits on the USER and resume on the answer.
|
|
1136
|
+
streamer, reactor, typing: stopTyping, sourceMsgId: msg.message_id,
|
|
1101
1137
|
// 0.7.4 (item B): fire THINKING when Claude actually starts
|
|
1102
1138
|
// emitting — not the moment we wrote stdin.
|
|
1103
1139
|
onFirstStream: () => reactor.setState('THINKING'),
|
|
@@ -1698,7 +1734,14 @@ function createBot(token) {
|
|
|
1698
1734
|
const apiRoot = config.bot?.apiRoot;
|
|
1699
1735
|
const bot = new Bot(token, {
|
|
1700
1736
|
client: {
|
|
1701
|
-
|
|
1737
|
+
// rc.15: with the local Bot API server, getFile DOWNLOADS the file
|
|
1738
|
+
// synchronously (server fetches it from Telegram's DC, then responds) —
|
|
1739
|
+
// a large lossless WAV can take >60s, so the cloud-tuned 60s timeout
|
|
1740
|
+
// fired before the download finished (the file still landed on the
|
|
1741
|
+
// server's disk, but polygram's getFile call already errored). The
|
|
1742
|
+
// local server is localhost, so non-download calls stay fast; the
|
|
1743
|
+
// higher ceiling only matters for big getFile downloads.
|
|
1744
|
+
timeoutSeconds: apiRoot ? 180 : 60,
|
|
1702
1745
|
...(apiRoot ? { apiRoot } : {}),
|
|
1703
1746
|
},
|
|
1704
1747
|
});
|
|
@@ -1708,9 +1751,6 @@ function createBot(token) {
|
|
|
1708
1751
|
let botUsername = '';
|
|
1709
1752
|
// Cached once @botUsername is known — was recompiling per inbound msg.
|
|
1710
1753
|
let mentionRe = null;
|
|
1711
|
-
// Hoisted admin-command matcher; was re-allocated per message.
|
|
1712
|
-
const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context|compact)(\s|$)/;
|
|
1713
|
-
const PAIR_CLAIM_RE = /^\/pair\s+\S+/;
|
|
1714
1754
|
|
|
1715
1755
|
// The filter in main() guarantees config.chats only contains chats owned
|
|
1716
1756
|
// by BOT_NAME, so any update for a chat not in config.chats is unknown —
|
|
@@ -1808,45 +1848,38 @@ function createBot(token) {
|
|
|
1808
1848
|
return newChat;
|
|
1809
1849
|
}
|
|
1810
1850
|
|
|
1851
|
+
// 0.13 D5: the intake chain (abort → admin/pair → rewind → ownsOpenOther ‖
|
|
1852
|
+
// shouldHandle → question-consume → dispatch) moved verbatim into the ONE
|
|
1853
|
+
// gate, lib/handlers/gate-inbound.js, with a tier×stage side-effect table —
|
|
1854
|
+
// the edited_message path and every redelivery now run the SAME chain
|
|
1855
|
+
// (pre-0.13 they ran divergent subsets; the divergences were bugs — an edit
|
|
1856
|
+
// to "/stop" was injected into the very turn it tried to kill, an edit
|
|
1857
|
+
// during an open "Other" capture never became the answer, and any group
|
|
1858
|
+
// member's bare "stop" aborted others' turns pre-gate). Late-bound deps are
|
|
1859
|
+
// getters because this runs at createBot time, before main() wires the
|
|
1860
|
+
// dispatcher/handlers.
|
|
1861
|
+
gateInbound = createGateInbound({
|
|
1862
|
+
config,
|
|
1863
|
+
getBotUsername: () => botUsername,
|
|
1864
|
+
getMentionRe: () => mentionRe,
|
|
1865
|
+
pairings: { hasLivePairing: (args) => !!pairings?.hasLivePairing(args) },
|
|
1866
|
+
isAbortRequest,
|
|
1867
|
+
handleAbortIfRequested: (...a) => handleAbortIfRequested(...a),
|
|
1868
|
+
getRewindHandler: () => rewindHandler,
|
|
1869
|
+
isRewindCommand,
|
|
1870
|
+
getQuestionHandlers: () => questionHandlers,
|
|
1871
|
+
shouldHandle,
|
|
1872
|
+
getSessionKey,
|
|
1873
|
+
dispatchHandleMessage: (...a) => dispatchHandleMessage(...a),
|
|
1874
|
+
bot,
|
|
1875
|
+
botName: BOT_NAME,
|
|
1876
|
+
logEvent,
|
|
1877
|
+
logger: console,
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1811
1880
|
// Shared post-validation dispatch. Called directly for single messages
|
|
1812
1881
|
// and for the synthesised "primary" of a media-group bundle.
|
|
1813
|
-
const dispatchRegularMessage = async (msg) => {
|
|
1814
|
-
const chatId = msg.chat.id.toString();
|
|
1815
|
-
const chatConfig = config.chats[chatId];
|
|
1816
|
-
if (!chatConfig) return;
|
|
1817
|
-
|
|
1818
|
-
const rawText = msg.text || '';
|
|
1819
|
-
const cleanText = mentionRe ? rawText.replace(mentionRe, '').trim() : rawText.trim();
|
|
1820
|
-
|
|
1821
|
-
// Abort: skip the queue entirely. Matches bilingual natural-
|
|
1822
|
-
// language cues + slash variants. handleAbortIfRequested
|
|
1823
|
-
// (lib/handlers/abort.js) returns true when handled — short-
|
|
1824
|
-
// circuit out of dispatch.
|
|
1825
|
-
if (await handleAbortIfRequested(msg, chatId, chatConfig, cleanText)) {
|
|
1826
|
-
return;
|
|
1827
|
-
}
|
|
1828
|
-
|
|
1829
|
-
const botAllowsCommands = !!config.bot?.allowConfigCommands;
|
|
1830
|
-
const isAdminCmd = botAllowsCommands && ADMIN_CMD_RE.test(cleanText);
|
|
1831
|
-
const isPairClaim = PAIR_CLAIM_RE.test(cleanText);
|
|
1832
|
-
if (isAdminCmd || isPairClaim) {
|
|
1833
|
-
msg.text = cleanText;
|
|
1834
|
-
const threadId = msg.message_thread_id?.toString();
|
|
1835
|
-
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
1836
|
-
await handleMessage(sessionKey, chatId, msg, bot);
|
|
1837
|
-
return;
|
|
1838
|
-
}
|
|
1839
|
-
|
|
1840
|
-
if (!shouldHandle(msg, chatConfig, botUsername)) return;
|
|
1841
|
-
|
|
1842
|
-
if (botUsername) {
|
|
1843
|
-
msg.text = cleanText;
|
|
1844
|
-
}
|
|
1845
|
-
|
|
1846
|
-
const threadId = msg.message_thread_id?.toString();
|
|
1847
|
-
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
1848
|
-
dispatchHandleMessage(sessionKey, chatId, msg, bot);
|
|
1849
|
-
};
|
|
1882
|
+
const dispatchRegularMessage = async (msg) => gateInbound(msg, { tier: 'fresh' });
|
|
1850
1883
|
|
|
1851
1884
|
// Media-group buffer: coalesce multi-photo uploads (Telegram delivers
|
|
1852
1885
|
// each attachment as a separate Message sharing a `media_group_id`) into
|
|
@@ -1885,6 +1918,10 @@ function createBot(token) {
|
|
|
1885
1918
|
}
|
|
1886
1919
|
|
|
1887
1920
|
const synthetic = { ...primary, _mergedAttachments: merged };
|
|
1921
|
+
// rc.16: carry the album sibling msg_ids so the status reactor can mirror
|
|
1922
|
+
// its emoji onto every item (not just the anchor) — see the reactor
|
|
1923
|
+
// `apply` closure + lib/telegram/album-reactions.js.
|
|
1924
|
+
if (siblingMsgIds.length) synthetic._albumSiblingMsgIds = siblingMsgIds;
|
|
1888
1925
|
// Carry the primary's text verbatim (dispatchRegularMessage re-cleans
|
|
1889
1926
|
// the mention). Caption → text so downstream sees it uniformly.
|
|
1890
1927
|
if (!synthetic.text && synthetic.caption) synthetic.text = synthetic.caption;
|
|
@@ -1954,6 +1991,8 @@ function createBot(token) {
|
|
|
1954
1991
|
const data = ctx.callbackQuery.data;
|
|
1955
1992
|
if (data.startsWith('cfg:')) {
|
|
1956
1993
|
await handleConfigCallback(ctx);
|
|
1994
|
+
} else if (data.startsWith('q:')) {
|
|
1995
|
+
if (questionHandlers) await questionHandlers.handleQuestionCallback(ctx);
|
|
1957
1996
|
} else {
|
|
1958
1997
|
await handleApprovalCallback(ctx);
|
|
1959
1998
|
}
|
|
@@ -1973,6 +2012,10 @@ function createBot(token) {
|
|
|
1973
2012
|
}
|
|
1974
2013
|
const chatId = ctx.editedMessage.chat.id.toString();
|
|
1975
2014
|
if (!knownChat(chatId)) return;
|
|
2015
|
+
// 0.12.0 spec §3 (HARD): read the OLD text BEFORE recordInbound overwrites the row — the
|
|
2016
|
+
// post-turn changed-guard compares it, and the re-dispatch quotes it in reply_to so claude sees
|
|
2017
|
+
// the before/after. Reading after recordInbound would yield the new text (useless).
|
|
2018
|
+
const oldText = db.getMessage(chatId, ctx.editedMessage.message_id)?.text ?? null;
|
|
1976
2019
|
recordInbound(ctx.editedMessage);
|
|
1977
2020
|
logEvent('message-edited', {
|
|
1978
2021
|
chat_id: chatId,
|
|
@@ -1981,16 +2024,29 @@ function createBot(token) {
|
|
|
1981
2024
|
});
|
|
1982
2025
|
console.log(`[${BOT_NAME}] edited ${chatId}/${ctx.editedMessage.message_id}`);
|
|
1983
2026
|
|
|
1984
|
-
// 0.
|
|
1985
|
-
//
|
|
1986
|
-
//
|
|
1987
|
-
//
|
|
1988
|
-
//
|
|
1989
|
-
//
|
|
2027
|
+
// 0.13 D5: gate FIRST (tier 'edit') — the edited message's CURRENT text
|
|
2028
|
+
// runs the same abort/admin/question-consume/shouldHandle chain as a fresh
|
|
2029
|
+
// message. Closes the S11 holes: an edit-to-"stop" now ABORTS (identity-
|
|
2030
|
+
// gated) instead of being injected into the very turn it tries to kill; an
|
|
2031
|
+
// edit while that user owns an open free-text "Other" capture becomes the
|
|
2032
|
+
// answer; a bystander's un-addressed edit is blocked BEFORE any inject.
|
|
2033
|
+
// Only a 'pass' proceeds to the fold/redeliver machinery below.
|
|
1990
2034
|
try {
|
|
1991
|
-
|
|
2035
|
+
const gateRes = await gateInbound(ctx.editedMessage, { tier: 'edit' });
|
|
2036
|
+
if (gateRes.action !== 'pass') {
|
|
2037
|
+
logEvent('edit-gated', {
|
|
2038
|
+
chat_id: chatId, msg_id: ctx.editedMessage.message_id,
|
|
2039
|
+
action: gateRes.action, stage: gateRes.stage ?? null,
|
|
2040
|
+
});
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
// Mid-turn (turn still in flight) → fold into the running turn via the 0.9.0
|
|
2044
|
+
// injector. Post-turn (idle) — OR the injector no-ops because the turn just
|
|
2045
|
+
// settled at the boundary — → re-dispatch as a NEW turn (edit re-delivery).
|
|
2046
|
+
const injected = maybeInjectEditCorrection?.(ctx.editedMessage);
|
|
2047
|
+
if (!injected) maybePostTurnEdit?.(ctx.editedMessage, oldText, botUsername, mentionRe);
|
|
1992
2048
|
} catch (err) {
|
|
1993
|
-
console.error(`[${BOT_NAME}] edit
|
|
2049
|
+
console.error(`[${BOT_NAME}] edit handler error: ${err.message}`);
|
|
1994
2050
|
}
|
|
1995
2051
|
});
|
|
1996
2052
|
|
|
@@ -2204,8 +2260,17 @@ async function main() {
|
|
|
2204
2260
|
|
|
2205
2261
|
bot = createBot(config.bot.token);
|
|
2206
2262
|
|
|
2263
|
+
// 0.13 D3: session-scoped feedback for cycles with NO pending turn
|
|
2264
|
+
// (ScheduleWakeup, fireUserMessage self-checks, injected messages picked up
|
|
2265
|
+
// as their own cycle) — typing for the cycle's duration + a 🤔 anchored to
|
|
2266
|
+
// the picked-up message when the input ledger names one.
|
|
2267
|
+
const sessionFeedback = createSessionFeedback({
|
|
2268
|
+
bot, tg, getChatIdFromKey, getThreadIdFromKey, botName: BOT_NAME,
|
|
2269
|
+
logEvent, logger: console,
|
|
2270
|
+
});
|
|
2271
|
+
|
|
2207
2272
|
const sdkCallbacks = createSdkCallbacks({
|
|
2208
|
-
db, dbWrite, config, bot, botName: BOT_NAME, tg, logEvent,
|
|
2273
|
+
db, dbWrite, config, bot, botName: BOT_NAME, tg, logEvent, sessionFeedback,
|
|
2209
2274
|
classifyToolName, announce, shouldAnnounce, contextHintShown,
|
|
2210
2275
|
extractAssistantText, getChatIdFromKey, getThreadIdFromKey,
|
|
2211
2276
|
// F#23: enable parse/sanitize/sticker/react on the autonomous-wakeup path.
|
|
@@ -2213,6 +2278,9 @@ async function main() {
|
|
|
2213
2278
|
// `[react:EMOJI]`, `No response requested.` all leaked as literal text.
|
|
2214
2279
|
parseResponse, sanitizeAssistantReply, chunkMarkdownText, deliverReplies,
|
|
2215
2280
|
processAndDeliverAgentText,
|
|
2281
|
+
// 0.12 interactive questions: 'question-asked' (claude called the ask tool)
|
|
2282
|
+
// → render the Telegram keyboard. Late-bound; questionHandlers is assigned below.
|
|
2283
|
+
renderQuestion: (payload) => questionHandlers?.renderAsk(payload),
|
|
2216
2284
|
logger: console,
|
|
2217
2285
|
});
|
|
2218
2286
|
// 0.10.0: sdkCallbacks (the polygram-side lifecycle handlers — status
|
|
@@ -2232,6 +2300,32 @@ async function main() {
|
|
|
2232
2300
|
config, db, bot, botName: BOT_NAME, tg, logEvent,
|
|
2233
2301
|
approvals, getChatIdFromKey, logger: console,
|
|
2234
2302
|
}));
|
|
2303
|
+
|
|
2304
|
+
// 0.12 interactive questions: store + handlers + timeout sweep. answerQuestion
|
|
2305
|
+
// is late-bound to pm (a tap can land minutes later, pm is live by then).
|
|
2306
|
+
const questionStore = createQuestionStore(db.raw);
|
|
2307
|
+
questionHandlers = createQuestionHandlers({
|
|
2308
|
+
questions: questionStore, tg, bot, botName: BOT_NAME, logEvent,
|
|
2309
|
+
answerQuestion: (sk, tc, result) => pm.answerQuestion(sk, tc, result),
|
|
2310
|
+
logger: console,
|
|
2311
|
+
});
|
|
2312
|
+
// Resolve expired questions with {timedout} so claude never hangs on an ignored ask.
|
|
2313
|
+
setInterval(() => {
|
|
2314
|
+
try {
|
|
2315
|
+
for (const row of questionStore.sweepTimedOut()) {
|
|
2316
|
+
questionHandlers.expireQuestion(row).catch((e) => console.error(`[${BOT_NAME}] question expire: ${e.message}`));
|
|
2317
|
+
}
|
|
2318
|
+
} catch (e) { console.error(`[${BOT_NAME}] question sweep: ${e.message}`); }
|
|
2319
|
+
}, 30_000).unref?.();
|
|
2320
|
+
|
|
2321
|
+
// 0.13 /rewind: detect + operator/ownership gate + turn-end defer + confirm (P1), backed by
|
|
2322
|
+
// the copy-only transcript-fork executor (P2/P3: fork → repoint the session → kill → delete
|
|
2323
|
+
// orphaned bot messages). channels/cli only; the fork mechanism was proven in P0.6.
|
|
2324
|
+
// See docs/0.13-rewind-design.md.
|
|
2325
|
+
const executeRewind = createRewindExecutor({ db, pm, tg, bot, botName: BOT_NAME, logEvent, logger: console });
|
|
2326
|
+
rewindHandler = createRewindHandler({
|
|
2327
|
+
pm, tg, bot, botName: BOT_NAME, logEvent, logger: console, executeRewind,
|
|
2328
|
+
});
|
|
2235
2329
|
buildSdkOptions = createBuildSdkOptions({
|
|
2236
2330
|
config,
|
|
2237
2331
|
botName: BOT_NAME,
|
|
@@ -2322,6 +2416,11 @@ async function main() {
|
|
|
2322
2416
|
// the verdict back via tmux send-keys "1"/"3"+Enter.
|
|
2323
2417
|
// makeCanUseTool handles admin card, chat_tool_decisions persistence,
|
|
2324
2418
|
// and timeout race — all reused from SDK.
|
|
2419
|
+
// 0.13 D2: 'input-dropped' → redeliver once via the D4 tail. Stable wrapper:
|
|
2420
|
+
// pm spread-copies the callbacks object at construction, and the redeliver
|
|
2421
|
+
// tail is wired later in main() — the wrapper late-binds it.
|
|
2422
|
+
sdkCallbacks.onInputDropped = (sessionKey, payload) => dropRedeliverer?.(sessionKey, payload);
|
|
2423
|
+
|
|
2325
2424
|
sdkCallbacks.onApprovalRequired = async (sessionKey, payload) => {
|
|
2326
2425
|
const { toolName, toolInput, id, respond } = payload || {};
|
|
2327
2426
|
if (typeof respond !== 'function') return;
|
|
@@ -2360,7 +2459,7 @@ async function main() {
|
|
|
2360
2459
|
});
|
|
2361
2460
|
handleConfigCallback = createHandleConfigCallback({
|
|
2362
2461
|
config, db, dbWrite, pm, getSessionKey,
|
|
2363
|
-
formatConfigInfoText, buildConfigKeyboard,
|
|
2462
|
+
formatConfigInfoText, buildConfigKeyboard, saveConfig,
|
|
2364
2463
|
botName: BOT_NAME, logger: console,
|
|
2365
2464
|
});
|
|
2366
2465
|
handleAbortIfRequested = createHandleAbort({
|
|
@@ -2396,6 +2495,34 @@ async function main() {
|
|
|
2396
2495
|
getIsShuttingDown: () => isShuttingDown,
|
|
2397
2496
|
logger: console,
|
|
2398
2497
|
}));
|
|
2498
|
+
// 0.12.0 post-turn edit re-delivery: constructed AFTER dispatchHandleMessage is assigned (above).
|
|
2499
|
+
// An edit while a turn is in flight folds via maybeInjectEditCorrection; an edit after the turn
|
|
2500
|
+
// (or when the injector no-ops at the boundary) re-dispatches as a new turn. The on-edit 👀 is a
|
|
2501
|
+
// pre-turn ack for the cold-spawn gap; the synthetic turn's own reactor then takes over the msg.
|
|
2502
|
+
maybePostTurnEdit = createEditRedelivery({
|
|
2503
|
+
pm, config, getSessionKey, shouldHandle, dispatchHandleMessage, bot,
|
|
2504
|
+
react: (chatId, msgId) => applyReactionToMessages({
|
|
2505
|
+
tg, bot, chatId, msgIds: [msgId], emoji: '👀', botName: BOT_NAME,
|
|
2506
|
+
}).catch(() => {}),
|
|
2507
|
+
logEvent, logger: console,
|
|
2508
|
+
});
|
|
2509
|
+
// 0.13 D4: the ONE redelivery tail — boot-replay (below) and the P3
|
|
2510
|
+
// drop-redeliverer converge on it (once-only + _isReplay + redelivery-tier
|
|
2511
|
+
// gate + 👀 ack + dispatch). startup-auto-retry deliberately stays a
|
|
2512
|
+
// same-process re-dispatch (its error path must SURFACE the friendly reset
|
|
2513
|
+
// reply, which the _isReplay tag would suppress); compact-replay stays a
|
|
2514
|
+
// system re-push outside the user-message gate (design §6.7).
|
|
2515
|
+
redeliverAsFreshTurn = createRedeliver({
|
|
2516
|
+
gateInbound: (...a) => gateInbound(...a),
|
|
2517
|
+
dispatchHandleMessage, getSessionKey, config, db, dbWrite,
|
|
2518
|
+
react: (chatId, msgId) => applyReactionToMessages({
|
|
2519
|
+
tg, bot, chatId, msgIds: [msgId], emoji: '👀', botName: BOT_NAME,
|
|
2520
|
+
}).catch(() => {}),
|
|
2521
|
+
bot, logEvent, logger: console,
|
|
2522
|
+
});
|
|
2523
|
+
dropRedeliverer = createDropRedeliverer({
|
|
2524
|
+
db, redeliver: redeliverAsFreshTurn, logEvent, logger: console,
|
|
2525
|
+
});
|
|
2399
2526
|
({ pollBot, startPollWatchdog } = createPollLoop({
|
|
2400
2527
|
db, dbWrite, config, botName: BOT_NAME,
|
|
2401
2528
|
isWellFormedMessage, getTopicName,
|
|
@@ -2405,7 +2532,7 @@ async function main() {
|
|
|
2405
2532
|
config, db, dbWrite, pm, pairings, parsePairingTtl,
|
|
2406
2533
|
contextHintShown, formatContextReply, getClaudeSessionId,
|
|
2407
2534
|
getOrSpawnForChat, parsePairCodeArgs,
|
|
2408
|
-
modelVersionsDesc: MODEL_VERSIONS_DESC,
|
|
2535
|
+
modelVersionsDesc: MODEL_VERSIONS_DESC, saveConfig,
|
|
2409
2536
|
botName: BOT_NAME, logEvent, logger: console,
|
|
2410
2537
|
});
|
|
2411
2538
|
console.log('[polygram] using SDK ProcessManager');
|
|
@@ -2448,6 +2575,28 @@ async function main() {
|
|
|
2448
2575
|
// 1. Stop accepting new inbound first so nothing new queues behind the drain.
|
|
2449
2576
|
if (bot && bot._stop) bot._stop();
|
|
2450
2577
|
|
|
2578
|
+
// 1.5 (0.13 D1): expire open interactive questions {cancelled} BEFORE the
|
|
2579
|
+
// drain. With D1 the asking turn stays in flight for the whole wait, so a
|
|
2580
|
+
// deploy during a question would otherwise eat the entire 30s drain and
|
|
2581
|
+
// mark the inbound replay-pending mid-ask. Cancelling unblocks claude's
|
|
2582
|
+
// ask so the cycle can end inside the drain; the boot-replay re-ask is the
|
|
2583
|
+
// documented recovery path (design §3 D1 ask-wait semantics).
|
|
2584
|
+
try {
|
|
2585
|
+
const openQuestions = questionStore.listOpen?.(BOT_NAME) || [];
|
|
2586
|
+
for (const row of openQuestions) {
|
|
2587
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2588
|
+
await questionHandlers.expireQuestion(row, {
|
|
2589
|
+
status: 'cancelled',
|
|
2590
|
+
message: 'Bot is restarting — this question was cancelled. It may be re-asked in a moment.',
|
|
2591
|
+
}).catch(() => {});
|
|
2592
|
+
}
|
|
2593
|
+
if (openQuestions.length) {
|
|
2594
|
+
logEvent('shutdown-questions-cancelled', { count: openQuestions.length });
|
|
2595
|
+
}
|
|
2596
|
+
} catch (err) {
|
|
2597
|
+
console.error(`[shutdown] question expiry failed: ${err.message}`);
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2451
2600
|
// 2. Drain in-flight handlers. Wait for inFlightHandlers to empty or
|
|
2452
2601
|
// SHUTDOWN_DRAIN_MS to elapse. pm handlers resolve naturally when
|
|
2453
2602
|
// result events arrive; the dispatcher's .finally decrements.
|
|
@@ -2610,22 +2759,19 @@ async function main() {
|
|
|
2610
2759
|
}
|
|
2611
2760
|
const chatConfig = config.chats[row.chat_id];
|
|
2612
2761
|
if (!chatConfig) { skipped += 1; continue; }
|
|
2613
|
-
//
|
|
2614
|
-
//
|
|
2615
|
-
//
|
|
2616
|
-
|
|
2617
|
-
//
|
|
2618
|
-
//
|
|
2619
|
-
//
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
const sessionKey = getSessionKey(row.chat_id, row.thread_id, chatConfig);
|
|
2627
|
-
dispatchHandleMessage(sessionKey, row.chat_id, reconstructed, bot);
|
|
2628
|
-
replayed += 1;
|
|
2762
|
+
// 0.13 D4: through the unified redelivery tail — _isReplay tag (error
|
|
2763
|
+
// reply suppressed, not replay-eligible), 'replay-attempted' pre-mark
|
|
2764
|
+
// (one-shot: even if THIS attempt dies mid-turn, the next boot won't
|
|
2765
|
+
// loop), the D5 gate at tier 'redelivery' (abort/admin-shaped rows are
|
|
2766
|
+
// never auto-re-executed; a row whose chat lost its pairing since is
|
|
2767
|
+
// re-checked), a 👀 ack so the recovery is visible, then dispatch.
|
|
2768
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2769
|
+
const r = await redeliverAsFreshTurn({
|
|
2770
|
+
chatId: row.chat_id, msg: reconstructed,
|
|
2771
|
+
source: 'boot-replay', preMark: 'replay-attempted',
|
|
2772
|
+
});
|
|
2773
|
+
if (r.ok) replayed += 1;
|
|
2774
|
+
else skipped += 1;
|
|
2629
2775
|
}
|
|
2630
2776
|
if (candidates.length > 0) {
|
|
2631
2777
|
console.log(`[replay] ${replayed} turns re-dispatched, ${skipped} skipped (already replied or no chat config)`);
|