@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.
- package/README.md +31 -1
- package/docs/configuration.md +23 -0
- package/openclaw.plugin.json +75 -0
- package/package.json +2 -1
- package/skills/lossless-claw/SKILL.md +33 -0
- package/skills/lossless-claw/references/architecture.md +52 -0
- package/skills/lossless-claw/references/config.md +263 -0
- package/skills/lossless-claw/references/diagnostics.md +79 -0
- package/skills/lossless-claw/references/recall-tools.md +55 -0
- package/skills/lossless-claw/references/session-lifecycle.md +59 -0
- package/src/assembler.ts +132 -36
- package/src/compaction.ts +22 -46
- package/src/db/config.ts +52 -20
- package/src/db/migration.ts +50 -13
- package/src/engine.ts +781 -172
- package/src/plugin/index.ts +45 -0
- package/src/plugin/lcm-command.ts +759 -0
- package/src/plugin/lcm-doctor-apply.ts +546 -0
- package/src/plugin/lcm-doctor-shared.ts +210 -0
- package/src/store/conversation-store.ts +60 -21
- package/src/store/parse-utc-timestamp.ts +25 -0
- package/src/store/summary-store.ts +380 -11
- package/src/summarize.ts +107 -20
- package/src/tools/lcm-expand-query-tool.ts +58 -25
- package/src/tools/lcm-expansion-recursion-guard.ts +87 -0
|
@@ -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
|
+
};
|