@a1hvdy/cc-openclaw 0.27.1 → 0.27.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/channels/telegram-mirror/askuser.js +2 -0
- package/dist/src/channels/telegram-mirror/card-renderer.d.ts +1 -22
- package/dist/src/channels/telegram-mirror/card-renderer.js +172 -20
- package/dist/src/channels/telegram-mirror/commands.d.ts +16 -0
- package/dist/src/channels/telegram-mirror/commands.js +53 -12
- package/dist/src/channels/telegram-mirror/inbound-handler.d.ts +18 -0
- package/dist/src/channels/telegram-mirror/inbound-handler.js +21 -8
- package/dist/src/channels/telegram-mirror/turn-bridge.d.ts +11 -0
- package/dist/src/channels/telegram-mirror/turn-bridge.js +31 -0
- package/dist/src/constants.d.ts +10 -0
- package/dist/src/constants.js +10 -0
- package/dist/src/lib/error-formatter.d.ts +14 -2
- package/dist/src/lib/error-formatter.js +23 -11
- package/dist/src/lib/error-renderer.js +3 -1
- package/dist/src/lib/html-render.d.ts +8 -16
- package/dist/src/lib/html-render.js +91 -1
- package/dist/src/lib/markdown-to-mdv2.js +2 -1
- package/dist/src/lib/probes.d.ts +50 -0
- package/dist/src/lib/probes.js +96 -0
- package/dist/src/lib/telegram-bot-api.d.ts +52 -6
- package/dist/src/lib/telegram-bot-api.js +180 -13
- package/dist/src/openai-compat/message-extractor.js +4 -0
- package/dist/src/openai-compat/openai-compat.js +12 -1
- package/dist/src/openai-compat/streaming-handler.js +7 -1
- package/dist/src/session/persisted-sessions.d.ts +11 -0
- package/dist/src/session/persisted-sessions.js +17 -0
- package/dist/src/session/session-manager.js +22 -6
- package/dist/src/session-bootstrap/cwd-patch.js +1 -2
- package/dist/src/types.d.ts +7 -0
- package/package.json +1 -1
|
@@ -27,6 +27,10 @@ import { request as httpsRequest } from 'node:https';
|
|
|
27
27
|
import { readFileSync } from 'node:fs';
|
|
28
28
|
import { homedir } from 'node:os';
|
|
29
29
|
import { join } from 'node:path';
|
|
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;
|
|
30
34
|
export const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
|
31
35
|
const PLUGIN_TAG = '[cc-openclaw/telegram-bot-api]';
|
|
32
36
|
// ─── Bot token state ───────────────────────────────────────────────────────
|
|
@@ -138,32 +142,96 @@ export function telegramApi(method, params) {
|
|
|
138
142
|
});
|
|
139
143
|
}
|
|
140
144
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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).
|
|
145
193
|
*/
|
|
146
194
|
export async function sendTg(chatId, text, threadId, replyMarkup, replyToMessageId) {
|
|
147
195
|
try {
|
|
148
196
|
const base = { chat_id: chatId, disable_web_page_preview: true };
|
|
149
197
|
if (threadId)
|
|
150
198
|
base.message_thread_id = Number(threadId);
|
|
151
|
-
if (replyMarkup)
|
|
152
|
-
base.reply_markup = replyMarkup;
|
|
153
199
|
if (replyToMessageId)
|
|
154
200
|
base.reply_to_message_id = Number(replyToMessageId);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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);
|
|
159
227
|
}
|
|
160
228
|
catch {
|
|
161
229
|
return { ok: false };
|
|
162
230
|
}
|
|
163
231
|
}
|
|
164
232
|
/**
|
|
165
|
-
* editMessageText with
|
|
166
|
-
* plain-text fallback.
|
|
233
|
+
* editMessageText with HTML parse_mode + 429 retry-after handling +
|
|
234
|
+
* plain-text fallback (v0.27.3 stripHtml fallback on rejection).
|
|
167
235
|
*/
|
|
168
236
|
export async function editTg(chatId, messageId, text, replyMarkup) {
|
|
169
237
|
try {
|
|
@@ -187,16 +255,115 @@ export async function editTg(chatId, messageId, text, replyMarkup) {
|
|
|
187
255
|
}
|
|
188
256
|
if (res.ok)
|
|
189
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) + '…';
|
|
190
270
|
const fallback = {
|
|
191
271
|
chat_id: chatId,
|
|
192
272
|
message_id: messageId,
|
|
193
|
-
text:
|
|
273
|
+
text: plain,
|
|
194
274
|
disable_web_page_preview: true,
|
|
195
275
|
};
|
|
196
276
|
if (replyMarkup)
|
|
197
277
|
fallback.reply_markup = replyMarkup;
|
|
198
278
|
return telegramApi('editMessageText', fallback);
|
|
199
279
|
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
process.stderr.write(`[cc-openclaw/telegram-bot-api] editTg threw: ${err.message}\n`);
|
|
282
|
+
return { ok: false };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Build a multipart/form-data body for sendDocument. PURE — no I/O — so the
|
|
287
|
+
* encoding (the R-3 risk) is unit-testable without a network round-trip.
|
|
288
|
+
*
|
|
289
|
+
* The document is sent as an inline InputFile (Content-Type text/markdown). The
|
|
290
|
+
* boundary MUST NOT appear in any field value or the file content; callers use a
|
|
291
|
+
* random 16-byte boundary (sendDocumentTg) so collision is astronomically
|
|
292
|
+
* unlikely against Markdown plan bodies.
|
|
293
|
+
*/
|
|
294
|
+
export function buildDocumentMultipart(opts) {
|
|
295
|
+
const { boundary } = opts;
|
|
296
|
+
const parts = [];
|
|
297
|
+
const textField = (name, value) => {
|
|
298
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`, 'utf8'));
|
|
299
|
+
};
|
|
300
|
+
textField('chat_id', String(opts.chatId));
|
|
301
|
+
if (opts.caption)
|
|
302
|
+
textField('caption', opts.caption);
|
|
303
|
+
if (opts.parseMode)
|
|
304
|
+
textField('parse_mode', opts.parseMode);
|
|
305
|
+
if (opts.threadId !== undefined)
|
|
306
|
+
textField('message_thread_id', String(opts.threadId));
|
|
307
|
+
if (opts.replyMarkup)
|
|
308
|
+
textField('reply_markup', JSON.stringify(opts.replyMarkup));
|
|
309
|
+
// The document file part — header, then raw content, then CRLF.
|
|
310
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="document"; filename="${opts.filename}"\r\nContent-Type: text/markdown\r\n\r\n`, 'utf8'));
|
|
311
|
+
parts.push(Buffer.from(opts.content, 'utf8'));
|
|
312
|
+
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8'));
|
|
313
|
+
return Buffer.concat(parts);
|
|
314
|
+
}
|
|
315
|
+
/** Low-level multipart POST. Mirrors `telegramApi` but sets a multipart
|
|
316
|
+
* Content-Type + a Buffer body. */
|
|
317
|
+
function telegramApiMultipart(method, boundary, body) {
|
|
318
|
+
return new Promise((resolve, reject) => {
|
|
319
|
+
const options = {
|
|
320
|
+
hostname: 'api.telegram.org',
|
|
321
|
+
path: `/bot${_botToken}/${method}`,
|
|
322
|
+
method: 'POST',
|
|
323
|
+
headers: {
|
|
324
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
325
|
+
'Content-Length': body.length,
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
const req = httpsRequest(options, (res) => {
|
|
329
|
+
let data = '';
|
|
330
|
+
res.on('data', (chunk) => (data += chunk));
|
|
331
|
+
res.on('end', () => {
|
|
332
|
+
try {
|
|
333
|
+
resolve(JSON.parse(data));
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
resolve({ ok: false, description: 'JSON parse error' });
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
req.on('error', (err) => reject(err));
|
|
341
|
+
req.setTimeout(15_000, () => {
|
|
342
|
+
req.destroy(new Error('Telegram API timeout'));
|
|
343
|
+
});
|
|
344
|
+
req.write(body);
|
|
345
|
+
req.end();
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Upload a text document (e.g. a plan .md) to a chat via sendDocument. Returns
|
|
350
|
+
* the API response, or {ok:false} on network/encoding failure (never throws).
|
|
351
|
+
*/
|
|
352
|
+
export async function sendDocumentTg(chatId, filename, content, opts = {}) {
|
|
353
|
+
try {
|
|
354
|
+
const boundary = `----ccopenclaw${randomBytes(16).toString('hex')}`;
|
|
355
|
+
const body = buildDocumentMultipart({
|
|
356
|
+
boundary,
|
|
357
|
+
chatId,
|
|
358
|
+
filename,
|
|
359
|
+
content,
|
|
360
|
+
caption: opts.caption,
|
|
361
|
+
parseMode: opts.parseMode,
|
|
362
|
+
threadId: opts.threadId,
|
|
363
|
+
replyMarkup: opts.replyMarkup,
|
|
364
|
+
});
|
|
365
|
+
return await telegramApiMultipart('sendDocument', boundary, body);
|
|
366
|
+
}
|
|
200
367
|
catch {
|
|
201
368
|
return { ok: false };
|
|
202
369
|
}
|
|
@@ -32,6 +32,7 @@ import { serializeToolResults, serializeToolResultsAsBlocks, } from './tool-resu
|
|
|
32
32
|
import { isToolStreamMode } from './mode-flags.js';
|
|
33
33
|
import { detectSlashCommand, maybeInlineSkill } from './skill-resolver.js';
|
|
34
34
|
import { isOpenaiCompatNewConvoHeuristic } from '../lib/config.js';
|
|
35
|
+
import { probeMultimodalContent } from '../lib/probes.js';
|
|
35
36
|
/**
|
|
36
37
|
* Extract the relevant parts from an OpenAI messages array.
|
|
37
38
|
*
|
|
@@ -59,6 +60,9 @@ export function extractUserMessage(messages, headers) {
|
|
|
59
60
|
if (!messages || messages.length === 0) {
|
|
60
61
|
throw new Error('messages array is empty');
|
|
61
62
|
}
|
|
63
|
+
// P0-C openai-body probe (observe-only, gated): does an image block survive to
|
|
64
|
+
// the request body before textOf() below strips non-text parts? See lib/probes.ts.
|
|
65
|
+
probeMultimodalContent(messages);
|
|
62
66
|
// Normalize content from any message: OpenAI API allows content as a string
|
|
63
67
|
// OR an array of content parts (e.g. multimodal messages with text + images).
|
|
64
68
|
// We need a string for the CLI, so arrays are joined.
|
|
@@ -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;
|