@martian-engineering/lossless-claw 0.8.0 โ†’ 0.8.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.
Files changed (52) hide show
  1. package/README.md +8 -0
  2. package/dist/index.js +19240 -0
  3. package/docs/configuration.md +15 -5
  4. package/openclaw.plugin.json +27 -3
  5. package/package.json +7 -6
  6. package/skills/lossless-claw/references/config.md +37 -0
  7. package/index.ts +0 -2
  8. package/src/assembler.ts +0 -1196
  9. package/src/compaction.ts +0 -1753
  10. package/src/db/config.ts +0 -345
  11. package/src/db/connection.ts +0 -151
  12. package/src/db/features.ts +0 -61
  13. package/src/db/migration.ts +0 -868
  14. package/src/engine.ts +0 -4486
  15. package/src/estimate-tokens.ts +0 -80
  16. package/src/expansion-auth.ts +0 -365
  17. package/src/expansion-policy.ts +0 -303
  18. package/src/expansion.ts +0 -383
  19. package/src/integrity.ts +0 -600
  20. package/src/large-files.ts +0 -546
  21. package/src/lcm-log.ts +0 -37
  22. package/src/openclaw-bridge.ts +0 -22
  23. package/src/plugin/index.ts +0 -2037
  24. package/src/plugin/lcm-command.ts +0 -1040
  25. package/src/plugin/lcm-doctor-apply.ts +0 -540
  26. package/src/plugin/lcm-doctor-cleaners.ts +0 -655
  27. package/src/plugin/lcm-doctor-shared.ts +0 -210
  28. package/src/plugin/shared-init.ts +0 -59
  29. package/src/prune.ts +0 -391
  30. package/src/retrieval.ts +0 -360
  31. package/src/session-patterns.ts +0 -23
  32. package/src/startup-banner-log.ts +0 -49
  33. package/src/store/compaction-telemetry-store.ts +0 -156
  34. package/src/store/conversation-store.ts +0 -929
  35. package/src/store/fts5-sanitize.ts +0 -50
  36. package/src/store/full-text-fallback.ts +0 -83
  37. package/src/store/full-text-sort.ts +0 -21
  38. package/src/store/index.ts +0 -39
  39. package/src/store/parse-utc-timestamp.ts +0 -25
  40. package/src/store/summary-store.ts +0 -1519
  41. package/src/summarize.ts +0 -1508
  42. package/src/tools/common.ts +0 -53
  43. package/src/tools/lcm-conversation-scope.ts +0 -127
  44. package/src/tools/lcm-describe-tool.ts +0 -245
  45. package/src/tools/lcm-expand-query-tool.ts +0 -1235
  46. package/src/tools/lcm-expand-tool.delegation.ts +0 -580
  47. package/src/tools/lcm-expand-tool.ts +0 -453
  48. package/src/tools/lcm-expansion-recursion-guard.ts +0 -373
  49. package/src/tools/lcm-grep-tool.ts +0 -228
  50. package/src/transaction-mutex.ts +0 -136
  51. package/src/transcript-repair.ts +0 -301
  52. package/src/types.ts +0 -165
