@martian-engineering/lossless-claw 0.6.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +26 -6
  2. package/docs/agent-tools.md +16 -5
  3. package/docs/configuration.md +223 -214
  4. package/openclaw.plugin.json +123 -0
  5. package/package.json +1 -1
  6. package/skills/lossless-claw/SKILL.md +3 -2
  7. package/skills/lossless-claw/references/architecture.md +12 -0
  8. package/skills/lossless-claw/references/config.md +135 -3
  9. package/skills/lossless-claw/references/diagnostics.md +13 -0
  10. package/src/assembler.ts +17 -5
  11. package/src/compaction.ts +161 -53
  12. package/src/db/config.ts +102 -4
  13. package/src/db/connection.ts +35 -7
  14. package/src/db/features.ts +24 -5
  15. package/src/db/migration.ts +257 -78
  16. package/src/engine.ts +1007 -110
  17. package/src/estimate-tokens.ts +80 -0
  18. package/src/lcm-log.ts +37 -0
  19. package/src/plugin/index.ts +493 -101
  20. package/src/plugin/lcm-command.ts +288 -7
  21. package/src/plugin/lcm-doctor-apply.ts +1 -3
  22. package/src/plugin/lcm-doctor-cleaners.ts +655 -0
  23. package/src/plugin/shared-init.ts +59 -0
  24. package/src/prune.ts +391 -0
  25. package/src/retrieval.ts +8 -9
  26. package/src/startup-banner-log.ts +1 -0
  27. package/src/store/compaction-telemetry-store.ts +156 -0
  28. package/src/store/conversation-store.ts +6 -1
  29. package/src/store/fts5-sanitize.ts +25 -4
  30. package/src/store/full-text-sort.ts +21 -0
  31. package/src/store/index.ts +8 -0
  32. package/src/store/summary-store.ts +21 -14
  33. package/src/summarize.ts +55 -34
  34. package/src/tools/lcm-describe-tool.ts +9 -4
  35. package/src/tools/lcm-expand-query-tool.ts +609 -200
  36. package/src/tools/lcm-expand-tool.ts +9 -4
  37. package/src/tools/lcm-grep-tool.ts +22 -8
  38. package/src/types.ts +1 -0
@@ -6,6 +6,13 @@ import type { LcmSummarizeFn } from "../summarize.js";
6
6
  import type { LcmDependencies } from "../types.js";
7
7
  import type { OpenClawPluginCommandDefinition, PluginCommandContext } from "openclaw/plugin-sdk";
