@a1hvdy/cc-openclaw 0.27.13 → 0.30.0
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.d.ts +10 -0
- package/dist/src/channels/telegram-mirror/askuser.js +44 -0
- package/dist/src/channels/telegram-mirror/commands.d.ts +12 -0
- package/dist/src/channels/telegram-mirror/commands.js +51 -13
- package/dist/src/channels/telegram-mirror/turn-bridge.d.ts +10 -0
- package/dist/src/channels/telegram-mirror/turn-bridge.js +43 -14
- package/dist/src/command-router/cc-handler.d.ts +1 -0
- package/dist/src/command-router/cc-handler.js +7 -0
- package/dist/src/command-router/resume-policy.js +36 -0
- package/dist/src/lib/cache-parity-decide.d.ts +64 -0
- package/dist/src/lib/cache-parity-decide.js +54 -0
- package/dist/src/lib/cc-cli-scan.d.ts +52 -0
- package/dist/src/lib/cc-cli-scan.js +217 -0
- package/dist/src/lib/config.d.ts +21 -0
- package/dist/src/lib/config.js +23 -0
- package/dist/src/lib/index.d.ts +7 -0
- package/dist/src/lib/index.js +10 -0
- package/dist/src/observability/perf-telemetry.d.ts +1 -1
- package/dist/src/openai-compat/non-streaming-handler.js +23 -12
- package/dist/src/openai-compat/streaming-handler.js +25 -9
- package/dist/src/session-bootstrap/cwd-patch.js +61 -1
- package/package.json +1 -1
|
@@ -33,6 +33,16 @@ interface ParsedQuestion {
|
|
|
33
33
|
multiSelect: boolean;
|
|
34
34
|
options: QOption[];
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* v0.28.0 — build a single inline button that, when tapped, resumes a Claude
|
|
38
|
+
* Code session. Reuses the proven askuser callback path: the payload is stashed
|
|
39
|
+
* in the shared (globalThis-anchored) CallbackMap and the callback_data is
|
|
40
|
+
* `ccmirror:<id>`, so taps route through the same before_dispatch interceptor.
|
|
41
|
+
*/
|
|
42
|
+
export declare function buildResumeButton(uuid: string, label: string): {
|
|
43
|
+
text: string;
|
|
44
|
+
callback_data: string;
|
|
45
|
+
};
|
|
36
46
|
/** Minimal subset of the registerInteractiveHandler ctx we use. */
|
|
37
47
|
export interface InteractiveCtx {
|
|
38
48
|
callback: {
|
|
@@ -27,6 +27,19 @@ import { probeInjectionEnqueued } from '../../lib/probes.js';
|
|
|
27
27
|
/** Namespace prefix for callback_data so api.registerInteractiveHandler routes
|
|
28
28
|
* taps here. Matched at the first ':' by the gateway (must be [A-Za-z0-9._-]+). */
|
|
29
29
|
export const ASKUSER_NS = 'ccmirror';
|
|
30
|
+
function getResumeBridge() {
|
|
31
|
+
return globalThis[Symbol.for('cc-openclaw:resume-bridge')];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* v0.28.0 — build a single inline button that, when tapped, resumes a Claude
|
|
35
|
+
* Code session. Reuses the proven askuser callback path: the payload is stashed
|
|
36
|
+
* in the shared (globalThis-anchored) CallbackMap and the callback_data is
|
|
37
|
+
* `ccmirror:<id>`, so taps route through the same before_dispatch interceptor.
|
|
38
|
+
*/
|
|
39
|
+
export function buildResumeButton(uuid, label) {
|
|
40
|
+
const id = _cb.create({ kind: 'resume', uuid });
|
|
41
|
+
return { text: label, callback_data: `${ASKUSER_NS}:${id}` };
|
|
42
|
+
}
|
|
30
43
|
// v0.26.5 — ALL askuser state is globalThis-anchored. captureAskUserQuestion is
|
|
31
44
|
// reached via turn-bridge's module graph while handleTap is reached via the
|
|
32
45
|
// inbound-handler graph; those can be SEPARATE module instances in the same
|
|
@@ -218,6 +231,37 @@ export async function handleTap(ctx, api) {
|
|
|
218
231
|
await answerCb(ctx, 'This prompt expired.');
|
|
219
232
|
return;
|
|
220
233
|
}
|
|
234
|
+
// v0.28.0 — session-picker resume tap. Not tied to a question (no qid):
|
|
235
|
+
// resolve the chat from the tap ctx and fire the resume bridge immediately.
|
|
236
|
+
if (payload.kind === 'resume') {
|
|
237
|
+
await answerCb(ctx, 'Resuming…');
|
|
238
|
+
const conv = String(ctx.conversationId ?? (ctx.chatId ?? ''));
|
|
239
|
+
let chatPart = conv;
|
|
240
|
+
let threadId;
|
|
241
|
+
const ti = conv.indexOf(':topic:');
|
|
242
|
+
if (ti >= 0) {
|
|
243
|
+
chatPart = conv.slice(0, ti);
|
|
244
|
+
threadId = conv.slice(ti + ':topic:'.length);
|
|
245
|
+
}
|
|
246
|
+
if (!chatPart) {
|
|
247
|
+
process.stderr.write('[cc-openclaw/askuser] resume tap: no chat resolved\n');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const bridge = getResumeBridge();
|
|
251
|
+
if (!bridge) {
|
|
252
|
+
await sendTg(chatPart, `Resume bridge unavailable — type <code>/cc resume ${escapeHtml(payload.uuid)}</code>`, threadId);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const text = bridge(chatPart, threadId, payload.uuid);
|
|
257
|
+
await sendTg(chatPart, escapeHtml(text), threadId);
|
|
258
|
+
process.stderr.write(`[cc-openclaw/askuser] resume fired uuid=${payload.uuid} chat=${chatPart}\n`);
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
await sendTg(chatPart, `Resume failed: ${escapeHtml(err.message)}`, threadId);
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
221
265
|
const s = _questions.get(payload.qid);
|
|
222
266
|
if (!s) {
|
|
223
267
|
await answerCb(ctx, 'This prompt is no longer active.');
|
|
@@ -96,6 +96,18 @@ export interface ParsedSlash {
|
|
|
96
96
|
args: string[];
|
|
97
97
|
}
|
|
98
98
|
export declare function parseSlash(text: string): ParsedSlash | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* v0.28.0 — `/sessions` is now a `claude -r`-style picker over the REAL Claude
|
|
101
|
+
* Code sessions. It mirrors `~/.claude/projects/**` (the store every `claude`
|
|
102
|
+
* subprocess — terminal or cco-spawned — writes to), so one list spans both
|
|
103
|
+
* surfaces. Each row shows the session's own ai-title (name) + last-prompt
|
|
104
|
+
* (description); tapping resumes it immediately via the globalThis resume
|
|
105
|
+
* bridge. Buttons use the proven `ccmirror:` callback path (buildResumeButton).
|
|
106
|
+
*
|
|
107
|
+
* The previous registry-backed keyboard (buildSessionsKeyboard / enrichRows)
|
|
108
|
+
* is retired here: its "switch" callbacks were never wired (M4 stub), and the
|
|
109
|
+
* slug registry only ever saw cco-Telegram sessions, not terminal ones.
|
|
110
|
+
*/
|
|
99
111
|
export declare function handleSessions(ctx: CommandContext): CommandResult;
|
|
100
112
|
export declare function handleNew(ctx: CommandContext): CommandResult;
|
|
101
113
|
export declare function handleStop(ctx: CommandContext): CommandResult;
|
|
@@ -26,8 +26,12 @@
|
|
|
26
26
|
* • Plan attachment via sendDocument (M9).
|
|
27
27
|
*/
|
|
28
28
|
import { register, unregister, list, getBySlug, } from '../../lib/session-registry.js';
|
|
29
|
-
import {
|
|
29
|
+
import { formatLastActivity, } from './sessions-keyboard.js';
|
|
30
30
|
import { stubQuotaReader } from './quota-reader.js';
|
|
31
|
+
import { scanClaudeCliSessions } from '../../lib/cc-cli-scan.js';
|
|
32
|
+
import { buildResumeButton } from './askuser.js';
|
|
33
|
+
import { escapeHtml } from '../../lib/html-render.js';
|
|
34
|
+
import { basename } from 'node:path';
|
|
31
35
|
export function parseSlash(text) {
|
|
32
36
|
const trimmed = text.trim();
|
|
33
37
|
if (!trimmed.startsWith('/'))
|
|
@@ -48,25 +52,59 @@ function enrichRows(entries, stateLookup) {
|
|
|
48
52
|
lastUsedAt: e.lastUsedAt,
|
|
49
53
|
}));
|
|
50
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* v0.28.0 — `/sessions` is now a `claude -r`-style picker over the REAL Claude
|
|
57
|
+
* Code sessions. It mirrors `~/.claude/projects/**` (the store every `claude`
|
|
58
|
+
* subprocess — terminal or cco-spawned — writes to), so one list spans both
|
|
59
|
+
* surfaces. Each row shows the session's own ai-title (name) + last-prompt
|
|
60
|
+
* (description); tapping resumes it immediately via the globalThis resume
|
|
61
|
+
* bridge. Buttons use the proven `ccmirror:` callback path (buildResumeButton).
|
|
62
|
+
*
|
|
63
|
+
* The previous registry-backed keyboard (buildSessionsKeyboard / enrichRows)
|
|
64
|
+
* is retired here: its "switch" callbacks were never wired (M4 stub), and the
|
|
65
|
+
* slug registry only ever saw cco-Telegram sessions, not terminal ones.
|
|
66
|
+
*/
|
|
51
67
|
export function handleSessions(ctx) {
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
68
|
+
const now = ctx.now ?? Date.now();
|
|
69
|
+
const all = scanClaudeCliSessions();
|
|
70
|
+
const shown = all.slice(0, 8);
|
|
71
|
+
if (shown.length === 0) {
|
|
72
|
+
return {
|
|
73
|
+
actions: [
|
|
74
|
+
{
|
|
75
|
+
type: 'sendMessage',
|
|
76
|
+
chat_id: ctx.chatId,
|
|
77
|
+
text: 'No resumable Claude Code sessions in the last 7 days.',
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const trunc = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
83
|
+
const bodyLines = ['🔵 <b>Resumable Claude Code sessions</b> — tap to resume:', ''];
|
|
84
|
+
const rows = [];
|
|
85
|
+
shown.forEach((s, i) => {
|
|
86
|
+
const n = i + 1;
|
|
87
|
+
const title = trunc(s.title || `session ${s.uuid.slice(0, 8)}`, 60);
|
|
88
|
+
const desc = trunc(s.desc || '', 70);
|
|
89
|
+
const rel = formatLastActivity(new Date(s.mtimeMs).toISOString(), now);
|
|
90
|
+
const where = s.cwd ? basename(s.cwd) : '';
|
|
91
|
+
const metaLine = [rel, where].filter(Boolean).join(' · ');
|
|
92
|
+
bodyLines.push(desc
|
|
93
|
+
? `<b>${n}.</b> ${escapeHtml(title)} — <i>${escapeHtml(desc)}</i> <code>${escapeHtml(metaLine)}</code>`
|
|
94
|
+
: `<b>${n}.</b> ${escapeHtml(title)} <code>${escapeHtml(metaLine)}</code>`);
|
|
95
|
+
const btnLabel = `${n} · ${trunc(s.title || s.uuid.slice(0, 8), 28)} · ${rel}`;
|
|
96
|
+
rows.push([buildResumeButton(s.uuid, btnLabel)]);
|
|
59
97
|
});
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
98
|
+
if (all.length > shown.length) {
|
|
99
|
+
bodyLines.push('', `<i>+${all.length - shown.length} older — resume by id: /cc resume <uuid></i>`);
|
|
100
|
+
}
|
|
63
101
|
return {
|
|
64
102
|
actions: [
|
|
65
103
|
{
|
|
66
104
|
type: 'sendMessage',
|
|
67
105
|
chat_id: ctx.chatId,
|
|
68
|
-
text:
|
|
69
|
-
reply_markup: { inline_keyboard:
|
|
106
|
+
text: bodyLines.join('\n'),
|
|
107
|
+
reply_markup: { inline_keyboard: rows },
|
|
70
108
|
},
|
|
71
109
|
],
|
|
72
110
|
};
|
|
@@ -77,6 +77,16 @@ export declare function pushAssistantText(text: string): void;
|
|
|
77
77
|
* thinking-visibility setting rather than leaking reasoning unconditionally.
|
|
78
78
|
*/
|
|
79
79
|
export declare function pushThinking(text: string): void;
|
|
80
|
+
/**
|
|
81
|
+
* #4 (dual-surface seam) — extract just the model's `★ Insight ─...─` block from
|
|
82
|
+
* the full answer text, for display as the finalized card's takeaway when the
|
|
83
|
+
* card no longer mirrors the whole answer (CC_OPENCLAW_CARD_ANSWER_MIRROR off,
|
|
84
|
+
* the default). Returns the block verbatim (renderTurn preserves ★ Insight
|
|
85
|
+
* inline), or '' when the answer carries no insight block → a clean
|
|
86
|
+
* status/tools/✓ Done card with no answer text. Matches the model's emitted
|
|
87
|
+
* shape: a `★ Insight` opener through the next box-drawing divider line.
|
|
88
|
+
*/
|
|
89
|
+
export declare function extractInsightForCard(fullText: string): string;
|
|
80
90
|
/**
|
|
81
91
|
* Finalize every active card at the END of a model turn. Flips each card's
|
|
82
92
|
* turn to 'done' (so renderTurn shows "✓ Done"), repaints once, and removes
|
|
@@ -259,6 +259,31 @@ export function pushThinking(text) {
|
|
|
259
259
|
void repaint(chatId, /* force */ false);
|
|
260
260
|
}
|
|
261
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* #4 (dual-surface seam) — extract just the model's `★ Insight ─...─` block from
|
|
264
|
+
* the full answer text, for display as the finalized card's takeaway when the
|
|
265
|
+
* card no longer mirrors the whole answer (CC_OPENCLAW_CARD_ANSWER_MIRROR off,
|
|
266
|
+
* the default). Returns the block verbatim (renderTurn preserves ★ Insight
|
|
267
|
+
* inline), or '' when the answer carries no insight block → a clean
|
|
268
|
+
* status/tools/✓ Done card with no answer text. Matches the model's emitted
|
|
269
|
+
* shape: a `★ Insight` opener through the next box-drawing divider line.
|
|
270
|
+
*/
|
|
271
|
+
export function extractInsightForCard(fullText) {
|
|
272
|
+
if (!fullText)
|
|
273
|
+
return '';
|
|
274
|
+
const start = fullText.indexOf('★ Insight');
|
|
275
|
+
if (start === -1)
|
|
276
|
+
return '';
|
|
277
|
+
const lines = fullText.slice(start).split('\n');
|
|
278
|
+
const out = [];
|
|
279
|
+
for (let i = 0; i < lines.length; i++) {
|
|
280
|
+
out.push(lines[i]);
|
|
281
|
+
// Closing divider: a line of ≥5 box-drawing / hyphen dashes after the opener.
|
|
282
|
+
if (i > 0 && /^[─-]{5,}\s*$/.test(lines[i].trim()))
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
return out.join('\n').trim();
|
|
286
|
+
}
|
|
262
287
|
/**
|
|
263
288
|
* Finalize every active card at the END of a model turn. Flips each card's
|
|
264
289
|
* turn to 'done' (so renderTurn shows "✓ Done"), repaints once, and removes
|
|
@@ -284,21 +309,25 @@ export async function finalizeActiveCards(deliveredText) {
|
|
|
284
309
|
card.sm.end(chatId);
|
|
285
310
|
const turn = card.sm.getTurn(chatId);
|
|
286
311
|
if (turn) {
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
312
|
+
// #4 dual-surface seam — the FINALIZED card's answer text is whatever the
|
|
313
|
+
// caller hands in via `deliveredText`; this function no longer decides
|
|
314
|
+
// dedup policy, the openai-compat handlers do (they know the gateway's
|
|
315
|
+
// delivery state). The three cases the callers pass:
|
|
316
|
+
//
|
|
317
|
+
// • happy path, default (CC_OPENCLAW_CARD_ANSWER_MIRROR off): the caller
|
|
318
|
+
// passes ONLY the extracted ★ Insight block (extractInsightForCard).
|
|
319
|
+
// The card finalizes to status/tools/✓ Done + the short takeaway; the
|
|
320
|
+
// gateway's native draft message is the sole full-answer surface. No
|
|
321
|
+
// double-stream, no "answer vanishes then reappears" seam.
|
|
322
|
+
// • happy path, legacy (flag on): the caller passes '' — the old v0.26.4
|
|
323
|
+
// dedup, where the full answer streamed live on the card then blanked
|
|
324
|
+
// here so it wasn't duplicated by the gateway reply.
|
|
325
|
+
// • disconnect (v0.27.6 Killer #2): the gateway socket died mid-turn and
|
|
326
|
+
// delivers NOTHING separately, so the caller passes the FULL accumulated
|
|
327
|
+
// text and the card KEEPS the whole report as the sole delivery channel.
|
|
294
328
|
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
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).
|
|
329
|
+
// There is still no upstream suppressUserDelivery knob, so the card never
|
|
330
|
+
// tries to be the answer pane on the happy path. renderTurn is unchanged.
|
|
302
331
|
turn.assistantText = deliveredText ?? '';
|
|
303
332
|
try {
|
|
304
333
|
await editTg(chatId, card.messageId, renderTurn(turn, card.meta));
|
|
@@ -39,6 +39,7 @@ export interface CcCommand {
|
|
|
39
39
|
prompt?: string;
|
|
40
40
|
target?: string;
|
|
41
41
|
}
|
|
42
|
+
export type ResumeBridge = (chatId: string, threadId: string | undefined, uuid: string) => string;
|
|
42
43
|
/**
|
|
43
44
|
* Parse a raw input string into a CcCommand.
|
|
44
45
|
* Returns null if the input is not a /cc or /cc+ command.
|
|
@@ -38,6 +38,13 @@ const activeSessions = new Map();
|
|
|
38
38
|
function _deps() {
|
|
39
39
|
return { sessionManager: sessionManager, activeSessions, logger };
|
|
40
40
|
}
|
|
41
|
+
const RESUME_BRIDGE_KEY = Symbol.for('cc-openclaw:resume-bridge');
|
|
42
|
+
globalThis[RESUME_BRIDGE_KEY] = ((chatId, threadId, uuid) => {
|
|
43
|
+
if (!sessionManager) {
|
|
44
|
+
return 'Claude Code handler not ready — try again in a few seconds.';
|
|
45
|
+
}
|
|
46
|
+
return handleResume(_deps(), chatId, threadId, uuid).text;
|
|
47
|
+
});
|
|
41
48
|
// ── parseCcCommand — pure exported parser ─────────────────────────────────
|
|
42
49
|
/**
|
|
43
50
|
* Parse a raw input string into a CcCommand.
|
|
@@ -13,7 +13,38 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { DEFAULT_CWD, sessionMapKey, saveSession, loadSession, loadSessionById, scanAllSessions, IDLE_TIMEOUT_MS, ACK_TIMEOUT_MS, scheduleIdle, _postTurnContextCheck, } from './launch-policy.js';
|
|
15
15
|
import { sendDirectReply } from './turn-formatter.js';
|
|
16
|
+
import { findCliSession, isFullClaudeUuid } from '../lib/cc-cli-scan.js';
|
|
16
17
|
const PLUGIN_TAG = '[cc-openclaw/resume-policy]';
|
|
18
|
+
/**
|
|
19
|
+
* Synthesize a SessionMeta for a Claude Code CLI session that cc-openclaw never
|
|
20
|
+
* created itself (a terminal `claude` session, or one started before this build).
|
|
21
|
+
* The transcript lives in `~/.claude/projects/<cwd>/<uuid>.jsonl`; we only need
|
|
22
|
+
* its uuid (the resumeSessionId) and the cwd it ran in. chatId is bound to the
|
|
23
|
+
* CURRENT chat so the resumed session belongs to whoever tapped/typed — this is
|
|
24
|
+
* the cross-surface bridge: start in the terminal, continue from Telegram.
|
|
25
|
+
*/
|
|
26
|
+
function synthCliMeta(uuid, chatId, threadId) {
|
|
27
|
+
const hit = findCliSession(uuid);
|
|
28
|
+
const short = uuid.replace(/-/g, '').slice(0, 8);
|
|
29
|
+
return {
|
|
30
|
+
id: short,
|
|
31
|
+
slug: `cli-${short}`,
|
|
32
|
+
sessionName: `cc-cli-${short}`,
|
|
33
|
+
chatId,
|
|
34
|
+
threadId,
|
|
35
|
+
senderId: '',
|
|
36
|
+
state: 'idle',
|
|
37
|
+
instruction: hit?.title || 'resumed CLI session',
|
|
38
|
+
turns: 0,
|
|
39
|
+
startedAt: new Date().toISOString(),
|
|
40
|
+
completedAt: null,
|
|
41
|
+
lastContinuedAt: null,
|
|
42
|
+
claudeSessionId: uuid.toLowerCase(),
|
|
43
|
+
cwd: hit?.cwd || DEFAULT_CWD,
|
|
44
|
+
output: null,
|
|
45
|
+
error: null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
17
48
|
// ── Continuation Handler ──────────────────────────────────────────────────
|
|
18
49
|
export function handleContinuation(deps, prompt, chatId, threadId) {
|
|
19
50
|
const { sessionManager, activeSessions, logger } = deps;
|
|
@@ -102,6 +133,11 @@ export function handleResume(deps, chatId, threadId, targetSlug) {
|
|
|
102
133
|
if (!meta) {
|
|
103
134
|
meta = loadSession(targetSlug);
|
|
104
135
|
}
|
|
136
|
+
// Mirrored Claude Code CLI session — not in cco's own store, but resumable
|
|
137
|
+
// straight from its transcript via `claude --resume <uuid>`.
|
|
138
|
+
if (!meta && isFullClaudeUuid(targetSlug)) {
|
|
139
|
+
meta = synthCliMeta(targetSlug, chatId, threadId);
|
|
140
|
+
}
|
|
105
141
|
if (!meta) {
|
|
106
142
|
return { handled: true, text: `Session "${targetSlug}" not found.` };
|
|
107
143
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressed cache-parity decision (v0.30.0).
|
|
3
|
+
*
|
|
4
|
+
* Problem (diagnosed 2026-05-23 from the live sysprompt-cost telemetry):
|
|
5
|
+
* cc-openclaw's cache-parity registry is keyed by sessionKey. The cache HIT
|
|
6
|
+
* path strips the role:system messages so the Claude CLI subprocess reuses its
|
|
7
|
+
* already-cached `--append-system-prompt` block; a MISS inlines the full ~7K
|
|
8
|
+
* system prompt into the user message (uncached). On the live box, 70% of all
|
|
9
|
+
* cache misses were `session_unknown` — the FIRST turn of a session, where the
|
|
10
|
+
* registry has no entry yet. Telegram conversations are short (median 1 turn /
|
|
11
|
+
* session, 23 of 35 single-turn), so cold-start dominated: the hit rate stalled
|
|
12
|
+
* at ~75% vs the terminal CLI's ~95%. The dynamic-envelope churn we originally
|
|
13
|
+
* set out to fix was only ~7.5% of turns.
|
|
14
|
+
*
|
|
15
|
+
* Key observation: every Savvy session shares the *identical* system prefix
|
|
16
|
+
* (same SOUL/USER/AGENTS/TOOLS/MEMORY + harness ⇒ same sysHash). So a brand-new
|
|
17
|
+
* session whose sysHash was already seen for some *other* session is a
|
|
18
|
+
* known-good prefix — its `--append-system-prompt` will be injected at
|
|
19
|
+
* startSession from the registry entry the route patch writes this same turn,
|
|
20
|
+
* so it is SAFE to strip the redundant inline and ride the cached path on
|
|
21
|
+
* turn 1 instead of re-billing the full prompt.
|
|
22
|
+
*
|
|
23
|
+
* Safety: the "warm-hash hit" only applies when the session is NEW (not yet in
|
|
24
|
+
* the SessionManager) — that guarantees startSession runs and appends the
|
|
25
|
+
* prompt. An EXISTING session missing its registry entry (e.g. registry wiped
|
|
26
|
+
* mid-life) keeps the legacy inline path so the model never loses its system
|
|
27
|
+
* prompt. A `hash_mismatch` (entry exists, different hash = genuine mid-session
|
|
28
|
+
* churn) also stays on the inline path: the CLI's append still holds the OLD
|
|
29
|
+
* prompt, so the new one must be delivered in-band.
|
|
30
|
+
*
|
|
31
|
+
* Pure + side-effect-free so the decision is unit-testable independent of the
|
|
32
|
+
* EmbeddedServer route closure (matches the codebase's pure-helper pattern:
|
|
33
|
+
* isPersistedSessionFresh, shouldWriteThroughResumeId).
|
|
34
|
+
*/
|
|
35
|
+
export type CacheParityAction = 'hit' | 'warm-hash-hit' | 'miss';
|
|
36
|
+
export interface CacheParityDecisionInput {
|
|
37
|
+
/** Registry entry for THIS sessionKey, if any. */
|
|
38
|
+
entry: {
|
|
39
|
+
hash: string;
|
|
40
|
+
} | undefined;
|
|
41
|
+
/** sha1 of the (stripped) system content for this turn. */
|
|
42
|
+
sysHash: string;
|
|
43
|
+
/** True if sysHash has been seen for ANY session this process (known-good prefix). */
|
|
44
|
+
knownHash: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* True if the SessionManager has no live session for this key yet. A new
|
|
47
|
+
* session guarantees startSession runs and injects appendSystemPrompt from
|
|
48
|
+
* the registry, which is what makes stripping the inline safe.
|
|
49
|
+
*/
|
|
50
|
+
sessionIsNew: boolean;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Decide how the route patch should treat the system prompt this turn.
|
|
54
|
+
*
|
|
55
|
+
* - 'hit' → registry entry matches this session: strip role:system,
|
|
56
|
+
* ride the already-cached append.
|
|
57
|
+
* - 'warm-hash-hit' → new session + known-good prefix: write the registry
|
|
58
|
+
* entry (so startSession appends it), strip role:system,
|
|
59
|
+
* ride the cached path. Closes the cold-start gap.
|
|
60
|
+
* - 'miss' → inline the system prompt into the user message (the safe
|
|
61
|
+
* legacy path): first-ever prefix, genuine churn, or an
|
|
62
|
+
* existing session that lost its registry entry.
|
|
63
|
+
*/
|
|
64
|
+
export declare function decideCacheParityAction(input: CacheParityDecisionInput): CacheParityAction;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressed cache-parity decision (v0.30.0).
|
|
3
|
+
*
|
|
4
|
+
* Problem (diagnosed 2026-05-23 from the live sysprompt-cost telemetry):
|
|
5
|
+
* cc-openclaw's cache-parity registry is keyed by sessionKey. The cache HIT
|
|
6
|
+
* path strips the role:system messages so the Claude CLI subprocess reuses its
|
|
7
|
+
* already-cached `--append-system-prompt` block; a MISS inlines the full ~7K
|
|
8
|
+
* system prompt into the user message (uncached). On the live box, 70% of all
|
|
9
|
+
* cache misses were `session_unknown` — the FIRST turn of a session, where the
|
|
10
|
+
* registry has no entry yet. Telegram conversations are short (median 1 turn /
|
|
11
|
+
* session, 23 of 35 single-turn), so cold-start dominated: the hit rate stalled
|
|
12
|
+
* at ~75% vs the terminal CLI's ~95%. The dynamic-envelope churn we originally
|
|
13
|
+
* set out to fix was only ~7.5% of turns.
|
|
14
|
+
*
|
|
15
|
+
* Key observation: every Savvy session shares the *identical* system prefix
|
|
16
|
+
* (same SOUL/USER/AGENTS/TOOLS/MEMORY + harness ⇒ same sysHash). So a brand-new
|
|
17
|
+
* session whose sysHash was already seen for some *other* session is a
|
|
18
|
+
* known-good prefix — its `--append-system-prompt` will be injected at
|
|
19
|
+
* startSession from the registry entry the route patch writes this same turn,
|
|
20
|
+
* so it is SAFE to strip the redundant inline and ride the cached path on
|
|
21
|
+
* turn 1 instead of re-billing the full prompt.
|
|
22
|
+
*
|
|
23
|
+
* Safety: the "warm-hash hit" only applies when the session is NEW (not yet in
|
|
24
|
+
* the SessionManager) — that guarantees startSession runs and appends the
|
|
25
|
+
* prompt. An EXISTING session missing its registry entry (e.g. registry wiped
|
|
26
|
+
* mid-life) keeps the legacy inline path so the model never loses its system
|
|
27
|
+
* prompt. A `hash_mismatch` (entry exists, different hash = genuine mid-session
|
|
28
|
+
* churn) also stays on the inline path: the CLI's append still holds the OLD
|
|
29
|
+
* prompt, so the new one must be delivered in-band.
|
|
30
|
+
*
|
|
31
|
+
* Pure + side-effect-free so the decision is unit-testable independent of the
|
|
32
|
+
* EmbeddedServer route closure (matches the codebase's pure-helper pattern:
|
|
33
|
+
* isPersistedSessionFresh, shouldWriteThroughResumeId).
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Decide how the route patch should treat the system prompt this turn.
|
|
37
|
+
*
|
|
38
|
+
* - 'hit' → registry entry matches this session: strip role:system,
|
|
39
|
+
* ride the already-cached append.
|
|
40
|
+
* - 'warm-hash-hit' → new session + known-good prefix: write the registry
|
|
41
|
+
* entry (so startSession appends it), strip role:system,
|
|
42
|
+
* ride the cached path. Closes the cold-start gap.
|
|
43
|
+
* - 'miss' → inline the system prompt into the user message (the safe
|
|
44
|
+
* legacy path): first-ever prefix, genuine churn, or an
|
|
45
|
+
* existing session that lost its registry entry.
|
|
46
|
+
*/
|
|
47
|
+
export function decideCacheParityAction(input) {
|
|
48
|
+
const { entry, sysHash, knownHash, sessionIsNew } = input;
|
|
49
|
+
if (entry && entry.hash === sysHash)
|
|
50
|
+
return 'hit';
|
|
51
|
+
if (!entry && knownHash && sessionIsNew)
|
|
52
|
+
return 'warm-hash-hit';
|
|
53
|
+
return 'miss';
|
|
54
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/lib/cc-cli-scan.ts — v0.28.0.
|
|
3
|
+
*
|
|
4
|
+
* Mirror (read-only) the actual Claude Code CLI sessions so they can be listed
|
|
5
|
+
* and resumed from Telegram, exactly like `claude -r` / `claude --resume`.
|
|
6
|
+
*
|
|
7
|
+
* Why "mirror, not migrate": cc-openclaw IS a `claude` subprocess wrapper, and
|
|
8
|
+
* both the terminal CLI and every cco-spawned session write their transcript to
|
|
9
|
+
* the SAME store — `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`. So
|
|
10
|
+
* `claude --resume <uuid>` already resumes any session in that tree regardless
|
|
11
|
+
* of who created it. The filesystem is the sync layer; copying into a parallel
|
|
12
|
+
* registry would only create drift. We read this tree at list-time, never write.
|
|
13
|
+
*
|
|
14
|
+
* Each jsonl already carries the metadata a picker needs:
|
|
15
|
+
* { "type":"ai-title", "aiTitle":"…", "sessionId":"<uuid>" } ← name
|
|
16
|
+
* { "type":"last-prompt", "lastPrompt":"…", "sessionId":"<uuid>" } ← description
|
|
17
|
+
* { "type":"user", "cwd":"/home/a1xai", … } ← resume cwd
|
|
18
|
+
* The filename (minus .jsonl) is the resumable sessionId (uuid).
|
|
19
|
+
*
|
|
20
|
+
* No fabrication: a session with no parseable title falls back to its first
|
|
21
|
+
* user message, then to the short uuid — never an invented label.
|
|
22
|
+
*/
|
|
23
|
+
export interface CliSession {
|
|
24
|
+
/** jsonl filename (minus .jsonl) === Claude session id. Pass to --resume. */
|
|
25
|
+
uuid: string;
|
|
26
|
+
/** ai-title if present, else first user message, else ''. */
|
|
27
|
+
title: string;
|
|
28
|
+
/** last-prompt if present, else first user message, else ''. */
|
|
29
|
+
desc: string;
|
|
30
|
+
/** cwd recorded in the transcript — required to resume the right session. */
|
|
31
|
+
cwd: string;
|
|
32
|
+
/** File mtime in ms — proxy for last activity. */
|
|
33
|
+
mtimeMs: number;
|
|
34
|
+
}
|
|
35
|
+
export interface ScanOpts {
|
|
36
|
+
/** Only sessions touched within this many ms (default 7 days). 0 = no limit. */
|
|
37
|
+
maxAgeMs?: number;
|
|
38
|
+
/** Only sessions whose recorded cwd matches exactly. */
|
|
39
|
+
cwdFilter?: string;
|
|
40
|
+
/** Cap the number of results (after sort-by-recency). */
|
|
41
|
+
limit?: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Scan `~/.claude/projects` for resumable Claude Code sessions, newest first.
|
|
45
|
+
* Best-effort and side-effect-free: any unreadable dir/file is skipped, never
|
|
46
|
+
* thrown. Returns [] when the store is absent.
|
|
47
|
+
*/
|
|
48
|
+
export declare function scanClaudeCliSessions(opts?: ScanOpts): CliSession[];
|
|
49
|
+
/** Look up a single CLI session by full uuid (case-insensitive). */
|
|
50
|
+
export declare function findCliSession(uuid: string): CliSession | undefined;
|
|
51
|
+
/** True if the string is a full Claude session uuid (not the 8-hex cco short id). */
|
|
52
|
+
export declare function isFullClaudeUuid(s: string): boolean;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/lib/cc-cli-scan.ts — v0.28.0.
|
|
3
|
+
*
|
|
4
|
+
* Mirror (read-only) the actual Claude Code CLI sessions so they can be listed
|
|
5
|
+
* and resumed from Telegram, exactly like `claude -r` / `claude --resume`.
|
|
6
|
+
*
|
|
7
|
+
* Why "mirror, not migrate": cc-openclaw IS a `claude` subprocess wrapper, and
|
|
8
|
+
* both the terminal CLI and every cco-spawned session write their transcript to
|
|
9
|
+
* the SAME store — `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`. So
|
|
10
|
+
* `claude --resume <uuid>` already resumes any session in that tree regardless
|
|
11
|
+
* of who created it. The filesystem is the sync layer; copying into a parallel
|
|
12
|
+
* registry would only create drift. We read this tree at list-time, never write.
|
|
13
|
+
*
|
|
14
|
+
* Each jsonl already carries the metadata a picker needs:
|
|
15
|
+
* { "type":"ai-title", "aiTitle":"…", "sessionId":"<uuid>" } ← name
|
|
16
|
+
* { "type":"last-prompt", "lastPrompt":"…", "sessionId":"<uuid>" } ← description
|
|
17
|
+
* { "type":"user", "cwd":"/home/a1xai", … } ← resume cwd
|
|
18
|
+
* The filename (minus .jsonl) is the resumable sessionId (uuid).
|
|
19
|
+
*
|
|
20
|
+
* No fabrication: a session with no parseable title falls back to its first
|
|
21
|
+
* user message, then to the short uuid — never an invented label.
|
|
22
|
+
*/
|
|
23
|
+
import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
/**
|
|
27
|
+
* Resolved at call time (not import) so tests can point the scan at a fixture
|
|
28
|
+
* tree via CC_OPENCLAW_CLAUDE_PROJECTS_DIR without re-importing the module.
|
|
29
|
+
*/
|
|
30
|
+
function claudeProjectsDir() {
|
|
31
|
+
return process.env.CC_OPENCLAW_CLAUDE_PROJECTS_DIR || join(homedir(), '.claude', 'projects');
|
|
32
|
+
}
|
|
33
|
+
/** 7-day window — matches cc-openclaw's session disk TTL. */
|
|
34
|
+
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
35
|
+
/** Skip deep-parsing transcripts larger than this (memory safety on the live box). */
|
|
36
|
+
const MAX_PARSE_BYTES = 8 * 1024 * 1024;
|
|
37
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
38
|
+
/** Pull the plain text out of a Claude transcript user-message content field. */
|
|
39
|
+
function userText(d) {
|
|
40
|
+
const msg = d.message;
|
|
41
|
+
const c = msg?.content;
|
|
42
|
+
if (typeof c === 'string')
|
|
43
|
+
return c;
|
|
44
|
+
if (Array.isArray(c)) {
|
|
45
|
+
return c
|
|
46
|
+
.map((b) => (b && typeof b.text === 'string' ? b.text : ''))
|
|
47
|
+
.join(' ')
|
|
48
|
+
.trim();
|
|
49
|
+
}
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* cc-openclaw runs `claude` headless, so its sessions get no ai-title / last-prompt
|
|
54
|
+
* (those are interactive-CLI features). For those, the only signal is the user
|
|
55
|
+
* messages — but OpenClaw wraps each turn in a metadata envelope. The real prompts
|
|
56
|
+
* live as "#<id> <date> <sender>: <text>" context lines plus a trailing bare
|
|
57
|
+
* message. Pull the first (opening topic) and last (current focus) real prompts;
|
|
58
|
+
* for a non-enveloped message (terminal session), the text itself is the prompt.
|
|
59
|
+
*/
|
|
60
|
+
/** True if a candidate line is envelope/JSON noise rather than a real prompt. */
|
|
61
|
+
function looksJunk(s) {
|
|
62
|
+
return (!s ||
|
|
63
|
+
/untrusted|metadata|inbound_meta|system-reminder/i.test(s) ||
|
|
64
|
+
/^[{}[\]"]/.test(s) || // JSON punctuation
|
|
65
|
+
/^"?[\w-]+"?\s*:/.test(s) // "key": value line
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
function parseEnvelope(raw) {
|
|
69
|
+
if (!raw)
|
|
70
|
+
return { first: '', last: '' };
|
|
71
|
+
const isEnvelope = /Conversation (info|context) \(untrusted/.test(raw) || raw.startsWith('<system');
|
|
72
|
+
if (!isEnvelope) {
|
|
73
|
+
const oneLine = raw.replace(/\s+/g, ' ').trim();
|
|
74
|
+
return { first: oneLine, last: oneLine };
|
|
75
|
+
}
|
|
76
|
+
const ctxLines = [...raw.matchAll(/^#\d+\s+.+?:\s+(.+)$/gm)]
|
|
77
|
+
.map((m) => m[1].trim())
|
|
78
|
+
.filter((l) => l && !looksJunk(l));
|
|
79
|
+
const afterFence = raw.split('```').pop() ?? '';
|
|
80
|
+
const trailing = afterFence
|
|
81
|
+
.split('\n')
|
|
82
|
+
.map((s) => s.trim())
|
|
83
|
+
.filter((l) => l && !/^#\d+/.test(l) && !l.startsWith('<') && !looksJunk(l))
|
|
84
|
+
.pop();
|
|
85
|
+
const first = ctxLines[0] || trailing || '';
|
|
86
|
+
const last = trailing || ctxLines[ctxLines.length - 1] || '';
|
|
87
|
+
return { first, last };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Extract title/desc/cwd from a single transcript. Cheap string pre-filter
|
|
91
|
+
* before JSON.parse keeps this fast even on large files. ai-title/last-prompt
|
|
92
|
+
* win when present (interactive sessions); otherwise we derive a name from the
|
|
93
|
+
* enveloped user prompts (headless cco sessions). cwd is taken from the first
|
|
94
|
+
* entry that records it.
|
|
95
|
+
*/
|
|
96
|
+
function extractMeta(filePath, sizeBytes) {
|
|
97
|
+
let title = '';
|
|
98
|
+
let desc = '';
|
|
99
|
+
let cwd = '';
|
|
100
|
+
let firstUser = '';
|
|
101
|
+
let lastUser = '';
|
|
102
|
+
if (sizeBytes > MAX_PARSE_BYTES)
|
|
103
|
+
return { title, desc, cwd };
|
|
104
|
+
let content;
|
|
105
|
+
try {
|
|
106
|
+
content = readFileSync(filePath, 'utf8');
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { title, desc, cwd };
|
|
110
|
+
}
|
|
111
|
+
for (const line of content.split('\n')) {
|
|
112
|
+
if (!line)
|
|
113
|
+
continue;
|
|
114
|
+
const hasTitle = line.includes('"ai-title"');
|
|
115
|
+
const hasPrompt = line.includes('"last-prompt"');
|
|
116
|
+
const needCwd = !cwd && line.includes('"cwd"');
|
|
117
|
+
const isUser = line.includes('"type":"user"');
|
|
118
|
+
if (!hasTitle && !hasPrompt && !needCwd && !isUser)
|
|
119
|
+
continue;
|
|
120
|
+
let d;
|
|
121
|
+
try {
|
|
122
|
+
d = JSON.parse(line);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (d.type === 'ai-title' && typeof d.aiTitle === 'string')
|
|
128
|
+
title = d.aiTitle;
|
|
129
|
+
else if (d.type === 'last-prompt' && typeof d.lastPrompt === 'string')
|
|
130
|
+
desc = d.lastPrompt;
|
|
131
|
+
if (!cwd && typeof d.cwd === 'string')
|
|
132
|
+
cwd = d.cwd;
|
|
133
|
+
if (d.type === 'user') {
|
|
134
|
+
const txt = userText(d);
|
|
135
|
+
if (txt) {
|
|
136
|
+
if (!firstUser)
|
|
137
|
+
firstUser = txt;
|
|
138
|
+
lastUser = txt;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const clean = (s) => s.replace(/\s+/g, ' ').trim();
|
|
143
|
+
const envFirst = parseEnvelope(firstUser);
|
|
144
|
+
const envLast = parseEnvelope(lastUser);
|
|
145
|
+
const finalTitle = clean(title || envFirst.first);
|
|
146
|
+
let finalDesc = clean(desc || envLast.last);
|
|
147
|
+
// Drop a description that's just noise or a duplicate of the title — the
|
|
148
|
+
// picker then shows the name alone rather than a redundant/garbage second line.
|
|
149
|
+
if (!finalDesc || looksJunk(finalDesc) || finalDesc === finalTitle)
|
|
150
|
+
finalDesc = '';
|
|
151
|
+
return { title: finalTitle, desc: finalDesc, cwd };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Scan `~/.claude/projects` for resumable Claude Code sessions, newest first.
|
|
155
|
+
* Best-effort and side-effect-free: any unreadable dir/file is skipped, never
|
|
156
|
+
* thrown. Returns [] when the store is absent.
|
|
157
|
+
*/
|
|
158
|
+
export function scanClaudeCliSessions(opts = {}) {
|
|
159
|
+
const maxAgeMs = opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
const results = [];
|
|
162
|
+
const baseDir = claudeProjectsDir();
|
|
163
|
+
if (!existsSync(baseDir))
|
|
164
|
+
return results;
|
|
165
|
+
let projectDirs;
|
|
166
|
+
try {
|
|
167
|
+
projectDirs = readdirSync(baseDir);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return results;
|
|
171
|
+
}
|
|
172
|
+
for (const proj of projectDirs) {
|
|
173
|
+
const projPath = join(baseDir, proj);
|
|
174
|
+
let files;
|
|
175
|
+
try {
|
|
176
|
+
if (!statSync(projPath).isDirectory())
|
|
177
|
+
continue;
|
|
178
|
+
files = readdirSync(projPath).filter((f) => f.endsWith('.jsonl'));
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
for (const file of files) {
|
|
184
|
+
const uuid = file.slice(0, -'.jsonl'.length);
|
|
185
|
+
if (!UUID_RE.test(uuid))
|
|
186
|
+
continue;
|
|
187
|
+
const fp = join(projPath, file);
|
|
188
|
+
let mtimeMs;
|
|
189
|
+
let sizeBytes;
|
|
190
|
+
try {
|
|
191
|
+
const st = statSync(fp);
|
|
192
|
+
mtimeMs = st.mtimeMs;
|
|
193
|
+
sizeBytes = st.size;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (maxAgeMs > 0 && now - mtimeMs > maxAgeMs)
|
|
199
|
+
continue;
|
|
200
|
+
const meta = extractMeta(fp, sizeBytes);
|
|
201
|
+
if (opts.cwdFilter && meta.cwd !== opts.cwdFilter)
|
|
202
|
+
continue;
|
|
203
|
+
results.push({ uuid, mtimeMs, ...meta });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
results.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
207
|
+
return typeof opts.limit === 'number' ? results.slice(0, opts.limit) : results;
|
|
208
|
+
}
|
|
209
|
+
/** Look up a single CLI session by full uuid (case-insensitive). */
|
|
210
|
+
export function findCliSession(uuid) {
|
|
211
|
+
const target = uuid.toLowerCase();
|
|
212
|
+
return scanClaudeCliSessions({ maxAgeMs: 0 }).find((s) => s.uuid.toLowerCase() === target);
|
|
213
|
+
}
|
|
214
|
+
/** True if the string is a full Claude session uuid (not the 8-hex cco short id). */
|
|
215
|
+
export function isFullClaudeUuid(s) {
|
|
216
|
+
return UUID_RE.test(s);
|
|
217
|
+
}
|
package/dist/src/lib/config.d.ts
CHANGED
|
@@ -61,6 +61,27 @@ export declare function getPerfAsyncCompactEnabled(): boolean;
|
|
|
61
61
|
export declare function getPerfReadBatchEnabled(): boolean;
|
|
62
62
|
/** M12 — direct claude-code SDK in-process. Default OFF (HIGH RISK, hard-gated). */
|
|
63
63
|
export declare function getPerfDirectSdkEnabled(): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* #4 (dual-surface seam / gap#2) — whether the live card mirrors the model's
|
|
66
|
+
* FULL answer text. Default OFF.
|
|
67
|
+
*
|
|
68
|
+
* The OpenClaw gateway already live-streams the answer to Telegram via its own
|
|
69
|
+
* native draft message. Mirroring the same text onto the card too produced
|
|
70
|
+
* (a) double live-streaming of the identical answer and (b) the finalize-blank
|
|
71
|
+
* "answer streams → vanishes → reappears below" seam, because the card had to
|
|
72
|
+
* wipe its copy on finalize to avoid a literal duplicate (no upstream
|
|
73
|
+
* suppressUserDelivery knob exists).
|
|
74
|
+
*
|
|
75
|
+
* With this OFF (default) the card is a pure ACTIVITY pane (status line · tools ·
|
|
76
|
+
* thinking · todos · ✓ Done · ★ Insight takeaway) and the gateway draft is the
|
|
77
|
+
* sole ANSWER pane — the Claude Code CLI split. The card still shows the short
|
|
78
|
+
* ★ Insight block on finalize (extracted from the accumulated text), so the
|
|
79
|
+
* signature "✓ Done + takeaway" card is preserved without the full-answer dup.
|
|
80
|
+
*
|
|
81
|
+
* Set CC_OPENCLAW_CARD_ANSWER_MIRROR=1 to restore the old in-card full-answer
|
|
82
|
+
* streaming (with the finalize-blank dedup) for comparison.
|
|
83
|
+
*/
|
|
84
|
+
export declare function getCardAnswerMirrorEnabled(): boolean;
|
|
64
85
|
export declare function getMaxConcurrentSessions(): number;
|
|
65
86
|
export declare function getSessionTtlMinutes(): number;
|
|
66
87
|
export declare function ensureUxBridgeAllSessionsDefault(): {
|
package/dist/src/lib/config.js
CHANGED
|
@@ -175,6 +175,29 @@ export function getPerfDirectSdkEnabled() {
|
|
|
175
175
|
return cfg.config.perfDirectSdkEnabled;
|
|
176
176
|
return process.env.CC_OPENCLAW_PERF_DIRECT_SDK === '1';
|
|
177
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* #4 (dual-surface seam / gap#2) — whether the live card mirrors the model's
|
|
180
|
+
* FULL answer text. Default OFF.
|
|
181
|
+
*
|
|
182
|
+
* The OpenClaw gateway already live-streams the answer to Telegram via its own
|
|
183
|
+
* native draft message. Mirroring the same text onto the card too produced
|
|
184
|
+
* (a) double live-streaming of the identical answer and (b) the finalize-blank
|
|
185
|
+
* "answer streams → vanishes → reappears below" seam, because the card had to
|
|
186
|
+
* wipe its copy on finalize to avoid a literal duplicate (no upstream
|
|
187
|
+
* suppressUserDelivery knob exists).
|
|
188
|
+
*
|
|
189
|
+
* With this OFF (default) the card is a pure ACTIVITY pane (status line · tools ·
|
|
190
|
+
* thinking · todos · ✓ Done · ★ Insight takeaway) and the gateway draft is the
|
|
191
|
+
* sole ANSWER pane — the Claude Code CLI split. The card still shows the short
|
|
192
|
+
* ★ Insight block on finalize (extracted from the accumulated text), so the
|
|
193
|
+
* signature "✓ Done + takeaway" card is preserved without the full-answer dup.
|
|
194
|
+
*
|
|
195
|
+
* Set CC_OPENCLAW_CARD_ANSWER_MIRROR=1 to restore the old in-card full-answer
|
|
196
|
+
* streaming (with the finalize-blank dedup) for comparison.
|
|
197
|
+
*/
|
|
198
|
+
export function getCardAnswerMirrorEnabled() {
|
|
199
|
+
return process.env.CC_OPENCLAW_CARD_ANSWER_MIRROR === '1';
|
|
200
|
+
}
|
|
178
201
|
// ── SessionManager bootstrap caps (cwd-patch eager-init) ─────────────────
|
|
179
202
|
// Defaults preserved from cwd-patch.ts:844-845.
|
|
180
203
|
export function getMaxConcurrentSessions() {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from './register-guard.js';
|
|
2
|
+
export { registerOnce } from './register-guard.js';
|
|
3
|
+
export { stripSysprompt, isStripEnabled, type StripOptions, type StripResult } from './sysprompt-strip.js';
|
|
4
|
+
export { isCacheParityEnabled, hashPrompt, recordAttachment, readRegistry, REGISTRY_PATH, type RegistryEntry, } from './cache-parity.js';
|
|
5
|
+
export { selectEngine, isCcOpenclawEnabled, captureSessionRoute, ACTIVE_FLAG_ENV, ROUTE_FLAG_ENV, type Engine, type SessionRoute, } from './config-service.js';
|
|
6
|
+
export { isTestMode, TEST_MODE_ENV, _setTestModeForTests } from './test-mode.js';
|
|
7
|
+
export { getAggressiveStripEnabled, getCacheParityEnabled, getLogLevel, isLogLevelDebug, } from './config.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './register-guard.js';
|
|
2
|
+
export { registerOnce } from './register-guard.js';
|
|
3
|
+
export { stripSysprompt, isStripEnabled } from './sysprompt-strip.js';
|
|
4
|
+
export { isCacheParityEnabled, hashPrompt, recordAttachment, readRegistry, REGISTRY_PATH, } from './cache-parity.js';
|
|
5
|
+
// Engine routing — originally `./route-flag.js`; collapsed into
|
|
6
|
+
// `./config-service.js` at Cluster A step 8. Same API, same semantics,
|
|
7
|
+
// single source of truth.
|
|
8
|
+
export { selectEngine, isCcOpenclawEnabled, captureSessionRoute, ACTIVE_FLAG_ENV, ROUTE_FLAG_ENV, } from './config-service.js';
|
|
9
|
+
export { isTestMode, TEST_MODE_ENV, _setTestModeForTests } from './test-mode.js';
|
|
10
|
+
export { getAggressiveStripEnabled, getCacheParityEnabled, getLogLevel, isLogLevelDebug, } from './config.js';
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* break the single `jq` pipeline. The `event` field makes filtering trivial.
|
|
28
28
|
*/
|
|
29
29
|
type PerfEventName = 'cache_check' | 'first_byte' | 'turn_end';
|
|
30
|
-
type CacheCheckCause = 'hit' | 'registry_empty' | 'hash_mismatch' | 'session_unknown' | 'disabled';
|
|
30
|
+
type CacheCheckCause = 'hit' | 'warm_hash' | 'registry_empty' | 'hash_mismatch' | 'session_unknown' | 'disabled';
|
|
31
31
|
interface PerfEventBase {
|
|
32
32
|
event: PerfEventName;
|
|
33
33
|
sessionKey?: string;
|
|
@@ -23,11 +23,11 @@
|
|
|
23
23
|
import { reportStatus, getToolDescription } from './status-reporter.js';
|
|
24
24
|
import { parseToolCallsFromText } from './tool-calls-parser.js';
|
|
25
25
|
import { formatCompletionResponse } from './response-formatter.js';
|
|
26
|
-
import { getSurfaceThinkingEnabled, getTtsAutoMode } from '../lib/config.js';
|
|
26
|
+
import { getSurfaceThinkingEnabled, getTtsAutoMode, getCardAnswerMirrorEnabled } from '../lib/config.js';
|
|
27
27
|
import { emit as emitTrajectory, emitTurnTrace } from '../lib/trajectory.js';
|
|
28
28
|
import { formatError, ERROR_CODES } from '../lib/error-formatter.js';
|
|
29
29
|
import { applyVoiceRecovery, _logVoiceDebug, detectVoiceIntent, hasTtsMarkers } from './voice-recovery.js';
|
|
30
|
-
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';
|
|
30
|
+
import { pushToolUse as mirrorPushToolUse, pushToolResult as mirrorPushToolResult, pushAssistantText as mirrorPushAssistantText, finalizeActiveCards as mirrorFinalizeActiveCards, extractInsightForCard as mirrorExtractInsightForCard, failActiveCards as mirrorFailActiveCards, classifyFailure, setCardMeta as mirrorSetCardMeta, readQuotaMeta as mirrorReadQuotaMeta, } from '../channels/telegram-mirror/turn-bridge.js';
|
|
31
31
|
import { cardStateDebug as mirrorCardStateDebug } from '../channels/telegram-mirror/card-state.js';
|
|
32
32
|
/** Coerce a userMessage (string | UserMessageBlock[]) to a flat string
|
|
33
33
|
* for voice-intent detection. Tool-result blocks aren't user prompts. */
|
|
@@ -72,6 +72,9 @@ slashCommand) {
|
|
|
72
72
|
clientDisconnected = true;
|
|
73
73
|
});
|
|
74
74
|
let deliveredText = '';
|
|
75
|
+
// #4 dual-surface seam — default OFF: card is the activity pane, the gateway
|
|
76
|
+
// reply is the answer pane (see streaming-handler / config.ts for rationale).
|
|
77
|
+
const mirrorAnswerToCard = getCardAnswerMirrorEnabled();
|
|
75
78
|
try {
|
|
76
79
|
reportStatus('thinking', 'Processing request...');
|
|
77
80
|
// v0.7.1: accumulate thinking-block content when surfaceThinking is on.
|
|
@@ -170,12 +173,14 @@ slashCommand) {
|
|
|
170
173
|
emitTrajectory('tool_use', { name: '_voice_recovery', inputKeys: [recovery.via] }, sessionName);
|
|
171
174
|
}
|
|
172
175
|
}
|
|
173
|
-
// v0.26.0 — push the final assistant text into the Telegram mirror
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
// v0.26.0 — push the final assistant text into the Telegram mirror card.
|
|
177
|
+
// #4 dual-surface seam — only when the legacy flag is on; by default the
|
|
178
|
+
// gateway reply is the answer pane and the card shows only the ★ Insight
|
|
179
|
+
// takeaway at finalize (see the finally block).
|
|
180
|
+
if (mirrorAnswerToCard)
|
|
181
|
+
mirrorPushAssistantText(outputText);
|
|
182
|
+
// v0.27.6 — remember the FULL text so the finally block can re-apply it as
|
|
183
|
+
// the sole channel if the socket died (see clientDisconnected note above).
|
|
179
184
|
deliveredText = outputText;
|
|
180
185
|
// Parse tool_calls from response text when caller provided tools
|
|
181
186
|
let traceToolCount = 0;
|
|
@@ -270,10 +275,16 @@ slashCommand) {
|
|
|
270
275
|
// v0.26.1 — finalize the Telegram mirror card at the true end of the model
|
|
271
276
|
// turn (see handleStreaming counterpart). Best-effort.
|
|
272
277
|
try {
|
|
273
|
-
// v0.27.6 — report-drop fix (Killer #2): keep the report on the card
|
|
274
|
-
// the socket died (mirror of the streaming handler).
|
|
275
|
-
//
|
|
276
|
-
|
|
278
|
+
// v0.27.6 — report-drop fix (Killer #2): keep the FULL report on the card
|
|
279
|
+
// when the socket died (mirror of the streaming handler).
|
|
280
|
+
// #4 dual-surface seam — happy path: ★ Insight takeaway (default) or ''
|
|
281
|
+
// (legacy in-card answer streamed live, blank-on-finalize dedup).
|
|
282
|
+
const finalizeText = clientDisconnected
|
|
283
|
+
? deliveredText
|
|
284
|
+
: mirrorAnswerToCard
|
|
285
|
+
? ''
|
|
286
|
+
: mirrorExtractInsightForCard(deliveredText);
|
|
287
|
+
await mirrorFinalizeActiveCards(finalizeText);
|
|
277
288
|
}
|
|
278
289
|
catch {
|
|
279
290
|
/* finalize is cosmetic; never propagate */
|
|
@@ -41,9 +41,9 @@ import { formatCompletionChunk } from './response-formatter.js';
|
|
|
41
41
|
import { isToolStreamMode } from './mode-flags.js';
|
|
42
42
|
import { emit as emitTrajectory, emitTurnTrace } from '../lib/trajectory.js';
|
|
43
43
|
import { formatError, ERROR_CODES } from '../lib/error-formatter.js';
|
|
44
|
-
import { getSurfaceThinkingEnabled, getTtsAutoMode } from '../lib/config.js';
|
|
44
|
+
import { getSurfaceThinkingEnabled, getTtsAutoMode, getCardAnswerMirrorEnabled } 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, pushThinking as mirrorPushThinking, 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, extractInsightForCard as mirrorExtractInsightForCard, 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
|
|
@@ -84,6 +84,9 @@ onFinalText) {
|
|
|
84
84
|
// runs permissionMode 'bypassPermissions' (openai-compat.ts) so we assert it.
|
|
85
85
|
// Quota is read once here (real status-tee snapshot or omitted).
|
|
86
86
|
mirrorSetCardMeta({ model, bypassPermissions: true, ...mirrorReadQuotaMeta() });
|
|
87
|
+
// #4 dual-surface seam — hoist once per turn (read off the hot delta loop).
|
|
88
|
+
// Default OFF: card is the activity pane, gateway draft is the answer pane.
|
|
89
|
+
const mirrorAnswerToCard = getCardAnswerMirrorEnabled();
|
|
87
90
|
res.writeHead(200, {
|
|
88
91
|
'Content-Type': 'text/event-stream',
|
|
89
92
|
'Cache-Control': 'no-cache',
|
|
@@ -236,9 +239,13 @@ onFinalText) {
|
|
|
236
239
|
markFirstByte();
|
|
237
240
|
}
|
|
238
241
|
accumulatedText += chunk;
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
+
// #4 dual-surface seam — only mirror the cumulative answer onto the
|
|
243
|
+
// card when the legacy flag is on. By default the card is NOT the
|
|
244
|
+
// answer pane (the gateway's native draft message already live-streams
|
|
245
|
+
// the same SSE content below), so mirroring here would double-stream
|
|
246
|
+
// and force the finalize-blank that made the answer vanish/reappear.
|
|
247
|
+
if (mirrorAnswerToCard)
|
|
248
|
+
mirrorPushAssistantText(accumulatedText);
|
|
242
249
|
writeSSE(JSON.stringify(formatCompletionChunk(completionId, model, { content: chunk }, null)));
|
|
243
250
|
}
|
|
244
251
|
else {
|
|
@@ -629,10 +636,19 @@ onFinalText) {
|
|
|
629
636
|
try {
|
|
630
637
|
// v0.27.6 — report-drop fix (Killer #2): when the gateway socket died
|
|
631
638
|
// mid-turn (clientDisconnected), the gateway delivers nothing separately,
|
|
632
|
-
// so pass the accumulated text and the finalized card KEEPS it as the
|
|
633
|
-
// sole delivery channel.
|
|
634
|
-
//
|
|
635
|
-
|
|
639
|
+
// so pass the FULL accumulated text and the finalized card KEEPS it as the
|
|
640
|
+
// sole delivery channel.
|
|
641
|
+
//
|
|
642
|
+
// #4 dual-surface seam — happy path (connected): the gateway draft is the
|
|
643
|
+
// answer pane, so the card shows only the short ★ Insight takeaway
|
|
644
|
+
// (default) or '' when the legacy in-card answer streamed live (flag on,
|
|
645
|
+
// old v0.26.4 blank-on-finalize dedup).
|
|
646
|
+
const finalizeText = clientDisconnected
|
|
647
|
+
? accumulatedText
|
|
648
|
+
: mirrorAnswerToCard
|
|
649
|
+
? ''
|
|
650
|
+
: mirrorExtractInsightForCard(accumulatedText);
|
|
651
|
+
await mirrorFinalizeActiveCards(finalizeText);
|
|
636
652
|
}
|
|
637
653
|
catch {
|
|
638
654
|
/* finalize is cosmetic; never propagate */
|
|
@@ -37,6 +37,7 @@ import { defaultRegisterGuard } from '../lib/register-guard.js';
|
|
|
37
37
|
import { isTestMode } from '../lib/test-mode.js';
|
|
38
38
|
import { writePerfEvent } from '../observability/perf-telemetry.js';
|
|
39
39
|
import { collapseSkillList } from '../lib/perf/skill-list-collapse.js';
|
|
40
|
+
import { decideCacheParityAction } from '../lib/cache-parity-decide.js';
|
|
40
41
|
import { isCacheParityTrackB, isTokenTelemetryEnabled, isSyspromptDumpEnabled, getMaxConcurrentSessions, getSessionTtlMinutes, ensureUxBridgeAllSessionsDefault, } from '../lib/config.js';
|
|
41
42
|
import { VENDOR_FILES } from '../lib/vendor-paths.js';
|
|
42
43
|
import { OpenClawConfigSchema, findMainAgent, getAgentPrimaryModel, getDefaultsPrimaryModel, isClaudeRoutedModel, } from '../types/upstream.js';
|
|
@@ -99,6 +100,7 @@ const METRICS = {
|
|
|
99
100
|
systemPromptInlined: 0,
|
|
100
101
|
uxMetaSeeded: 0,
|
|
101
102
|
cacheParityHits: 0,
|
|
103
|
+
cacheParityWarmHashHits: 0,
|
|
102
104
|
cacheParityMisses: 0,
|
|
103
105
|
cacheParityRegistryWrites: 0,
|
|
104
106
|
cacheParityAppendInjections: 0,
|
|
@@ -143,6 +145,24 @@ function _setSystemInlineCache(key, val) {
|
|
|
143
145
|
}
|
|
144
146
|
_systemInlineCache.set(key, val);
|
|
145
147
|
}
|
|
148
|
+
// ── Known sysprompt-hash set (v0.30.0 — content-addressed cache parity) ──────
|
|
149
|
+
// Cross-session record of every sysHash written to the cache-parity registry
|
|
150
|
+
// this process. Lets a brand-new session recognise the shared Savvy system
|
|
151
|
+
// prefix and ride the cached append path on turn 1 instead of inlining the full
|
|
152
|
+
// ~7K prompt — the dominant cold-start miss (see lib/cache-parity-decide.ts).
|
|
153
|
+
// Bounded FIFO: distinct prefixes are few (one per build), 64 is ample headroom.
|
|
154
|
+
const _knownSysHashes = new Set();
|
|
155
|
+
const KNOWN_SYS_HASH_MAX = 64;
|
|
156
|
+
function _rememberSysHash(hash) {
|
|
157
|
+
if (_knownSysHashes.has(hash))
|
|
158
|
+
return;
|
|
159
|
+
if (_knownSysHashes.size >= KNOWN_SYS_HASH_MAX) {
|
|
160
|
+
const oldest = _knownSysHashes.values().next().value;
|
|
161
|
+
if (oldest !== undefined)
|
|
162
|
+
_knownSysHashes.delete(oldest);
|
|
163
|
+
}
|
|
164
|
+
_knownSysHashes.add(hash);
|
|
165
|
+
}
|
|
146
166
|
// ── Tool dump hash guard (v0.6.0 — per-session-key cache + fast-skip) ──
|
|
147
167
|
// Pre-v0.6.0: a single global `_lastToolDumpHash` thrashed when multiple
|
|
148
168
|
// sessions had different tool sets. JSON.stringify + SHA1 ran on EVERY
|
|
@@ -655,11 +675,30 @@ function applyRoutePatch(EmbeddedServer) {
|
|
|
655
675
|
try {
|
|
656
676
|
const reg = _readCacheParityRegistry();
|
|
657
677
|
const entry = reg[sessionKey];
|
|
658
|
-
|
|
678
|
+
// sessionIsNew: no live session in the manager ⇒ startSession will
|
|
679
|
+
// run on this request and inject appendSystemPrompt from the
|
|
680
|
+
// registry entry we write below. That guarantee is what makes the
|
|
681
|
+
// warm-hash strip safe (see lib/cache-parity-decide.ts). Default
|
|
682
|
+
// to false on any access failure — conservative: an unproven
|
|
683
|
+
// append guarantee falls back to the safe inline path.
|
|
684
|
+
let sessionIsNew = false;
|
|
685
|
+
try {
|
|
686
|
+
const mgr = this.manager;
|
|
687
|
+
sessionIsNew = !(mgr?.sessions?.has?.('openai-' + sessionKey) ?? false);
|
|
688
|
+
}
|
|
689
|
+
catch { /* keep sessionIsNew=false (inline fallback) */ }
|
|
690
|
+
const action = decideCacheParityAction({
|
|
691
|
+
entry: entry ? { hash: entry.hash } : undefined,
|
|
692
|
+
sysHash,
|
|
693
|
+
knownHash: _knownSysHashes.has(sysHash),
|
|
694
|
+
sessionIsNew,
|
|
695
|
+
});
|
|
696
|
+
if (action === 'hit') {
|
|
659
697
|
body.messages = messages.filter(m => m?.role !== 'system');
|
|
660
698
|
METRICS.cacheParityHits++;
|
|
661
699
|
METRICS.systemPromptInlined++;
|
|
662
700
|
cacheParityHandled = true;
|
|
701
|
+
_rememberSysHash(sysHash);
|
|
663
702
|
writePerfEvent({
|
|
664
703
|
event: 'cache_check',
|
|
665
704
|
sessionKey,
|
|
@@ -668,8 +707,29 @@ function applyRoutePatch(EmbeddedServer) {
|
|
|
668
707
|
sysHash,
|
|
669
708
|
});
|
|
670
709
|
}
|
|
710
|
+
else if (action === 'warm-hash-hit') {
|
|
711
|
+
// Cold-start win: new session + known-good Savvy prefix. Write
|
|
712
|
+
// the entry so startSession appends it, strip the redundant
|
|
713
|
+
// inline, ride the cached path on turn 1.
|
|
714
|
+
_writeCacheParityEntry(sessionKey, sysHash, sysContent);
|
|
715
|
+
body.messages = messages.filter(m => m?.role !== 'system');
|
|
716
|
+
METRICS.cacheParityWarmHashHits++;
|
|
717
|
+
METRICS.cacheParityRegistryWrites++;
|
|
718
|
+
METRICS.systemPromptInlined++;
|
|
719
|
+
cacheParityHandled = true;
|
|
720
|
+
_rememberSysHash(sysHash);
|
|
721
|
+
logger.info(`${TAG} cache-parity warm-hash hit: session=${sessionKey} hash=${sysHash} sysLen=${sysContent.length} (cold-start dedup; startSession will append)`);
|
|
722
|
+
writePerfEvent({
|
|
723
|
+
event: 'cache_check',
|
|
724
|
+
sessionKey,
|
|
725
|
+
outcome: 'hit',
|
|
726
|
+
cause: 'warm_hash',
|
|
727
|
+
sysHash,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
671
730
|
else {
|
|
672
731
|
_writeCacheParityEntry(sessionKey, sysHash, sysContent);
|
|
732
|
+
_rememberSysHash(sysHash);
|
|
673
733
|
METRICS.cacheParityMisses++;
|
|
674
734
|
METRICS.cacheParityRegistryWrites++;
|
|
675
735
|
logger.info(`${TAG} cache-parity miss: session=${sessionKey} oldHash=${entry?.hash || 'none'} newHash=${sysHash} sysLen=${sysContent.length} (registry updated; next session start will append)`);
|