@@ -1,1040 +0,0 @@
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
- applyDoctorCleaners,
11
- getDoctorCleanerApplyUnavailableReason,
12
- getDoctorCleanerFilterIds,
13
- scanDoctorCleaners,
14
- type DoctorCleanerId,
15
- } from "./lcm-doctor-cleaners.js";
16
- import {
17
- detectDoctorMarker,
18
- getDoctorSummaryStats,
19
- type DoctorSummaryStats,
20
- } from "./lcm-doctor-shared.js";
21
-
22
- const VISIBLE_COMMAND = "/lossless";
23
- const HIDDEN_ALIAS = "/lcm";
24
-
25
- type LcmStatusStats = {
26
- conversationCount: number;
27
- summaryCount: number;
28
- storedSummaryTokens: number;
29
- summarizedSourceTokens: number;
30
- leafSummaryCount: number;
31
- condensedSummaryCount: number;
32
- };
33
-
34
- type LcmConversationStatusStats = {
35
- conversationId: number;
36
- sessionId: string;
37
- sessionKey: string | null;
38
- messageCount: number;
39
- summaryCount: number;
40
- storedSummaryTokens: number;
41
- summarizedSourceTokens: number;
42
- contextTokenCount: number;
43
- compressedTokenCount: number;
44
- leafSummaryCount: number;
45
- condensedSummaryCount: number;
46
- };
47
-
48
- type CurrentConversationResolution =
49
- | {
50
- kind: "resolved";
51
- source: "session_key" | "session_key_via_session_id" | "session_id";
52
- stats: LcmConversationStatusStats;
53
- }
54
- | {
55
- kind: "unavailable";
56
- reason: string;
57
- };
58
-
59
- type ParsedLcmCommand =
60
- | { kind: "status" }
61
- | { kind: "doctor"; apply: boolean }
62
- | { kind: "doctor_cleaners"; apply: boolean; filterId?: DoctorCleanerId; vacuum: boolean }
63
- | { kind: "help"; error?: string };
64
-
65
- const DOCTOR_CLEANER_IDS = new Set<DoctorCleanerId>(getDoctorCleanerFilterIds());
66
-
67
- function asRecord(value: unknown): Record<string, unknown> | undefined {
68
- return value && typeof value === "object" && !Array.isArray(value)
69
- ? (value as Record<string, unknown>)
70
- : undefined;
71
- }
72
-
73
- function formatBoolean(value: boolean): string {
74
- return value ? "yes" : "no";
75
- }
76
-
77
- function formatNumber(value: number): string {
78
- return new Intl.NumberFormat("en-US").format(value);
79
- }
80
-
81
- function formatBytes(bytes: number): string {
82
- if (!Number.isFinite(bytes) || bytes < 0) {
83
- return "unknown";
84
- }
85
- if (bytes < 1024) {
86
- return `${bytes} B`;
87
- }
88
- const units = ["KB", "MB", "GB", "TB"];
89
- let value = bytes / 1024;
90
- let unitIndex = 0;
91
- while (value >= 1024 && unitIndex < units.length - 1) {
92
- value /= 1024;
93
- unitIndex += 1;
94
- }
95
- const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
96
- return `${value.toFixed(precision)} ${units[unitIndex]}`;
97
- }
98
-
99
- function formatCommand(command: string): string {
100
- return `\`${command}\``;
101
- }
102
-
103
- function buildHeaderLines(): string[] {
104
- return [
105
- `**๐Ÿฆ€ Lossless Claw v${packageJson.version}**`,
106
- `Help: ${formatCommand(`${VISIBLE_COMMAND} help`)} ยท Alias: ${formatCommand(HIDDEN_ALIAS)}`,
107
- ];
108
- }
109
-
110
- function buildSection(title: string, lines: string[]): string {
111
- return [`**${title}**`, ...lines.map((line) => ` ${line}`)].join("\n");
112
- }
113
-
114
- function buildStatLine(label: string, value: string): string {
115
- return `${label}: ${value}`;
116
- }
117
-
118
- function formatCompressionRatio(contextTokens: number, compressedTokens: number): string {
119
- if (
120
- !Number.isFinite(contextTokens) ||
121
- contextTokens <= 0 ||
122
- !Number.isFinite(compressedTokens) ||
123
- compressedTokens <= 0
124
- ) {
125
- return "n/a";
126
- }
127
- const ratio = Math.max(1, Math.round(compressedTokens / contextTokens));
128
- return `1:${formatNumber(ratio)}`;
129
- }
130
-
131
- function truncateMiddle(value: string, maxChars: number): string {
132
- if (value.length <= maxChars) {
133
- return value;
134
- }
135
- if (maxChars <= 3) {
136
- return value.slice(0, maxChars);
137
- }
138
- const head = Math.ceil((maxChars - 1) / 2);
139
- const tail = Math.floor((maxChars - 1) / 2);
140
- return `${value.slice(0, head)}โ€ฆ${value.slice(value.length - tail)}`;
141
- }
142
-
143
- function splitArgs(rawArgs: string | undefined): string[] {
144
- return (rawArgs ?? "")
145
- .trim()
146
- .split(/\s+/)
147
- .map((token) => token.trim())
148
- .filter(Boolean);
149
- }
150
-
151
- function parseDoctorCleanerApplyArgs(tokens: string[]):
152
- | { ok: true; filterId?: DoctorCleanerId; vacuum: boolean }
153
- | { ok: false; error: string } {
154
- let filterId: DoctorCleanerId | undefined;
155
- let vacuum = false;
156
-
157
- for (const token of tokens) {
158
- const normalized = token.toLowerCase();
159
- if (normalized === "vacuum") {
160
- vacuum = true;
161
- continue;
162
- }
163
- if (DOCTOR_CLEANER_IDS.has(normalized as DoctorCleanerId) && !filterId) {
164
- filterId = normalized as DoctorCleanerId;
165
- continue;
166
- }
167
- return {
168
- ok: false,
169
- error:
170
- `\`${VISIBLE_COMMAND} doctor clean apply\` accepts at most one filter id (\`${getDoctorCleanerFilterIds().join("`, `")}\`) plus optional \`vacuum\`.`,
171
- };
172
- }
173
-
174
- return { ok: true, filterId, vacuum };
175
- }
176
-
177
- function parseLcmCommand(rawArgs: string | undefined): ParsedLcmCommand {
178
- const tokens = splitArgs(rawArgs);
179
- if (tokens.length === 0) {
180
- return { kind: "status" };
181
- }
182
-
183
- const [head, ...rest] = tokens;
184
- switch (head.toLowerCase()) {
185
- case "status":
186
- return rest.length === 0
187
- ? { kind: "status" }
188
- : { kind: "help", error: "`/lcm status` does not accept extra arguments." };
189
- case "doctor":
190
- if (rest.length === 0) {
191
- return { kind: "doctor", apply: false };
192
- }
193
- if (rest.length === 1 && rest[0]?.toLowerCase() === "clean") {
194
- return { kind: "doctor_cleaners", apply: false, vacuum: false };
195
- }
196
- if (rest[0]?.toLowerCase() === "clean" && rest[1]?.toLowerCase() === "apply") {
197
- const parsedApply = parseDoctorCleanerApplyArgs(rest.slice(2));
198
- return parsedApply.ok
199
- ? {
200
- kind: "doctor_cleaners",
201
- apply: true,
202
- filterId: parsedApply.filterId,
203
- vacuum: parsedApply.vacuum,
204
- }
205
- : { kind: "help", error: parsedApply.error };
206
- }
207
- if (rest.length === 1 && rest[0]?.toLowerCase() === "apply") {
208
- return { kind: "doctor", apply: true };
209
- }
210
- return {
211
- kind: "help",
212
- error:
213
- `\`${VISIBLE_COMMAND} doctor\` accepts no arguments, \`clean\` for global high-confidence junk diagnostics, \`clean apply [filter-id] [vacuum]\` for cleanup, or \`apply\` for the scoped summary repair path.`,
214
- };
215
- case "help":
216
- return { kind: "help" };
217
- default:
218
- return {
219
- kind: "help",
220
- error: `Unknown subcommand \`${head}\`. Supported: status, doctor, doctor clean, doctor apply, help.`,
221
- };
222
- }
223
- }
224
-
225
- function getLcmStatusStats(db: DatabaseSync): LcmStatusStats {
226
- const row = db
227
- .prepare(
228
- `SELECT
229
- COALESCE((SELECT COUNT(*) FROM conversations), 0) AS conversation_count,
230
- COALESCE(COUNT(*), 0) AS summary_count,
231
- COALESCE(SUM(token_count), 0) AS stored_summary_tokens,
232
- COALESCE(SUM(CASE WHEN kind = 'leaf' THEN source_message_token_count ELSE 0 END), 0) AS summarized_source_tokens,
233
- COALESCE(SUM(CASE WHEN kind = 'leaf' THEN 1 ELSE 0 END), 0) AS leaf_summary_count,
234
- COALESCE(SUM(CASE WHEN kind = 'condensed' THEN 1 ELSE 0 END), 0) AS condensed_summary_count
235
- FROM summaries`,
236
- )
237
- .get() as
238
- | {
239
- conversation_count: number;
240
- summary_count: number;
241
- stored_summary_tokens: number;
242
- summarized_source_tokens: number;
243
- leaf_summary_count: number;
244
- condensed_summary_count: number;
245
- }
246
- | undefined;
247
-
248
- return {
249
- conversationCount: row?.conversation_count ?? 0,
250
- summaryCount: row?.summary_count ?? 0,
251
- storedSummaryTokens: row?.stored_summary_tokens ?? 0,
252
- summarizedSourceTokens: row?.summarized_source_tokens ?? 0,
253
- leafSummaryCount: row?.leaf_summary_count ?? 0,
254
- condensedSummaryCount: row?.condensed_summary_count ?? 0,
255
- };
256
- }
257
-
258
- function getConversationStatusStats(
259
- db: DatabaseSync,
260
- conversationId: number,
261
- ): LcmConversationStatusStats | null {
262
- const row = db
263
- .prepare(
264
- `SELECT
265
- c.conversation_id,
266
- c.session_id,
267
- c.session_key,
268
- COALESCE((SELECT COUNT(*) FROM messages WHERE conversation_id = c.conversation_id), 0) AS message_count,
269
- COALESCE((SELECT COUNT(*) FROM summaries WHERE conversation_id = c.conversation_id), 0) AS summary_count,
270
- COALESCE((SELECT SUM(token_count) FROM summaries WHERE conversation_id = c.conversation_id), 0) AS stored_summary_tokens,
271
- 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,
272
- COALESCE((
273
- SELECT SUM(token_count)
274
- FROM (
275
- SELECT m.token_count AS token_count
276
- FROM context_items ci
277
- JOIN messages m ON m.message_id = ci.message_id
278
- WHERE ci.conversation_id = c.conversation_id
279
- AND ci.item_type = 'message'
280
- UNION ALL
281
- SELECT s.token_count AS token_count
282
- FROM context_items ci
283
- JOIN summaries s ON s.summary_id = ci.summary_id
284
- WHERE ci.conversation_id = c.conversation_id
285
- AND ci.item_type = 'summary'
286
- ) context_token_rows
287
- ), 0) AS context_token_count,
288
- COALESCE((
289
- SELECT SUM(COALESCE(s.source_message_token_count, 0) + COALESCE(s.descendant_token_count, 0))
290
- FROM context_items ci
291
- JOIN summaries s ON s.summary_id = ci.summary_id
292
- WHERE ci.conversation_id = c.conversation_id
293
- AND ci.item_type = 'summary'
294
- ), 0) AS compressed_token_count,
295
- 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,
296
- 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
297
- FROM conversations c
298
- WHERE c.conversation_id = ?`,
299
- )
300
- .get(conversationId) as
301
- | {
302
- conversation_id: number;
303
- session_id: string;
304
- session_key: string | null;
305
- message_count: number;
306
- summary_count: number;
307
- stored_summary_tokens: number;
308
- summarized_source_tokens: number;
309
- context_token_count: number;
310
- compressed_token_count: number;
311
- leaf_summary_count: number;
312
- condensed_summary_count: number;
313
- }
314
- | undefined;
315
-
316
- if (!row) {
317
- return null;
318
- }
319
-
320
- return {
321
- conversationId: row.conversation_id,
322
- sessionId: row.session_id,
323
- sessionKey: row.session_key,
324
- messageCount: row.message_count,
325
- summaryCount: row.summary_count,
326
- storedSummaryTokens: row.stored_summary_tokens,
327
- summarizedSourceTokens: row.summarized_source_tokens,
328
- contextTokenCount: row.context_token_count,
329
- compressedTokenCount: row.compressed_token_count,
330
- leafSummaryCount: row.leaf_summary_count,
331
- condensedSummaryCount: row.condensed_summary_count,
332
- };
333
- }
334
-
335
- function normalizeIdentity(value: string | undefined): string | undefined {
336
- const normalized = value?.trim();
337
- return normalized ? normalized : undefined;
338
- }
339
-
340
- function getConversationStatusBySessionKey(
341
- db: DatabaseSync,
342
- sessionKey: string,
343
- ): LcmConversationStatusStats | null {
344
- const row = db
345
- .prepare(`SELECT conversation_id FROM conversations WHERE session_key = ? LIMIT 1`)
346
- .get(sessionKey) as { conversation_id: number } | undefined;
347
-
348
- if (!row) {
349
- return null;
350
- }
351
-
352
- return getConversationStatusStats(db, row.conversation_id);
353
- }
354
-
355
- function getConversationStatusBySessionId(
356
- db: DatabaseSync,
357
- sessionId: string,
358
- ): LcmConversationStatusStats | null {
359
- const row = db
360
- .prepare(
361
- `SELECT conversation_id
362
- FROM conversations
363
- WHERE session_id = ?
364
- ORDER BY created_at DESC
365
- LIMIT 1`,
366
- )
367
- .get(sessionId) as { conversation_id: number } | undefined;
368
-
369
- if (!row) {
370
- return null;
371
- }
372
-
373
- return getConversationStatusStats(db, row.conversation_id);
374
- }
375
-
376
- async function resolveCurrentConversation(params: {
377
- ctx: PluginCommandContext;
378
- db: DatabaseSync;
379
- }): Promise<CurrentConversationResolution> {
380
- const sessionKey = normalizeIdentity(params.ctx.sessionKey);
381
- const sessionId = normalizeIdentity(params.ctx.sessionId);
382
-
383
- if (sessionKey) {
384
- const bySessionKey = getConversationStatusBySessionKey(params.db, sessionKey);
385
- if (bySessionKey) {
386
- return { kind: "resolved", source: "session_key", stats: bySessionKey };
387
- }
388
-
389
- if (sessionId) {
390
- const bySessionId = getConversationStatusBySessionId(params.db, sessionId);
391
- if (bySessionId) {
392
- if (!bySessionId.sessionKey || bySessionId.sessionKey === sessionKey) {
393
- return {
394
- kind: "resolved",
395
- source: "session_key_via_session_id",
396
- stats: bySessionId,
397
- };
398
- }
399
-
400
- return {
401
- kind: "unavailable",
402
- 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.`,
403
- };
404
- }
405
- }
406
-
407
- return {
408
- kind: "unavailable",
409
- reason: sessionId
410
- ? `No LCM conversation is stored yet for active session key ${formatCommand(sessionKey)} or active session id ${formatCommand(sessionId)}.`
411
- : `No LCM conversation is stored yet for active session key ${formatCommand(sessionKey)}.`,
412
- };
413
- }
414
-
415
- if (sessionId) {
416
- const bySessionId = getConversationStatusBySessionId(params.db, sessionId);
417
- if (bySessionId) {
418
- return { kind: "resolved", source: "session_id", stats: bySessionId };
419
- }
420
-
421
- return {
422
- kind: "unavailable",
423
- reason: `OpenClaw did not expose an active session key here. Tried active session id ${formatCommand(sessionId)}, but no stored LCM conversation matched it.`,
424
- };
425
- }
426
-
427
- return {
428
- kind: "unavailable",
429
- reason: "OpenClaw did not expose an active session key or session id here, so only GLOBAL stats are available.",
430
- };
431
- }
432
-
433
- function resolvePluginEnabled(config: unknown): boolean {
434
- const root = asRecord(config);
435
- const plugins = asRecord(root?.plugins);
436
- const entries = asRecord(plugins?.entries);
437
- const entry = asRecord(entries?.["lossless-claw"]);
438
- if (typeof entry?.enabled === "boolean") {
439
- return entry.enabled;
440
- }
441
- return true;
442
- }
443
-
444
- function resolveContextEngineSlot(config: unknown): string {
445
- const root = asRecord(config);
446
- const plugins = asRecord(root?.plugins);
447
- const slots = asRecord(plugins?.slots);
448
- return typeof slots?.contextEngine === "string" ? slots.contextEngine.trim() : "";
449
- }
450
-
451
- function resolvePluginSelected(config: unknown): boolean {
452
- const slot = resolveContextEngineSlot(config);
453
- return slot === "" || slot === "lossless-claw" || slot === "default";
454
- }
455
-
456
- function resolveDbSizeLabel(dbPath: string): string {
457
- const trimmed = dbPath.trim();
458
- if (!trimmed || trimmed === ":memory:" || trimmed.startsWith("file::memory:")) {
459
- return "in-memory";
460
- }
461
- try {
462
- return formatBytes(statSync(trimmed).size);
463
- } catch {
464
- return "missing";
465
- }
466
- }
467
-
468
- function buildHelpText(error?: string): string {
469
- const lines = [
470
- ...(error ? [`โš ๏ธ ${error}`, ""] : []),
471
- ...buildHeaderLines(),
472
- "",
473
- buildSection("๐Ÿ“˜ Commands", [
474
- buildStatLine(formatCommand(VISIBLE_COMMAND), "Show compact status output."),
475
- buildStatLine(formatCommand(`${VISIBLE_COMMAND} status`), "Show plugin, Global, and current-conversation status."),
476
- buildStatLine(formatCommand(`${VISIBLE_COMMAND} doctor`), "Scan for broken or truncated summaries."),
477
- buildStatLine(
478
- formatCommand(`${VISIBLE_COMMAND} doctor clean`),
479
- "Report global high-confidence junk candidates without deleting anything.",
480
- ),
481
- buildStatLine(
482
- formatCommand(`${VISIBLE_COMMAND} doctor clean apply`),
483
- "Delete approved high-confidence cleaner matches after creating a DB backup.",
484
- ),
485
- buildStatLine(formatCommand(`${VISIBLE_COMMAND} doctor apply`), "Repair broken summaries in the current conversation."),
486
- ]),
487
- "",
488
- buildSection("๐Ÿงญ Notes", [
489
- buildStatLine("subcommands", `Discover them with ${formatCommand(`${VISIBLE_COMMAND} help`)}.`),
490
- buildStatLine("alias", `${formatCommand(HIDDEN_ALIAS)} is accepted as a shorter alias.`),
491
- buildStatLine("current conversation", "Uses the active LCM session when the host exposes session identity."),
492
- ]),
493
- ];
494
- return lines.join("\n");
495
- }
496
-
497
- function buildDoctorCleanerExampleLine(params: {
498
- conversationId: number;
499
- sessionKey: string | null;
500
- messageCount: number;
501
- firstMessagePreview: string | null;
502
- }): string {
503
- const sessionKey = params.sessionKey ? formatCommand(truncateMiddle(params.sessionKey, 44)) : "missing";
504
- const preview = params.firstMessagePreview ? ` ยท first: ${JSON.stringify(params.firstMessagePreview)}` : "";
505
- return `conv ${formatNumber(params.conversationId)} ยท session key ${sessionKey} ยท messages ${formatNumber(params.messageCount)}${preview}`;
506
- }
507
-
508
- async function buildStatusText(params: {
509
- ctx: PluginCommandContext;
510
- db: DatabaseSync;
511
- config: LcmConfig;
512
- }): Promise<string> {
513
- const status = getLcmStatusStats(params.db);
514
- const doctor = getDoctorSummaryStats(params.db);
515
- const enabled = resolvePluginEnabled(params.ctx.config);
516
- const selected = resolvePluginSelected(params.ctx.config);
517
- const slot = resolveContextEngineSlot(params.ctx.config);
518
- const dbSize = resolveDbSizeLabel(params.config.databasePath);
519
- const current = await resolveCurrentConversation({
520
- ctx: params.ctx,
521
- db: params.db,
522
- });
523
-
524
- const lines = [
525
- ...buildHeaderLines(),
526
- "",
527
- buildSection("๐Ÿงฉ Plugin", [
528
- buildStatLine("enabled", formatBoolean(enabled)),
529
- buildStatLine("selected", `${formatBoolean(selected)}${slot ? ` (slot=${slot})` : " (slot=unset)"}`),
530
- buildStatLine("db path", params.config.databasePath),
531
- buildStatLine("db size", dbSize),
532
- ]),
533
- "",
534
- buildSection("๐ŸŒ Global", [
535
- buildStatLine("conversations", formatNumber(status.conversationCount)),
536
- buildStatLine(
537
- "summaries",
538
- `${formatNumber(status.summaryCount)} (${formatNumber(status.leafSummaryCount)} leaf, ${formatNumber(status.condensedSummaryCount)} condensed)`,
539
- ),
540
- buildStatLine("stored summary tokens", formatNumber(status.storedSummaryTokens)),
541
- buildStatLine("summarized source tokens", formatNumber(status.summarizedSourceTokens)),
542
- ]),
543
- "",
544
- ];
545
-
546
- if (current.kind === "resolved") {
547
- const conversationDoctor =
548
- doctor.byConversation.get(current.stats.conversationId) ?? {
549
- total: 0,
550
- old: 0,
551
- truncated: 0,
552
- fallback: 0,
553
- };
554
- lines.push(
555
- buildSection("๐Ÿ“ Current conversation", [
556
- buildStatLine("conversation id", formatNumber(current.stats.conversationId)),
557
- buildStatLine(
558
- "session key",
559
- current.stats.sessionKey ? formatCommand(truncateMiddle(current.stats.sessionKey, 44)) : "missing",
560
- ),
561
- buildStatLine("messages", formatNumber(current.stats.messageCount)),
562
- buildStatLine(
563
- "summaries",
564
- `${formatNumber(current.stats.summaryCount)} (${formatNumber(current.stats.leafSummaryCount)} leaf, ${formatNumber(current.stats.condensedSummaryCount)} condensed)`,
565
- ),
566
- buildStatLine("stored summary tokens", formatNumber(current.stats.storedSummaryTokens)),
567
- buildStatLine("summarized source tokens", formatNumber(current.stats.summarizedSourceTokens)),
568
- buildStatLine("tokens in context", formatNumber(current.stats.contextTokenCount)),
569
- buildStatLine(
570
- "compression ratio",
571
- formatCompressionRatio(current.stats.contextTokenCount, current.stats.compressedTokenCount),
572
- ),
573
- buildStatLine(
574
- "doctor",
575
- conversationDoctor.total > 0
576
- ? `${formatNumber(conversationDoctor.total)} issue(s) in this conversation`
577
- : "clean",
578
- ),
579
- ]),
580
- );
581
- } else {
582
- lines.push(
583
- buildSection("๐Ÿ“ Current conversation", [
584
- buildStatLine("status", "unavailable"),
585
- buildStatLine("reason", current.reason),
586
- buildStatLine("fallback", "Showing Global stats only."),
587
- ]),
588
- );
589
- }
590
-
591
- return lines.join("\n");
592
- }
593
-
594
- async function buildDoctorText(params: {
595
- ctx: PluginCommandContext;
596
- db: DatabaseSync;
597
- }): Promise<string> {
598
- const current = await resolveCurrentConversation(params);
599
-
600
- if (current.kind === "unavailable") {
601
- return [
602
- ...buildHeaderLines(),
603
- "",
604
- "๐Ÿฉบ Lossless Claw Doctor",
605
- "",
606
- buildSection("๐Ÿ“ Current conversation", [
607
- buildStatLine("status", "unavailable"),
608
- buildStatLine("reason", current.reason),
609
- buildStatLine("fallback", "Doctor is conversation-scoped, so no global scan ran."),
610
- ]),
611
- ].join("\n");
612
- }
613
-
614
- const stats = getDoctorSummaryStats(params.db, current.stats.conversationId);
615
- const lines = [
616
- ...buildHeaderLines(),
617
- "",
618
- "๐Ÿฉบ Lossless Claw Doctor",
619
- "",
620
- buildSection("๐Ÿ“ Current conversation", [
621
- buildStatLine("conversation id", formatNumber(current.stats.conversationId)),
622
- buildStatLine(
623
- "session key",
624
- current.stats.sessionKey ? formatCommand(truncateMiddle(current.stats.sessionKey, 44)) : "missing",
625
- ),
626
- buildStatLine("scope", "this conversation only"),
627
- ]),
628
- "",
629
- buildSection("๐Ÿงช Scan", [
630
- buildStatLine("detected summaries", formatNumber(stats.total)),
631
- buildStatLine("old-marker summaries", formatNumber(stats.old)),
632
- buildStatLine("truncated-marker summaries", formatNumber(stats.truncated)),
633
- buildStatLine("fallback-marker summaries", formatNumber(stats.fallback)),
634
- buildStatLine("result", stats.total === 0 ? "clean" : "issues found"),
635
- ]),
636
- ];
637
-
638
- if (stats.total > 0) {
639
- const summaryList = stats.candidates
640
- .slice()
641
- .sort((left, right) => left.summaryId.localeCompare(right.summaryId))
642
- .map((candidate) => `${candidate.summaryId} (${candidate.markerKind})`)
643
- .join(", ");
644
- lines.push(
645
- "",
646
- buildSection("๐Ÿงท Affected summaries", [summaryList]),
647
- "",
648
- buildSection("๐Ÿ› ๏ธ Next step", [
649
- `${formatCommand(`${VISIBLE_COMMAND} doctor apply`)} repairs these in place for the current conversation.`,
650
- ]),
651
- );
652
- }
653
-
654
- return lines.join("\n");
655
- }
656
-
657
- async function buildDoctorCleanersText(params: {
658
- db: DatabaseSync;
659
- }): Promise<string> {
660
- const scan = scanDoctorCleaners(params.db);
661
- const lines = [
662
- ...buildHeaderLines(),
663
- "",
664
- "๐Ÿฉบ Lossless Claw Doctor Clean",
665
- "",
666
- buildSection("๐ŸŒ Global scan", [
667
- buildStatLine("filters", formatNumber(scan.filters.length)),
668
- buildStatLine("matched conversations", formatNumber(scan.totalDistinctConversations)),
669
- buildStatLine("matched messages", formatNumber(scan.totalDistinctMessages)),
670
- buildStatLine("mode", "read-only diagnostics"),
671
- ]),
672
- ];
673
-
674
- if (scan.filters.every((filter) => filter.conversationCount === 0)) {
675
- lines.push(
676
- "",
677
- buildSection("โœ… Result", ["No high-confidence cleaner candidates detected."]),
678
- );
679
- return lines.join("\n");
680
- }
681
-
682
- for (const filter of scan.filters) {
683
- lines.push(
684
- "",
685
- buildSection(`๐Ÿงน ${filter.label}`, [
686
- buildStatLine("filter id", formatCommand(filter.id)),
687
- buildStatLine("description", filter.description),
688
- buildStatLine("matched conversations", formatNumber(filter.conversationCount)),
689
- buildStatLine("matched messages", formatNumber(filter.messageCount)),
690
- ]),
691
- );
692
-
693
- if (filter.examples.length > 0) {
694
- lines.push(
695
- "",
696
- buildSection(
697
- "๐Ÿงท Examples",
698
- filter.examples.map((example) => buildDoctorCleanerExampleLine(example)),
699
- ),
700
- );
701
- }
702
- }
703
-
704
- lines.push(
705
- "",
706
- buildSection("๐Ÿ› ๏ธ Next step", [
707
- `Review the examples, then run ${formatCommand(`${VISIBLE_COMMAND} doctor clean apply`)} to delete approved matches after Lossless Claw creates a backup.`,
708
- ]),
709
- );
710
-
711
- return lines.join("\n");
712
- }
713
-
714
- function runQuickCheck(db: DatabaseSync): string {
715
- const rows = db.prepare(`PRAGMA quick_check`).all() as Array<{ quick_check?: string }>;
716
- const results = rows
717
- .map((row) => row.quick_check)
718
- .filter((value): value is string => typeof value === "string" && value.length > 0);
719
-
720
- if (results.length === 0) {
721
- return "unknown";
722
- }
723
-
724
- if (results.length === 1 && results[0] === "ok") {
725
- return "ok";
726
- }
727
-
728
- return results.join("; ");
729
- }
730
-
731
- function isPassingQuickCheck(result: string): boolean {
732
- return result === "ok";
733
- }
734
-
735
- async function buildDoctorCleanersApplyText(params: {
736
- db: DatabaseSync;
737
- config: LcmConfig;
738
- filterId?: DoctorCleanerId;
739
- vacuum: boolean;
740
- }): Promise<string> {
741
- const filterIds = params.filterId ? [params.filterId] : undefined;
742
- const unavailableReason = getDoctorCleanerApplyUnavailableReason(params.config.databasePath);
743
- const lines = [
744
- ...buildHeaderLines(),
745
- "",
746
- "๐Ÿฉบ Lossless Claw Doctor Clean Apply",
747
- "",
748
- buildSection("๐ŸŒ Cleaner scope", [
749
- buildStatLine(
750
- "filters",
751
- filterIds && filterIds.length > 0
752
- ? filterIds.map((filter) => formatCommand(filter)).join(", ")
753
- : "all approved cleaner filters",
754
- ),
755
- buildStatLine("vacuum requested", formatBoolean(params.vacuum)),
756
- ]),
757
- "",
758
- ];
759
- if (unavailableReason) {
760
- lines.push(
761
- buildSection("๐Ÿ› ๏ธ Apply", [
762
- buildStatLine("status", "unavailable"),
763
- buildStatLine("reason", unavailableReason),
764
- ]),
765
- );
766
- return lines.join("\n");
767
- }
768
-
769
- const before = scanDoctorCleaners(params.db, filterIds);
770
- lines.splice(
771
- lines.length - 1,
772
- 0,
773
- buildSection("๐Ÿ“Š Current matches", [
774
- buildStatLine("matched conversations before apply", formatNumber(before.totalDistinctConversations)),
775
- buildStatLine("matched messages before apply", formatNumber(before.totalDistinctMessages)),
776
- ]),
777
- "",
778
- );
779
-
780
- if (before.totalDistinctConversations === 0) {
781
- lines.push(
782
- buildSection("๐Ÿ› ๏ธ Apply", [
783
- buildStatLine("status", "completed"),
784
- buildStatLine("backup path", "skipped (no matches)"),
785
- buildStatLine("deleted conversations", "0"),
786
- buildStatLine("deleted messages", "0"),
787
- buildStatLine("vacuumed", "no"),
788
- buildStatLine("quick_check", "not run (no writes)"),
789
- buildStatLine("result", "clean; no deletes ran"),
790
- ]),
791
- );
792
- return lines.join("\n");
793
- }
794
-
795
- let result: ReturnType<typeof applyDoctorCleaners>;
796
- try {
797
- result = applyDoctorCleaners(params.db, {
798
- databasePath: params.config.databasePath,
799
- filterIds,
800
- vacuum: params.vacuum,
801
- });
802
- } catch (error) {
803
- lines.push(
804
- buildSection("๐Ÿ› ๏ธ Apply", [
805
- buildStatLine("status", "failed"),
806
- buildStatLine(
807
- "reason",
808
- error instanceof Error ? error.message : "unknown cleaner apply failure",
809
- ),
810
- ]),
811
- );
812
- return lines.join("\n");
813
- }
814
-
815
- if (result.kind === "unavailable") {
816
- lines.push(
817
- buildSection("๐Ÿ› ๏ธ Apply", [
818
- buildStatLine("status", "unavailable"),
819
- buildStatLine("reason", result.reason),
820
- ]),
821
- );
822
- return lines.join("\n");
823
- }
824
-
825
- const quickCheck = runQuickCheck(params.db);
826
- const quickCheckPassed = isPassingQuickCheck(quickCheck);
827
- lines.push(
828
- buildSection("๐Ÿ› ๏ธ Apply", [
829
- buildStatLine("status", quickCheckPassed ? "completed" : "warning"),
830
- buildStatLine("backup path", result.backupPath),
831
- buildStatLine("deleted conversations", formatNumber(result.deletedConversations)),
832
- buildStatLine("deleted messages", formatNumber(result.deletedMessages)),
833
- buildStatLine("vacuumed", formatBoolean(result.vacuumed)),
834
- buildStatLine("quick_check", quickCheck),
835
- buildStatLine(
836
- "result",
837
- quickCheckPassed
838
- ? result.deletedConversations > 0
839
- ? `removed ${formatNumber(result.deletedConversations)} conversation(s)`
840
- : "clean; no deletes ran"
841
- : "writes committed, but SQLite integrity verification reported problems; inspect the database or restore from the backup before continuing",
842
- ),
843
- ]),
844
- );
845
-
846
- return lines.join("\n");
847
- }
848
-
849
- async function buildDoctorApplyText(params: {
850
- ctx: PluginCommandContext;
851
- db: DatabaseSync;
852
- config: LcmConfig;
853
- deps?: LcmDependencies;
854
- summarize?: LcmSummarizeFn;
855
- }): Promise<string> {
856
- const current = await resolveCurrentConversation(params);
857
-
858
- if (current.kind === "unavailable") {
859
- return [
860
- ...buildHeaderLines(),
861
- "",
862
- "๐Ÿฉบ Lossless Claw Doctor Apply",
863
- "",
864
- buildSection("๐Ÿ“ Current conversation", [
865
- buildStatLine("status", "unavailable"),
866
- buildStatLine("reason", current.reason),
867
- buildStatLine("fallback", "Doctor apply is conversation-scoped, so no global repair ran."),
868
- ]),
869
- ].join("\n");
870
- }
871
-
872
- const stats = getDoctorSummaryStats(params.db, current.stats.conversationId);
873
- let result: Awaited<ReturnType<typeof applyScopedDoctorRepair>>;
874
- try {
875
- result = await applyScopedDoctorRepair({
876
- db: params.db,
877
- config: params.config,
878
- conversationId: current.stats.conversationId,
879
- deps: params.deps,
880
- summarize: params.summarize,
881
- runtimeConfig: params.ctx.config,
882
- });
883
- } catch (error) {
884
- return [
885
- ...buildHeaderLines(),
886
- "",
887
- "๐Ÿฉบ Lossless Claw Doctor Apply",
888
- "",
889
- buildSection("๐Ÿ“ Current conversation", [
890
- buildStatLine("conversation id", formatNumber(current.stats.conversationId)),
891
- buildStatLine(
892
- "session key",
893
- current.stats.sessionKey ? formatCommand(truncateMiddle(current.stats.sessionKey, 44)) : "missing",
894
- ),
895
- buildStatLine("scope", "this conversation only"),
896
- ]),
897
- "",
898
- buildSection("๐Ÿ› ๏ธ Apply", [
899
- buildStatLine("mode", "in-place summary rewrite"),
900
- buildStatLine("status", "failed"),
901
- buildStatLine("reason", error instanceof Error ? error.message : "unknown repair failure"),
902
- ]),
903
- ].join("\n");
904
- }
905
-
906
- const lines = [
907
- ...buildHeaderLines(),
908
- "",
909
- "๐Ÿฉบ Lossless Claw Doctor Apply",
910
- "",
911
- buildSection("๐Ÿ“ Current conversation", [
912
- buildStatLine("conversation id", formatNumber(current.stats.conversationId)),
913
- buildStatLine(
914
- "session key",
915
- current.stats.sessionKey ? formatCommand(truncateMiddle(current.stats.sessionKey, 44)) : "missing",
916
- ),
917
- buildStatLine("scope", "this conversation only"),
918
- ]),
919
- "",
920
- ];
921
-
922
- if (result.kind === "unavailable") {
923
- lines.push(
924
- buildSection("๐Ÿ› ๏ธ Apply", [
925
- buildStatLine("mode", "in-place summary rewrite"),
926
- buildStatLine("status", "unavailable"),
927
- buildStatLine("reason", result.reason),
928
- ]),
929
- );
930
- return lines.join("\n");
931
- }
932
-
933
- lines.push(
934
- buildSection("๐Ÿ› ๏ธ Apply", [
935
- buildStatLine("mode", "in-place summary rewrite"),
936
- buildStatLine("detected summaries", formatNumber(stats.total)),
937
- buildStatLine("old-marker summaries", formatNumber(stats.old)),
938
- buildStatLine("truncated-marker summaries", formatNumber(stats.truncated)),
939
- buildStatLine("fallback-marker summaries", formatNumber(stats.fallback)),
940
- buildStatLine("repaired summaries", formatNumber(result.repaired)),
941
- buildStatLine("unchanged summaries", formatNumber(result.unchanged)),
942
- buildStatLine("skipped summaries", formatNumber(result.skipped.length)),
943
- buildStatLine(
944
- "result",
945
- stats.total === 0
946
- ? "clean; no writes ran"
947
- : result.repaired > 0
948
- ? `repaired ${formatNumber(result.repaired)} summary(s) in place`
949
- : "no repairs applied",
950
- ),
951
- ]),
952
- );
953
-
954
- if (result.repairedSummaryIds.length > 0) {
955
- lines.push(
956
- "",
957
- buildSection("๐Ÿงท Repaired summaries", [result.repairedSummaryIds.join(", ")]),
958
- );
959
- }
960
-
961
- if (result.skipped.length > 0) {
962
- lines.push(
963
- "",
964
- buildSection(
965
- "โš ๏ธ Deferred",
966
- result.skipped.map((item) => `${item.summaryId}: ${item.reason}`),
967
- ),
968
- );
969
- }
970
-
971
- return lines.join("\n");
972
- }
973
-
974
- export function createLcmCommand(params: {
975
- db: DatabaseSync | (() => DatabaseSync | Promise<DatabaseSync>);
976
- config: LcmConfig;
977
- deps?: LcmDependencies;
978
- summarize?: LcmSummarizeFn;
979
- }): OpenClawPluginCommandDefinition {
980
- const getDb = async (): Promise<DatabaseSync> =>
981
- typeof params.db === "function" ? await params.db() : params.db;
982
-
983
- return {
984
- name: "lcm",
985
- nativeNames: {
986
- default: "lossless",
987
- },
988
- nativeProgressMessages: {
989
- telegram: "Lossless Claw is working...",
990
- },
991
- description:
992
- "Show Lossless Claw health, scan broken summaries, inspect high-confidence junk candidates, and run scoped doctor actions.",
993
- acceptsArgs: true,
994
- handler: async (ctx) => {
995
- const parsed = parseLcmCommand(ctx.args);
996
- switch (parsed.kind) {
997
- case "status":
998
- return { text: await buildStatusText({ ctx, db: await getDb(), config: params.config }) };
999
- case "doctor":
1000
- return parsed.apply
1001
- ? {
1002
- text: await buildDoctorApplyText({
1003
- ctx,
1004
- db: await getDb(),
1005
- config: params.config,
1006
- deps: params.deps,
1007
- summarize: params.summarize,
1008
- }),
1009
- }
1010
- : { text: await buildDoctorText({ ctx, db: await getDb() }) };
1011
- case "doctor_cleaners":
1012
- return parsed.apply
1013
- ? {
1014
- text: await buildDoctorCleanersApplyText({
1015
- db: await getDb(),
1016
- config: params.config,
1017
- filterId: parsed.filterId,
1018
- vacuum: parsed.vacuum,
1019
- }),
1020
- }
1021
- : { text: await buildDoctorCleanersText({ db: await getDb() }) };
1022
- case "help":
1023
- return { text: buildHelpText(parsed.error) };
1024
- }
1025
- },
1026
- };
1027
- }
1028
-
1029
- export const __testing = {
1030
- parseLcmCommand,
1031
- detectDoctorMarker,
1032
- getDoctorSummaryStats,
1033
- getLcmStatusStats,
1034
- getConversationStatusStats,
1035
- scanDoctorCleaners,
1036
- resolveCurrentConversation,
1037
- resolveContextEngineSlot,
1038
- resolvePluginEnabled,
1039
- resolvePluginSelected,
1040
- };