@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
@@ -0,0 +1,21 @@
1
+ export type SearchSort = "recency" | "relevance" | "hybrid";
2
+
3
+ export const AGE_DECAY_RATE = 0.001;
4
+
5
+ /**
6
+ * Build the ORDER BY clause for FTS5-backed searches.
7
+ *
8
+ * `rank` is FTS5's BM25 score where lower (more negative) is better.
9
+ * `hybrid` keeps that relevance signal but applies a mild age penalty before
10
+ * LIMIT is enforced so older strong matches can still surface.
11
+ */
12
+ export function buildFtsOrderBy(sort: SearchSort | undefined, createdAtExpr: string): string {
13
+ switch (sort ?? "recency") {
14
+ case "relevance":
15
+ return `rank ASC, ${createdAtExpr} DESC`;
16
+ case "hybrid":
17
+ return `(rank / (1 + ((julianday('now') - julianday(${createdAtExpr})) * 24 * ${AGE_DECAY_RATE}))) ASC, ${createdAtExpr} DESC`;
18
+ default:
19
+ return `${createdAtExpr} DESC`;
20
+ }
21
+ }
@@ -29,3 +29,11 @@ export type {
29
29
  UpsertConversationBootstrapStateInput,
30
30
  ConversationBootstrapStateRecord,
31
31
  } from "./summary-store.js";
32
+
33
+ export { CompactionTelemetryStore } from "./compaction-telemetry-store.js";
34
+ export type {
35
+ CacheState,
36
+ ActivityBand,
37
+ ConversationCompactionTelemetryRecord,
38
+ UpsertConversationCompactionTelemetryInput,
39
+ } from "./compaction-telemetry-store.js";
@@ -3,6 +3,7 @@ import { withDatabaseTransaction } from "../transaction-mutex.js";
3
3
  import { sanitizeFts5Query } from "./fts5-sanitize.js";
4
4
  import { buildLikeSearchPlan, containsCjk, createFallbackSnippet } from "./full-text-fallback.js";
5
5
  import { parseUtcTimestamp, parseUtcTimestampOrNull } from "./parse-utc-timestamp.js";
6
+ import { buildFtsOrderBy, type SearchSort } from "./full-text-sort.js";
6
7
 
7
8
  export type SummaryKind = "leaf" | "condensed";
8
9
  export type ContextItemType = "message" | "summary";
@@ -68,6 +69,7 @@ export type SummarySearchInput = {
68
69
  since?: Date;
69
70
  before?: Date;
70
71
  limit?: number;
72
+ sort?: SearchSort;
71
73
  };
72
74
 
