@a1hvdy/cc-openclaw 0.27.1 → 0.27.4
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/dist/src/channels/telegram-mirror/askuser.js +2 -0
- package/dist/src/channels/telegram-mirror/card-renderer.d.ts +1 -22
- package/dist/src/channels/telegram-mirror/card-renderer.js +172 -20
- package/dist/src/channels/telegram-mirror/commands.d.ts +16 -0
- package/dist/src/channels/telegram-mirror/commands.js +53 -12
- package/dist/src/channels/telegram-mirror/inbound-handler.d.ts +18 -0
- package/dist/src/channels/telegram-mirror/inbound-handler.js +21 -8
- package/dist/src/channels/telegram-mirror/turn-bridge.d.ts +11 -0
- package/dist/src/channels/telegram-mirror/turn-bridge.js +31 -0
- package/dist/src/constants.d.ts +10 -0
- package/dist/src/constants.js +10 -0
- package/dist/src/lib/error-formatter.d.ts +14 -2
- package/dist/src/lib/error-formatter.js +23 -11
- package/dist/src/lib/error-renderer.js +3 -1
- package/dist/src/lib/html-render.d.ts +8 -16
- package/dist/src/lib/html-render.js +91 -1
- package/dist/src/lib/markdown-to-mdv2.js +2 -1
- package/dist/src/lib/probes.d.ts +50 -0
- package/dist/src/lib/probes.js +96 -0
- package/dist/src/lib/telegram-bot-api.d.ts +52 -6
- package/dist/src/lib/telegram-bot-api.js +180 -13
- package/dist/src/openai-compat/message-extractor.js +4 -0
- package/dist/src/openai-compat/openai-compat.js +12 -1
- package/dist/src/openai-compat/streaming-handler.js +7 -1
- package/dist/src/session/persisted-sessions.d.ts +11 -0
- package/dist/src/session/persisted-sessions.js +17 -0
- package/dist/src/session/session-manager.js +22 -6
- package/dist/src/session-bootstrap/cwd-patch.js +1 -2
- package/dist/src/types.d.ts +7 -0
- package/package.json +1 -1
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { CallbackMap } from './callback-mapping.js';
|
|
24
24
|
import { sendTg, editTg, telegramApi } from '../../lib/telegram-bot-api.js';
|
|
25
25
|
import { escapeHtml } from '../../lib/html-render.js';
|
|
26
|
+
import { probeInjectionEnqueued } from '../../lib/probes.js';
|
|
26
27
|
/** Namespace prefix for callback_data so api.registerInteractiveHandler routes
|
|
27
28
|
* taps here. Matched at the first ':' by the gateway (must be [A-Za-z0-9._-]+). */
|
|
28
29
|
export const ASKUSER_NS = 'ccmirror';
|
|
@@ -165,6 +166,7 @@ function injectAnswer(api, ctx, text) {
|
|
|
165
166
|
return;
|
|
166
167
|
}
|
|
167
168
|
try {
|
|
169
|
+
probeInjectionEnqueued(sessionKey, 'askuser'); // P0-A (observe-only, gated)
|
|
168
170
|
api.enqueueNextTurnInjection({
|
|
169
171
|
sessionKey,
|
|
170
172
|
text: `[User answered the AskUserQuestion]: ${text}`,
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import type { Turn, ToolCallRecord } from './state-machine.js';
|
|
17
17
|
import type { CardMeta } from './card-state.js';
|
|
18
|
+
export declare function toolDiffBlock(tc: ToolCallRecord): string;
|
|
18
19
|
/**
|
|
19
20
|
* Format one tool-call line for inclusion in the rendered card body.
|
|
20
21
|
* "✓ Bash · ls -la"
|
|
@@ -36,28 +37,6 @@ export declare function renderToolLine(tc: ToolCallRecord): string;
|
|
|
36
37
|
* without depending on MarkdownV2 escapes. Empty thinking returns ''.
|
|
37
38
|
*/
|
|
38
39
|
export declare function renderThinkingBlock(thinkingText: string): string;
|
|
39
|
-
/**
|
|
40
|
-
* Render the full turn into a Telegram-safe message body. The mirror keeps
|
|
41
|
-
* a single message per turn that gets edited in place — this function is
|
|
42
|
-
* idempotent for a given turn snapshot and produces the next body string.
|
|
43
|
-
*
|
|
44
|
-
* Body shape (lines separated by \n):
|
|
45
|
-
*
|
|
46
|
-
* ▶ Working ← or "✓ Done" when state==='done'
|
|
47
|
-
* ✓ Bash · ls -la ← one line per tool call, in arrival order
|
|
48
|
-
* … Read · src/x.ts
|
|
49
|
-
* <empty line>
|
|
50
|
-
* 💭 Thinking ← M6, only when thinkingText non-empty
|
|
51
|
-
* > reasoning line 1
|
|
52
|
-
* > reasoning line 2
|
|
53
|
-
* <empty line>
|
|
54
|
-
* <assistant text> ← present only when non-empty;
|
|
55
|
-
* ★ Insight ─ blocks preserved INLINE
|
|
56
|
-
* at their emission position (M6).
|
|
57
|
-
*
|
|
58
|
-
* Lines with no content (e.g. empty tool list, empty assistant text) are
|
|
59
|
-
* omitted entirely so the rendered body never has trailing whitespace.
|
|
60
|
-
*/
|
|
61
40
|
export declare function renderTurn(turn: Turn, meta?: CardMeta): string;
|
|
62
41
|
/**
|
|
63
42
|
* A render action — abstraction the dispatch layer (added in a later
|
|
@@ -101,8 +101,13 @@ function toolResultText(tc) {
|
|
|
101
101
|
* readable (Telegram's 4096-char message cap) while still surfacing tool output
|
|
102
102
|
* the way the CLI shows it inline.
|
|
103
103
|
*/
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
// v0.27.4 M3 — CLI-parity gap #4: the prior 3-line/200-char cap was far stingier
|
|
105
|
+
// than the terminal (which shows full, scrollable output). Raised to 10 lines/500
|
|
106
|
+
// chars — a ~2.5× visibility gain that stays card-safe because renderTurn's
|
|
107
|
+
// per-entry budget loop renders newest-first and collapses older tools to a
|
|
108
|
+
// "… +N earlier" marker, so a single fat result can't starve the others.
|
|
109
|
+
const RESULT_MAX_LINES = 10;
|
|
110
|
+
const RESULT_MAX_CHARS = 500;
|
|
106
111
|
function truncateResult(s) {
|
|
107
112
|
const lines = s.split('\n');
|
|
108
113
|
let cut = lines.length > RESULT_MAX_LINES;
|
|
@@ -113,6 +118,70 @@ function truncateResult(s) {
|
|
|
113
118
|
}
|
|
114
119
|
return cut ? out.replace(/\s+$/, '') + '\n…' : out;
|
|
115
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* v0.27.4 M2 — CLI-parity gap #3: render a compact file diff for Edit / Write /
|
|
123
|
+
* MultiEdit from the tool INPUT (the result payload is only "File updated", so
|
|
124
|
+
* the meaningful change is in old_string/new_string / content / edits[]). Mirrors
|
|
125
|
+
* the terminal's inline diff: `- removed` / `+ added` lines, capped for the card.
|
|
126
|
+
* Returns RAW text (caller wraps in a <pre> block, which HTML-escapes it). '' for
|
|
127
|
+
* non-edit tools or empty input (no-fake-data — never invents a diff).
|
|
128
|
+
*/
|
|
129
|
+
const DIFF_MAX_LINES = 8;
|
|
130
|
+
const DIFF_MAX_CHARS = 320;
|
|
131
|
+
function truncateDiff(lines) {
|
|
132
|
+
let cut = lines.length > DIFF_MAX_LINES;
|
|
133
|
+
let out = lines.slice(0, DIFF_MAX_LINES).join('\n');
|
|
134
|
+
if (out.length > DIFF_MAX_CHARS) {
|
|
135
|
+
out = out.slice(0, DIFF_MAX_CHARS);
|
|
136
|
+
cut = true;
|
|
137
|
+
}
|
|
138
|
+
return cut ? out.replace(/\s+$/, '') + '\n…' : out;
|
|
139
|
+
}
|
|
140
|
+
export function toolDiffBlock(tc) {
|
|
141
|
+
const n = tc.name.toLowerCase();
|
|
142
|
+
const input = tc.input;
|
|
143
|
+
if (!input || typeof input !== 'object')
|
|
144
|
+
return '';
|
|
145
|
+
const lines = [];
|
|
146
|
+
if (n === 'edit') {
|
|
147
|
+
const oldS = typeof input.old_string === 'string' ? input.old_string : '';
|
|
148
|
+
const newS = typeof input.new_string === 'string' ? input.new_string : '';
|
|
149
|
+
if (!oldS && !newS)
|
|
150
|
+
return '';
|
|
151
|
+
for (const l of oldS.split('\n'))
|
|
152
|
+
lines.push(`- ${l}`);
|
|
153
|
+
for (const l of newS.split('\n'))
|
|
154
|
+
lines.push(`+ ${l}`);
|
|
155
|
+
}
|
|
156
|
+
else if (n === 'multiedit') {
|
|
157
|
+
const edits = Array.isArray(input.edits) ? input.edits : [];
|
|
158
|
+
if (edits.length === 0)
|
|
159
|
+
return '';
|
|
160
|
+
lines.push(`~ ${edits.length} edit${edits.length === 1 ? '' : 's'}`);
|
|
161
|
+
const first = edits[0];
|
|
162
|
+
if (first) {
|
|
163
|
+
const oldS = typeof first.old_string === 'string' ? first.old_string : '';
|
|
164
|
+
const newS = typeof first.new_string === 'string' ? first.new_string : '';
|
|
165
|
+
for (const l of oldS.split('\n'))
|
|
166
|
+
lines.push(`- ${l}`);
|
|
167
|
+
for (const l of newS.split('\n'))
|
|
168
|
+
lines.push(`+ ${l}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else if (n === 'write') {
|
|
172
|
+
const content = typeof input.content === 'string' ? input.content : '';
|
|
173
|
+
if (!content)
|
|
174
|
+
return '';
|
|
175
|
+
const contentLines = content.split('\n');
|
|
176
|
+
lines.push(`+ new file (${contentLines.length} line${contentLines.length === 1 ? '' : 's'})`);
|
|
177
|
+
for (const l of contentLines)
|
|
178
|
+
lines.push(`+ ${l}`);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
return '';
|
|
182
|
+
}
|
|
183
|
+
return truncateDiff(lines);
|
|
184
|
+
}
|
|
116
185
|
/**
|
|
117
186
|
* Format one tool-call line for inclusion in the rendered card body.
|
|
118
187
|
* "✓ Bash · ls -la"
|
|
@@ -199,8 +268,27 @@ export function renderThinkingBlock(thinkingText) {
|
|
|
199
268
|
* Lines with no content (e.g. empty tool list, empty assistant text) are
|
|
200
269
|
* omitted entirely so the rendered body never has trailing whitespace.
|
|
201
270
|
*/
|
|
271
|
+
/**
|
|
272
|
+
* Telegram hard-caps a single message at 4096 chars; an edit that exceeds it is
|
|
273
|
+
* rejected outright (and editTg's fallback can't help — same oversized text).
|
|
274
|
+
* v0.27.3: budget the rendered body. v0.27.0 M2 began emitting a
|
|
275
|
+
* <pre><code> result block under every tool line, which on a heavy multi-tool
|
|
276
|
+
* turn (e.g. a 15-Bash version check) silently blew past 4096 → every edit
|
|
277
|
+
* failed → the card froze as a bare "✓ Done" with no activity. The fix renders a
|
|
278
|
+
* GUARANTEED spine (status + header + tool LINES) first, then adds the richer,
|
|
279
|
+
* droppable blocks (result previews, todos, thinking, assistant text) only while
|
|
280
|
+
* budget remains. The card therefore always shows the tool activity.
|
|
281
|
+
*/
|
|
282
|
+
const TG_BODY_BUDGET = 3900; // margin under the 4096 hard cap (entities + safety)
|
|
202
283
|
export function renderTurn(turn, meta) {
|
|
203
284
|
const lines = [];
|
|
285
|
+
// Running length tracker (string.length + the join newline). O(1) per push;
|
|
286
|
+
// avoids re-joining the array on every budget check.
|
|
287
|
+
let used = 0;
|
|
288
|
+
const push = (s) => {
|
|
289
|
+
lines.push(s);
|
|
290
|
+
used += s.length + 1;
|
|
291
|
+
};
|
|
204
292
|
// v0.26.2 M1 — CC-CLI-style status line at the top (model · CC ver · ⏱ · 🔧 ·
|
|
205
293
|
// bypass). Gated on `meta` presence so legacy no-meta callers (and the initial
|
|
206
294
|
// pre-model inbound card) render exactly as before — no regression. Once the
|
|
@@ -210,17 +298,17 @@ export function renderTurn(turn, meta) {
|
|
|
210
298
|
// v0.27.0 M1 — bold the status line (the card header) via HTML <b>. Content
|
|
211
299
|
// HTML-escaped first ( & < > only — model/version dots & brackets are safe).
|
|
212
300
|
if (status)
|
|
213
|
-
|
|
301
|
+
push(`<b>${escapeHtml(status)}</b>`);
|
|
214
302
|
// v0.26.2 M2 — meter row (context % + quota % + reset). Same no-fake-data
|
|
215
303
|
// gating: omitted unless real values are present. HTML-escaped for safety.
|
|
216
304
|
const meters = renderMeters(meta);
|
|
217
305
|
if (meters)
|
|
218
|
-
|
|
306
|
+
push(escapeHtml(meters));
|
|
219
307
|
// v0.26.4 styling — divider between the status/telemetry block and the
|
|
220
308
|
// activity block (only when a status block was actually rendered). The
|
|
221
309
|
// heavy-bar glyph is not HTML-significant, so it needs no escaping.
|
|
222
310
|
if (lines.length > 0)
|
|
223
|
-
|
|
311
|
+
push('━━━━━━━━━━━━');
|
|
224
312
|
}
|
|
225
313
|
// Turn-status header. v0.27.0 M4 — a failed turn shows "❌ <reason>" so it
|
|
226
314
|
// never dies as an eternal "…"; reason is HTML-escaped (it's an error message).
|
|
@@ -229,42 +317,106 @@ export function renderTurn(turn, meta) {
|
|
|
229
317
|
: turn.state === 'done'
|
|
230
318
|
? '✓ Done'
|
|
231
319
|
: '▶ Working';
|
|
232
|
-
|
|
320
|
+
push(header);
|
|
233
321
|
if (turn.toolCalls.length > 0) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
322
|
+
// Build tool ENTRIES (tool line + its optional <pre><code> result block)
|
|
323
|
+
// newest-first, keeping what fits under budget; the older overflow collapses
|
|
324
|
+
// to a "… +N earlier tools" marker. Budgeting the line AND its result block
|
|
325
|
+
// TOGETHER is the fix for the v0.27.0 regression: the prior code budgeted
|
|
326
|
+
// lines alone, then result blocks ate the remaining room and the leftover
|
|
327
|
+
// lines pushed unguarded — bursting Telegram's 4096 cap. Reserve TAIL_RESERVE
|
|
328
|
+
// so the assistant text / todos / thinking below always have somewhere to go.
|
|
329
|
+
const TAIL_RESERVE = 700;
|
|
330
|
+
const cap = TG_BODY_BUDGET - TAIL_RESERVE;
|
|
331
|
+
const entries = []; // oldest→newest display order
|
|
332
|
+
let shown = 0;
|
|
333
|
+
let acc = 0;
|
|
334
|
+
for (let i = turn.toolCalls.length - 1; i >= 0; i--) {
|
|
335
|
+
const tc = turn.toolCalls[i];
|
|
336
|
+
const line = renderToolLine(tc);
|
|
337
|
+
// v0.27.4 M2 — for Edit/Write/MultiEdit show a diff (from input) instead of
|
|
338
|
+
// the "File updated" result; other tools keep the result preview.
|
|
339
|
+
const diffText = toolDiffBlock(tc);
|
|
238
340
|
const resultText = toolResultText(tc);
|
|
239
|
-
|
|
240
|
-
|
|
341
|
+
const block = diffText
|
|
342
|
+
? pre(diffText)
|
|
343
|
+
: resultText
|
|
344
|
+
? pre(truncateResult(resultText))
|
|
345
|
+
: '';
|
|
346
|
+
const withBlock = block ? `${line}\n${block}` : line;
|
|
347
|
+
// Prefer line+block; if that bursts the cap, fall back to the line alone
|
|
348
|
+
// (the result preview is the droppable part, the activity line is not).
|
|
349
|
+
if (used + acc + withBlock.length + 1 <= cap) {
|
|
350
|
+
entries.unshift(withBlock);
|
|
351
|
+
acc += withBlock.length + 1;
|
|
352
|
+
shown++;
|
|
353
|
+
}
|
|
354
|
+
else if (used + acc + line.length + 1 <= cap) {
|
|
355
|
+
entries.unshift(line);
|
|
356
|
+
acc += line.length + 1;
|
|
357
|
+
shown++;
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
break; // no room for even the bare line → stop; rest become "+N earlier"
|
|
361
|
+
}
|
|
241
362
|
}
|
|
363
|
+
const hidden = turn.toolCalls.length - shown;
|
|
364
|
+
if (hidden > 0)
|
|
365
|
+
push(`… +${hidden} earlier tool${hidden === 1 ? '' : 's'}`);
|
|
366
|
+
for (const e of entries)
|
|
367
|
+
push(e);
|
|
242
368
|
}
|
|
243
369
|
// v0.26.2 M4 — todo checklist (real data from the agent's TodoWrite calls).
|
|
244
370
|
// Escaped as a block (glyphs are safe; content + the "+N more" plus sign are
|
|
245
371
|
// not, so the whole block goes through the escaper).
|
|
246
372
|
const todoBlock = renderTodos(meta);
|
|
247
373
|
if (todoBlock) {
|
|
248
|
-
|
|
249
|
-
|
|
374
|
+
const block = escapeHtml(todoBlock);
|
|
375
|
+
if (used + block.length + 2 <= TG_BODY_BUDGET) {
|
|
376
|
+
push('');
|
|
377
|
+
push(block);
|
|
378
|
+
}
|
|
250
379
|
}
|
|
251
380
|
// Defensive default — Turn.thinkingText is required by the interface, but
|
|
252
381
|
// some test helpers and pre-M6 fixtures construct Turn-shaped objects
|
|
253
382
|
// without setting it. Treat absent / undefined as empty.
|
|
254
383
|
const thinking = renderThinkingBlock(turn.thinkingText ?? '');
|
|
255
|
-
if (thinking.length > 0) {
|
|
256
|
-
|
|
257
|
-
|
|
384
|
+
if (thinking.length > 0 && used + thinking.length + 2 <= TG_BODY_BUDGET) {
|
|
385
|
+
push('');
|
|
386
|
+
push(thinking);
|
|
258
387
|
}
|
|
259
388
|
const text = turn.assistantText.trim();
|
|
260
389
|
if (text.length > 0) {
|
|
261
|
-
lines.push('');
|
|
262
390
|
// v0.27.0 M1 — render the model's markdown as Telegram HTML with structure
|
|
263
391
|
// preserved (**bold**→<b>, `code`→<code>, ```bash fences→<pre><code
|
|
264
392
|
// class="language-bash">). HTML-escapes all remaining content (& < >).
|
|
265
|
-
|
|
393
|
+
// Budget-aware: if the full text won't fit, render a truncated head so the
|
|
394
|
+
// card still ends with readable context rather than dropping it entirely.
|
|
395
|
+
const room = TG_BODY_BUDGET - used - 2;
|
|
396
|
+
if (room > 0) {
|
|
397
|
+
const full = markdownToHtml(text);
|
|
398
|
+
if (full.length <= room) {
|
|
399
|
+
push('');
|
|
400
|
+
push(full);
|
|
401
|
+
}
|
|
402
|
+
else if (room > 240) {
|
|
403
|
+
// Truncate the SOURCE before markdown→HTML so we never split an HTML tag
|
|
404
|
+
// mid-emit (which would break Telegram's parser). Leave slack for the "…".
|
|
405
|
+
const headSrc = text.slice(0, room - 40).replace(/\s+\S*$/, '');
|
|
406
|
+
push('');
|
|
407
|
+
push(markdownToHtml(headSrc) + '\n…');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
266
410
|
}
|
|
267
|
-
|
|
411
|
+
// Guaranteed safety net: even if the budgeted assembly above has a logic gap,
|
|
412
|
+
// a card MUST NOT exceed Telegram's 4096-char hard cap (an over-cap edit is
|
|
413
|
+
// rejected outright). A blunt slice here can split an HTML tag, but editTg's
|
|
414
|
+
// hardened plain-text fallback strips tags on rejection, so this degrades
|
|
415
|
+
// gracefully rather than freezing the card.
|
|
416
|
+
const body = lines.join('\n');
|
|
417
|
+
if (body.length <= 4096)
|
|
418
|
+
return body;
|
|
419
|
+
return body.slice(0, 4095) + '…';
|
|
268
420
|
}
|
|
269
421
|
/**
|
|
270
422
|
* Decide whether the current turn snapshot should be sent (first render)
|
|
@@ -126,6 +126,22 @@ export declare function handleCancel(ctx: CommandContext): CommandResult;
|
|
|
126
126
|
* resolver call recordLaptopCheck().
|
|
127
127
|
*/
|
|
128
128
|
export declare function handleSoakCheck(ctx: CommandContext): CommandResult;
|
|
129
|
+
/**
|
|
130
|
+
* Full user-facing command catalogue, used by /help. The TOP_7 are the
|
|
131
|
+
* phone-tier set synced to Telegram's command menu; the workflow extras
|
|
132
|
+
* (compose/send/cancel) + help round out what the mirror actually claims.
|
|
133
|
+
*/
|
|
134
|
+
export declare const ALL_COMMANDS: ReadonlyArray<{
|
|
135
|
+
command: string;
|
|
136
|
+
description: string;
|
|
137
|
+
}>;
|
|
138
|
+
/**
|
|
139
|
+
* /help — list the mirror's commands. Discoverability close for gap #6: the 11
|
|
140
|
+
* commands were previously invisible. Notes the passthrough so the user knows
|
|
141
|
+
* any other slash (custom skills like /pregoal) or plain text reaches Claude.
|
|
142
|
+
* HTML-safe content (only the intentional <b> tag); sendTg renders parse_mode:HTML.
|
|
143
|
+
*/
|
|
144
|
+
export declare function handleHelp(ctx: CommandContext): CommandResult;
|
|
129
145
|
export type CommandHandler = (ctx: CommandContext) => CommandResult;
|
|
130
146
|
export declare const COMMAND_HANDLERS: Record<string, CommandHandler>;
|
|
131
147
|
/**
|
|
@@ -98,17 +98,21 @@ export function handleNew(ctx) {
|
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
const existing = getBySlug(slug);
|
|
101
|
-
// Session-name comes from the engine
|
|
101
|
+
// Session-name comes from the engine when a real turn fronts this slug; until
|
|
102
|
+
// then we store a placeholder. The registry IS the real state that /sessions
|
|
103
|
+
// and /status read — so the confirmation reflects the actual registry count
|
|
104
|
+
// (planning D-5/D-2), not a false "engine wire-up lands in M5" promise.
|
|
102
105
|
const sessionName = existing?.sessionName ?? `pending-${slug}-${Date.now()}`;
|
|
103
106
|
register(slug, sessionName);
|
|
107
|
+
const total = list().length;
|
|
104
108
|
return {
|
|
105
109
|
actions: [
|
|
106
110
|
{
|
|
107
111
|
type: 'sendMessage',
|
|
108
112
|
chat_id: ctx.chatId,
|
|
109
113
|
text: existing
|
|
110
|
-
? `Session "${slug}" already registered
|
|
111
|
-
: `Session "${slug}" registered.
|
|
114
|
+
? `Session "${slug}" already registered (${total} total). Open it from /sessions.`
|
|
115
|
+
: `Session "${slug}" registered (${total} total). Open it from /sessions.`,
|
|
112
116
|
},
|
|
113
117
|
],
|
|
114
118
|
};
|
|
@@ -128,13 +132,14 @@ export function handleStop(ctx) {
|
|
|
128
132
|
};
|
|
129
133
|
}
|
|
130
134
|
const removed = unregister(slug);
|
|
135
|
+
const remaining = list().length;
|
|
131
136
|
return {
|
|
132
137
|
actions: [
|
|
133
138
|
{
|
|
134
139
|
type: 'sendMessage',
|
|
135
140
|
chat_id: ctx.chatId,
|
|
136
141
|
text: removed
|
|
137
|
-
? `Session "${slug}" stopped.`
|
|
142
|
+
? `Session "${slug}" stopped (${remaining} remaining).`
|
|
138
143
|
: `No registered session named "${slug}".`,
|
|
139
144
|
},
|
|
140
145
|
],
|
|
@@ -166,16 +171,17 @@ export function handleStatus(ctx) {
|
|
|
166
171
|
}
|
|
167
172
|
// ── /compact ─────────────────────────────────────────────────────────────
|
|
168
173
|
export function handleCompact(ctx) {
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
174
|
+
// D-6 (planning): honest stub. The Telegram bridge has no session-control
|
|
175
|
+
// primitive to trigger context compaction on the running session — the only
|
|
176
|
+
// plugin levers are enqueueNextTurnInjection (text, next-turn only) and
|
|
177
|
+
// registerInteractiveHandler. So /compact is CLI-only until/unless OpenClaw
|
|
178
|
+
// exposes a control primitive. Claiming "queued" would be a lie (it never runs).
|
|
173
179
|
return {
|
|
174
180
|
actions: [
|
|
175
181
|
{
|
|
176
182
|
type: 'sendMessage',
|
|
177
183
|
chat_id: ctx.chatId,
|
|
178
|
-
text:
|
|
184
|
+
text: "⚠️ /compact is CLI-only — the Telegram bridge can't trigger context compaction (no session-control primitive). Run it from Claude Code directly.",
|
|
179
185
|
},
|
|
180
186
|
],
|
|
181
187
|
};
|
|
@@ -212,14 +218,15 @@ export function handleCost(ctx) {
|
|
|
212
218
|
}
|
|
213
219
|
// ── /rewind ──────────────────────────────────────────────────────────────
|
|
214
220
|
export function handleRewind(ctx) {
|
|
215
|
-
//
|
|
216
|
-
//
|
|
221
|
+
// D-6 (planning): honest stub — same rationale as /compact. Rewinding a
|
|
222
|
+
// running session needs a session-control primitive the plugin can't reach;
|
|
223
|
+
// it's CLI-only. "Queued" would never actually run, so we say so plainly.
|
|
217
224
|
return {
|
|
218
225
|
actions: [
|
|
219
226
|
{
|
|
220
227
|
type: 'sendMessage',
|
|
221
228
|
chat_id: ctx.chatId,
|
|
222
|
-
text:
|
|
229
|
+
text: "⚠️ /rewind is CLI-only — the Telegram bridge can't rewind a session (no session-control primitive). Run it from Claude Code directly.",
|
|
223
230
|
},
|
|
224
231
|
],
|
|
225
232
|
};
|
|
@@ -351,6 +358,38 @@ export function handleSoakCheck(ctx) {
|
|
|
351
358
|
],
|
|
352
359
|
};
|
|
353
360
|
}
|
|
361
|
+
// ── /help (v0.27.4 M7 — CLI-parity gap #6) ───────────────────────────────
|
|
362
|
+
/**
|
|
363
|
+
* Full user-facing command catalogue, used by /help. The TOP_7 are the
|
|
364
|
+
* phone-tier set synced to Telegram's command menu; the workflow extras
|
|
365
|
+
* (compose/send/cancel) + help round out what the mirror actually claims.
|
|
366
|
+
*/
|
|
367
|
+
export const ALL_COMMANDS = [
|
|
368
|
+
{ command: 'sessions', description: 'List + switch fronted Claude session' },
|
|
369
|
+
{ command: 'new', description: 'Register a new session slug — /new <slug>' },
|
|
370
|
+
{ command: 'stop', description: 'Stop a registered session — /stop <slug>' },
|
|
371
|
+
{ command: 'status', description: 'Show active sessions and their state' },
|
|
372
|
+
{ command: 'cost', description: 'Show Max 20x usage + weekly burn' },
|
|
373
|
+
{ command: 'compact', description: 'Compact context (CLI-only — see note)' },
|
|
374
|
+
{ command: 'rewind', description: 'Rewind a session (CLI-only — see note)' },
|
|
375
|
+
{ command: 'compose', description: 'Start a multi-message draft — finish with /send' },
|
|
376
|
+
{ command: 'send', description: 'Send the composed draft to Claude' },
|
|
377
|
+
{ command: 'cancel', description: 'Cancel the active compose draft' },
|
|
378
|
+
{ command: 'help', description: 'Show this command list' },
|
|
379
|
+
];
|
|
380
|
+
/**
|
|
381
|
+
* /help — list the mirror's commands. Discoverability close for gap #6: the 11
|
|
382
|
+
* commands were previously invisible. Notes the passthrough so the user knows
|
|
383
|
+
* any other slash (custom skills like /pregoal) or plain text reaches Claude.
|
|
384
|
+
* HTML-safe content (only the intentional <b> tag); sendTg renders parse_mode:HTML.
|
|
385
|
+
*/
|
|
386
|
+
export function handleHelp(ctx) {
|
|
387
|
+
const lines = ['<b>cc-openclaw Telegram commands</b>', ''];
|
|
388
|
+
for (const c of ALL_COMMANDS)
|
|
389
|
+
lines.push(`/${c.command} — ${c.description}`);
|
|
390
|
+
lines.push('', 'Any other slash command or message is sent straight to Claude.');
|
|
391
|
+
return { actions: [{ type: 'sendMessage', chat_id: ctx.chatId, text: lines.join('\n') }] };
|
|
392
|
+
}
|
|
354
393
|
export const COMMAND_HANDLERS = {
|
|
355
394
|
sessions: handleSessions,
|
|
356
395
|
new: handleNew,
|
|
@@ -363,6 +402,8 @@ export const COMMAND_HANDLERS = {
|
|
|
363
402
|
send: handleSend,
|
|
364
403
|
cancel: handleCancel,
|
|
365
404
|
'soak-check': handleSoakCheck,
|
|
405
|
+
help: handleHelp,
|
|
406
|
+
commands: handleHelp,
|
|
366
407
|
};
|
|
367
408
|
/**
|
|
368
409
|
* Telegram bot commands list — declarative source for M5's setMyCommands sync.
|
|
@@ -22,9 +22,14 @@
|
|
|
22
22
|
* Single shared CallbackMap + ComposeBuffer per process so callback
|
|
23
23
|
* resolution and compose state survive across handler invocations.
|
|
24
24
|
*/
|
|
25
|
+
import { type TelegramAction } from './commands.js';
|
|
25
26
|
import { CallbackMap } from './callback-mapping.js';
|
|
26
27
|
import { ComposeBuffer } from './compose-buffer.js';
|
|
27
28
|
import { type InteractiveCtx, type InjectApi } from './askuser.js';
|
|
29
|
+
interface InboundLogger {
|
|
30
|
+
info: (msg: string) => void;
|
|
31
|
+
warn: (msg: string) => void;
|
|
32
|
+
}
|
|
28
33
|
export interface InboundHandlerApi {
|
|
29
34
|
on(event: string, handler: (...args: unknown[]) => unknown | Promise<unknown>): void;
|
|
30
35
|
logger?: {
|
|
@@ -50,6 +55,19 @@ export interface HandlerState {
|
|
|
50
55
|
composeBuffer: ComposeBuffer;
|
|
51
56
|
}
|
|
52
57
|
export declare function createHandlerState(): HandlerState;
|
|
58
|
+
/**
|
|
59
|
+
* Forward a single TelegramAction to the actual Telegram API. Returns
|
|
60
|
+
* the API response (or {ok:false} on failure). Pure I/O — no state
|
|
61
|
+
* mutation. Exported for unit testing (planning M-B/B2).
|
|
62
|
+
*
|
|
63
|
+
* planning M-B/B2 (D-3): the `sendDocument` branch is now wired to the
|
|
64
|
+
* multipart `sendDocumentTg` helper (was a no-op warn). NOTE: the PRODUCER of
|
|
65
|
+
* sendDocument actions — ExitPlanMode detection → buildPlanAttachment — is
|
|
66
|
+
* milestone M-B/B3, deferred pending probe P0-B. Until B3 lands this branch is
|
|
67
|
+
* dormant forwarding infrastructure, not yet a user-reachable feature.
|
|
68
|
+
*/
|
|
69
|
+
export declare function forwardAction(action: TelegramAction, threadId: number | undefined, logger: InboundLogger): Promise<void>;
|
|
53
70
|
/** Test-only — reset module-level dispatch + card state. */
|
|
54
71
|
export declare function _resetSubscriptionForTests(): void;
|
|
55
72
|
export declare function registerInboundHandler(api: InboundHandlerApi, state?: HandlerState): HandlerState;
|
|
73
|
+
export {};
|
|
@@ -23,13 +23,14 @@
|
|
|
23
23
|
* resolution and compose state survive across handler invocations.
|
|
24
24
|
*/
|
|
25
25
|
import { dispatchCommand, parseSlash, COMMAND_HANDLERS } from './commands.js';
|
|
26
|
-
import { sendTg, editTg } from '../../lib/telegram-bot-api.js';
|
|
26
|
+
import { sendTg, editTg, sendDocumentTg } from '../../lib/telegram-bot-api.js';
|
|
27
27
|
import { CallbackMap } from './callback-mapping.js';
|
|
28
28
|
import { ComposeBuffer } from './compose-buffer.js';
|
|
29
29
|
import { TurnStateMachine } from './state-machine.js';
|
|
30
30
|
import { renderTurn } from './card-renderer.js';
|
|
31
31
|
import { cardState as _cardState, cardStateDebug } from './card-state.js';
|
|
32
32
|
import { handleTap, handleTapData, isAskUserCallback, rememberSessionKey, ASKUSER_NS, } from './askuser.js';
|
|
33
|
+
import { probeInboundShape, probeToolUse } from '../../lib/probes.js';
|
|
33
34
|
const PLUGIN_TAG = '[cc-openclaw/telegram-mirror/inbound]';
|
|
34
35
|
// v0.26.3 M5 — register the AskUserQuestion interactive tap handler exactly
|
|
35
36
|
// once per process (registerInboundHandler runs on every register() call).
|
|
@@ -76,12 +77,15 @@ const MIRROR_COMMANDS = new Set(Object.keys(COMMAND_HANDLERS));
|
|
|
76
77
|
/**
|
|
77
78
|
* Forward a single TelegramAction to the actual Telegram API. Returns
|
|
78
79
|
* the API response (or {ok:false} on failure). Pure I/O — no state
|
|
79
|
-
* mutation.
|
|
80
|
+
* mutation. Exported for unit testing (planning M-B/B2).
|
|
80
81
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
82
|
+
* planning M-B/B2 (D-3): the `sendDocument` branch is now wired to the
|
|
83
|
+
* multipart `sendDocumentTg` helper (was a no-op warn). NOTE: the PRODUCER of
|
|
84
|
+
* sendDocument actions — ExitPlanMode detection → buildPlanAttachment — is
|
|
85
|
+
* milestone M-B/B3, deferred pending probe P0-B. Until B3 lands this branch is
|
|
86
|
+
* dormant forwarding infrastructure, not yet a user-reachable feature.
|
|
83
87
|
*/
|
|
84
|
-
async function forwardAction(action, threadId, logger) {
|
|
88
|
+
export async function forwardAction(action, threadId, logger) {
|
|
85
89
|
try {
|
|
86
90
|
if (action.type === 'sendMessage') {
|
|
87
91
|
await sendTg(String(action.chat_id), action.text, threadId !== undefined ? String(threadId) : undefined, action.reply_markup);
|
|
@@ -91,8 +95,15 @@ async function forwardAction(action, threadId, logger) {
|
|
|
91
95
|
await editTg(String(action.chat_id), action.message_id, action.text, action.reply_markup);
|
|
92
96
|
return;
|
|
93
97
|
}
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
if (action.type === 'sendDocument') {
|
|
99
|
+
await sendDocumentTg(String(action.chat_id), action.filename, action.content, {
|
|
100
|
+
caption: action.caption,
|
|
101
|
+
replyMarkup: action.reply_markup,
|
|
102
|
+
threadId,
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
logger.warn(`${PLUGIN_TAG} action type "${action.type}" not forwarded (no handler)`);
|
|
96
107
|
}
|
|
97
108
|
catch (err) {
|
|
98
109
|
logger.warn(`${PLUGIN_TAG} forwardAction failed: ${err.message}`);
|
|
@@ -205,6 +216,7 @@ export function registerInboundHandler(api, state = createHandlerState()) {
|
|
|
205
216
|
// Per-event-id dedup at dispatch layer (purpose=slash).
|
|
206
217
|
if (_seenOrMark('slash', _eventId(event)))
|
|
207
218
|
return undefined;
|
|
219
|
+
probeInboundShape(event); // P0-C inbound surface (observe-only, gated)
|
|
208
220
|
// Extract text from the canonical (2026.5.x) `event.content` field;
|
|
209
221
|
// fall back to legacy nested paths if a future gateway version reverts.
|
|
210
222
|
const text = (typeof event.content === 'string' ? event.content : undefined) ??
|
|
@@ -380,7 +392,7 @@ export function registerInboundHandler(api, state = createHandlerState()) {
|
|
|
380
392
|
if (parsed && MIRROR_COMMANDS.has(parsed.cmd))
|
|
381
393
|
return undefined;
|
|
382
394
|
const fromKey = chatIdFromSessionKey(event.sessionKey);
|
|
383
|
-
|
|
395
|
+
const chatId = fromKey.chatId || (event.senderId !== undefined ? String(event.senderId) : '');
|
|
384
396
|
if (!chatId)
|
|
385
397
|
return undefined;
|
|
386
398
|
const threadId = fromKey.threadId !== undefined ? fromKey.threadId : event.raw?.message?.message_thread_id;
|
|
@@ -413,6 +425,7 @@ export function registerInboundHandler(api, state = createHandlerState()) {
|
|
|
413
425
|
const event = args[0];
|
|
414
426
|
dumpShapeOnce('before_tool_call', event);
|
|
415
427
|
const ev = event;
|
|
428
|
+
probeToolUse(ev); // P0-B ExitPlanMode detection (observe-only, gated)
|
|
416
429
|
const evId = _eventId({ sessionKey: (ev?.sessionKey ?? ev?.ctx?.['SessionKey']), timestamp: ev?.timestamp, content: `tool_use:${String((ev?.id ?? ev?.tool_use_id ?? ''))}` });
|
|
417
430
|
if (_seenOrMark('tool_use', evId))
|
|
418
431
|
return undefined;
|
|
@@ -66,6 +66,17 @@ export declare function pushToolResult(toolUseId: string | undefined, output: un
|
|
|
66
66
|
* don't hammer Telegram with edits.
|
|
67
67
|
*/
|
|
68
68
|
export declare function pushAssistantText(text: string): void;
|
|
69
|
+
/**
|
|
70
|
+
* Record extended-thinking text on every active card (v0.27.4 M1 — CLI-parity
|
|
71
|
+
* gap #1). The streaming handler calls this from its `onThinking` callback as
|
|
72
|
+
* reasoning deltas arrive, passing the CUMULATIVE buffer (not the delta) — so
|
|
73
|
+
* the live card grows a 💭 block exactly the way the terminal streams thinking
|
|
74
|
+
* before the answer. Overwrites turn.thinkingText (same pattern as
|
|
75
|
+
* pushAssistantText), debounced repaint. Only fires while surfaceThinking is on
|
|
76
|
+
* (the handler gates the callback), so card-thinking matches the CLI's
|
|
77
|
+
* thinking-visibility setting rather than leaking reasoning unconditionally.
|
|
78
|
+
*/
|
|
79
|
+
export declare function pushThinking(text: string): void;
|
|
69
80
|
/**
|
|
70
81
|
* Finalize every active card at the END of a model turn. Flips each card's
|
|
71
82
|
* turn to 'done' (so renderTurn shows "✓ Done"), repaints once, and removes
|
|
@@ -113,6 +113,7 @@ export function readQuotaMeta() {
|
|
|
113
113
|
// low-frequency, so log every call (including the cards=0 failure case, logged
|
|
114
114
|
// BEFORE the early return). pushAssistantText is per-chunk, so throttle it.
|
|
115
115
|
let _lastTextLogAt = 0;
|
|
116
|
+
let _lastThinkLogAt = 0;
|
|
116
117
|
function logPush(kind, cards, extra) {
|
|
117
118
|
process.stderr.write(`[cc-openclaw/turn-bridge] ${kind} pid=${process.pid} ${cardStateDebug()}${extra}\n`);
|
|
118
119
|
}
|
|
@@ -228,6 +229,36 @@ export function pushAssistantText(text) {
|
|
|
228
229
|
void repaint(chatId, /* force */ false);
|
|
229
230
|
}
|
|
230
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Record extended-thinking text on every active card (v0.27.4 M1 — CLI-parity
|
|
234
|
+
* gap #1). The streaming handler calls this from its `onThinking` callback as
|
|
235
|
+
* reasoning deltas arrive, passing the CUMULATIVE buffer (not the delta) — so
|
|
236
|
+
* the live card grows a 💭 block exactly the way the terminal streams thinking
|
|
237
|
+
* before the answer. Overwrites turn.thinkingText (same pattern as
|
|
238
|
+
* pushAssistantText), debounced repaint. Only fires while surfaceThinking is on
|
|
239
|
+
* (the handler gates the callback), so card-thinking matches the CLI's
|
|
240
|
+
* thinking-visibility setting rather than leaking reasoning unconditionally.
|
|
241
|
+
*/
|
|
242
|
+
export function pushThinking(text) {
|
|
243
|
+
const chats = activeChatIds();
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
if (chats.length === 0 || now - _lastThinkLogAt > 1500) {
|
|
246
|
+
_lastThinkLogAt = now;
|
|
247
|
+
logPush('pushThinking', chats.length, ` len=${text.length}`);
|
|
248
|
+
}
|
|
249
|
+
if (chats.length === 0)
|
|
250
|
+
return;
|
|
251
|
+
for (const chatId of chats) {
|
|
252
|
+
const card = cardState.get(chatId);
|
|
253
|
+
if (!card)
|
|
254
|
+
continue;
|
|
255
|
+
const turn = card.sm.getTurn(chatId);
|
|
256
|
+
if (!turn || turn.state !== 'working')
|
|
257
|
+
continue;
|
|
258
|
+
turn.thinkingText = text;
|
|
259
|
+
void repaint(chatId, /* force */ false);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
231
262
|
/**
|
|
232
263
|
* Finalize every active card at the END of a model turn. Flips each card's
|
|
233
264
|
* turn to 'done' (so renderTurn shows "✓ Done"), repaints once, and removes
|