@a1hvdy/cc-openclaw 0.27.2 → 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/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 +34 -0
- package/dist/src/channels/telegram-mirror/inbound-handler.js +1 -1
- 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/telegram-bot-api.d.ts +22 -6
- package/dist/src/lib/telegram-bot-api.js +94 -14
- 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
|
@@ -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
|
/**
|
|
@@ -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
|
|
@@ -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
|
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
|
*
|
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
|
*
|
|
@@ -49,7 +49,12 @@ export interface ErrorContext {
|
|
|
49
49
|
export interface FormattedError {
|
|
50
50
|
/** NDJSON-ready row */
|
|
51
51
|
jsonlRow: ErrorJsonlRow;
|
|
52
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Telegram message text (HTML parse_mode). v0.27.3 — converted from
|
|
54
|
+
* MarkdownV2 to HTML so the entire Telegram surface (live card + error
|
|
55
|
+
* alerts) renders through ONE parse mode. error-renderer sends this with
|
|
56
|
+
* parse_mode: 'HTML'.
|
|
57
|
+
*/
|
|
53
58
|
telegramText: string;
|
|
54
59
|
}
|
|
55
60
|
export interface ErrorJsonlRow {
|
|
@@ -64,7 +69,14 @@ export interface ErrorJsonlRow {
|
|
|
64
69
|
}
|
|
65
70
|
/** Extract a usable message from unknown thrown values. */
|
|
66
71
|
export declare function extractMessage(error: unknown): string;
|
|
67
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Escape characters special to Telegram MarkdownV2.
|
|
74
|
+
*
|
|
75
|
+
* v0.27.3 — RETAINED for back-compat (and its own unit tests) but no longer
|
|
76
|
+
* used by formatError, which now emits HTML (see the telegramText block) so the
|
|
77
|
+
* whole Telegram surface renders through one parse mode. Kept exported in case a
|
|
78
|
+
* caller still needs MarkdownV2 escaping.
|
|
79
|
+
*/
|
|
68
80
|
export declare function escapeMdV2(text: string): string;
|
|
69
81
|
/**
|
|
70
82
|
* Pure formatter — no side effects on the return value, but DOES emit
|
|
@@ -80,7 +80,14 @@ function extractStack(error) {
|
|
|
80
80
|
}
|
|
81
81
|
return undefined;
|
|
82
82
|
}
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Escape characters special to Telegram MarkdownV2.
|
|
85
|
+
*
|
|
86
|
+
* v0.27.3 — RETAINED for back-compat (and its own unit tests) but no longer
|
|
87
|
+
* used by formatError, which now emits HTML (see the telegramText block) so the
|
|
88
|
+
* whole Telegram surface renders through one parse mode. Kept exported in case a
|
|
89
|
+
* caller still needs MarkdownV2 escaping.
|
|
90
|
+
*/
|
|
84
91
|
export function escapeMdV2(text) {
|
|
85
92
|
// Per Telegram docs, these must be escaped: _ * [ ] ( ) ~ ` > # + - = | { } . !
|
|
86
93
|
return text.replace(/[_*[\]()~`>#+=|{}.!\\-]/g, (c) => `\\${c}`);
|
|
@@ -97,6 +104,7 @@ const SEVERITY_EMOJI = {
|
|
|
97
104
|
// are unset, so the import itself has zero runtime cost.
|
|
98
105
|
import { emit as emitTrajectory } from './trajectory.js';
|
|
99
106
|
import { metricsRegistry } from '../health/metrics.js';
|
|
107
|
+
import { escapeHtml } from './html-render.js';
|
|
100
108
|
/**
|
|
101
109
|
* Pure formatter — no side effects on the return value, but DOES emit
|
|
102
110
|
* trajectory + metrics events for centralized observability per Pillars A+B.
|
|
@@ -128,27 +136,31 @@ export function formatError(error, context) {
|
|
|
128
136
|
...(stack !== undefined ? { stack } : {}),
|
|
129
137
|
...(context.details !== undefined ? { details: context.details } : {}),
|
|
130
138
|
};
|
|
139
|
+
// v0.27.3 — emit Telegram HTML (sent with parse_mode: 'HTML' by
|
|
140
|
+
// error-renderer) so error alerts share the live card's single render path.
|
|
141
|
+
// <b> code, <code> message, <i> timestamp; all interpolated text is
|
|
142
|
+
// HTML-escaped (& < >) so a stray < in a message can't break the parser.
|
|
131
143
|
const emoji = SEVERITY_EMOJI[severity];
|
|
132
|
-
const safeCode =
|
|
133
|
-
const safeMsg =
|
|
134
|
-
const safeTs =
|
|
144
|
+
const safeCode = escapeHtml(context.code);
|
|
145
|
+
const safeMsg = escapeHtml(message.length > 300 ? message.slice(0, 300) + '...' : message);
|
|
146
|
+
const safeTs = escapeHtml(ts.slice(0, 19).replace('T', ' '));
|
|
135
147
|
const lines = [
|
|
136
|
-
`${emoji}
|
|
137
|
-
|
|
138
|
-
|
|
148
|
+
`${emoji} <b>${safeCode}</b>`,
|
|
149
|
+
`<code>${safeMsg}</code>`,
|
|
150
|
+
`<i>${safeTs}</i>`,
|
|
139
151
|
];
|
|
140
152
|
if (context.sessionId) {
|
|
141
|
-
lines.push(`session:
|
|
153
|
+
lines.push(`session: <code>${escapeHtml(context.sessionId)}</code>`);
|
|
142
154
|
}
|
|
143
155
|
if (context.laptopId) {
|
|
144
|
-
lines.push(`laptop:
|
|
156
|
+
lines.push(`laptop: <code>${escapeHtml(context.laptopId)}</code>`);
|
|
145
157
|
}
|
|
146
158
|
if (context.details && Object.keys(context.details).length > 0) {
|
|
147
159
|
const detailParts = Object.entries(context.details)
|
|
148
160
|
.slice(0, 5)
|
|
149
|
-
.map(([k, v]) => `${
|
|
161
|
+
.map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}`)
|
|
150
162
|
.join(', ');
|
|
151
|
-
lines.push(
|
|
163
|
+
lines.push(`<i>${detailParts}</i>`);
|
|
152
164
|
}
|
|
153
165
|
const telegramText = lines.join('\n');
|
|
154
166
|
return { jsonlRow, telegramText };
|
|
@@ -67,7 +67,9 @@ export async function renderError(formatted, opts = {}) {
|
|
|
67
67
|
const params = {
|
|
68
68
|
chat_id: chatId,
|
|
69
69
|
text: telegramText,
|
|
70
|
-
|
|
70
|
+
// v0.27.3 — HTML (formatter now emits HTML) so error alerts share the live
|
|
71
|
+
// card's single render path. The 400-fallback below sends plain text.
|
|
72
|
+
parse_mode: 'HTML',
|
|
71
73
|
};
|
|
72
74
|
if (opts.threadId)
|
|
73
75
|
params.message_thread_id = opts.threadId;
|
|
@@ -18,26 +18,18 @@
|
|
|
18
18
|
export declare function escapeHtml(text: string | null | undefined): string;
|
|
19
19
|
/** Inline monospace span: `<code>escaped</code>`. */
|
|
20
20
|
export declare function code(s: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Strip Telegram HTML markup back to readable plain text (v0.27.3). Used
|
|
23
|
+
* by editTg's plain-text fallback: when an HTML edit is rejected, re-sending the
|
|
24
|
+
* SAME string without parse_mode would dump literal `<b>`/`<pre>` tags into the
|
|
25
|
+
* chat. This removes the tags and decodes the basic entities so the fallback
|
|
26
|
+
* stays legible. Not a general sanitizer — scoped to the tags this module emits.
|
|
27
|
+
*/
|
|
28
|
+
export declare function stripHtml(input: string | null | undefined): string;
|
|
21
29
|
/**
|
|
22
30
|
* Fenced code block: `<pre><code class="language-LANG">escaped</code></pre>`.
|
|
23
31
|
* Omits the class when no language is given. Body is HTML-escaped (the only
|
|
24
32
|
* escaping Telegram requires inside pre/code).
|
|
25
33
|
*/
|
|
26
34
|
export declare function pre(body: string, lang?: string): string;
|
|
27
|
-
/**
|
|
28
|
-
* Convert a subset of CommonMark Markdown to Telegram HTML with structure
|
|
29
|
-
* preserved (`**bold**`→`<b>`, fenced blocks→`<pre><code>`, etc.). Returns ''
|
|
30
|
-
* for null/undefined.
|
|
31
|
-
*
|
|
32
|
-
* Algorithm (placeholder substitution, NUL-sentinel keyed — mirrors
|
|
33
|
-
* markdown-to-mdv2.ts so behaviour is auditable side-by-side):
|
|
34
|
-
* 1. code fences → <pre><code [class]>…</code></pre>
|
|
35
|
-
* 2. inline code → <code>…</code>
|
|
36
|
-
* 3. bold (**…**) → <b>…</b>
|
|
37
|
-
* 4. italic (*…* / _…_) → <i>…</i>
|
|
38
|
-
* 5. ATX headers (#…) at line start → <b>…</b>
|
|
39
|
-
* 6. links [label](url) → <a href="url">label</a>
|
|
40
|
-
* 7. escape remaining text (& < >)
|
|
41
|
-
* 8. restore placeholders verbatim
|
|
42
|
-
*/
|
|
43
35
|
export declare function markdownToHtml(input: string | null | undefined): string;
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* pre+code[class=language-X], blockquote, tg-spoiler. Everything else in text
|
|
15
15
|
* content must have &, <, > escaped.
|
|
16
16
|
*/
|
|
17
|
+
/* eslint-disable no-control-regex -- NUL-byte sentinel delimiters intentionally guard placeholders through the bulk-escape step, then are restored */
|
|
17
18
|
/** Escape the three HTML-significant chars for Telegram HTML text content. */
|
|
18
19
|
export function escapeHtml(text) {
|
|
19
20
|
if (text == null)
|
|
@@ -27,6 +28,23 @@ export function escapeHtml(text) {
|
|
|
27
28
|
export function code(s) {
|
|
28
29
|
return `<code>${escapeHtml(s)}</code>`;
|
|
29
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Strip Telegram HTML markup back to readable plain text (v0.27.3). Used
|
|
33
|
+
* by editTg's plain-text fallback: when an HTML edit is rejected, re-sending the
|
|
34
|
+
* SAME string without parse_mode would dump literal `<b>`/`<pre>` tags into the
|
|
35
|
+
* chat. This removes the tags and decodes the basic entities so the fallback
|
|
36
|
+
* stays legible. Not a general sanitizer — scoped to the tags this module emits.
|
|
37
|
+
*/
|
|
38
|
+
export function stripHtml(input) {
|
|
39
|
+
if (input == null)
|
|
40
|
+
return '';
|
|
41
|
+
return String(input)
|
|
42
|
+
.replace(/<\/?(?:b|strong|i|em|u|s|a|code|pre|blockquote|tg-spoiler)\b[^>]*>/gi, '')
|
|
43
|
+
.replace(/</g, '<')
|
|
44
|
+
.replace(/>/g, '>')
|
|
45
|
+
.replace(/"/g, '"')
|
|
46
|
+
.replace(/&/g, '&');
|
|
47
|
+
}
|
|
30
48
|
/**
|
|
31
49
|
* Fenced code block: `<pre><code class="language-LANG">escaped</code></pre>`.
|
|
32
50
|
* Omits the class when no language is given. Body is HTML-escaped (the only
|
|
@@ -52,16 +70,73 @@ export function pre(body, lang) {
|
|
|
52
70
|
* 7. escape remaining text (& < >)
|
|
53
71
|
* 8. restore placeholders verbatim
|
|
54
72
|
*/
|
|
73
|
+
/** Split a GFM table row into trimmed cells, dropping the empty cells produced
|
|
74
|
+
* by leading/trailing pipes (`| a | b |` → ['a','b']). */
|
|
75
|
+
function splitTableRow(row) {
|
|
76
|
+
const cells = row.split('|').map((c) => c.trim());
|
|
77
|
+
if (cells.length && cells[0] === '')
|
|
78
|
+
cells.shift();
|
|
79
|
+
if (cells.length && cells[cells.length - 1] === '')
|
|
80
|
+
cells.pop();
|
|
81
|
+
return cells;
|
|
82
|
+
}
|
|
83
|
+
/** Render parsed table rows as a padded monospace block. Telegram HTML has no
|
|
84
|
+
* <table>, so column-aligned text inside <pre> is the faithful terminal-style
|
|
85
|
+
* rendering. Returns a ready <pre><code> string (content HTML-escaped by pre()). */
|
|
86
|
+
function renderPaddedTable(rows) {
|
|
87
|
+
const cols = Math.max(...rows.map((r) => r.length));
|
|
88
|
+
const widths = [];
|
|
89
|
+
for (let c = 0; c < cols; c++) {
|
|
90
|
+
widths[c] = Math.max(...rows.map((r) => (r[c] ?? '').length));
|
|
91
|
+
}
|
|
92
|
+
const body = rows
|
|
93
|
+
.map((r) => Array.from({ length: cols }, (_v, c) => (r[c] ?? '').padEnd(widths[c]))
|
|
94
|
+
.join(' | ')
|
|
95
|
+
.replace(/\s+$/, ''))
|
|
96
|
+
.join('\n');
|
|
97
|
+
return pre(body);
|
|
98
|
+
}
|
|
55
99
|
export function markdownToHtml(input) {
|
|
56
100
|
if (input == null)
|
|
57
101
|
return '';
|
|
58
102
|
let text = String(input);
|
|
59
103
|
const codeBlocks = [];
|
|
60
|
-
text = text.replace(/```([a-zA-Z0-9_
|
|
104
|
+
text = text.replace(/```([a-zA-Z0-9_+-]*)\n?([\s\S]*?)```/g, (_m, lang, body) => {
|
|
61
105
|
const idx = codeBlocks.length;
|
|
62
106
|
codeBlocks.push(pre(body.replace(/\n$/, ''), lang || undefined));
|
|
63
107
|
return `\x00CODEBLOCK${idx}\x00`;
|
|
64
108
|
});
|
|
109
|
+
// v0.27.4 M4 — GFM pipe tables → padded monospace <pre> (gap #5). Telegram HTML
|
|
110
|
+
// has no <table>; column-aligned text is the terminal-faithful rendering. A
|
|
111
|
+
// table is a row containing `|` immediately followed by a `---` separator row.
|
|
112
|
+
const tables = [];
|
|
113
|
+
{
|
|
114
|
+
const lines = text.split('\n');
|
|
115
|
+
const out = [];
|
|
116
|
+
let i = 0;
|
|
117
|
+
while (i < lines.length) {
|
|
118
|
+
const header = lines[i];
|
|
119
|
+
const sep = lines[i + 1];
|
|
120
|
+
const isSep = sep != null && sep.includes('-') && /^\s*\|?[\s:|-]+\|?\s*$/.test(sep);
|
|
121
|
+
if (header != null && header.includes('|') && isSep) {
|
|
122
|
+
const block = [splitTableRow(header)];
|
|
123
|
+
let j = i + 2;
|
|
124
|
+
while (j < lines.length && lines[j].includes('|') && lines[j].trim() !== '') {
|
|
125
|
+
block.push(splitTableRow(lines[j]));
|
|
126
|
+
j++;
|
|
127
|
+
}
|
|
128
|
+
const idx = tables.length;
|
|
129
|
+
tables.push(renderPaddedTable(block));
|
|
130
|
+
out.push(`\x00TABLE${idx}\x00`);
|
|
131
|
+
i = j;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
out.push(header);
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
text = out.join('\n');
|
|
139
|
+
}
|
|
65
140
|
const inlineCodes = [];
|
|
66
141
|
text = text.replace(/`([^`\n]+)`/g, (_m, body) => {
|
|
67
142
|
const idx = inlineCodes.length;
|
|
@@ -74,6 +149,14 @@ export function markdownToHtml(input) {
|
|
|
74
149
|
bolds.push(`<b>${escapeHtml(body)}</b>`);
|
|
75
150
|
return `\x00BOLD${idx}\x00`;
|
|
76
151
|
});
|
|
152
|
+
// v0.27.4 M4 — strikethrough ~~text~~ → <s> (gap #5). After bold so the `~~`
|
|
153
|
+
// pass never sees `**`-delimited spans.
|
|
154
|
+
const strikes = [];
|
|
155
|
+
text = text.replace(/~~([^~\n]+)~~/g, (_m, body) => {
|
|
156
|
+
const idx = strikes.length;
|
|
157
|
+
strikes.push(`<s>${escapeHtml(body)}</s>`);
|
|
158
|
+
return `\x00STRIKE${idx}\x00`;
|
|
159
|
+
});
|
|
77
160
|
const italics = [];
|
|
78
161
|
text = text.replace(/(?<![\w*])\*([^*\n]+)\*(?!\w)/g, (_m, body) => {
|
|
79
162
|
const idx = italics.length;
|
|
@@ -99,10 +182,17 @@ export function markdownToHtml(input) {
|
|
|
99
182
|
links.push(`<a href="${safeUrl}">${escapeHtml(label)}</a>`);
|
|
100
183
|
return `\x00LINK${idx}\x00`;
|
|
101
184
|
});
|
|
185
|
+
// v0.27.4 M4 — unordered list markers (-, *, +) at line start → "• " bullet
|
|
186
|
+
// (gap #5). Ordered lists (1. 2.) already read fine as plain text. The bullet
|
|
187
|
+
// glyph isn't HTML-significant, so it survives the escape below. Uses [ \t]
|
|
188
|
+
// (not \s) so it never consumes the line break.
|
|
189
|
+
text = text.replace(/^([ \t]*)[-*+][ \t]+/gm, (_m, indent) => `${indent}• `);
|
|
102
190
|
text = escapeHtml(text);
|
|
103
191
|
text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m, idx) => codeBlocks[Number(idx)]);
|
|
192
|
+
text = text.replace(/\x00TABLE(\d+)\x00/g, (_m, idx) => tables[Number(idx)]);
|
|
104
193
|
text = text.replace(/\x00INLINECODE(\d+)\x00/g, (_m, idx) => inlineCodes[Number(idx)]);
|
|
105
194
|
text = text.replace(/\x00BOLD(\d+)\x00/g, (_m, idx) => bolds[Number(idx)]);
|
|
195
|
+
text = text.replace(/\x00STRIKE(\d+)\x00/g, (_m, idx) => strikes[Number(idx)]);
|
|
106
196
|
text = text.replace(/\x00ITALIC(\d+)\x00/g, (_m, idx) => italics[Number(idx)]);
|
|
107
197
|
text = text.replace(/\x00HEADER(\d+)\x00/g, (_m, idx) => headers[Number(idx)]);
|
|
108
198
|
text = text.replace(/\x00LINK(\d+)\x00/g, (_m, idx) => links[Number(idx)]);
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
* - "CODE"/"BOLD"/etc letters are not in the special-char set
|
|
47
47
|
* So placeholders survive the bulk-escape step intact.
|
|
48
48
|
*/
|
|
49
|
+
/* eslint-disable no-control-regex -- NUL-byte sentinel delimiters intentionally guard placeholders through the bulk-escape step, then are restored */
|
|
49
50
|
/** MarkdownV2 special characters (Telegram Bot API spec). Outside code spans
|
|
50
51
|
* / pre blocks, these MUST be backslash-escaped when intended as content. */
|
|
51
52
|
const MDV2_TEXT_SPECIAL = /[_*[\]()~`>#+\-=|{}.!\\]/g;
|
|
@@ -73,7 +74,7 @@ export function markdownToMdv2(input) {
|
|
|
73
74
|
// Step 1 — extract code fences (triple-backtick blocks). Body verbatim
|
|
74
75
|
// except backticks and backslashes are escaped per Telegram spec.
|
|
75
76
|
const codeBlocks = [];
|
|
76
|
-
text = text.replace(/```([a-zA-Z0-9_
|
|
77
|
+
text = text.replace(/```([a-zA-Z0-9_+-]*)\n?([\s\S]*?)```/g, (_m, lang, body) => {
|
|
77
78
|
const idx = codeBlocks.length;
|
|
78
79
|
const langStr = lang || '';
|
|
79
80
|
const escapedBody = escapeCodeContent(body);
|
|
@@ -87,15 +87,31 @@ export interface TelegramApiResponse {
|
|
|
87
87
|
*/
|
|
88
88
|
export declare function telegramApi(method: string, params: Record<string, unknown>): Promise<TelegramApiResponse>;
|
|
89
89
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
90
|
+
* v0.27.4 M6 — CLI-parity gap #8: split a message that exceeds Telegram's 4096
|
|
91
|
+
* char cap into ≤max-char chunks so long cc-openclaw-originated content sends as
|
|
92
|
+
* sequential messages instead of being rejected outright. Splits on newline
|
|
93
|
+
* boundaries (never mid-line) so HTML constructs mostly stay intact; a single
|
|
94
|
+
* line longer than max is hard-split. Returns [text] unchanged when ≤max (the
|
|
95
|
+
* common path — no behavior change for normal messages).
|
|
96
|
+
*
|
|
97
|
+
* Scope note: this covers cc-openclaw's OWN sends (slash/error responses). The
|
|
98
|
+
* live card is one edited message (truncated by design) and the model's final
|
|
99
|
+
* answer is delivered by the OpenClaw gateway — neither flows through here.
|
|
100
|
+
*/
|
|
101
|
+
export declare function splitForTelegram(text: string, max?: number): string[];
|
|
102
|
+
/**
|
|
103
|
+
* sendMessage with HTML parse_mode first + plain-text fallback. The fallback
|
|
104
|
+
* is the v0.20.1 fix: prior implementation stripped punctuation on parse
|
|
105
|
+
* errors; current behaviour retries with parse_mode omitted so all content
|
|
106
|
+
* survives. (v0.27.0 switched the live mirror MarkdownV2 → HTML; v0.27.3
|
|
107
|
+
* converted the last MarkdownV2 emitter, error-formatter, so the whole
|
|
108
|
+
* Telegram surface is now one HTML render path.) v0.27.4 M6 — auto-chunks
|
|
109
|
+
* over-cap text and the plain fallback now strips HTML (was: re-sent raw tags).
|
|
94
110
|
*/
|
|
95
111
|
export declare function sendTg(chatId: string | number, text: string, threadId?: string | number, replyMarkup?: unknown, replyToMessageId?: number | null): Promise<TelegramApiResponse>;
|
|
96
112
|
/**
|
|
97
|
-
* editMessageText with
|
|
98
|
-
* plain-text fallback.
|
|
113
|
+
* editMessageText with HTML parse_mode + 429 retry-after handling +
|
|
114
|
+
* plain-text fallback (v0.27.3 stripHtml fallback on rejection).
|
|
99
115
|
*/
|
|
100
116
|
export declare function editTg(chatId: string | number, messageId: number, text: string, replyMarkup?: unknown): Promise<TelegramApiResponse>;
|
|
101
117
|
export interface SendDocumentOptions {
|
|
@@ -28,6 +28,9 @@ import { readFileSync } from 'node:fs';
|
|
|
28
28
|
import { homedir } from 'node:os';
|
|
29
29
|
import { join } from 'node:path';
|
|
30
30
|
import { randomBytes } from 'node:crypto';
|
|
31
|
+
import { stripHtml } from './html-render.js';
|
|
32
|
+
/** Telegram's hard per-message character cap. */
|
|
33
|
+
const TG_MAX_CHARS = 4096;
|
|
31
34
|
export const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
|
32
35
|
const PLUGIN_TAG = '[cc-openclaw/telegram-bot-api]';
|
|
33
36
|
// ─── Bot token state ───────────────────────────────────────────────────────
|
|
@@ -139,32 +142,96 @@ export function telegramApi(method, params) {
|
|
|
139
142
|
});
|
|
140
143
|
}
|
|
141
144
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
145
|
+
* v0.27.4 M6 — CLI-parity gap #8: split a message that exceeds Telegram's 4096
|
|
146
|
+
* char cap into ≤max-char chunks so long cc-openclaw-originated content sends as
|
|
147
|
+
* sequential messages instead of being rejected outright. Splits on newline
|
|
148
|
+
* boundaries (never mid-line) so HTML constructs mostly stay intact; a single
|
|
149
|
+
* line longer than max is hard-split. Returns [text] unchanged when ≤max (the
|
|
150
|
+
* common path — no behavior change for normal messages).
|
|
151
|
+
*
|
|
152
|
+
* Scope note: this covers cc-openclaw's OWN sends (slash/error responses). The
|
|
153
|
+
* live card is one edited message (truncated by design) and the model's final
|
|
154
|
+
* answer is delivered by the OpenClaw gateway — neither flows through here.
|
|
155
|
+
*/
|
|
156
|
+
export function splitForTelegram(text, max = TG_MAX_CHARS) {
|
|
157
|
+
if (text.length <= max)
|
|
158
|
+
return [text];
|
|
159
|
+
const chunks = [];
|
|
160
|
+
let current = '';
|
|
161
|
+
for (const line of text.split('\n')) {
|
|
162
|
+
if (line.length > max) {
|
|
163
|
+
if (current) {
|
|
164
|
+
chunks.push(current);
|
|
165
|
+
current = '';
|
|
166
|
+
}
|
|
167
|
+
for (let i = 0; i < line.length; i += max)
|
|
168
|
+
chunks.push(line.slice(i, i + max));
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const candidate = current ? `${current}\n${line}` : line;
|
|
172
|
+
if (candidate.length > max) {
|
|
173
|
+
if (current)
|
|
174
|
+
chunks.push(current);
|
|
175
|
+
current = line;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
current = candidate;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (current)
|
|
182
|
+
chunks.push(current);
|
|
183
|
+
return chunks;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* sendMessage with HTML parse_mode first + plain-text fallback. The fallback
|
|
187
|
+
* is the v0.20.1 fix: prior implementation stripped punctuation on parse
|
|
188
|
+
* errors; current behaviour retries with parse_mode omitted so all content
|
|
189
|
+
* survives. (v0.27.0 switched the live mirror MarkdownV2 → HTML; v0.27.3
|
|
190
|
+
* converted the last MarkdownV2 emitter, error-formatter, so the whole
|
|
191
|
+
* Telegram surface is now one HTML render path.) v0.27.4 M6 — auto-chunks
|
|
192
|
+
* over-cap text and the plain fallback now strips HTML (was: re-sent raw tags).
|
|
146
193
|
*/
|
|
147
194
|
export async function sendTg(chatId, text, threadId, replyMarkup, replyToMessageId) {
|
|
148
195
|
try {
|
|
149
196
|
const base = { chat_id: chatId, disable_web_page_preview: true };
|
|
150
197
|
if (threadId)
|
|
151
198
|
base.message_thread_id = Number(threadId);
|
|
152
|
-
if (replyMarkup)
|
|
153
|
-
base.reply_markup = replyMarkup;
|
|
154
199
|
if (replyToMessageId)
|
|
155
200
|
base.reply_to_message_id = Number(replyToMessageId);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
201
|
+
// Send one body with HTML first, then a stripped plain-text fallback so a
|
|
202
|
+
// chunk that split an HTML tag still lands legibly (mirrors editTg v0.27.3).
|
|
203
|
+
const sendOne = async (body, markup) => {
|
|
204
|
+
const params = { ...base, text: body, parse_mode: 'HTML' };
|
|
205
|
+
if (markup)
|
|
206
|
+
params.reply_markup = markup;
|
|
207
|
+
const res = await telegramApi('sendMessage', params);
|
|
208
|
+
if (res.ok)
|
|
209
|
+
return res;
|
|
210
|
+
const fb = { ...base, text: stripHtml(body) || 'Session update' };
|
|
211
|
+
if (markup)
|
|
212
|
+
fb.reply_markup = markup;
|
|
213
|
+
return telegramApi('sendMessage', fb);
|
|
214
|
+
};
|
|
215
|
+
if (text.length > TG_MAX_CHARS) {
|
|
216
|
+
const chunks = splitForTelegram(text, TG_MAX_CHARS);
|
|
217
|
+
let first = { ok: false };
|
|
218
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
219
|
+
// Keyboard rides only the LAST chunk (actions belong with the tail).
|
|
220
|
+
const res = await sendOne(chunks[i], i === chunks.length - 1 ? replyMarkup : undefined);
|
|
221
|
+
if (i === 0)
|
|
222
|
+
first = res;
|
|
223
|
+
}
|
|
224
|
+
return first;
|
|
225
|
+
}
|
|
226
|
+
return await sendOne(text, replyMarkup);
|
|
160
227
|
}
|
|
161
228
|
catch {
|
|
162
229
|
return { ok: false };
|
|
163
230
|
}
|
|
164
231
|
}
|
|
165
232
|
/**
|
|
166
|
-
* editMessageText with
|
|
167
|
-
* plain-text fallback.
|
|
233
|
+
* editMessageText with HTML parse_mode + 429 retry-after handling +
|
|
234
|
+
* plain-text fallback (v0.27.3 stripHtml fallback on rejection).
|
|
168
235
|
*/
|
|
169
236
|
export async function editTg(chatId, messageId, text, replyMarkup) {
|
|
170
237
|
try {
|
|
@@ -188,17 +255,30 @@ export async function editTg(chatId, messageId, text, replyMarkup) {
|
|
|
188
255
|
}
|
|
189
256
|
if (res.ok)
|
|
190
257
|
return res;
|
|
258
|
+
// v0.27.3 — the HTML edit was rejected (commonly "can't parse
|
|
259
|
+
// entities" or "message is too long"). Re-sending the SAME string without
|
|
260
|
+
// parse_mode would dump literal <b>/<pre> tags into the chat AND, if the
|
|
261
|
+
// reason was length, fail identically. Strip the markup back to plain text
|
|
262
|
+
// and hard-truncate to Telegram's cap so the fallback can actually land.
|
|
263
|
+
// Log the original failure so a broken live card is never silent again
|
|
264
|
+
// (the prior swallow is exactly why the 4096-overflow regression hid).
|
|
265
|
+
process.stderr.write(`[cc-openclaw/telegram-bot-api] editTg HTML edit rejected (len=${text.length}) ` +
|
|
266
|
+
`code=${res.error_code ?? '?'} desc=${JSON.stringify(res.description ?? '')} — plain-text fallback\n`);
|
|
267
|
+
let plain = stripHtml(text) || 'Session update';
|
|
268
|
+
if (plain.length > TG_MAX_CHARS)
|
|
269
|
+
plain = plain.slice(0, TG_MAX_CHARS - 1) + '…';
|
|
191
270
|
const fallback = {
|
|
192
271
|
chat_id: chatId,
|
|
193
272
|
message_id: messageId,
|
|
194
|
-
text:
|
|
273
|
+
text: plain,
|
|
195
274
|
disable_web_page_preview: true,
|
|
196
275
|
};
|
|
197
276
|
if (replyMarkup)
|
|
198
277
|
fallback.reply_markup = replyMarkup;
|
|
199
278
|
return telegramApi('editMessageText', fallback);
|
|
200
279
|
}
|
|
201
|
-
catch {
|
|
280
|
+
catch (err) {
|
|
281
|
+
process.stderr.write(`[cc-openclaw/telegram-bot-api] editTg threw: ${err.message}\n`);
|
|
202
282
|
return { ok: false };
|
|
203
283
|
}
|
|
204
284
|
}
|
|
@@ -10,7 +10,7 @@ import * as path from 'node:path';
|
|
|
10
10
|
import * as os from 'node:os';
|
|
11
11
|
import { randomUUID } from 'node:crypto';
|
|
12
12
|
import { resolveEngineAndModel } from '../models.js';
|
|
13
|
-
import { OPENAI_COMPAT_DEFAULT_MODEL, OPENAI_COMPAT_AUTO_COMPACT_THRESHOLD, } from '../constants.js';
|
|
13
|
+
import { OPENAI_COMPAT_DEFAULT_MODEL, OPENAI_COMPAT_AUTO_COMPACT_THRESHOLD, RESUME_FRESHNESS_MS, } from '../constants.js';
|
|
14
14
|
import { isToolsPerMessageModeEnabled, isToolStreamMode } from './mode-flags.js';
|
|
15
15
|
import { resolveSessionKey, sessionNameFromKey } from './session-key-resolver.js';
|
|
16
16
|
import { buildSessionSystemPrompt, buildToolPromptBlock } from './prompts.js';
|
|
@@ -272,6 +272,17 @@ export async function handleChatCompletion(manager, body, headers, res) {
|
|
|
272
272
|
// Note: noSessionPersistence (--no-session-persistence) is NOT set
|
|
273
273
|
// because some CLI forks don't support this flag.
|
|
274
274
|
skipPersistence: true,
|
|
275
|
+
// v0.27.4 (M4/M6): opt this session into freshness-windowed --resume so a
|
|
276
|
+
// gateway restart or stalled-session watchdog SIGTERM doesn't wipe the
|
|
277
|
+
// conversation. Persists the claudeSessionId (despite skipPersistence) and
|
|
278
|
+
// the next turn for this chat resumes it iff it was active within the
|
|
279
|
+
// window — older sessions still start fresh (anti-stale). Env override
|
|
280
|
+
// CC_OPENCLAW_RESUME_FRESHNESS_MS; default RESUME_FRESHNESS_MS (30 min).
|
|
281
|
+
resumeFreshnessMs: (() => {
|
|
282
|
+
const v = process.env.CC_OPENCLAW_RESUME_FRESHNESS_MS;
|
|
283
|
+
const n = v !== undefined ? parseInt(v, 10) : NaN;
|
|
284
|
+
return Number.isFinite(n) && n > 0 ? n : RESUME_FRESHNESS_MS;
|
|
285
|
+
})(),
|
|
275
286
|
// v0.7.4 EMERGENCY RESTORE: re-enable --include-partial-messages for
|
|
276
287
|
// openai-compat sessions. v0.6.0 made this opt-in (default OFF) for
|
|
277
288
|
// a 10-100× JSON overhead drop, but the engine never grew the
|
|
@@ -43,7 +43,7 @@ import { emit as emitTrajectory, emitTurnTrace } from '../lib/trajectory.js';
|
|
|
43
43
|
import { formatError, ERROR_CODES } from '../lib/error-formatter.js';
|
|
44
44
|
import { getSurfaceThinkingEnabled, getTtsAutoMode } from '../lib/config.js';
|
|
45
45
|
import { applyVoiceRecovery, detectVoiceIntent, hasTtsMarkers, _logVoiceDebug } from './voice-recovery.js';
|
|
46
|
-
import { pushToolUse as mirrorPushToolUse, pushToolResult as mirrorPushToolResult, pushAssistantText as mirrorPushAssistantText, finalizeActiveCards as mirrorFinalizeActiveCards, failActiveCards as mirrorFailActiveCards, classifyFailure, setCardMeta as mirrorSetCardMeta, readQuotaMeta as mirrorReadQuotaMeta, } from '../channels/telegram-mirror/turn-bridge.js';
|
|
46
|
+
import { pushToolUse as mirrorPushToolUse, pushToolResult as mirrorPushToolResult, pushAssistantText as mirrorPushAssistantText, pushThinking as mirrorPushThinking, finalizeActiveCards as mirrorFinalizeActiveCards, failActiveCards as mirrorFailActiveCards, classifyFailure, setCardMeta as mirrorSetCardMeta, readQuotaMeta as mirrorReadQuotaMeta, } from '../channels/telegram-mirror/turn-bridge.js';
|
|
47
47
|
import { cardStateDebug as mirrorCardStateDebug } from '../channels/telegram-mirror/card-state.js';
|
|
48
48
|
import { writePerfEvent } from '../observability/perf-telemetry.js';
|
|
49
49
|
/** Coerce a userMessage (string | UserMessageBlock[]) to a flat string
|
|
@@ -327,6 +327,12 @@ slashCommand) {
|
|
|
327
327
|
if (!text)
|
|
328
328
|
return;
|
|
329
329
|
thinkingBuffer += text;
|
|
330
|
+
// v0.27.4 M1 — surface thinking on the live Telegram card too (gap
|
|
331
|
+
// #1). Pass the cumulative buffer; pushThinking overwrites
|
|
332
|
+
// turn.thinkingText so the 💭 block grows in place. Same surfacing
|
|
333
|
+
// gate as the SSE reasoning emit below (this callback only exists
|
|
334
|
+
// when surfaceThinking is on).
|
|
335
|
+
mirrorPushThinking(thinkingBuffer);
|
|
330
336
|
const chunk = {
|
|
331
337
|
id: completionId,
|
|
332
338
|
object: 'chat.completion.chunk',
|
|
@@ -21,6 +21,17 @@ export interface PersistedSession {
|
|
|
21
21
|
lastResumed: string;
|
|
22
22
|
lastActivity: number;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* v0.27.4 (M4/M6) — resume-freshness gate. A persisted Claude session is
|
|
26
|
+
* eligible for --resume only if its last activity is within `freshnessMs`.
|
|
27
|
+
* This restores cross-restart / post-watchdog-kill conversation continuity for
|
|
28
|
+
* openai-compat sessions WITHOUT reintroducing the stale-resume hazard that
|
|
29
|
+
* motivated skipPersistence: a session idle longer than the window starts
|
|
30
|
+
* fresh. Returns false for a missing entry, a non-numeric lastActivity, or a
|
|
31
|
+
* non-positive/non-finite window (resume disabled). Pure + side-effect-free so
|
|
32
|
+
* the decision is unit-testable independent of the disk layer.
|
|
33
|
+
*/
|
|
34
|
+
export declare function isPersistedSessionFresh(persisted: Pick<PersistedSession, 'lastActivity'> | undefined, now: number, freshnessMs: number): boolean;
|
|
24
35
|
export declare function loadPersistedSessions(): Map<string, PersistedSession>;
|
|
25
36
|
export declare function savePersistedSessions(sessions: Map<string, PersistedSession>, logger?: Logger): void;
|
|
26
37
|
export declare function savePersistedSessionsAsync(sessions: Map<string, PersistedSession>, logger?: Logger): void;
|
|
@@ -14,6 +14,23 @@ import { createConsoleLogger } from '../logger.js';
|
|
|
14
14
|
import { PERSIST_DISK_TTL_MS } from '../constants.js';
|
|
15
15
|
export const PERSIST_DIR = path.join(os.homedir(), '.openclaw');
|
|
16
16
|
export const PERSIST_FILE = path.join(PERSIST_DIR, 'claude-sessions.json');
|
|
17
|
+
/**
|
|
18
|
+
* v0.27.4 (M4/M6) — resume-freshness gate. A persisted Claude session is
|
|
19
|
+
* eligible for --resume only if its last activity is within `freshnessMs`.
|
|
20
|
+
* This restores cross-restart / post-watchdog-kill conversation continuity for
|
|
21
|
+
* openai-compat sessions WITHOUT reintroducing the stale-resume hazard that
|
|
22
|
+
* motivated skipPersistence: a session idle longer than the window starts
|
|
23
|
+
* fresh. Returns false for a missing entry, a non-numeric lastActivity, or a
|
|
24
|
+
* non-positive/non-finite window (resume disabled). Pure + side-effect-free so
|
|
25
|
+
* the decision is unit-testable independent of the disk layer.
|
|
26
|
+
*/
|
|
27
|
+
export function isPersistedSessionFresh(persisted, now, freshnessMs) {
|
|
28
|
+
if (!persisted || typeof persisted.lastActivity !== 'number')
|
|
29
|
+
return false;
|
|
30
|
+
if (!Number.isFinite(freshnessMs) || freshnessMs <= 0)
|
|
31
|
+
return false;
|
|
32
|
+
return now - persisted.lastActivity <= freshnessMs;
|
|
33
|
+
}
|
|
17
34
|
export function loadPersistedSessions() {
|
|
18
35
|
try {
|
|
19
36
|
if (!fs.existsSync(PERSIST_FILE))
|
|
@@ -33,7 +33,7 @@ function getPluginVersion() {
|
|
|
33
33
|
// ─── Persistence ─────────────────────────────────────────────────────────────
|
|
34
34
|
// Extracted to `./persisted-sessions.ts` 2026-05-13 — coherent persistence
|
|
35
35
|
// layer (load + sync atomic-write + async-write + types + constants).
|
|
36
|
-
import { loadPersistedSessions, savePersistedSessions, savePersistedSessionsAsync, } from './persisted-sessions.js';
|
|
36
|
+
import { loadPersistedSessions, savePersistedSessions, savePersistedSessionsAsync, isPersistedSessionFresh, } from './persisted-sessions.js';
|
|
37
37
|
// Debounce helper — coalesces rapid writes into one
|
|
38
38
|
// `makeDebounced` extracted to `../lib/debounce.ts` 2026-05-13 —
|
|
39
39
|
// pure-function hot-path decomposition.
|
|
@@ -158,10 +158,21 @@ export class SessionManager {
|
|
|
158
158
|
}
|
|
159
159
|
this._recordSpawn();
|
|
160
160
|
// Auto-resume: if we have a persisted claudeSessionId for this name, inject it.
|
|
161
|
-
//
|
|
162
|
-
//
|
|
161
|
+
// Normal (non-skipPersistence) sessions resume unconditionally as before.
|
|
162
|
+
// skipPersistence sessions normally must NOT resume stale CLI state from a
|
|
163
|
+
// previous server run — EXCEPT v0.27.4 (M4/M6): when they opt into
|
|
164
|
+
// freshness-windowed resume (config.resumeFreshnessMs, set by the
|
|
165
|
+
// openai-compat bridge), resume the prior session iff it's still fresh, so
|
|
166
|
+
// Savvy keeps context across a gateway restart / watchdog-kill while a
|
|
167
|
+
// long-idle session still starts fresh.
|
|
163
168
|
const skipPersist = !!config.skipPersistence;
|
|
164
|
-
|
|
169
|
+
let persisted = skipPersist ? undefined : this.persistedSessions.get(name);
|
|
170
|
+
if (skipPersist && typeof config.resumeFreshnessMs === 'number') {
|
|
171
|
+
const candidate = this.persistedSessions.get(name);
|
|
172
|
+
if (isPersistedSessionFresh(candidate, Date.now(), config.resumeFreshnessMs)) {
|
|
173
|
+
persisted = candidate;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
165
176
|
// Unified: only use resumeSessionId (claudeResumeId is an internal alias, not exposed)
|
|
166
177
|
const resumeId = config.resumeSessionId ?? persisted?.claudeSessionId;
|
|
167
178
|
const fullConfig = {
|
|
@@ -349,10 +360,15 @@ export class SessionManager {
|
|
|
349
360
|
}
|
|
350
361
|
const result = await managed.session.send(message, sendOpts);
|
|
351
362
|
// Update session ID if available (skip disk persist for ephemeral
|
|
352
|
-
// sessions that were started with skipPersistence)
|
|
363
|
+
// sessions that were started with skipPersistence) — EXCEPT v0.27.4
|
|
364
|
+
// (M4/M6): a session that opted into freshness-windowed resume must be
|
|
365
|
+
// persisted (even though skipPersistence is true) so its claudeSessionId
|
|
366
|
+
// is on disk for the next turn / a post-restart resume.
|
|
353
367
|
if (managed.session.sessionId) {
|
|
354
368
|
managed.claudeSessionId = managed.session.sessionId;
|
|
355
|
-
|
|
369
|
+
const optedIntoFreshResume = typeof managed.config.resumeFreshnessMs === 'number' &&
|
|
370
|
+
managed.config.resumeFreshnessMs > 0;
|
|
371
|
+
if (this.persistedSessions.has(name) || optedIntoFreshResume) {
|
|
356
372
|
this._persistSession(name, managed);
|
|
357
373
|
}
|
|
358
374
|
}
|
|
@@ -84,8 +84,6 @@ const PATHS = {
|
|
|
84
84
|
const DEPS_STUB_PATH = join(PATHS.openclawDist, 'commands-status-deps.runtime.js');
|
|
85
85
|
const STATUS_STUB_PATH = join(PATHS.openclawRoot, 'status.runtime.js');
|
|
86
86
|
const AUTO_REPLY_STATUS_PATH = join(PATHS.autoReplyDir, 'commands-status.runtime.js');
|
|
87
|
-
const SAVVY_REGISTRY_PATH = join(HOME, '.openclaw/savvy-resume-registry.json');
|
|
88
|
-
const CLAUDE_SESSIONS_PATH = join(HOME, '.openclaw/claude-sessions.json');
|
|
89
87
|
const CACHE_PARITY_REGISTRY_PATH = join(HOME, '.openclaw/openclaw-cache-parity-registry.json');
|
|
90
88
|
// Patch identity symbols (module-scoped, stable across re-imports within a process)
|
|
91
89
|
const PATCH_MARKER = Symbol.for('claude-local-enhancer:patched');
|
|
@@ -166,6 +164,7 @@ function _setToolDumpCacheEntry(key, val) {
|
|
|
166
164
|
let _lastToolDumpHash = null;
|
|
167
165
|
// ── sessionId capture state ───────────────────────────────────────────────
|
|
168
166
|
let _lastCapturedJson = '';
|
|
167
|
+
// ── Resume registry helpers ───────────────────────────────────────────────
|
|
169
168
|
// `restoreClaudeSessionsFromBackup` + `writeBackupRegistry` extracted to
|
|
170
169
|
// `./resume-registry.ts` 2026-05-13. The wrapper preserves the caller-less
|
|
171
170
|
// call signature locally by closing over the module `logger`.
|
package/dist/src/types.d.ts
CHANGED
|
@@ -137,6 +137,13 @@ export interface SessionConfig {
|
|
|
137
137
|
sessionName?: string;
|
|
138
138
|
claudeResumeId?: string;
|
|
139
139
|
resumeSessionId?: string;
|
|
140
|
+
/** v0.27.4 (M4/M6) — opt a skipPersistence session into freshness-windowed
|
|
141
|
+
* --resume across restart / watchdog-kill. When set (ms), the SessionManager
|
|
142
|
+
* persists this session's claudeSessionId and, on the next start for the same
|
|
143
|
+
* name, resumes it iff its last activity is within this window. Used by the
|
|
144
|
+
* openai-compat bridge so Savvy keeps context across a gateway restart while
|
|
145
|
+
* still starting fresh after a long idle gap. */
|
|
146
|
+
resumeFreshnessMs?: number;
|
|
140
147
|
forkSession?: boolean;
|
|
141
148
|
addDir?: string[];
|
|
142
149
|
effort?: EffortLevel;
|