8
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";
9
16
  import {
10
17
  detectDoctorMarker,
11
18
  getDoctorSummaryStats,
@@ -52,8 +59,11 @@ type CurrentConversationResolution =
52
59
  type ParsedLcmCommand =
53
60
  | { kind: "status" }
54
61
  | { kind: "doctor"; apply: boolean }
62
+ | { kind: "doctor_cleaners"; apply: boolean; filterId?: DoctorCleanerId; vacuum: boolean }
55
63
  | { kind: "help"; error?: string };
56
64
 
65
+ const DOCTOR_CLEANER_IDS = new Set<DoctorCleanerId>(getDoctorCleanerFilterIds());
66
+
57
67
  function asRecord(value: unknown): Record<string, unknown> | undefined {
58
68
  return value && typeof value === "object" && !Array.isArray(value)
59
69
  ? (value as Record<string, unknown>)
@@ -138,6 +148,32 @@ function splitArgs(rawArgs: string | undefined): string[] {
138
148
  .filter(Boolean);
139
149
  }
140
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
+
141
177
  function parseLcmCommand(rawArgs: string | undefined): ParsedLcmCommand {
142
178
  const tokens = splitArgs(rawArgs);
143
179
  if (tokens.length === 0) {
@@ -154,19 +190,34 @@ function parseLcmCommand(rawArgs: string | undefined): ParsedLcmCommand {
154
190
  if (rest.length === 0) {
155
191
  return { kind: "doctor", apply: false };
156
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
+ }
157
207
  if (rest.length === 1 && rest[0]?.toLowerCase() === "apply") {
158
208
  return { kind: "doctor", apply: true };
159
209
  }
160
210
  return {
161
211
  kind: "help",
162
- error: "`/lcm doctor` accepts no arguments, or `apply` for the scoped repair path.",
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.`,
163
214
  };
164
215
  case "help":
165
216
  return { kind: "help" };
166
217
  default:
167
218
  return {
168
219
  kind: "help",
169
- error: `Unknown subcommand \`${head}\`. Supported: status, doctor, doctor apply.`,
220
+ error: `Unknown subcommand \`${head}\`. Supported: status, doctor, doctor clean, doctor apply, help.`,
170
221
  };
171
222
  }
172
223
  }
@@ -423,6 +474,14 @@ function buildHelpText(error?: string): string {
423
474
  buildStatLine(formatCommand(VISIBLE_COMMAND), "Show compact status output."),
424
475
  buildStatLine(formatCommand(`${VISIBLE_COMMAND} status`), "Show plugin, Global, and current-conversation status."),
425
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
+ ),
426
485
  buildStatLine(formatCommand(`${VISIBLE_COMMAND} doctor apply`), "Repair broken summaries in the current conversation."),
427
486
  ]),
428
487
  "",
@@ -435,6 +494,17 @@ function buildHelpText(error?: string): string {
435
494
  return lines.join("\n");
436
495
  }
437
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
+
438
508
  async function buildStatusText(params: {
439
509
  ctx: PluginCommandContext;
440
510
  db: DatabaseSync;
@@ -584,6 +654,198 @@ async function buildDoctorText(params: {
584
654
  return lines.join("\n");
585
655
  }
586
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
+
587
849
  async function buildDoctorApplyText(params: {
588
850
  ctx: PluginCommandContext;
589
851
  db: DatabaseSync;
@@ -710,35 +972,53 @@ async function buildDoctorApplyText(params: {
710
972
  }
711
973
 
712
974
  export function createLcmCommand(params: {
713
- db: DatabaseSync;
975
+ db: DatabaseSync | (() => DatabaseSync | Promise<DatabaseSync>);
714
976
  config: LcmConfig;
715
977
  deps?: LcmDependencies;
716
978
  summarize?: LcmSummarizeFn;
717
979
  }): OpenClawPluginCommandDefinition {
980
+ const getDb = async (): Promise<DatabaseSync> =>
981
+ typeof params.db === "function" ? await params.db() : params.db;
982
+
718
983
  return {
719
984
  name: "lcm",
720
985
  nativeNames: {
721
986
  default: "lossless",
722
987
  },
723
- description: "Show Lossless Claw health, scan broken summaries, and repair scoped doctor issues.",
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.",
724
993
  acceptsArgs: true,
725
994
  handler: async (ctx) => {
726
995
  const parsed = parseLcmCommand(ctx.args);
727
996
  switch (parsed.kind) {
728
997
  case "status":
729
- return { text: await buildStatusText({ ctx, db: params.db, config: params.config }) };
998
+ return { text: await buildStatusText({ ctx, db: await getDb(), config: params.config }) };
730
999
  case "doctor":
731
1000
  return parsed.apply
732
1001
  ? {
733
1002
  text: await buildDoctorApplyText({
734
1003
  ctx,
735
- db: params.db,
1004
+ db: await getDb(),
736
1005
  config: params.config,
737
1006
  deps: params.deps,
738
1007
  summarize: params.summarize,
739
1008
  }),
740
1009
  }
741
- : { text: await buildDoctorText({ ctx, db: params.db }) };
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() }) };
742
1022
  case "help":
743
1023
  return { text: buildHelpText(parsed.error) };
744
1024
  }
@@ -752,6 +1032,7 @@ export const __testing = {
752
1032
  getDoctorSummaryStats,
753
1033
  getLcmStatusStats,
754
1034
  getConversationStatusStats,
1035
+ scanDoctorCleaners,
755
1036
  resolveCurrentConversation,
756
1037
  resolveContextEngineSlot,
757
1038
  resolvePluginEnabled,
@@ -6,6 +6,7 @@ import type { LcmSummarizeFn } from "../summarize.js";
6
6
  import { createLcmSummarizeFromLegacyParams } from "../summarize.js";
7
7
  import type { LcmDependencies } from "../types.js";
8
8
  import { detectDoctorMarker, loadDoctorTargets, type DoctorTargetRecord } from "./lcm-doctor-shared.js";
9
+ import { estimateTokens } from "../estimate-tokens.js";
9
10
 
10
11
  type SummaryOverride = {
11
12
  content: string;
@@ -524,9 +525,6 @@ function parseSqliteTimestamp(value: string | null | undefined): Date | null {
524
525
  return null;
525
526
  }
526
527
 
527
- function estimateTokens(text: string): number {
528
- return Math.max(1, Math.ceil(text.length / 4));
529
- }
530
528
 
531
529
  function updateSummaryFts(db: DatabaseSync, summaryId: string, content: string): void {
532
530
  try {