@a1hvdy/cc-openclaw 0.27.2 → 0.27.6
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/card-renderer.d.ts +8 -22
- package/dist/src/channels/telegram-mirror/card-renderer.js +227 -22
- package/dist/src/channels/telegram-mirror/commands.d.ts +16 -0
- package/dist/src/channels/telegram-mirror/commands.js +34 -0
- package/dist/src/channels/telegram-mirror/inbound-handler.js +1 -1
- package/dist/src/channels/telegram-mirror/turn-bridge.d.ts +12 -1
- package/dist/src/channels/telegram-mirror/turn-bridge.js +41 -2
- package/dist/src/constants.d.ts +27 -0
- package/dist/src/constants.js +28 -0
- package/dist/src/engines/persistent-session.d.ts +5 -0
- package/dist/src/engines/persistent-session.js +27 -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 +23 -16
- package/dist/src/lib/html-render.js +127 -1
- package/dist/src/lib/markdown-to-mdv2.js +2 -1
- package/dist/src/lib/telegram-bot-api.d.ts +22 -6
- package/dist/src/lib/telegram-bot-api.js +94 -14
- package/dist/src/openai-compat/non-streaming-handler.js +18 -1
- package/dist/src/openai-compat/openai-compat.js +61 -2
- package/dist/src/openai-compat/request-coalescer.d.ts +77 -0
- package/dist/src/openai-compat/request-coalescer.js +157 -0
- package/dist/src/openai-compat/streaming-handler.d.ts +9 -1
- package/dist/src/openai-compat/streaming-handler.js +40 -5
- 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/watchdogs.d.ts +3 -0
- package/dist/src/session/watchdogs.js +6 -0
- package/dist/src/session-bootstrap/cwd-patch.js +1 -2
- package/dist/src/types.d.ts +11 -0
- package/package.json +1 -1
- package/dist/src/config/drift-detector.d.ts +0 -28
- package/dist/src/config/drift-detector.js +0 -74
- package/dist/src/lib/stale-pid-files.d.ts +0 -17
- package/dist/src/lib/stale-pid-files.js +0 -39
- package/dist/src/persistence/snapshot.d.ts +0 -18
- package/dist/src/persistence/snapshot.js +0 -31
- package/dist/src/persistence/wal.d.ts +0 -17
- package/dist/src/persistence/wal.js +0 -31
- package/dist/src/types/index.d.ts +0 -15
- package/dist/src/types/index.js +0 -15
- package/dist/src/types/session.d.ts +0 -48
- package/dist/src/types/session.js +0 -19
|
@@ -15,6 +15,14 @@
|
|
|
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;
|
|
19
|
+
/**
|
|
20
|
+
* v0.27.5 — pick a syntax-highlight language for a tool's RESULT block so the
|
|
21
|
+
* card colors output the way the terminal does. Bash/shell → 'bash'; file tools
|
|
22
|
+
* → inferred from the `file_path` extension via EXT_LANG. Unknown → undefined
|
|
23
|
+
* (renders classless, no regression — never guess a language we can't justify).
|
|
24
|
+
*/
|
|
25
|
+
export declare function langForTool(tc: ToolCallRecord): string | undefined;
|
|
18
26
|
/**
|
|
19
27
|
* Format one tool-call line for inclusion in the rendered card body.
|
|
20
28
|
* "✓ Bash · ls -la"
|
|
@@ -36,28 +44,6 @@ export declare function renderToolLine(tc: ToolCallRecord): string;
|
|
|
36
44
|
* without depending on MarkdownV2 escapes. Empty thinking returns ''.
|
|
37
45
|
*/
|
|
38
46
|
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
47
|
export declare function renderTurn(turn: Turn, meta?: CardMeta): string;
|
|
62
48
|
/**
|
|
63
49
|
* A render action — abstraction the dispatch layer (added in a later
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Tests live at tests/channels/telegram-mirror/m2-render-pipeline.test.ts.
|
|
15
15
|
*/
|
|
16
16
|
import { renderStatusLine, renderMeters, renderTodos } from './status-line.js';
|
|
17
|
-
import { escapeHtml, code, pre, markdownToHtml } from '../../lib/html-render.js';
|
|
17
|
+
import { escapeHtml, code, pre, markdownToHtml, closeDanglingTags } from '../../lib/html-render.js';
|
|
18
18
|
// v0.27.0 M1 — the card renders as Telegram HTML (parse_mode: 'HTML'), not
|
|
19
19
|
// MarkdownV2. HTML only needs `& < >` escaped (no per-char backslash litter)
|
|
20
20
|
// and supports <pre><code class="language-…"> + <blockquote> for true
|
|
@@ -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,112 @@ 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
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* v0.27.5 — extension → Telegram syntax-highlight language. Telegram's only
|
|
187
|
+
* source of color is `<pre><code class="language-X">`; this maps a file
|
|
188
|
+
* extension to the `X` it recognizes. Unlisted extensions fall through to
|
|
189
|
+
* `undefined` (classless block, exactly as today).
|
|
190
|
+
*/
|
|
191
|
+
const EXT_LANG = {
|
|
192
|
+
ts: 'typescript',
|
|
193
|
+
tsx: 'typescript',
|
|
194
|
+
js: 'javascript',
|
|
195
|
+
jsx: 'javascript',
|
|
196
|
+
py: 'python',
|
|
197
|
+
sh: 'bash',
|
|
198
|
+
json: 'json',
|
|
199
|
+
md: 'markdown',
|
|
200
|
+
yml: 'yaml',
|
|
201
|
+
yaml: 'yaml',
|
|
202
|
+
sql: 'sql',
|
|
203
|
+
go: 'go',
|
|
204
|
+
rs: 'rust',
|
|
205
|
+
css: 'css',
|
|
206
|
+
html: 'html',
|
|
207
|
+
toml: 'toml',
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* v0.27.5 — pick a syntax-highlight language for a tool's RESULT block so the
|
|
211
|
+
* card colors output the way the terminal does. Bash/shell → 'bash'; file tools
|
|
212
|
+
* → inferred from the `file_path` extension via EXT_LANG. Unknown → undefined
|
|
213
|
+
* (renders classless, no regression — never guess a language we can't justify).
|
|
214
|
+
*/
|
|
215
|
+
export function langForTool(tc) {
|
|
216
|
+
const n = tc.name.toLowerCase();
|
|
217
|
+
if (n === 'bash' || n === 'shell')
|
|
218
|
+
return 'bash';
|
|
219
|
+
const input = tc.input;
|
|
220
|
+
if (input && typeof input === 'object' && typeof input.file_path === 'string') {
|
|
221
|
+
const m = /\.([a-z0-9]+)$/i.exec(input.file_path);
|
|
222
|
+
if (m)
|
|
223
|
+
return EXT_LANG[m[1].toLowerCase()];
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
116
227
|
/**
|
|
117
228
|
* Format one tool-call line for inclusion in the rendered card body.
|
|
118
229
|
* "✓ Bash · ls -la"
|
|
@@ -171,11 +282,14 @@ export function renderThinkingBlock(thinkingText) {
|
|
|
171
282
|
// v0.27.0 M1 — Telegram HTML <blockquote> renders the reasoning as a single
|
|
172
283
|
// indented quote (terminal-style), distinct from the assistant text. Content
|
|
173
284
|
// is HTML-escaped; newlines are preserved inside the blockquote.
|
|
285
|
+
// v0.27.5 M3 — `expandable` collapses long reasoning to a tap-to-expand quote,
|
|
286
|
+
// keeping the card compact. stripHtml's `blockquote\b[^>]*>` already tolerates
|
|
287
|
+
// the attribute, so the plain-text fallback is unaffected.
|
|
174
288
|
const body = trimmed
|
|
175
289
|
.split('\n')
|
|
176
290
|
.map((l) => escapeHtml(l))
|
|
177
291
|
.join('\n');
|
|
178
|
-
return `💭 Thinking\n<blockquote>${body}</blockquote>`;
|
|
292
|
+
return `💭 Thinking\n<blockquote expandable>${body}</blockquote>`;
|
|
179
293
|
}
|
|
180
294
|
/**
|
|
181
295
|
* Render the full turn into a Telegram-safe message body. The mirror keeps
|
|
@@ -199,8 +313,27 @@ export function renderThinkingBlock(thinkingText) {
|
|
|
199
313
|
* Lines with no content (e.g. empty tool list, empty assistant text) are
|
|
200
314
|
* omitted entirely so the rendered body never has trailing whitespace.
|
|
201
315
|
*/
|
|
316
|
+
/**
|
|
317
|
+
* Telegram hard-caps a single message at 4096 chars; an edit that exceeds it is
|
|
318
|
+
* rejected outright (and editTg's fallback can't help — same oversized text).
|
|
319
|
+
* v0.27.3: budget the rendered body. v0.27.0 M2 began emitting a
|
|
320
|
+
* <pre><code> result block under every tool line, which on a heavy multi-tool
|
|
321
|
+
* turn (e.g. a 15-Bash version check) silently blew past 4096 → every edit
|
|
322
|
+
* failed → the card froze as a bare "✓ Done" with no activity. The fix renders a
|
|
323
|
+
* GUARANTEED spine (status + header + tool LINES) first, then adds the richer,
|
|
324
|
+
* droppable blocks (result previews, todos, thinking, assistant text) only while
|
|
325
|
+
* budget remains. The card therefore always shows the tool activity.
|
|
326
|
+
*/
|
|
327
|
+
const TG_BODY_BUDGET = 3900; // margin under the 4096 hard cap (entities + safety)
|
|
202
328
|
export function renderTurn(turn, meta) {
|
|
203
329
|
const lines = [];
|
|
330
|
+
// Running length tracker (string.length + the join newline). O(1) per push;
|
|
331
|
+
// avoids re-joining the array on every budget check.
|
|
332
|
+
let used = 0;
|
|
333
|
+
const push = (s) => {
|
|
334
|
+
lines.push(s);
|
|
335
|
+
used += s.length + 1;
|
|
336
|
+
};
|
|
204
337
|
// v0.26.2 M1 — CC-CLI-style status line at the top (model · CC ver · ⏱ · 🔧 ·
|
|
205
338
|
// bypass). Gated on `meta` presence so legacy no-meta callers (and the initial
|
|
206
339
|
// pre-model inbound card) render exactly as before — no regression. Once the
|
|
@@ -210,17 +343,17 @@ export function renderTurn(turn, meta) {
|
|
|
210
343
|
// v0.27.0 M1 — bold the status line (the card header) via HTML <b>. Content
|
|
211
344
|
// HTML-escaped first ( & < > only — model/version dots & brackets are safe).
|
|
212
345
|
if (status)
|
|
213
|
-
|
|
346
|
+
push(`<b>${escapeHtml(status)}</b>`);
|
|
214
347
|
// v0.26.2 M2 — meter row (context % + quota % + reset). Same no-fake-data
|
|
215
348
|
// gating: omitted unless real values are present. HTML-escaped for safety.
|
|
216
349
|
const meters = renderMeters(meta);
|
|
217
350
|
if (meters)
|
|
218
|
-
|
|
351
|
+
push(escapeHtml(meters));
|
|
219
352
|
// v0.26.4 styling — divider between the status/telemetry block and the
|
|
220
353
|
// activity block (only when a status block was actually rendered). The
|
|
221
354
|
// heavy-bar glyph is not HTML-significant, so it needs no escaping.
|
|
222
355
|
if (lines.length > 0)
|
|
223
|
-
|
|
356
|
+
push('━━━━━━━━━━━━');
|
|
224
357
|
}
|
|
225
358
|
// Turn-status header. v0.27.0 M4 — a failed turn shows "❌ <reason>" so it
|
|
226
359
|
// never dies as an eternal "…"; reason is HTML-escaped (it's an error message).
|
|
@@ -229,42 +362,114 @@ export function renderTurn(turn, meta) {
|
|
|
229
362
|
: turn.state === 'done'
|
|
230
363
|
? '✓ Done'
|
|
231
364
|
: '▶ Working';
|
|
232
|
-
|
|
365
|
+
push(header);
|
|
233
366
|
if (turn.toolCalls.length > 0) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
367
|
+
// Build tool ENTRIES (tool line + its optional <pre><code> result block)
|
|
368
|
+
// newest-first, keeping what fits under budget; the older overflow collapses
|
|
369
|
+
// to a "… +N earlier tools" marker. Budgeting the line AND its result block
|
|
370
|
+
// TOGETHER is the fix for the v0.27.0 regression: the prior code budgeted
|
|
371
|
+
// lines alone, then result blocks ate the remaining room and the leftover
|
|
372
|
+
// lines pushed unguarded — bursting Telegram's 4096 cap. Reserve TAIL_RESERVE
|
|
373
|
+
// so the assistant text / todos / thinking below always have somewhere to go.
|
|
374
|
+
const TAIL_RESERVE = 700;
|
|
375
|
+
const cap = TG_BODY_BUDGET - TAIL_RESERVE;
|
|
376
|
+
const entries = []; // oldest→newest display order
|
|
377
|
+
let shown = 0;
|
|
378
|
+
let acc = 0;
|
|
379
|
+
for (let i = turn.toolCalls.length - 1; i >= 0; i--) {
|
|
380
|
+
const tc = turn.toolCalls[i];
|
|
381
|
+
const line = renderToolLine(tc);
|
|
382
|
+
// v0.27.4 M2 — for Edit/Write/MultiEdit show a diff (from input) instead of
|
|
383
|
+
// the "File updated" result; other tools keep the result preview.
|
|
384
|
+
const diffText = toolDiffBlock(tc);
|
|
238
385
|
const resultText = toolResultText(tc);
|
|
239
|
-
|
|
240
|
-
|
|
386
|
+
const block = diffText
|
|
387
|
+
? pre(diffText, 'diff') // v0.27.5 M1 — Telegram colors -/+ lines red/green
|
|
388
|
+
: resultText
|
|
389
|
+
? pre(truncateResult(resultText), langForTool(tc)) // v0.27.5 M2 — lang-highlight output
|
|
390
|
+
: '';
|
|
391
|
+
const withBlock = block ? `${line}\n${block}` : line;
|
|
392
|
+
// Prefer line+block; if that bursts the cap, fall back to the line alone
|
|
393
|
+
// (the result preview is the droppable part, the activity line is not).
|
|
394
|
+
if (used + acc + withBlock.length + 1 <= cap) {
|
|
395
|
+
entries.unshift(withBlock);
|
|
396
|
+
acc += withBlock.length + 1;
|
|
397
|
+
shown++;
|
|
398
|
+
}
|
|
399
|
+
else if (used + acc + line.length + 1 <= cap) {
|
|
400
|
+
entries.unshift(line);
|
|
401
|
+
acc += line.length + 1;
|
|
402
|
+
shown++;
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
break; // no room for even the bare line → stop; rest become "+N earlier"
|
|
406
|
+
}
|
|
241
407
|
}
|
|
408
|
+
const hidden = turn.toolCalls.length - shown;
|
|
409
|
+
if (hidden > 0)
|
|
410
|
+
push(`… +${hidden} earlier tool${hidden === 1 ? '' : 's'}`);
|
|
411
|
+
for (const e of entries)
|
|
412
|
+
push(e);
|
|
242
413
|
}
|
|
243
414
|
// v0.26.2 M4 — todo checklist (real data from the agent's TodoWrite calls).
|
|
244
415
|
// Escaped as a block (glyphs are safe; content + the "+N more" plus sign are
|
|
245
416
|
// not, so the whole block goes through the escaper).
|
|
246
417
|
const todoBlock = renderTodos(meta);
|
|
247
418
|
if (todoBlock) {
|
|
248
|
-
|
|
249
|
-
|
|
419
|
+
const block = escapeHtml(todoBlock);
|
|
420
|
+
if (used + block.length + 2 <= TG_BODY_BUDGET) {
|
|
421
|
+
push('');
|
|
422
|
+
push(block);
|
|
423
|
+
}
|
|
250
424
|
}
|
|
251
425
|
// Defensive default — Turn.thinkingText is required by the interface, but
|
|
252
426
|
// some test helpers and pre-M6 fixtures construct Turn-shaped objects
|
|
253
427
|
// without setting it. Treat absent / undefined as empty.
|
|
254
428
|
const thinking = renderThinkingBlock(turn.thinkingText ?? '');
|
|
255
|
-
if (thinking.length > 0) {
|
|
256
|
-
|
|
257
|
-
|
|
429
|
+
if (thinking.length > 0 && used + thinking.length + 2 <= TG_BODY_BUDGET) {
|
|
430
|
+
push('');
|
|
431
|
+
push(thinking);
|
|
258
432
|
}
|
|
259
433
|
const text = turn.assistantText.trim();
|
|
260
434
|
if (text.length > 0) {
|
|
261
|
-
lines.push('');
|
|
262
435
|
// v0.27.0 M1 — render the model's markdown as Telegram HTML with structure
|
|
263
436
|
// preserved (**bold**→<b>, `code`→<code>, ```bash fences→<pre><code
|
|
264
437
|
// class="language-bash">). HTML-escapes all remaining content (& < >).
|
|
265
|
-
|
|
438
|
+
// Budget-aware: if the full text won't fit, render a truncated head so the
|
|
439
|
+
// card still ends with readable context rather than dropping it entirely.
|
|
440
|
+
const room = TG_BODY_BUDGET - used - 2;
|
|
441
|
+
if (room > 0) {
|
|
442
|
+
const full = markdownToHtml(text);
|
|
443
|
+
if (full.length <= room) {
|
|
444
|
+
push('');
|
|
445
|
+
push(full);
|
|
446
|
+
}
|
|
447
|
+
else if (room > 240) {
|
|
448
|
+
// Truncate the SOURCE before markdown→HTML so we never split an HTML tag
|
|
449
|
+
// mid-emit (which would break Telegram's parser). Leave slack for the "…".
|
|
450
|
+
const headSrc = text.slice(0, room - 40).replace(/\s+\S*$/, '');
|
|
451
|
+
push('');
|
|
452
|
+
push(markdownToHtml(headSrc) + '\n…');
|
|
453
|
+
}
|
|
454
|
+
}
|
|
266
455
|
}
|
|
267
|
-
|
|
456
|
+
// Guaranteed safety net: even if the budgeted assembly above has a logic gap,
|
|
457
|
+
// a card MUST NOT exceed Telegram's 4096-char hard cap (an over-cap edit is
|
|
458
|
+
// rejected outright).
|
|
459
|
+
const body = lines.join('\n');
|
|
460
|
+
if (body.length <= 4096)
|
|
461
|
+
return body;
|
|
462
|
+
// v0.27.5 M4 — a blunt slice can split an HTML tag, which makes Telegram reject
|
|
463
|
+
// the edit and forces editTg's plain-text fallback (the path that surfaces
|
|
464
|
+
// literal `<pre>`/`**`/`- ` markup — the exact "raw markdown" leak we're
|
|
465
|
+
// closing). Instead: truncate at the last whole line ≤ 4090, close any tag the
|
|
466
|
+
// slice left dangling (LIFO), then append the ellipsis. The result is always
|
|
467
|
+
// well-formed HTML, so editTg never has to fall back. The 3900 budget means
|
|
468
|
+
// this rarely fires, but it must be safe when it does.
|
|
469
|
+
const hard = body.slice(0, 4090);
|
|
470
|
+
const nl = hard.lastIndexOf('\n');
|
|
471
|
+
const sliced = nl > 0 ? hard.slice(0, nl) : hard;
|
|
472
|
+
return closeDanglingTags(sliced) + '\n…';
|
|
268
473
|
}
|
|
269
474
|
/**
|
|
270
475
|
* 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
|
/**
|
|
@@ -358,6 +358,38 @@ export function handleSoakCheck(ctx) {
|
|
|
358
358
|
],
|
|
359
359
|
};
|
|
360
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
|
+
}
|
|
361
393
|
export const COMMAND_HANDLERS = {
|
|
362
394
|
sessions: handleSessions,
|
|
363
395
|
new: handleNew,
|
|
@@ -370,6 +402,8 @@ export const COMMAND_HANDLERS = {
|
|
|
370
402
|
send: handleSend,
|
|
371
403
|
cancel: handleCancel,
|
|
372
404
|
'soak-check': handleSoakCheck,
|
|
405
|
+
help: handleHelp,
|
|
406
|
+
commands: handleHelp,
|
|
373
407
|
};
|
|
374
408
|
/**
|
|
375
409
|
* Telegram bot commands list — declarative source for M5's setMyCommands sync.
|
|
@@ -392,7 +392,7 @@ export function registerInboundHandler(api, state = createHandlerState()) {
|
|
|
392
392
|
if (parsed && MIRROR_COMMANDS.has(parsed.cmd))
|
|
393
393
|
return undefined;
|
|
394
394
|
const fromKey = chatIdFromSessionKey(event.sessionKey);
|
|
395
|
-
|
|
395
|
+
const chatId = fromKey.chatId || (event.senderId !== undefined ? String(event.senderId) : '');
|
|
396
396
|
if (!chatId)
|
|
397
397
|
return undefined;
|
|
398
398
|
const threadId = fromKey.threadId !== undefined ? fromKey.threadId : event.raw?.message?.message_thread_id;
|
|
@@ -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
|
|
@@ -79,7 +90,7 @@ export declare function pushAssistantText(text: string): void;
|
|
|
79
90
|
* into the card during the turn, then calls this on turn end. Single-tenant:
|
|
80
91
|
* finalizes ALL active cards (one at a time in practice).
|
|
81
92
|
*/
|
|
82
|
-
export declare function finalizeActiveCards(): Promise<void>;
|
|
93
|
+
export declare function finalizeActiveCards(deliveredText?: string): Promise<void>;
|
|
83
94
|
/**
|
|
84
95
|
* Map a caught error to a short, user-readable card reason (v0.27.0 M4). Keeps
|
|
85
96
|
* the "❌ <reason>" header concise and classifies the common cc-openclaw-side
|
|
@@ -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
|
|
@@ -241,7 +272,7 @@ export function pushAssistantText(text) {
|
|
|
241
272
|
* into the card during the turn, then calls this on turn end. Single-tenant:
|
|
242
273
|
* finalizes ALL active cards (one at a time in practice).
|
|
243
274
|
*/
|
|
244
|
-
export async function finalizeActiveCards() {
|
|
275
|
+
export async function finalizeActiveCards(deliveredText) {
|
|
245
276
|
const chats = activeChatIds();
|
|
246
277
|
if (chats.length === 0)
|
|
247
278
|
return;
|
|
@@ -260,7 +291,15 @@ export async function finalizeActiveCards() {
|
|
|
260
291
|
// The plugin can't suppress the gateway reply (no suppressUserDelivery
|
|
261
292
|
// knob), so the card yields the final text and finalizes to a clean
|
|
262
293
|
// status/tools/✓ Done activity view. renderTurn itself is unchanged.
|
|
263
|
-
|
|
294
|
+
//
|
|
295
|
+
// v0.27.6 disconnect exception (Killer #2 report-drop) — when the gateway
|
|
296
|
+
// socket died mid-turn the gateway delivers NOTHING separately, so the
|
|
297
|
+
// dedup assumption breaks and wiping the text yields total silence
|
|
298
|
+
// ("✓ Done" then nothing). The caller passes the accumulated text in that
|
|
299
|
+
// case (deliveredText); the finalized card then KEEPS the full report as
|
|
300
|
+
// the sole delivery channel. On the happy path deliveredText is undefined
|
|
301
|
+
// → '' → behavior unchanged (gateway delivers, no duplicate).
|
|
302
|
+
turn.assistantText = deliveredText ?? '';
|
|
264
303
|
try {
|
|
265
304
|
await editTg(chatId, card.messageId, renderTurn(turn, card.meta));
|
|
266
305
|
}
|
package/dist/src/constants.d.ts
CHANGED
|
@@ -36,6 +36,16 @@ export declare const TURN_TIMEOUT_MS = 900000;
|
|
|
36
36
|
export declare const STALLED_SESSION_KILL_MS = 180000;
|
|
37
37
|
/** How often the stalled-session watchdog scans the sessions Map. */
|
|
38
38
|
export declare const STALLED_WATCH_INTERVAL_MS = 30000;
|
|
39
|
+
/** v0.27.4 (M4/M6) — resume-freshness window for openai-compat sessions.
|
|
40
|
+
* After a gateway restart OR a stalled-session watchdog SIGTERM, the in-process
|
|
41
|
+
* session object is gone; without this, the next turn for the same chat spawns
|
|
42
|
+
* a FRESH Claude conversation (no --resume) and the user loses all context.
|
|
43
|
+
* When an openai-compat session opts in (config.resumeFreshnessMs), the next
|
|
44
|
+
* turn resumes the prior Claude session ONLY if it was active within this
|
|
45
|
+
* window — older sessions start fresh, preserving the anti-stale intent that
|
|
46
|
+
* motivated skipPersistence. Default 30 min; env-overridable at the use site
|
|
47
|
+
* via CC_OPENCLAW_RESUME_FRESHNESS_MS. */
|
|
48
|
+
export declare const RESUME_FRESHNESS_MS = 1800000;
|
|
39
49
|
/** Runaway-loop watchdog: max new cc-openclaw subprocess spawns within
|
|
40
50
|
* RUNAWAY_LOOP_WINDOW_MS before the next spawn is refused.
|
|
41
51
|
*
|
|
@@ -176,3 +186,20 @@ export declare const CC_AUTO_COMPACT_THRESHOLD = 70;
|
|
|
176
186
|
* recreate) instead of compacting. Used when /compact itself would leave
|
|
177
187
|
* the session too close to the model's window cap to do useful work. */
|
|
178
188
|
export declare const CC_HARD_RESET_THRESHOLD = 90;
|
|
189
|
+
/** Single-flight window for the openai-compat streaming path. When two
|
|
190
|
+
* requests with a byte-identical (sessionName + input) signature arrive
|
|
191
|
+
* within this window, the second is treated as a duplicate (an OpenClaw
|
|
192
|
+
* retry of a stream it perceived as dead) and JOINS the in-flight turn
|
|
193
|
+
* instead of spawning a SECOND full model run. The leader's result is
|
|
194
|
+
* replayed to the follower; the model executes exactly once.
|
|
195
|
+
*
|
|
196
|
+
* Born from the 2026-05-22 incident: an OOM SIGKILL (exit 137) made
|
|
197
|
+
* OpenClaw retry, and session-manager's per-session send-chain SERIALIZES
|
|
198
|
+
* (rather than coalesces) the retry — so the turn ran twice and delivered
|
|
199
|
+
* two identical Telegram messages. This guards the duplicate at its source.
|
|
200
|
+
*
|
|
201
|
+
* Set CC_OPENCLAW_DEDUP_WINDOW_MS=0 to disable (pure fail-open to the prior
|
|
202
|
+
* serialize-and-rerun behavior). Default 45s comfortably exceeds a typical
|
|
203
|
+
* OpenClaw read-timeout-then-retry gap without risking a real, distinct
|
|
204
|
+
* follow-up message colliding with a just-finished identical one. */
|
|
205
|
+
export declare const DEDUP_WINDOW_MS = 45000;
|
package/dist/src/constants.js
CHANGED
|
@@ -39,6 +39,16 @@ export const TURN_TIMEOUT_MS = 900_000;
|
|
|
39
39
|
export const STALLED_SESSION_KILL_MS = 180_000;
|
|
40
40
|
/** How often the stalled-session watchdog scans the sessions Map. */
|
|
41
41
|
export const STALLED_WATCH_INTERVAL_MS = 30_000;
|
|
42
|
+
/** v0.27.4 (M4/M6) — resume-freshness window for openai-compat sessions.
|
|
43
|
+
* After a gateway restart OR a stalled-session watchdog SIGTERM, the in-process
|
|
44
|
+
* session object is gone; without this, the next turn for the same chat spawns
|
|
45
|
+
* a FRESH Claude conversation (no --resume) and the user loses all context.
|
|
46
|
+
* When an openai-compat session opts in (config.resumeFreshnessMs), the next
|
|
47
|
+
* turn resumes the prior Claude session ONLY if it was active within this
|
|
48
|
+
* window — older sessions start fresh, preserving the anti-stale intent that
|
|
49
|
+
* motivated skipPersistence. Default 30 min; env-overridable at the use site
|
|
50
|
+
* via CC_OPENCLAW_RESUME_FRESHNESS_MS. */
|
|
51
|
+
export const RESUME_FRESHNESS_MS = 1_800_000;
|
|
42
52
|
/** Runaway-loop watchdog: max new cc-openclaw subprocess spawns within
|
|
43
53
|
* RUNAWAY_LOOP_WINDOW_MS before the next spawn is refused.
|
|
44
54
|
*
|
|
@@ -185,3 +195,21 @@ export const CC_AUTO_COMPACT_THRESHOLD = 70;
|
|
|
185
195
|
* recreate) instead of compacting. Used when /compact itself would leave
|
|
186
196
|
* the session too close to the model's window cap to do useful work. */
|
|
187
197
|
export const CC_HARD_RESET_THRESHOLD = 90;
|
|
198
|
+
// ─── request coalescing / duplicate-turn defense (v0.27.5) ──────────────────
|
|
199
|
+
/** Single-flight window for the openai-compat streaming path. When two
|
|
200
|
+
* requests with a byte-identical (sessionName + input) signature arrive
|
|
201
|
+
* within this window, the second is treated as a duplicate (an OpenClaw
|
|
202
|
+
* retry of a stream it perceived as dead) and JOINS the in-flight turn
|
|
203
|
+
* instead of spawning a SECOND full model run. The leader's result is
|
|
204
|
+
* replayed to the follower; the model executes exactly once.
|
|
205
|
+
*
|
|
206
|
+
* Born from the 2026-05-22 incident: an OOM SIGKILL (exit 137) made
|
|
207
|
+
* OpenClaw retry, and session-manager's per-session send-chain SERIALIZES
|
|
208
|
+
* (rather than coalesces) the retry — so the turn ran twice and delivered
|
|
209
|
+
* two identical Telegram messages. This guards the duplicate at its source.
|
|
210
|
+
*
|
|
211
|
+
* Set CC_OPENCLAW_DEDUP_WINDOW_MS=0 to disable (pure fail-open to the prior
|
|
212
|
+
* serialize-and-rerun behavior). Default 45s comfortably exceeds a typical
|
|
213
|
+
* OpenClaw read-timeout-then-retry gap without risking a real, distinct
|
|
214
|
+
* follow-up message colliding with a just-finished identical one. */
|
|
215
|
+
export const DEDUP_WINDOW_MS = 45_000;
|
|
@@ -27,6 +27,11 @@ interface InternalStats {
|
|
|
27
27
|
lastActivity: string | null;
|
|
28
28
|
/** v0.27.x — last PROGRESS event ts (excludes api_retry); watchdog keys off it. */
|
|
29
29
|
lastProgressAt: string | null;
|
|
30
|
+
/** v0.27.6 — count of tool calls currently in flight (dispatched onToolUse
|
|
31
|
+
* without a matching onToolResult yet). The stalled-session watchdog treats
|
|
32
|
+
* inFlightTools > 0 as "alive" so a long quiet Bash/build/test step is never
|
|
33
|
+
* killed mid-run. Reset to 0 at turn boundaries (start / complete / close). */
|
|
34
|
+
inFlightTools: number;
|
|
30
35
|
history: Array<{
|
|
31
36
|
time: string;
|
|
32
37
|
type: string;
|