@martian-engineering/lossless-claw 0.7.0 → 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.
@@ -143,6 +143,7 @@ type RuntimeModelAuth = {
143
143
  const MODEL_AUTH_PR_URL = "https://github.com/openclaw/openclaw/pull/41090";
144
144
  const MODEL_AUTH_MERGE_COMMIT = "4790e40";
145
145
  const MODEL_AUTH_REQUIRED_RELEASE = "the first OpenClaw release after 2026.3.8";
146
+ const PROVIDER_API_RESOLUTION_ERROR_PREFIX = "[lcm] unable to resolve API family for provider ";
146
147
  const AUTH_ERROR_TEXT_PATTERN =
147
148
  /\b401\b|unauthorized|unauthorised|invalid[_ -]?token|invalid[_ -]?api[_ -]?key|authentication failed|authorization failed|missing scope|insufficient scope|model\.request\b/i;
148
149
  const AUTH_ERROR_STATUS_KEYS = ["status", "statusCode", "status_code"] as const;
@@ -174,6 +175,8 @@ const LOSSLESS_RECALL_POLICY_PROMPT = [
174
175
  "",
175
176
  "**`lcm_grep` routing guidance:**",
176
177
  '- Prefer `mode: "full_text"` for keyword or topical recall; keep `mode: "regex"` for literal patterns.',
178
+ '- Full-text queries use FTS5 semantics, and FTS5 defaults to AND matching, so extra terms make matching stricter rather than broader.',
179
+ '- Prefer 1-3 distinctive full-text terms or one quoted phrase. Do not pad queries with synonyms or extra keywords.',
177
180
  '- Wrap exact multi-word phrases in quotes, for example `"error handling"`.',
178
181
  '- Keep the default `sort: "recency"` for "what just happened?" lookups.',
179
182
  '- Use `sort: "relevance"` when hunting for the best older match on a topic.',
@@ -182,6 +185,17 @@ const LOSSLESS_RECALL_POLICY_PROMPT = [
182
185
  "**`lcm_expand_query` usage** — two patterns (always requires `prompt`):",
183
186
  "- With IDs: `lcm_expand_query(summaryIds: [\"sum_xxx\"], prompt: \"What config changes were discussed?\")`",
184
187
  "- With search: `lcm_expand_query(query: \"database migration\", prompt: \"What strategy was decided?\")`",
188
+ "- `query` uses the same FTS5 full-text search path as `lcm_grep`, so the same query-construction rules apply.",
189
+ "- `query` is for matching candidate summaries; `prompt` is the natural-language question or task to answer after expansion.",
190
+ "- FTS5 defaults to AND matching, so more query terms narrow results instead of broadening them.",
191
+ "- For `query`, use 1-3 distinctive terms or a quoted phrase. Do not stuff synonyms or extra keywords into it.",
192
+ "**Scope selection rule:**",
193
+ "- Start with the current conversation scope.",
194
+ "- If the in-context summaries already look relevant to the user's question, prefer `lcm_grep` or `lcm_expand_query` without `allConversations`.",
195
+ "- Use `allConversations: true` only when the current summaries do not appear sufficient, the question seems outside the current conversation, or the user is explicitly asking about work across sessions.",
196
+ "- For global discovery, prefer `lcm_grep(..., allConversations: true)` first.",
197
+ "- If global matches are found and the user needs one synthesized answer, use `lcm_expand_query(..., allConversations: true)`; this is bounded synthesis, not exhaustive expansion.",
198
+ "- If you already know the exact target conversation, prefer explicit `conversationId` instead of `allConversations`.",
185
199
  "- Optional: `maxTokens` (default 2000), `conversationId`, `allConversations: true`",
186
200
  "- Keep raw summary IDs out of normal user-facing prose unless the user explicitly asks for sources or IDs.",
187
201
  "",
@@ -204,6 +218,27 @@ function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnaps
204
218
  };
205
219
  }
206
220
 
221
+ /** Coerce a plugin-config-like value into a plain object when possible. */
222
+ function toPluginConfig(value: unknown): Record<string, unknown> | undefined {
223
+ return value && typeof value === "object" && !Array.isArray(value)
224
+ ? (value as Record<string, unknown>)
225
+ : undefined;
226
+ }
227
+
228
+ /** Resolve plugin config from direct runtime injection or the root OpenClaw config fallback. */
229
+ function resolvePluginConfig(api: OpenClawPluginApi): Record<string, unknown> | undefined {
230
+ const directPluginConfig = toPluginConfig(api.pluginConfig);
231
+ if (directPluginConfig && Object.keys(directPluginConfig).length > 0) {
232
+ return directPluginConfig;
233
+ }
234
+
235
+ const rootConfig = toPluginConfig(api.config);
236
+ const plugins = toPluginConfig(rootConfig?.plugins);
237
+ const entries = toPluginConfig(plugins?.entries);
238
+ const pluginEntry = toPluginConfig(entries?.["lossless-claw"]);
239
+ return toPluginConfig(pluginEntry?.config);
240
+ }
241
+
207
242
  function truncateErrorMessage(message: string, maxChars = 240): string {
208
243
  return message.length <= maxChars ? message : `${message.slice(0, maxChars)}...`;
209
244
  }
@@ -501,7 +536,7 @@ function normalizeProviderId(provider: string): string {
501
536
  }
502
537
 
503
538
  /** Resolve known provider API defaults when model lookup misses. */
504
- function inferApiFromProvider(provider: string): string {
539
+ function inferApiFromProvider(provider: string): string | undefined {
505
540
  const normalized = normalizeProviderId(provider);
506
541
  const map: Record<string, string> = {
507
542
  anthropic: "anthropic-messages",
@@ -514,7 +549,7 @@ function inferApiFromProvider(provider: string): string {
514
549
  "google-vertex": "google-vertex",
515
550
  "amazon-bedrock": "bedrock-converse-stream",
516
551
  };
517
- return map[normalized] ?? "openai-responses";
552
+ return map[normalized];
518
553
  }
519
554
 
520
555
  /** Codex Responses rejects `temperature`; omit it for that API family. */
@@ -604,12 +639,18 @@ function buildModelAuthLookupModel(params: {
604
639
  provider: string;
605
640
  model: string;
606
641
  api?: string;
642
+ contextWindow?: number;
607
643
  }): RuntimeModelAuthModel {
644
+ const contextWindow =
645
+ typeof params.contextWindow === "number" && Number.isFinite(params.contextWindow) && params.contextWindow > 0
646
+ ? params.contextWindow
647
+ : 1_000_000;
648
+
608
649
  return {
609
650
  id: params.model,
610
651
  name: params.model,
611
652
  provider: params.provider,
612
- api: params.api?.trim() || inferApiFromProvider(params.provider),
653
+ api: params.api?.trim() || inferApiFromProvider(params.provider) || "",
613
654
  reasoning: false,
614
655
  input: ["text"],
615
656
  cost: {
@@ -618,7 +659,7 @@ function buildModelAuthLookupModel(params: {
618
659
  cacheRead: 0,
619
660
  cacheWrite: 0,
620
661
  },
621
- contextWindow: 200_000,
662
+ contextWindow,
622
663
  maxTokens: 8_000,
623
664
  };
624
665
  }
@@ -1211,10 +1252,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1211
1252
  envSnapshot.openclawDefaultModel = readDefaultModelFromConfig(api.config);
1212
1253
  const modelAuth = getRuntimeModelAuth(api);
1213
1254
  const readEnv: ReadEnvFn = (key) => process.env[key];
1214
- const pluginConfig =
1215
- api.pluginConfig && typeof api.pluginConfig === "object" && !Array.isArray(api.pluginConfig)
1216
- ? api.pluginConfig
1217
- : undefined;
1255
+ const pluginConfig = resolvePluginConfig(api);
1218
1256
  const config = resolveLcmConfig(process.env, pluginConfig);
1219
1257
  const log = createLcmLogger(api);
1220
1258
 
@@ -1260,7 +1298,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1260
1298
  try {
1261
1299
  const modelAuthKey = resolveApiKeyFromAuthResult(
1262
1300
  await modelAuth.getApiKeyForModel({
1263
- model: buildModelAuthLookupModel({ provider, model }),
1301
+ model: buildModelAuthLookupModel({ provider, model, contextWindow: 1_000_000 }),
1264
1302
  cfg: modelAuthConfig,
1265
1303
  ...(options?.profileId ? { profileId: options.profileId } : {}),
1266
1304
  ...(options?.preferredProfile ? { preferredProfile: options.preferredProfile } : {}),
@@ -1346,6 +1384,9 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1346
1384
  const knownModel =
1347
1385
  typeof mod.getModel === "function" ? mod.getModel(providerId, modelId) : undefined;
1348
1386
  const fallbackApi =
1387
+ (isRecord(knownModel) && typeof knownModel.api === "string" && knownModel.api.trim()
1388
+ ? knownModel.api.trim()
1389
+ : undefined) ||
1349
1390
  providerApi?.trim() ||
1350
1391
  resolveProviderApiFromRuntimeConfig(effectiveRuntimeConfig, providerId) ||
1351
1392
  (() => {
@@ -1360,6 +1401,11 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1360
1401
  return first.api.trim();
1361
1402
  })() ||
1362
1403
  inferApiFromProvider(providerId);
1404
+ if (!fallbackApi) {
1405
+ throw new Error(
1406
+ `[lcm] unable to resolve API family for provider ${providerId}; set models.providers.${providerId}.api explicitly instead of falling back implicitly.`,
1407
+ );
1408
+ }
1363
1409
  const modelAuthConfig = resolveModelAuthConfig(effectiveRuntimeConfig);
1364
1410
 
1365
1411
  // Resolve provider-level config (baseUrl, headers, etc.) from runtime config.
@@ -1386,18 +1432,29 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1386
1432
  ...knownModel,
1387
1433
  id: knownModel.id,
1388
1434
  provider: knownModel.provider,
1389
- api: knownModel.api,
1390
- // Merge baseUrl/headers from provider config if not already on the model.
1435
+ api:
1436
+ typeof providerLevelConfig.api === "string" && providerLevelConfig.api.trim()
1437
+ ? providerLevelConfig.api.trim()
1438
+ : knownModel.api,
1439
+ // Provider config must be able to override built-in transport defaults.
1440
+ // Otherwise built-in providers like `openai` keep their catalog baseUrl
1441
+ // (`https://api.openai.com/v1`) even when OpenClaw runtime config points
1442
+ // that provider id at a custom proxy.
1391
1443
  // Always set baseUrl to a string — pi-ai's detectCompat() crashes when
1392
1444
  // baseUrl is undefined.
1393
1445
  baseUrl:
1394
- typeof knownModel.baseUrl === "string"
1395
- ? knownModel.baseUrl
1396
- : typeof providerLevelConfig.baseUrl === "string"
1397
- ? providerLevelConfig.baseUrl
1446
+ typeof providerLevelConfig.baseUrl === "string"
1447
+ ? providerLevelConfig.baseUrl
1448
+ : typeof knownModel.baseUrl === "string"
1449
+ ? knownModel.baseUrl
1398
1450
  : "",
1399
- ...(knownModel.headers == null && isRecord(providerLevelConfig.headers)
1400
- ? { headers: providerLevelConfig.headers }
1451
+ ...(isRecord(providerLevelConfig.headers)
1452
+ ? {
1453
+ headers: {
1454
+ ...(isRecord(knownModel.headers) ? knownModel.headers : {}),
1455
+ ...providerLevelConfig.headers,
1456
+ },
1457
+ }
1401
1458
  : {}),
1402
1459
  }
1403
1460
  : {
@@ -1413,7 +1470,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1413
1470
  cacheRead: 0,
1414
1471
  cacheWrite: 0,
1415
1472
  },
1416
- contextWindow: 200_000,
1473
+ contextWindow: 1_000_000,
1417
1474
  maxTokens: 8_000,
1418
1475
  // Always set baseUrl to a string — pi-ai's detectCompat() crashes when
1419
1476
  // baseUrl is undefined.
@@ -1433,6 +1490,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1433
1490
  provider: providerId,
1434
1491
  model: modelId,
1435
1492
  api: resolvedModel.api,
1493
+ contextWindow: resolvedModel.contextWindow,
1436
1494
  }),
1437
1495
  cfg: modelAuthConfig,
1438
1496
  ...(authProfileId ? { profileId: authProfileId } : {}),
@@ -1476,6 +1534,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1476
1534
  provider: providerId,
1477
1535
  model: modelId,
1478
1536
  api: resolvedModel.api,
1537
+ contextWindow: resolvedModel.contextWindow,
1479
1538
  }),
1480
1539
  cfg: modelAuthConfig,
1481
1540
  ...(authProfileId ? { profileId: authProfileId } : {}),
@@ -1582,9 +1641,19 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1582
1641
  } catch (err) {
1583
1642
  log.error(`[lcm] completeSimple error: ${describeLogError(err)}`);
1584
1643
  const authError = detectProviderAuthError(err);
1644
+ const configError =
1645
+ !authError &&
1646
+ err instanceof Error &&
1647
+ err.message.startsWith(PROVIDER_API_RESOLUTION_ERROR_PREFIX)
1648
+ ? {
1649
+ kind: "provider_config",
1650
+ message: err.message,
1651
+ }
1652
+ : undefined;
1585
1653
  return {
1586
1654
  content: [],
1587
1655
  ...(authError ? { error: authError } : {}),
1656
+ ...(configError ? { error: configError } : {}),
1588
1657
  };
1589
1658
  }
1590
1659
  },
@@ -1777,6 +1846,7 @@ const lcmPlugin = {
1777
1846
  // when the same DB path is already initialized.
1778
1847
  const existingInit = getSharedInit(normalizedDbPath);
1779
1848
  if (existingInit && !existingInit.stopped) {
1849
+ deps.log.info(`[lcm] Reusing shared engine init for db=${normalizedDbPath}`);
1780
1850
  wirePluginHandlers(api, deps, existingInit);
1781
1851
  return;
1782
1852
  }
@@ -1797,15 +1867,22 @@ const lcmPlugin = {
1797
1867
 
1798
1868
  /** Build a live DB+engine pair and roll back the DB handle if engine init fails. */
1799
1869
  function initializeEngine(): LcmContextEngine {
1870
+ const startedAt = Date.now();
1800
1871
  const nextDatabase = createLcmDatabaseConnection(dbPath);
1801
1872
  try {
1802
1873
  const nextEngine = new LcmContextEngine(deps, nextDatabase);
1803
1874
  database = nextDatabase;
1804
1875
  lcm = nextEngine;
1805
1876
  initError = null;
1877
+ deps.log.info(
1878
+ `[lcm] Engine initialized for db=${normalizedDbPath} duration=${Date.now() - startedAt}ms`,
1879
+ );
1806
1880
  return nextEngine;
1807
1881
  } catch (error) {
1808
1882
  closeLcmConnection(nextDatabase);
1883
+ deps.log.info(
1884
+ `[lcm] Engine init failed for db=${normalizedDbPath} duration=${Date.now() - startedAt}ms error=${toInitError(error).message}`,
1885
+ );
1809
1886
  throw error;
1810
1887
  }
1811
1888
  }
@@ -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;
@@ -726,7 +988,8 @@ export function createLcmCommand(params: {
726
988
  nativeProgressMessages: {
727
989
  telegram: "Lossless Claw is working...",
728
990
  },
729
- description: "Show Lossless Claw health, scan broken summaries, and repair scoped doctor issues.",
991
+ description:
992
+ "Show Lossless Claw health, scan broken summaries, inspect high-confidence junk candidates, and run scoped doctor actions.",
730
993
  acceptsArgs: true,
731
994
  handler: async (ctx) => {
732
995
  const parsed = parseLcmCommand(ctx.args);
@@ -745,6 +1008,17 @@ export function createLcmCommand(params: {
745
1008
  }),
746
1009
  }
747
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() }) };
748
1022
  case "help":
749
1023
  return { text: buildHelpText(parsed.error) };
750
1024
  }
@@ -758,6 +1032,7 @@ export const __testing = {
758
1032
  getDoctorSummaryStats,
759
1033
  getLcmStatusStats,
760
1034
  getConversationStatusStats,
1035
+ scanDoctorCleaners,
761
1036
  resolveCurrentConversation,
762
1037
  resolveContextEngineSlot,
763
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 {