@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.
- package/README.md +11 -3
- package/docs/agent-tools.md +9 -4
- package/docs/configuration.md +9 -0
- package/package.json +1 -1
- package/skills/lossless-claw/SKILL.md +3 -2
- package/skills/lossless-claw/references/architecture.md +12 -0
- package/skills/lossless-claw/references/diagnostics.md +13 -0
- package/src/assembler.ts +12 -4
- package/src/compaction.ts +12 -15
- package/src/db/connection.ts +15 -5
- package/src/db/features.ts +24 -5
- package/src/db/migration.ts +201 -79
- package/src/engine.ts +199 -19
- package/src/estimate-tokens.ts +80 -0
- package/src/plugin/index.ts +95 -18
- package/src/plugin/lcm-command.ts +278 -3
- package/src/plugin/lcm-doctor-apply.ts +1 -3
- package/src/plugin/lcm-doctor-cleaners.ts +655 -0
- package/src/retrieval.ts +1 -4
- package/src/summarize.ts +1 -4
- package/src/tools/lcm-expand-query-tool.ts +598 -194
- package/src/tools/lcm-grep-tool.ts +2 -2
package/src/plugin/index.ts
CHANGED
|
@@ -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]
|
|
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
|
|
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:
|
|
1390
|
-
|
|
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
|
|
1395
|
-
?
|
|
1396
|
-
: typeof
|
|
1397
|
-
?
|
|
1446
|
+
typeof providerLevelConfig.baseUrl === "string"
|
|
1447
|
+
? providerLevelConfig.baseUrl
|
|
1448
|
+
: typeof knownModel.baseUrl === "string"
|
|
1449
|
+
? knownModel.baseUrl
|
|
1398
1450
|
: "",
|
|
1399
|
-
...(
|
|
1400
|
-
? {
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|