polygram 0.8.0-rc.65 → 0.8.0-rc.67
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/db.js +16 -1
- package/lib/parse-response.js +56 -1
- package/lib/stream-reply.js +35 -1
- package/package.json +1 -1
- package/polygram.js +19 -1
|
@@ -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.67",
|
|
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 plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"telegram",
|
package/lib/db.js
CHANGED
|
@@ -381,7 +381,22 @@ function wrap(db) {
|
|
|
381
381
|
AND json_extract(detail_json, '$.session_key') = ?
|
|
382
382
|
LIMIT 1
|
|
383
383
|
`).get(c.id, c.session_key);
|
|
384
|
-
if (
|
|
384
|
+
if (boundary) continue;
|
|
385
|
+
// rc.66: also skip if a previous boot has already handled
|
|
386
|
+
// this orphan (silent replay via compact-replay event, OR
|
|
387
|
+
// surface-fallback via compact-failed-restart event). Both
|
|
388
|
+
// of those record `original_ts` in their detail_json
|
|
389
|
+
// matching the original compact-command's ts. Without this
|
|
390
|
+
// dedupe, every subsequent deploy re-surfaces / re-replays
|
|
391
|
+
// the same orphan (annoying noise).
|
|
392
|
+
const handled = db.prepare(`
|
|
393
|
+
SELECT id FROM events
|
|
394
|
+
WHERE kind IN ('compact-replay', 'compact-failed-restart')
|
|
395
|
+
AND json_extract(detail_json, '$.original_ts') = ?
|
|
396
|
+
LIMIT 1
|
|
397
|
+
`).get(c.ts);
|
|
398
|
+
if (handled) continue;
|
|
399
|
+
orphans.push(c);
|
|
385
400
|
}
|
|
386
401
|
return orphans;
|
|
387
402
|
},
|
package/lib/parse-response.js
CHANGED
|
@@ -165,4 +165,59 @@ function parseResponse(text, { stickerMap = {}, emojiToSticker = {} } = {}) {
|
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
/**
|
|
169
|
+
* rc.67: streamer-side pre-processor.
|
|
170
|
+
*
|
|
171
|
+
* The streamer (lib/stream-reply.js) writes the FIRST chunk of a turn to
|
|
172
|
+
* Telegram as soon as text crosses minChars. The DB row for that bubble
|
|
173
|
+
* snapshots whatever was sent, and editMessageText calls don't update
|
|
174
|
+
* the row — so if the agent emitted `[sticker:working]` in the first
|
|
175
|
+
* chunk, the messages.text column captures it verbatim and the bubble
|
|
176
|
+
* shows the literal tag until parseResponse + streamer.finalize clean it
|
|
177
|
+
* up at turn end. That cleanup is fragile in three ways:
|
|
178
|
+
* - parseResponse returns the tag verbatim if stickerMap[name] is
|
|
179
|
+
* falsy (unknown sticker name OR map not loaded). Then
|
|
180
|
+
* finalize sees parsed.text === currentText and takes the no-op
|
|
181
|
+
* branch (stream-reply.js:286-289), bubble untouched.
|
|
182
|
+
* - The turn never reaches `result` (interrupt, transient error,
|
|
183
|
+
* hung query) → onResult never fires, no parseResponse, no edit.
|
|
184
|
+
* - The final edit fails inside the HTML→plain fallback in
|
|
185
|
+
* lib/telegram.js without surfacing telegram-edit-failed.
|
|
186
|
+
*
|
|
187
|
+
* stripInlineTags fixes the leak architecturally: applied at chunk-time
|
|
188
|
+
* (via createStreamer's `transformText` hook), the bubble + DB row never
|
|
189
|
+
* carry a recognised tag in the first place. parseResponse remains the
|
|
190
|
+
* canonical extractor — it surfaces stickers/reactions for outbound
|
|
191
|
+
* dispatch — but its `text` output is now a no-op compared to what the
|
|
192
|
+
* streamer already showed the user.
|
|
193
|
+
*
|
|
194
|
+
* Output is intentionally identical to `parseResponse(text, deps).text`
|
|
195
|
+
* for the same input, modulo `parseResponse`'s leading-trim of the
|
|
196
|
+
* fully-resolved final text. (Streaming text could legitimately end on
|
|
197
|
+
* a partial-token whitespace; we match the per-line right-trim and
|
|
198
|
+
* triple-blank-line collapse but don't touch the outer edges so a
|
|
199
|
+
* mid-stream "Done. " stays "Done." after right-trim — fine — but a
|
|
200
|
+
* legitimate intentional-leading-newline stays.)
|
|
201
|
+
*/
|
|
202
|
+
function stripInlineTags(text, { stickerMap = {} } = {}) {
|
|
203
|
+
if (text == null) return '';
|
|
204
|
+
let cleaned = String(text).replace(STICKER_TAG_INLINE_RE, (match, name) => {
|
|
205
|
+
return stickerMap[name] ? '' : match;
|
|
206
|
+
});
|
|
207
|
+
cleaned = cleaned.replace(REACT_TAG_INLINE_RE, () => '');
|
|
208
|
+
return cleaned
|
|
209
|
+
.split('\n')
|
|
210
|
+
.map((line) => line.replace(/[ \t]+$/g, ''))
|
|
211
|
+
.join('\n')
|
|
212
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
213
|
+
.trim();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
parseResponse,
|
|
218
|
+
stripInlineTags,
|
|
219
|
+
STICKER_TAG_RE,
|
|
220
|
+
STICKER_TAG_INLINE_RE,
|
|
221
|
+
REACT_TAG_RE,
|
|
222
|
+
REACT_TAG_INLINE_RE,
|
|
223
|
+
};
|
package/lib/stream-reply.js
CHANGED
|
@@ -55,6 +55,20 @@ function createStreamer({
|
|
|
55
55
|
schedule = setTimeout,
|
|
56
56
|
cancel = clearTimeout,
|
|
57
57
|
logger = console,
|
|
58
|
+
// rc.67: pre-processor applied to every chunk before send/edit. polygram
|
|
59
|
+
// passes stripInlineTags(...) so [sticker:NAME] / [react:EMOJI] never
|
|
60
|
+
// reach the bubble or the messages.text DB row. Default identity keeps
|
|
61
|
+
// existing tests + non-polygram callers untouched.
|
|
62
|
+
//
|
|
63
|
+
// Why here (streamer) and not in polygram's send callback: the streamer
|
|
64
|
+
// owns currentText/latestText state used by finalize's no-op-edit
|
|
65
|
+
// optimisation. If pre-processing only happened in send/edit closures,
|
|
66
|
+
// the streamer's internal state would carry raw text and finalize's
|
|
67
|
+
// body-vs-currentText comparison would still fire spurious edits.
|
|
68
|
+
// Applying transformText here means the WHOLE state machine sees clean
|
|
69
|
+
// text — finalize correctly takes the no-op branch when the bubble is
|
|
70
|
+
// already final.
|
|
71
|
+
transformText = null,
|
|
58
72
|
// rc.44: by default, KEEP intermediate text bubbles when
|
|
59
73
|
// forceNewMessage transitions to a fresh bubble for a new
|
|
60
74
|
// top-level assistant message. These are NOT "thinking" tokens
|
|
@@ -110,8 +124,23 @@ function createStreamer({
|
|
|
110
124
|
return s.slice(0, maxLen - 3) + '...';
|
|
111
125
|
}
|
|
112
126
|
|
|
127
|
+
// rc.67: scrub recognised inline tags BEFORE the streamer commits text
|
|
128
|
+
// to its state machine. Identity when no transformer was configured.
|
|
129
|
+
// Defensive: if transformText throws, fall back to the raw text rather
|
|
130
|
+
// than swallow the chunk — log via injected logger.
|
|
131
|
+
function applyTransform(text) {
|
|
132
|
+
if (!transformText) return text;
|
|
133
|
+
try {
|
|
134
|
+
return transformText(text);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
logger.error?.(`[stream] transformText threw, falling back to raw: ${err.message}`);
|
|
137
|
+
return text;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
113
141
|
async function onChunk(text) {
|
|
114
142
|
if (state === 'finalized') return;
|
|
143
|
+
text = applyTransform(text);
|
|
115
144
|
latestText = text;
|
|
116
145
|
|
|
117
146
|
// idle: not yet sent the initial message. Only fire the initial send
|
|
@@ -269,7 +298,12 @@ function createStreamer({
|
|
|
269
298
|
|
|
270
299
|
// live → finalize.
|
|
271
300
|
state = 'finalized';
|
|
272
|
-
|
|
301
|
+
// rc.67: defense-in-depth — even if a caller passes raw text to
|
|
302
|
+
// finalize, transformText scrubs it before the bubble's last edit.
|
|
303
|
+
// Apply to BOTH branches (explicit finalText AND fallback to
|
|
304
|
+
// latestText) so the comparison `body === currentText` is always
|
|
305
|
+
// apples-to-apples (currentText was already transformed in onChunk).
|
|
306
|
+
let body = applyTransform(finalText ?? latestText);
|
|
273
307
|
if (errorSuffix) body = `${body}\n\n⚠️ ${errorSuffix}`;
|
|
274
308
|
|
|
275
309
|
// If body overflows the single-message cap, the caller needs to
|
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.67",
|
|
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
|
@@ -1377,10 +1377,24 @@ const stdinLock = createAsyncLock();
|
|
|
1377
1377
|
// mimicking the format as plain text. Without the new branch the
|
|
1378
1378
|
// placeholder was rendered verbatim in the chat instead of swapped for
|
|
1379
1379
|
// the actual sticker.
|
|
1380
|
-
const {
|
|
1380
|
+
const {
|
|
1381
|
+
parseResponse: parseResponseImpl,
|
|
1382
|
+
stripInlineTags: stripInlineTagsImpl,
|
|
1383
|
+
} = require('./lib/parse-response');
|
|
1381
1384
|
function parseResponse(text) {
|
|
1382
1385
|
return parseResponseImpl(text, { stickerMap, emojiToSticker });
|
|
1383
1386
|
}
|
|
1387
|
+
// rc.67: pre-processor for the streamer. Strips recognised inline
|
|
1388
|
+
// `[sticker:NAME]` and any `[react:EMOJI]` tags BEFORE the chunk is
|
|
1389
|
+
// committed to the bubble + DB row, so the user never sees a literal
|
|
1390
|
+
// tag even when the turn-end finalize path doesn't manage to clean it
|
|
1391
|
+
// (interrupt, error, hung query, edit failure, or the stickerMap-miss
|
|
1392
|
+
// no-op branch). parseResponse continues to surface the same tags in
|
|
1393
|
+
// `parsed.stickers[]` / `parsed.reactions[]` for outbound dispatch via
|
|
1394
|
+
// sendInlineStickers / sendInlineReactions.
|
|
1395
|
+
function stripInlineTagsForStreamer(text) {
|
|
1396
|
+
return stripInlineTagsImpl(text, { stickerMap });
|
|
1397
|
+
}
|
|
1384
1398
|
|
|
1385
1399
|
// ─── Cron/IPC send ─────────────────────────────────────────────────
|
|
1386
1400
|
|
|
@@ -2572,6 +2586,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2572
2586
|
// code path. For short responses the streamer stays idle and we fall
|
|
2573
2587
|
// through to the normal send path via finalize() returning streamed=false.
|
|
2574
2588
|
const streamer = createStreamer({
|
|
2589
|
+
// rc.67: pre-process every chunk to strip recognised
|
|
2590
|
+
// [sticker:NAME] / [react:EMOJI] tags BEFORE the bubble or DB row
|
|
2591
|
+
// captures them. See stripInlineTagsForStreamer above.
|
|
2592
|
+
transformText: stripInlineTagsForStreamer,
|
|
2575
2593
|
send: async (text) => {
|
|
2576
2594
|
const params = {
|
|
2577
2595
|
chat_id: chatId, text,
|