73
75
  export type SummarySearchResult = {
@@ -958,7 +960,10 @@ export class SummaryStore {
958
960
  .run(conversationId, startOrdinal, summaryId);
959
961
 
960
962
  // 3. Resequence all ordinals to maintain contiguity (no gaps).
961
- // Fetch current items, then update ordinals in order.
963
+ // Pre-compute ranks from a SELECT (safe snapshot), then apply
964
+ // via 2-pass UPDATE loop using negative temps to avoid UNIQUE
965
+ // constraint violations. The SELECT reads post-delete/insert
966
+ // state and provides a consistent snapshot for resequencing.
962
967
  const items = this.db
963
968
  .prepare(
964
969
  `SELECT ordinal FROM context_items
@@ -967,18 +972,17 @@ export class SummaryStore {
967
972
  )
968
973
  .all(conversationId) as unknown as { ordinal: number }[];
969
974
 
970
- const updateStmt = this.db.prepare(
971
- `UPDATE context_items
972
- SET ordinal = ?
973
- WHERE conversation_id = ? AND ordinal = ?`,
974
- );
975
-
976
- // Use negative temp ordinals first to avoid unique constraint conflicts.
977
- for (let i = 0; i < items.length; i++) {
978
- updateStmt.run(-(i + 1), conversationId, items[i].ordinal);
979
- }
980
- for (let i = 0; i < items.length; i++) {
981
- updateStmt.run(i, conversationId, -(i + 1));
975
+ if (items.length > 0 && items.some((item, i) => item.ordinal !== i)) {
976
+ const updateStmt = this.db.prepare(
977
+ `UPDATE context_items SET ordinal = ?
978
+ WHERE conversation_id = ? AND ordinal = ?`,
979
+ );
980
+ for (let i = 0; i < items.length; i++) {
981
+ updateStmt.run(-(i + 1), conversationId, items[i].ordinal);
982
+ }
983
+ for (let i = 0; i < items.length; i++) {
984
+ updateStmt.run(i, conversationId, -(i + 1));
985
+ }
982
986
  }
983
987
  }
984
988
 
@@ -1051,6 +1055,7 @@ export class SummaryStore {
1051
1055
  input.conversationId,
1052
1056
  input.since,
1053
1057
  input.before,
1058
+ input.sort,
1054
1059
  );
1055
1060
  } catch {
1056
1061
  return this.searchLike(
@@ -1073,6 +1078,7 @@ export class SummaryStore {
1073
1078
  conversationId?: number,
1074
1079
  since?: Date,
1075
1080
  before?: Date,
1081
+ sort?: SearchSort,
1076
1082
  ): SummarySearchResult[] {
1077
1083
  const where: string[] = ["summaries_fts MATCH ?"];
1078
1084
  const args: Array<string | number> = [sanitizeFts5Query(query)];
@@ -1089,6 +1095,7 @@ export class SummaryStore {
1089
1095
  args.push(before.toISOString());
1090
1096
  }
1091
1097
  args.push(limit);
1098
+ const orderBy = buildFtsOrderBy(sort, "s.created_at");
1092
1099
 
1093
1100
  const sql = `SELECT
1094
1101
  summaries_fts.summary_id,
@@ -1100,7 +1107,7 @@ export class SummaryStore {
1100
1107
  FROM summaries_fts
1101
1108
  JOIN summaries s ON s.summary_id = summaries_fts.summary_id
1102
1109
  WHERE ${where.join(" AND ")}
1103
- ORDER BY s.created_at DESC
1110
+ ORDER BY ${orderBy}
1104
1111
  LIMIT ?`;
1105
1112
  const rows = this.db.prepare(sql).all(...args) as unknown as SummarySearchRow[];
1106
1113
  return rows.map(toSearchResult);
package/src/summarize.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import { describeLogError } from "./lcm-log.js";
1
2
  import type { LcmDependencies } from "./types.js";
3
+ import { estimateTokens } from "./estimate-tokens.js";
2
4
 
3
5
  export type LcmSummarizeOptions = {
4
6
  previousSummary?: string;
@@ -173,10 +175,6 @@ function resolveProviderApiFromLegacyConfig(
173
175
  return undefined;
174
176
  }
175
177
 
176
- /** Approximate token estimate used for target-sizing prompts. */
177
- function estimateTokens(text: string): number {
178
- return Math.ceil(text.length / 4);
179
- }
180
178
 
181
179
  /** Narrow unknown values to plain object records. */
182
180
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -1068,6 +1066,17 @@ function resolveSummaryCandidates(params: {
1068
1066
  },
1069
1067
  ];
1070
1068
 
1069
+ // Append explicit fallback providers from config.
1070
+ for (const fb of params.deps.config.fallbackProviders ?? []) {
1071
+ resolutionCandidates.push({
1072
+ levelName: `explicit fallback (${fb.provider}/${fb.model})`,
1073
+ modelRef: `${fb.provider}/${fb.model}`,
1074
+ providerHint: fb.provider,
1075
+ hasExplicitProvider: true,
1076
+ useLegacyAuthProfile: false,
1077
+ });
1078
+ }
1079
+
1071
1080
  const resolvedCandidates: ResolvedSummaryCandidate[] = [];
1072
1081
  for (const candidate of resolutionCandidates) {
1073
1082
  if (!candidate.modelRef) {
@@ -1088,9 +1097,8 @@ function resolveSummaryCandidates(params: {
1088
1097
  });
1089
1098
  }
1090
1099
  } catch (err) {
1091
- console.error(
1092
- `[lcm] createLcmSummarize: resolveModel FAILED at ${candidate.levelName}:`,
1093
- err instanceof Error ? err.message : err,
1100
+ params.deps.log.error(
1101
+ `[lcm] createLcmSummarize: resolveModel FAILED at ${candidate.levelName}: ${describeLogError(err)}`,
1094
1102
  );
1095
1103
  }
1096
1104
  }
@@ -1111,7 +1119,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1111
1119
  }): Promise<{ fn: LcmSummarizeFn; model: string; breakerKey: string } | undefined> {
1112
1120
  const resolvedCandidates = resolveSummaryCandidates(params);
1113
1121
  if (resolvedCandidates.length === 0) {
1114
- console.error("[lcm] createLcmSummarize: no summary model candidates resolved");
1122
+ params.deps.log.error("[lcm] createLcmSummarize: no summary model candidates resolved");
1115
1123
  return undefined;
1116
1124
  }
1117
1125
 
@@ -1197,6 +1205,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1197
1205
  requestApiKey: string | undefined,
1198
1206
  label: string,
1199
1207
  reasoning?: string,
1208
+ options?: { skipModelAuth?: boolean },
1200
1209
  ) =>
1201
1210
  withTimeout(params.deps.complete({
1202
1211
  provider,
@@ -1215,6 +1224,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1215
1224
  ],
1216
1225
  maxTokens: targetTokens,
1217
1226
  ...(reasoning ? { reasoning } : {}),
1227
+ ...(options?.skipModelAuth === true ? { skipModelAuth: true } : {}),
1218
1228
  }), summarizerTimeoutMs, label);
1219
1229
 
1220
1230
  const retryWithoutModelAuth = async (
@@ -1226,8 +1236,8 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1226
1236
  if (runtimeManagedAuth) {
1227
1237
  throw initialAuthError;
1228
1238
  }
1229
- console.warn(initialAuthError.message);
1230
- console.warn(
1239
+ params.deps.log.warn(initialAuthError.message);
1240
+ params.deps.log.warn(
1231
1241
  `[lcm] summarizer auth retry: retrying ${provider}/${model} without runtime.modelAuth credentials.`,
1232
1242
  );
1233
1243
 
@@ -1236,14 +1246,16 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1236
1246
  skipModelAuth: true,
1237
1247
  });
1238
1248
  if (!directApiKey) {
1239
- console.warn(
1249
+ params.deps.log.warn(
1240
1250
  `[lcm] summarizer auth retry unavailable: no direct credentials found for ${provider}/${model}.`,
1241
1251
  );
1242
1252
  throw initialAuthError;
1243
1253
  }
1244
1254
 
1245
1255
  try {
1246
- const directResult = await runSummarizerCall(directApiKey, "auth-retry", reasoning);
1256
+ const directResult = await runSummarizerCall(directApiKey, "auth-retry", reasoning, {
1257
+ skipModelAuth: true,
1258
+ });
1247
1259
  // Use requireStructuralSignal on the retry success path too — the
1248
1260
  // summary text may legitimately contain auth-error phrases.
1249
1261
  const directFailure = extractProviderAuthFailure(directResult, {
@@ -1255,10 +1267,10 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1255
1267
  model,
1256
1268
  failure: directFailure,
1257
1269
  });
1258
- console.warn(retryAuthError.message);
1270
+ params.deps.log.warn(retryAuthError.message);
1259
1271
  throw retryAuthError;
1260
1272
  }
1261
- console.warn(
1273
+ params.deps.log.info(
1262
1274
  `[lcm] summarizer auth retry succeeded; provider=${provider}; model=${model}; source=direct-credentials`,
1263
1275
  );
1264
1276
  return directResult;
@@ -1277,7 +1289,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1277
1289
  model,
1278
1290
  failure: directFailure,
1279
1291
  });
1280
- console.warn(retryAuthError.message);
1292
+ params.deps.log.warn(retryAuthError.message);
1281
1293
  throw retryAuthError;
1282
1294
  }
1283
1295
  throw directErr;
@@ -1317,31 +1329,35 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1317
1329
  if (err instanceof LcmProviderAuthError) {
1318
1330
  lastAuthError = err;
1319
1331
  if (nextCandidate) {
1320
- console.warn(
1321
- `[lcm] summarizer auth fallback: retrying with ${nextCandidate.provider}/${nextCandidate.model} after ${provider}/${model} failed auth.`,
1332
+ params.deps.log.warn(
1333
+ `[lcm] PROVIDER FALLBACK: ${provider}/${model} auth failed → trying ${nextCandidate.provider}/${nextCandidate.model}`,
1322
1334
  );
1335
+ const backoffMs = Math.min(500 * Math.pow(2, index), 8000);
1336
+ await new Promise((r) => setTimeout(r, backoffMs));
1323
1337
  continue;
1324
1338
  }
1325
1339
  throw lastAuthError;
1326
1340
  }
1327
1341
  const errMsg = err instanceof Error ? err.message : String(err);
1328
1342
  const isTimeout = errMsg.includes("summarizer timeout");
1329
- console.warn(
1343
+ params.deps.log.warn(
1330
1344
  `[lcm] summarizer ${isTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${summarizerTimeoutMs}ms; error=${errMsg}`,
1331
1345
  );
1332
1346
  if (nextCandidate) {
1333
- console.warn(
1334
- `[lcm] summarizer candidate fallback: retrying with ${nextCandidate.provider}/${nextCandidate.model} after ${provider}/${model} ${isTimeout ? "timed out" : "failed"}.`,
1347
+ params.deps.log.warn(
1348
+ `[lcm] PROVIDER FALLBACK: ${provider}/${model} ${isTimeout ? "timed out" : "failed"} → trying ${nextCandidate.provider}/${nextCandidate.model}`,
1335
1349
  );
1350
+ const backoffMs = Math.min(500 * Math.pow(2, index), 8000);
1351
+ await new Promise((r) => setTimeout(r, backoffMs));
1336
1352
  continue;
1337
1353
  }
1338
1354
  if (err instanceof SummarizerTimeoutError) {
1339
- console.error(
1355
+ params.deps.log.warn(
1340
1356
  `[lcm] summarizer timed out; provider=${provider}; model=${model}; source=fallback`,
1341
1357
  );
1342
1358
  return buildDeterministicFallbackSummary(text, targetTokens);
1343
1359
  }
1344
- return "";
1360
+ break;
1345
1361
  }
1346
1362
 
1347
1363
  const normalized = normalizeCompletionSummary(result.content);
@@ -1358,7 +1374,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1358
1374
  if (envelopeNormalized.summary) {
1359
1375
  summary = envelopeNormalized.summary;
1360
1376
  summarySource = "envelope";
1361
- console.error(
1377
+ params.deps.log.info(
1362
1378
  `[lcm] recovered summary from response envelope; provider=${provider}; model=${model}; ` +
1363
1379
  `block_types=${formatBlockTypes(envelopeNormalized.blockTypes)}; source=envelope`,
1364
1380
  );
@@ -1386,7 +1402,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1386
1402
  if (responseDiag) {
1387
1403
  diagParts.push(responseDiag);
1388
1404
  }
1389
- console.error(`${diagParts.join("; ")}; retrying with conservative settings`);
1405
+ params.deps.log.warn(`${diagParts.join("; ")}; retrying with conservative settings`);
1390
1406
 
1391
1407
  // Single retry with conservative parameters: low temperature and low
1392
1408
  // reasoning budget to coax a textual response from providers that
@@ -1401,7 +1417,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1401
1417
 
1402
1418
  if (summary) {
1403
1419
  summarySource = "retry";
1404
- console.error(
1420
+ params.deps.log.info(
1405
1421
  `[lcm] retry succeeded; provider=${provider}; model=${model}; ` +
1406
1422
  `block_types=${formatBlockTypes(retryEnvelopeNormalized.blockTypes)}; source=retry`,
1407
1423
  );
@@ -1418,21 +1434,23 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1418
1434
  retryParts.push(retryDiag);
1419
1435
  }
1420
1436
  if (nextCandidate) {
1421
- console.warn(
1437
+ params.deps.log.warn(
1422
1438
  `${retryParts.join("; ")}; retrying with ${nextCandidate.provider}/${nextCandidate.model}`,
1423
1439
  );
1424
1440
  continue;
1425
1441
  }
1426
- console.error(`${retryParts.join("; ")}; falling back to truncation`);
1442
+ params.deps.log.warn(`${retryParts.join("; ")}; falling back to truncation`);
1427
1443
  summary = initialSummary;
1428
1444
  }
1429
1445
  } catch (retryErr) {
1430
1446
  if (retryErr instanceof LcmProviderAuthError) {
1431
1447
  lastAuthError = retryErr;
1432
1448
  if (nextCandidate) {
1433
- console.warn(
1434
- `[lcm] summarizer auth fallback: retrying with ${nextCandidate.provider}/${nextCandidate.model} after ${provider}/${model} failed auth.`,
1449
+ params.deps.log.warn(
1450
+ `[lcm] PROVIDER FALLBACK: ${provider}/${model} auth failed on retry → trying ${nextCandidate.provider}/${nextCandidate.model}`,
1435
1451
  );
1452
+ const backoffMs = Math.min(500 * Math.pow(2, index), 8000);
1453
+ await new Promise((r) => setTimeout(r, backoffMs));
1436
1454
  continue;
1437
1455
  }
1438
1456
  throw lastAuthError;
@@ -1441,12 +1459,12 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1441
1459
  const retryErrMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
1442
1460
  const isRetryTimeout = retryErrMsg.includes("summarizer timeout");
1443
1461
  if (nextCandidate) {
1444
- console.warn(
1462
+ params.deps.log.warn(
1445
1463
  `[lcm] retry ${isRetryTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${summarizerTimeoutMs}ms; error=${retryErrMsg}; retrying with ${nextCandidate.provider}/${nextCandidate.model}`,
1446
1464
  );
1447
1465
  continue;
1448
1466
  }
1449
- console.warn(
1467
+ params.deps.log.warn(
1450
1468
  `[lcm] retry ${isRetryTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${summarizerTimeoutMs}ms; error=${retryErrMsg}; falling back to truncation`,
1451
1469
  );
1452
1470
  summary = initialSummary;
@@ -1455,14 +1473,14 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1455
1473
 
1456
1474
  if (!summary) {
1457
1475
  summarySource = "fallback";
1458
- console.error(
1476
+ params.deps.log.error(
1459
1477
  `[lcm] all extraction attempts exhausted; provider=${provider}; model=${model}; source=fallback`,
1460
1478
  );
1461
1479
  return buildDeterministicFallbackSummary(text, targetTokens);
1462
1480
  }
1463
1481
 
1464
1482
  if (summarySource !== "content") {
1465
- console.error(
1483
+ params.deps.log.info(
1466
1484
  `[lcm] summary resolved via non-content path; provider=${provider}; model=${model}; source=${summarySource}`,
1467
1485
  );
1468
1486
  }
@@ -1470,10 +1488,13 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1470
1488
  return summary;
1471
1489
  }
1472
1490
 
1491
+ params.deps.log.error(
1492
+ `[lcm] ALL PROVIDERS EXHAUSTED: ${resolvedCandidates.length} candidate(s) tried, none succeeded. Compaction falling back to deterministic truncation. Check provider keys and quotas.`,
1493
+ );
1473
1494
  if (lastAuthError) {
1474
1495
  throw lastAuthError;
1475
1496
  }
1476
- return "";
1497
+ return buildDeterministicFallbackSummary(text, targetTokens);
1477
1498
  };
1478
1499
 
1479
1500
  return {
@@ -57,7 +57,8 @@ function normalizeRequestedTokenCap(value: unknown): number | undefined {
57
57
 
58
58
  export function createLcmDescribeTool(input: {
59
59
  deps: LcmDependencies;
60
- lcm: LcmContextEngine;
60
+ lcm?: LcmContextEngine;
61
+ getLcm?: () => Promise<LcmContextEngine>;
61
62
  sessionId?: string;
62
63
  sessionKey?: string;
63
64
  }): AnyAgentTool {
@@ -71,12 +72,16 @@ export function createLcmDescribeTool(input: {
71
72
  "token counts, and file exploration results.",
72
73
  parameters: LcmDescribeSchema,
73
74
  async execute(_toolCallId, params) {
74
- const retrieval = input.lcm.getRetrieval();
75
- const timezone = input.lcm.timezone;
75
+ const lcm = input.lcm ?? (await input.getLcm?.());
76
+ if (!lcm) {
77
+ throw new Error("LCM engine is unavailable.");
78
+ }
79
+ const retrieval = lcm.getRetrieval();
80
+ const timezone = lcm.timezone;
76
81
  const p = params as Record<string, unknown>;
77
82
  const id = (p.id as string).trim();
78
83
  const conversationScope = await resolveLcmConversationScope({
79
- lcm: input.lcm,
84
+ lcm,
80
85
  deps: input.deps,
81
86
  sessionId: input.sessionId,
82
87
  sessionKey: input.sessionKey,