@martian-engineering/lossless-claw 0.5.3 โ†’ 0.6.1

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.
@@ -0,0 +1,759 @@
1
+ import { statSync } from "node:fs";
2
+ import type { DatabaseSync } from "node:sqlite";
3
+ import packageJson from "../../package.json" with { type: "json" };
4
+ import type { LcmConfig } from "../db/config.js";
5
+ import type { LcmSummarizeFn } from "../summarize.js";
6
+ import type { LcmDependencies } from "../types.js";
7
+ import type { OpenClawPluginCommandDefinition, PluginCommandContext } from "openclaw/plugin-sdk";
8
+ import { applyScopedDoctorRepair } from "./lcm-doctor-apply.js";
9
+ import {
10
+ detectDoctorMarker,
11
+ getDoctorSummaryStats,
12
+ type DoctorSummaryStats,
13
+ } from "./lcm-doctor-shared.js";
14
+
15
+ const VISIBLE_COMMAND = "/lossless";
16
+ const HIDDEN_ALIAS = "/lcm";
17
+
18
+ type LcmStatusStats = {
19
+ conversationCount: number;
20
+ summaryCount: number;
21
+ storedSummaryTokens: number;
22
+ summarizedSourceTokens: number;
23
+ leafSummaryCount: number;
24
+ condensedSummaryCount: number;
25
+ };
26
+
27
+ type LcmConversationStatusStats = {
28
+ conversationId: number;
29
+ sessionId: string;
30
+ sessionKey: string | null;
31
+ messageCount: number;
32
+ summaryCount: number;
33
+ storedSummaryTokens: number;
34
+ summarizedSourceTokens: number;
35
+ contextTokenCount: number;
36
+ compressedTokenCount: number;
37
+ leafSummaryCount: number;
38
+ condensedSummaryCount: number;
39
+ };
40
+
41
+ type CurrentConversationResolution =
42
+ | {
43
+ kind: "resolved";
44
+ source: "session_key" | "session_key_via_session_id" | "session_id";
45
+ stats: LcmConversationStatusStats;
46
+ }
47
+ | {
48
+ kind: "unavailable";
49
+ reason: string;
50
+ };
51
+
52
+ type ParsedLcmCommand =
53
+ | { kind: "status" }
54
+ | { kind: "doctor"; apply: boolean }
55
+ | { kind: "help"; error?: string };
56
+
57
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
58
+ return value && typeof value === "object" && !Array.isArray(value)
59
+ ? (value as Record<string, unknown>)
60
+ : undefined;
61
+ }
62
+
63
+ function formatBoolean(value: boolean): string {
64
+ return value ? "yes" : "no";
65
+ }
66
+
67
+ function formatNumber(value: number): string {
68
+ return new Intl.NumberFormat("en-US").format(value);
69
+ }
70
+
71
+ function formatBytes(bytes: number): string {
72
+ if (!Number.isFinite(bytes) || bytes < 0) {
73
+ return "unknown";
74
+ }
75
+ if (bytes < 1024) {
76
+ return `${bytes} B`;
77
+ }
78
+ const units = ["KB", "MB", "GB", "TB"];
79
+ let value = bytes / 1024;
80
+ let unitIndex = 0;
81
+ while (value >= 1024 && unitIndex < units.length - 1) {
82
+ value /= 1024;
83
+ unitIndex += 1;
84
+ }
85
+ const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
86
+ return `${value.toFixed(precision)} ${units[unitIndex]}`;
87
+ }
88
+
89
+ function formatCommand(command: string): string {
90
+ return `\`${command}\``;
91
+ }
92
+
93
+ function buildHeaderLines(): string[] {
94
+ return [
95
+ `**๐Ÿฆ€ Lossless Claw v${packageJson.version}**`,
96
+ `Help: ${formatCommand(`${VISIBLE_COMMAND} help`)} ยท Alias: ${formatCommand(HIDDEN_ALIAS)}`,
97
+ ];
98
+ }
99
+
100
+ function buildSection(title: string, lines: string[]): string {
101
+ return [`**${title}**`, ...lines.map((line) => ` ${line}`)].join("\n");
102
+ }
103
+
104
+ function buildStatLine(label: string, value: string): string {
105
+ return `${label}: ${value}`;
106
+ }
107
+
108
+ function formatCompressionRatio(contextTokens: number, compressedTokens: number): string {
109
+ if (
110
+ !Number.isFinite(contextTokens) ||
111
+ contextTokens <= 0 ||
112
+ !Number.isFinite(compressedTokens) ||
113
+ compressedTokens <= 0
114
+ ) {
115
+ return "n/a";
116
+ }
117
+ const ratio = Math.max(1, Math.round(compressedTokens / contextTokens));
118
+ return `1:${formatNumber(ratio)}`;
119
+ }
120
+
121
+ function truncateMiddle(value: string, maxChars: number): string {
122
+ if (value.length <= maxChars) {
123
+ return value;
124
+ }
125
+ if (maxChars <= 3) {
126
+ return value.slice(0, maxChars);
127
+ }
128
+ const head = Math.ceil((maxChars - 1) / 2);
129
+ const tail = Math.floor((maxChars - 1) / 2);
130
+ return `${value.slice(0, head)}โ€ฆ${value.slice(value.length - tail)}`;
131
+ }
132
+
133
+ function splitArgs(rawArgs: string | undefined): string[] {
134
+ return (rawArgs ?? "")
135
+ .trim()
136
+ .split(/\s+/)
137
+ .map((token) => token.trim())
138
+ .filter(Boolean);
139
+ }
140
+
141
+ function parseLcmCommand(rawArgs: string | undefined): ParsedLcmCommand {
142
+ const tokens = splitArgs(rawArgs);
143
+ if (tokens.length === 0) {
144
+ return { kind: "status" };
145
+ }
146
+
147
+ const [head, ...rest] = tokens;
148
+ switch (head.toLowerCase()) {
149
+ case "status":
150
+ return rest.length === 0
151
+ ? { kind: "status" }
152
+ : { kind: "help", error: "`/lcm status` does not accept extra arguments." };
153
+ case "doctor":
154
+ if (rest.length === 0) {
155
+ return { kind: "doctor", apply: false };
156
+ }
157
+ if (rest.length === 1 && rest[0]?.toLowerCase() === "apply") {
158
+ return { kind: "doctor", apply: true };
159
+ }
160
+ return {
161
+ kind: "help",
162
+ error: "`/lcm doctor` accepts no arguments, or `apply` for the scoped repair path.",
163
+ };
164
+ case "help":
165
+ return { kind: "help" };
166
+ default:
167
+ return {
168
+ kind: "help",
169
+ error: `Unknown subcommand \`${head}\`. Supported: status, doctor, doctor apply.`,
170
+ };
171
+ }
172
+ }
173
+
174
+ function getLcmStatusStats(db: DatabaseSync): LcmStatusStats {
175
+ const row = db
176
+ .prepare(
177
+ `SELECT
178
+ COALESCE((SELECT COUNT(*) FROM conversations), 0) AS conversation_count,
179
+ COALESCE(COUNT(*), 0) AS summary_count,
180
+ COALESCE(SUM(token_count), 0) AS stored_summary_tokens,
181
+ COALESCE(SUM(CASE WHEN kind = 'leaf' THEN source_message_token_count ELSE 0 END), 0) AS summarized_source_tokens,
182
+ COALESCE(SUM(CASE WHEN kind = 'leaf' THEN 1 ELSE 0 END), 0) AS leaf_summary_count,
183
+ COALESCE(SUM(CASE WHEN kind = 'condensed' THEN 1 ELSE 0 END), 0) AS condensed_summary_count
184
+ FROM summaries`,
185
+ )
186
+ .get() as
187
+ | {
188
+ conversation_count: number;
189
+ summary_count: number;
190
+ stored_summary_tokens: number;
191
+ summarized_source_tokens: number;
192
+ leaf_summary_count: number;
193
+ condensed_summary_count: number;
194
+ }
195
+ | undefined;
196
+
197
+ return {
198
+ conversationCount: row?.conversation_count ?? 0,
199
+ summaryCount: row?.summary_count ?? 0,
200
+ storedSummaryTokens: row?.stored_summary_tokens ?? 0,
201
+ summarizedSourceTokens: row?.summarized_source_tokens ?? 0,
202
+ leafSummaryCount: row?.leaf_summary_count ?? 0,
203
+ condensedSummaryCount: row?.condensed_summary_count ?? 0,
204
+ };
205
+ }
206
+
207
+ function getConversationStatusStats(
208
+ db: DatabaseSync,
209
+ conversationId: number,
210
+ ): LcmConversationStatusStats | null {
211
+ const row = db
212
+ .prepare(
213
+ `SELECT
214
+ c.conversation_id,
215
+ c.session_id,
216
+ c.session_key,
217
+ COALESCE((SELECT COUNT(*) FROM messages WHERE conversation_id = c.conversation_id), 0) AS message_count,
218
+ COALESCE((SELECT COUNT(*) FROM summaries WHERE conversation_id = c.conversation_id), 0) AS summary_count,
219
+ COALESCE((SELECT SUM(token_count) FROM summaries WHERE conversation_id = c.conversation_id), 0) AS stored_summary_tokens,
220
+ COALESCE((SELECT SUM(CASE WHEN kind = 'leaf' THEN source_message_token_count ELSE 0 END) FROM summaries WHERE conversation_id = c.conversation_id), 0) AS summarized_source_tokens,
221
+ COALESCE((
222
+ SELECT SUM(token_count)
223
+ FROM (
224
+ SELECT m.token_count AS token_count
225
+ FROM context_items ci
226
+ JOIN messages m ON m.message_id = ci.message_id
227
+ WHERE ci.conversation_id = c.conversation_id
228
+ AND ci.item_type = 'message'
229
+ UNION ALL
230
+ SELECT s.token_count AS token_count
231
+ FROM context_items ci
232
+ JOIN summaries s ON s.summary_id = ci.summary_id
233
+ WHERE ci.conversation_id = c.conversation_id
234
+ AND ci.item_type = 'summary'
235
+ ) context_token_rows
236
+ ), 0) AS context_token_count,
237
+ COALESCE((
238
+ SELECT SUM(COALESCE(s.source_message_token_count, 0) + COALESCE(s.descendant_token_count, 0))
239
+ FROM context_items ci
240
+ JOIN summaries s ON s.summary_id = ci.summary_id
241
+ WHERE ci.conversation_id = c.conversation_id
242
+ AND ci.item_type = 'summary'
243
+ ), 0) AS compressed_token_count,
244
+ COALESCE((SELECT SUM(CASE WHEN kind = 'leaf' THEN 1 ELSE 0 END) FROM summaries WHERE conversation_id = c.conversation_id), 0) AS leaf_summary_count,
245
+ COALESCE((SELECT SUM(CASE WHEN kind = 'condensed' THEN 1 ELSE 0 END) FROM summaries WHERE conversation_id = c.conversation_id), 0) AS condensed_summary_count
246
+ FROM conversations c
247
+ WHERE c.conversation_id = ?`,
248
+ )
249
+ .get(conversationId) as
250
+ | {
251
+ conversation_id: number;
252
+ session_id: string;
253
+ session_key: string | null;
254
+ message_count: number;
255
+ summary_count: number;
256
+ stored_summary_tokens: number;
257
+ summarized_source_tokens: number;
258
+ context_token_count: number;
259
+ compressed_token_count: number;
260
+ leaf_summary_count: number;
261
+ condensed_summary_count: number;
262
+ }
263
+ | undefined;
264
+
265
+ if (!row) {
266
+ return null;
267
+ }
268
+
269
+ return {
270
+ conversationId: row.conversation_id,
271
+ sessionId: row.session_id,
272
+ sessionKey: row.session_key,
273
+ messageCount: row.message_count,
274
+ summaryCount: row.summary_count,
275
+ storedSummaryTokens: row.stored_summary_tokens,
276
+ summarizedSourceTokens: row.summarized_source_tokens,
277
+ contextTokenCount: row.context_token_count,
278
+ compressedTokenCount: row.compressed_token_count,
279
+ leafSummaryCount: row.leaf_summary_count,
280
+ condensedSummaryCount: row.condensed_summary_count,
281
+ };
282
+ }
283
+
284
+ function normalizeIdentity(value: string | undefined): string | undefined {
285
+ const normalized = value?.trim();
286
+ return normalized ? normalized : undefined;
287
+ }
288
+
289
+ function getConversationStatusBySessionKey(
290
+ db: DatabaseSync,
291
+ sessionKey: string,
292
+ ): LcmConversationStatusStats | null {
293
+ const row = db
294
+ .prepare(`SELECT conversation_id FROM conversations WHERE session_key = ? LIMIT 1`)
295
+ .get(sessionKey) as { conversation_id: number } | undefined;
296
+
297
+ if (!row) {
298
+ return null;
299
+ }
300
+
301
+ return getConversationStatusStats(db, row.conversation_id);
302
+ }
303
+
304
+ function getConversationStatusBySessionId(
305
+ db: DatabaseSync,
306
+ sessionId: string,
307
+ ): LcmConversationStatusStats | null {
308
+ const row = db
309
+ .prepare(
310
+ `SELECT conversation_id
311
+ FROM conversations
312
+ WHERE session_id = ?
313
+ ORDER BY created_at DESC
314
+ LIMIT 1`,
315
+ )
316
+ .get(sessionId) as { conversation_id: number } | undefined;
317
+
318
+ if (!row) {
319
+ return null;
320
+ }
321
+
322
+ return getConversationStatusStats(db, row.conversation_id);
323
+ }
324
+
325
+ async function resolveCurrentConversation(params: {
326
+ ctx: PluginCommandContext;
327
+ db: DatabaseSync;
328
+ }): Promise<CurrentConversationResolution> {
329
+ const sessionKey = normalizeIdentity(params.ctx.sessionKey);
330
+ const sessionId = normalizeIdentity(params.ctx.sessionId);
331
+
332
+ if (sessionKey) {
333
+ const bySessionKey = getConversationStatusBySessionKey(params.db, sessionKey);
334
+ if (bySessionKey) {
335
+ return { kind: "resolved", source: "session_key", stats: bySessionKey };
336
+ }
337
+
338
+ if (sessionId) {
339
+ const bySessionId = getConversationStatusBySessionId(params.db, sessionId);
340
+ if (bySessionId) {
341
+ if (!bySessionId.sessionKey || bySessionId.sessionKey === sessionKey) {
342
+ return {
343
+ kind: "resolved",
344
+ source: "session_key_via_session_id",
345
+ stats: bySessionId,
346
+ };
347
+ }
348
+
349
+ return {
350
+ kind: "unavailable",
351
+ reason: `Active session key ${formatCommand(sessionKey)} is not stored in LCM yet. Session id fallback found conversation #${formatNumber(bySessionId.conversationId)}, but it is bound to ${formatCommand(bySessionId.sessionKey)}, so Global stats are safer.`,
352
+ };
353
+ }
354
+ }
355
+
356
+ return {
357
+ kind: "unavailable",
358
+ reason: sessionId
359
+ ? `No LCM conversation is stored yet for active session key ${formatCommand(sessionKey)} or active session id ${formatCommand(sessionId)}.`
360
+ : `No LCM conversation is stored yet for active session key ${formatCommand(sessionKey)}.`,
361
+ };
362
+ }
363
+
364
+ if (sessionId) {
365
+ const bySessionId = getConversationStatusBySessionId(params.db, sessionId);
366
+ if (bySessionId) {
367
+ return { kind: "resolved", source: "session_id", stats: bySessionId };
368
+ }
369
+
370
+ return {
371
+ kind: "unavailable",
372
+ reason: `OpenClaw did not expose an active session key here. Tried active session id ${formatCommand(sessionId)}, but no stored LCM conversation matched it.`,
373
+ };
374
+ }
375
+
376
+ return {
377
+ kind: "unavailable",
378
+ reason: "OpenClaw did not expose an active session key or session id here, so only GLOBAL stats are available.",
379
+ };
380
+ }
381
+
382
+ function resolvePluginEnabled(config: unknown): boolean {
383
+ const root = asRecord(config);
384
+ const plugins = asRecord(root?.plugins);
385
+ const entries = asRecord(plugins?.entries);
386
+ const entry = asRecord(entries?.["lossless-claw"]);
387
+ if (typeof entry?.enabled === "boolean") {
388
+ return entry.enabled;
389
+ }
390
+ return true;
391
+ }
392
+
393
+ function resolveContextEngineSlot(config: unknown): string {
394
+ const root = asRecord(config);
395
+ const plugins = asRecord(root?.plugins);
396
+ const slots = asRecord(plugins?.slots);
397
+ return typeof slots?.contextEngine === "string" ? slots.contextEngine.trim() : "";
398
+ }
399
+
400
+ function resolvePluginSelected(config: unknown): boolean {
401
+ const slot = resolveContextEngineSlot(config);
402
+ return slot === "" || slot === "lossless-claw" || slot === "default";
403
+ }
404
+
405
+ function resolveDbSizeLabel(dbPath: string): string {
406
+ const trimmed = dbPath.trim();
407
+ if (!trimmed || trimmed === ":memory:" || trimmed.startsWith("file::memory:")) {
408
+ return "in-memory";
409
+ }
410
+ try {
411
+ return formatBytes(statSync(trimmed).size);
412
+ } catch {
413
+ return "missing";
414
+ }
415
+ }
416
+
417
+ function buildHelpText(error?: string): string {
418
+ const lines = [
419
+ ...(error ? [`โš ๏ธ ${error}`, ""] : []),
420
+ ...buildHeaderLines(),
421
+ "",
422
+ buildSection("๐Ÿ“˜ Commands", [
423
+ buildStatLine(formatCommand(VISIBLE_COMMAND), "Show compact status output."),
424
+ buildStatLine(formatCommand(`${VISIBLE_COMMAND} status`), "Show plugin, Global, and current-conversation status."),
425
+ buildStatLine(formatCommand(`${VISIBLE_COMMAND} doctor`), "Scan for broken or truncated summaries."),
426
+ buildStatLine(formatCommand(`${VISIBLE_COMMAND} doctor apply`), "Repair broken summaries in the current conversation."),
427
+ ]),
428
+ "",
429
+ buildSection("๐Ÿงญ Notes", [
430
+ buildStatLine("subcommands", `Discover them with ${formatCommand(`${VISIBLE_COMMAND} help`)}.`),
431
+ buildStatLine("alias", `${formatCommand(HIDDEN_ALIAS)} is accepted as a shorter alias.`),
432
+ buildStatLine("current conversation", "Uses the active LCM session when the host exposes session identity."),
433
+ ]),
434
+ ];
435
+ return lines.join("\n");
436
+ }
437
+
438
+ async function buildStatusText(params: {
439
+ ctx: PluginCommandContext;
440
+ db: DatabaseSync;
441
+ config: LcmConfig;
442
+ }): Promise<string> {
443
+ const status = getLcmStatusStats(params.db);
444
+ const doctor = getDoctorSummaryStats(params.db);
445
+ const enabled = resolvePluginEnabled(params.ctx.config);
446
+ const selected = resolvePluginSelected(params.ctx.config);
447
+ const slot = resolveContextEngineSlot(params.ctx.config);
448
+ const dbSize = resolveDbSizeLabel(params.config.databasePath);
449
+ const current = await resolveCurrentConversation({
450
+ ctx: params.ctx,
451
+ db: params.db,
452
+ });
453
+
454
+ const lines = [
455
+ ...buildHeaderLines(),
456
+ "",
457
+ buildSection("๐Ÿงฉ Plugin", [
458
+ buildStatLine("enabled", formatBoolean(enabled)),
459
+ buildStatLine("selected", `${formatBoolean(selected)}${slot ? ` (slot=${slot})` : " (slot=unset)"}`),
460
+ buildStatLine("db path", params.config.databasePath),
461
+ buildStatLine("db size", dbSize),
462
+ ]),
463
+ "",
464
+ buildSection("๐ŸŒ Global", [
465
+ buildStatLine("conversations", formatNumber(status.conversationCount)),
466
+ buildStatLine(
467
+ "summaries",
468
+ `${formatNumber(status.summaryCount)} (${formatNumber(status.leafSummaryCount)} leaf, ${formatNumber(status.condensedSummaryCount)} condensed)`,
469
+ ),
470
+ buildStatLine("stored summary tokens", formatNumber(status.storedSummaryTokens)),
471
+ buildStatLine("summarized source tokens", formatNumber(status.summarizedSourceTokens)),
472
+ ]),
473
+ "",
474
+ ];
475
+
476
+ if (current.kind === "resolved") {
477
+ const conversationDoctor =
478
+ doctor.byConversation.get(current.stats.conversationId) ?? {
479
+ total: 0,
480
+ old: 0,
481
+ truncated: 0,
482
+ fallback: 0,
483
+ };
484
+ lines.push(
485
+ buildSection("๐Ÿ“ Current conversation", [
486
+ buildStatLine("conversation id", formatNumber(current.stats.conversationId)),
487
+ buildStatLine(
488
+ "session key",
489
+ current.stats.sessionKey ? formatCommand(truncateMiddle(current.stats.sessionKey, 44)) : "missing",
490
+ ),
491
+ buildStatLine("messages", formatNumber(current.stats.messageCount)),
492
+ buildStatLine(
493
+ "summaries",
494
+ `${formatNumber(current.stats.summaryCount)} (${formatNumber(current.stats.leafSummaryCount)} leaf, ${formatNumber(current.stats.condensedSummaryCount)} condensed)`,
495
+ ),
496
+ buildStatLine("stored summary tokens", formatNumber(current.stats.storedSummaryTokens)),
497
+ buildStatLine("summarized source tokens", formatNumber(current.stats.summarizedSourceTokens)),
498
+ buildStatLine("tokens in context", formatNumber(current.stats.contextTokenCount)),
499
+ buildStatLine(
500
+ "compression ratio",
501
+ formatCompressionRatio(current.stats.contextTokenCount, current.stats.compressedTokenCount),
502
+ ),
503
+ buildStatLine(
504
+ "doctor",
505
+ conversationDoctor.total > 0
506
+ ? `${formatNumber(conversationDoctor.total)} issue(s) in this conversation`
507
+ : "clean",
508
+ ),
509
+ ]),
510
+ );
511
+ } else {
512
+ lines.push(
513
+ buildSection("๐Ÿ“ Current conversation", [
514
+ buildStatLine("status", "unavailable"),
515
+ buildStatLine("reason", current.reason),
516
+ buildStatLine("fallback", "Showing Global stats only."),
517
+ ]),
518
+ );
519
+ }
520
+
521
+ return lines.join("\n");
522
+ }
523
+
524
+ async function buildDoctorText(params: {
525
+ ctx: PluginCommandContext;
526
+ db: DatabaseSync;
527
+ }): Promise<string> {
528
+ const current = await resolveCurrentConversation(params);
529
+
530
+ if (current.kind === "unavailable") {
531
+ return [
532
+ ...buildHeaderLines(),
533
+ "",
534
+ "๐Ÿฉบ Lossless Claw Doctor",
535
+ "",
536
+ buildSection("๐Ÿ“ Current conversation", [
537
+ buildStatLine("status", "unavailable"),
538
+ buildStatLine("reason", current.reason),
539
+ buildStatLine("fallback", "Doctor is conversation-scoped, so no global scan ran."),
540
+ ]),
541
+ ].join("\n");
542
+ }
543
+
544
+ const stats = getDoctorSummaryStats(params.db, current.stats.conversationId);
545
+ const lines = [
546
+ ...buildHeaderLines(),
547
+ "",
548
+ "๐Ÿฉบ Lossless Claw Doctor",
549
+ "",
550
+ buildSection("๐Ÿ“ Current conversation", [
551
+ buildStatLine("conversation id", formatNumber(current.stats.conversationId)),
552
+ buildStatLine(
553
+ "session key",
554
+ current.stats.sessionKey ? formatCommand(truncateMiddle(current.stats.sessionKey, 44)) : "missing",
555
+ ),
556
+ buildStatLine("scope", "this conversation only"),
557
+ ]),
558
+ "",
559
+ buildSection("๐Ÿงช Scan", [
560
+ buildStatLine("detected summaries", formatNumber(stats.total)),
561
+ buildStatLine("old-marker summaries", formatNumber(stats.old)),
562
+ buildStatLine("truncated-marker summaries", formatNumber(stats.truncated)),
563
+ buildStatLine("fallback-marker summaries", formatNumber(stats.fallback)),
564
+ buildStatLine("result", stats.total === 0 ? "clean" : "issues found"),
565
+ ]),
566
+ ];
567
+
568
+ if (stats.total > 0) {
569
+ const summaryList = stats.candidates
570
+ .slice()
571
+ .sort((left, right) => left.summaryId.localeCompare(right.summaryId))
572
+ .map((candidate) => `${candidate.summaryId} (${candidate.markerKind})`)
573
+ .join(", ");
574
+ lines.push(
575
+ "",
576
+ buildSection("๐Ÿงท Affected summaries", [summaryList]),
577
+ "",
578
+ buildSection("๐Ÿ› ๏ธ Next step", [
579
+ `${formatCommand(`${VISIBLE_COMMAND} doctor apply`)} repairs these in place for the current conversation.`,
580
+ ]),
581
+ );
582
+ }
583
+
584
+ return lines.join("\n");
585
+ }
586
+
587
+ async function buildDoctorApplyText(params: {
588
+ ctx: PluginCommandContext;
589
+ db: DatabaseSync;
590
+ config: LcmConfig;
591
+ deps?: LcmDependencies;
592
+ summarize?: LcmSummarizeFn;
593
+ }): Promise<string> {
594
+ const current = await resolveCurrentConversation(params);
595
+
596
+ if (current.kind === "unavailable") {
597
+ return [
598
+ ...buildHeaderLines(),
599
+ "",
600
+ "๐Ÿฉบ Lossless Claw Doctor Apply",
601
+ "",
602
+ buildSection("๐Ÿ“ Current conversation", [
603
+ buildStatLine("status", "unavailable"),
604
+ buildStatLine("reason", current.reason),
605
+ buildStatLine("fallback", "Doctor apply is conversation-scoped, so no global repair ran."),
606
+ ]),
607
+ ].join("\n");
608
+ }
609
+
610
+ const stats = getDoctorSummaryStats(params.db, current.stats.conversationId);
611
+ let result: Awaited<ReturnType<typeof applyScopedDoctorRepair>>;
612
+ try {
613
+ result = await applyScopedDoctorRepair({
614
+ db: params.db,
615
+ config: params.config,
616
+ conversationId: current.stats.conversationId,
617
+ deps: params.deps,
618
+ summarize: params.summarize,
619
+ runtimeConfig: params.ctx.config,
620
+ });
621
+ } catch (error) {
622
+ return [
623
+ ...buildHeaderLines(),
624
+ "",
625
+ "๐Ÿฉบ Lossless Claw Doctor Apply",
626
+ "",
627
+ buildSection("๐Ÿ“ Current conversation", [
628
+ buildStatLine("conversation id", formatNumber(current.stats.conversationId)),
629
+ buildStatLine(
630
+ "session key",
631
+ current.stats.sessionKey ? formatCommand(truncateMiddle(current.stats.sessionKey, 44)) : "missing",
632
+ ),
633
+ buildStatLine("scope", "this conversation only"),
634
+ ]),
635
+ "",
636
+ buildSection("๐Ÿ› ๏ธ Apply", [
637
+ buildStatLine("mode", "in-place summary rewrite"),
638
+ buildStatLine("status", "failed"),
639
+ buildStatLine("reason", error instanceof Error ? error.message : "unknown repair failure"),
640
+ ]),
641
+ ].join("\n");
642
+ }
643
+
644
+ const lines = [
645
+ ...buildHeaderLines(),
646
+ "",
647
+ "๐Ÿฉบ Lossless Claw Doctor Apply",
648
+ "",
649
+ buildSection("๐Ÿ“ Current conversation", [
650
+ buildStatLine("conversation id", formatNumber(current.stats.conversationId)),
651
+ buildStatLine(
652
+ "session key",
653
+ current.stats.sessionKey ? formatCommand(truncateMiddle(current.stats.sessionKey, 44)) : "missing",
654
+ ),
655
+ buildStatLine("scope", "this conversation only"),
656
+ ]),
657
+ "",
658
+ ];
659
+
660
+ if (result.kind === "unavailable") {
661
+ lines.push(
662
+ buildSection("๐Ÿ› ๏ธ Apply", [
663
+ buildStatLine("mode", "in-place summary rewrite"),
664
+ buildStatLine("status", "unavailable"),
665
+ buildStatLine("reason", result.reason),
666
+ ]),
667
+ );
668
+ return lines.join("\n");
669
+ }
670
+
671
+ lines.push(
672
+ buildSection("๐Ÿ› ๏ธ Apply", [
673
+ buildStatLine("mode", "in-place summary rewrite"),
674
+ buildStatLine("detected summaries", formatNumber(stats.total)),
675
+ buildStatLine("old-marker summaries", formatNumber(stats.old)),
676
+ buildStatLine("truncated-marker summaries", formatNumber(stats.truncated)),
677
+ buildStatLine("fallback-marker summaries", formatNumber(stats.fallback)),
678
+ buildStatLine("repaired summaries", formatNumber(result.repaired)),
679
+ buildStatLine("unchanged summaries", formatNumber(result.unchanged)),
680
+ buildStatLine("skipped summaries", formatNumber(result.skipped.length)),
681
+ buildStatLine(
682
+ "result",
683
+ stats.total === 0
684
+ ? "clean; no writes ran"
685
+ : result.repaired > 0
686
+ ? `repaired ${formatNumber(result.repaired)} summary(s) in place`
687
+ : "no repairs applied",
688
+ ),
689
+ ]),
690
+ );
691
+
692
+ if (result.repairedSummaryIds.length > 0) {
693
+ lines.push(
694
+ "",
695
+ buildSection("๐Ÿงท Repaired summaries", [result.repairedSummaryIds.join(", ")]),
696
+ );
697
+ }
698
+
699
+ if (result.skipped.length > 0) {
700
+ lines.push(
701
+ "",
702
+ buildSection(
703
+ "โš ๏ธ Deferred",
704
+ result.skipped.map((item) => `${item.summaryId}: ${item.reason}`),
705
+ ),
706
+ );
707
+ }
708
+
709
+ return lines.join("\n");
710
+ }
711
+
712
+ export function createLcmCommand(params: {
713
+ db: DatabaseSync;
714
+ config: LcmConfig;
715
+ deps?: LcmDependencies;
716
+ summarize?: LcmSummarizeFn;
717
+ }): OpenClawPluginCommandDefinition {
718
+ return {
719
+ name: "lcm",
720
+ nativeNames: {
721
+ default: "lossless",
722
+ },
723
+ description: "Show Lossless Claw health, scan broken summaries, and repair scoped doctor issues.",
724
+ acceptsArgs: true,
725
+ handler: async (ctx) => {
726
+ const parsed = parseLcmCommand(ctx.args);
727
+ switch (parsed.kind) {
728
+ case "status":
729
+ return { text: await buildStatusText({ ctx, db: params.db, config: params.config }) };
730
+ case "doctor":
731
+ return parsed.apply
732
+ ? {
733
+ text: await buildDoctorApplyText({
734
+ ctx,
735
+ db: params.db,
736
+ config: params.config,
737
+ deps: params.deps,
738
+ summarize: params.summarize,
739
+ }),
740
+ }
741
+ : { text: await buildDoctorText({ ctx, db: params.db }) };
742
+ case "help":
743
+ return { text: buildHelpText(parsed.error) };
744
+ }
745
+ },
746
+ };
747
+ }
748
+
749
+ export const __testing = {
750
+ parseLcmCommand,
751
+ detectDoctorMarker,
752
+ getDoctorSummaryStats,
753
+ getLcmStatusStats,
754
+ getConversationStatusStats,
755
+ resolveCurrentConversation,
756
+ resolveContextEngineSlot,
757
+ resolvePluginEnabled,
758
+ resolvePluginSelected,
759
+ };