@martian-engineering/lossless-claw 0.6.2 → 0.7.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 +16 -4
- package/docs/agent-tools.md +7 -1
- package/docs/configuration.md +200 -200
- package/openclaw.plugin.json +123 -0
- package/package.json +1 -1
- package/skills/lossless-claw/references/config.md +135 -3
- package/src/assembler.ts +5 -1
- package/src/compaction.ts +149 -38
- package/src/db/config.ts +102 -4
- package/src/db/connection.ts +20 -2
- package/src/db/migration.ts +57 -0
- package/src/engine.ts +980 -122
- package/src/lcm-log.ts +37 -0
- package/src/plugin/index.ts +407 -74
- package/src/plugin/lcm-command.ts +10 -4
- package/src/plugin/shared-init.ts +59 -0
- package/src/prune.ts +391 -0
- package/src/retrieval.ts +7 -5
- package/src/startup-banner-log.ts +1 -0
- package/src/store/compaction-telemetry-store.ts +156 -0
- package/src/store/conversation-store.ts +6 -1
- package/src/store/fts5-sanitize.ts +25 -4
- package/src/store/full-text-sort.ts +21 -0
- package/src/store/index.ts +8 -0
- package/src/store/summary-store.ts +21 -14
- package/src/summarize.ts +54 -30
- package/src/tools/lcm-describe-tool.ts +9 -4
- package/src/tools/lcm-expand-query-tool.ts +11 -6
- package/src/tools/lcm-expand-tool.ts +9 -4
- package/src/tools/lcm-grep-tool.ts +22 -8
- package/src/types.ts +1 -0
package/src/lcm-log.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { LcmDependencies } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export type LcmLogger = LcmDependencies["log"];
|
|
5
|
+
|
|
6
|
+
/** Silent logger used when a caller does not provide an explicit sink. */
|
|
7
|
+
export const NOOP_LCM_LOGGER: LcmLogger = {
|
|
8
|
+
info: () => {},
|
|
9
|
+
warn: () => {},
|
|
10
|
+
error: () => {},
|
|
11
|
+
debug: () => {},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Format unknown failures into stable one-line log text. */
|
|
15
|
+
export function describeLogError(error: unknown): string {
|
|
16
|
+
return error instanceof Error ? error.message : String(error);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Create the LCM logger, preferring OpenClaw's file-backed runtime logger. */
|
|
20
|
+
export function createLcmLogger(api: OpenClawPluginApi): LcmLogger {
|
|
21
|
+
const runtimeLogger = api.runtime.logging?.getChildLogger?.({ plugin: "lossless-claw" });
|
|
22
|
+
if (runtimeLogger) {
|
|
23
|
+
return {
|
|
24
|
+
info: (message) => runtimeLogger.info(message),
|
|
25
|
+
warn: (message) => runtimeLogger.warn(message),
|
|
26
|
+
error: (message) => runtimeLogger.error(message),
|
|
27
|
+
debug: (message) => runtimeLogger.debug?.(message),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
info: (message) => api.logger.info(message),
|
|
33
|
+
warn: (message) => api.logger.warn(message),
|
|
34
|
+
error: (message) => api.logger.error(message),
|
|
35
|
+
debug: (message) => api.logger.debug?.(message),
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/plugin/index.ts
CHANGED
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
9
10
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
10
11
|
import { resolveLcmConfig } from "../db/config.js";
|
|
11
|
-
import { createLcmDatabaseConnection } from "../db/connection.js";
|
|
12
|
+
import { closeLcmConnection, createLcmDatabaseConnection, normalizePath } from "../db/connection.js";
|
|
12
13
|
import { LcmContextEngine } from "../engine.js";
|
|
14
|
+
import { createLcmLogger, describeLogError } from "../lcm-log.js";
|
|
13
15
|
import { logStartupBannerOnce } from "../startup-banner-log.js";
|
|
16
|
+
import { getSharedInit, setSharedInit, removeSharedInit } from "./shared-init.js";
|
|
17
|
+
import type { SharedLcmInit } from "./shared-init.js";
|
|
14
18
|
import { createLcmDescribeTool } from "../tools/lcm-describe-tool.js";
|
|
15
19
|
import { createLcmExpandQueryTool } from "../tools/lcm-expand-query-tool.js";
|
|
16
20
|
import { createLcmExpandTool } from "../tools/lcm-expand-tool.js";
|
|
@@ -64,6 +68,37 @@ type CompleteSimpleOptions = {
|
|
|
64
68
|
|
|
65
69
|
type RuntimeModelAuthResult = {
|
|
66
70
|
apiKey?: string;
|
|
71
|
+
baseUrl?: string;
|
|
72
|
+
request?: RuntimeModelRequestTransportOverrides;
|
|
73
|
+
expiresAt?: number;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type RuntimeModelRequestAuthOverride =
|
|
77
|
+
| {
|
|
78
|
+
mode: "provider-default";
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
mode: "authorization-bearer";
|
|
82
|
+
token: string;
|
|
83
|
+
}
|
|
84
|
+
| {
|
|
85
|
+
mode: "header";
|
|
86
|
+
headerName: string;
|
|
87
|
+
value: string;
|
|
88
|
+
prefix?: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
type RuntimeModelRequestTransportOverrides = {
|
|
92
|
+
headers?: Record<string, string>;
|
|
93
|
+
auth?: RuntimeModelRequestAuthOverride;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
type SessionEndLifecycleEvent = {
|
|
97
|
+
sessionId?: string;
|
|
98
|
+
sessionKey?: string;
|
|
99
|
+
reason?: string;
|
|
100
|
+
nextSessionId?: string;
|
|
101
|
+
nextSessionKey?: string;
|
|
67
102
|
};
|
|
68
103
|
|
|
69
104
|
type RuntimeModelAuthModel = {
|
|
@@ -96,6 +131,13 @@ type RuntimeModelAuth = {
|
|
|
96
131
|
profileId?: string;
|
|
97
132
|
preferredProfile?: string;
|
|
98
133
|
}) => Promise<RuntimeModelAuthResult | undefined>;
|
|
134
|
+
getRuntimeAuthForModel?: (params: {
|
|
135
|
+
model: RuntimeModelAuthModel;
|
|
136
|
+
cfg?: OpenClawPluginApi["config"];
|
|
137
|
+
profileId?: string;
|
|
138
|
+
preferredProfile?: string;
|
|
139
|
+
workspaceDir?: string;
|
|
140
|
+
}) => Promise<RuntimeModelAuthResult | undefined>;
|
|
99
141
|
};
|
|
100
142
|
|
|
101
143
|
const MODEL_AUTH_PR_URL = "https://github.com/openclaw/openclaw/pull/41090";
|
|
@@ -128,12 +170,20 @@ const LOSSLESS_RECALL_POLICY_PROMPT = [
|
|
|
128
170
|
"Recall order for compacted conversation history:",
|
|
129
171
|
"1. `lcm_grep` — search by regex or full-text across messages and summaries",
|
|
130
172
|
"2. `lcm_describe` — inspect a specific summary (cheap, no sub-agent)",
|
|
131
|
-
"3. `lcm_expand_query` — deep recall: spawns bounded sub-agent, expands DAG, returns answer
|
|
173
|
+
"3. `lcm_expand_query` — deep recall: spawns bounded sub-agent, expands DAG, and returns answer plus cited summary IDs in tool output for follow-up (~120s, don't ration it)",
|
|
174
|
+
"",
|
|
175
|
+
"**`lcm_grep` routing guidance:**",
|
|
176
|
+
'- Prefer `mode: "full_text"` for keyword or topical recall; keep `mode: "regex"` for literal patterns.',
|
|
177
|
+
'- Wrap exact multi-word phrases in quotes, for example `"error handling"`.',
|
|
178
|
+
'- Keep the default `sort: "recency"` for "what just happened?" lookups.',
|
|
179
|
+
'- Use `sort: "relevance"` when hunting for the best older match on a topic.',
|
|
180
|
+
'- Use `sort: "hybrid"` when relevance matters but newer context should still get a boost.',
|
|
132
181
|
"",
|
|
133
182
|
"**`lcm_expand_query` usage** — two patterns (always requires `prompt`):",
|
|
134
183
|
"- With IDs: `lcm_expand_query(summaryIds: [\"sum_xxx\"], prompt: \"What config changes were discussed?\")`",
|
|
135
184
|
"- With search: `lcm_expand_query(query: \"database migration\", prompt: \"What strategy was decided?\")`",
|
|
136
185
|
"- Optional: `maxTokens` (default 2000), `conversationId`, `allConversations: true`",
|
|
186
|
+
"- Keep raw summary IDs out of normal user-facing prose unless the user explicitly asks for sources or IDs.",
|
|
137
187
|
"",
|
|
138
188
|
"These precedence rules apply only to compacted conversation history. Lossless-claw does not supersede memory tools globally.",
|
|
139
189
|
"",
|
|
@@ -579,6 +629,78 @@ function resolveApiKeyFromAuthResult(auth: RuntimeModelAuthResult | undefined):
|
|
|
579
629
|
return apiKey ? apiKey : undefined;
|
|
580
630
|
}
|
|
581
631
|
|
|
632
|
+
/** Normalize a runtime auth override base URL when present. */
|
|
633
|
+
function resolveBaseUrlFromAuthResult(auth: RuntimeModelAuthResult | undefined): string | undefined {
|
|
634
|
+
const baseUrl = auth?.baseUrl?.trim();
|
|
635
|
+
return baseUrl ? baseUrl : undefined;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Normalize raw runtime auth headers into plain string headers. */
|
|
639
|
+
function resolveRuntimeAuthHeaders(
|
|
640
|
+
request: RuntimeModelRequestTransportOverrides | undefined,
|
|
641
|
+
): Record<string, string> | undefined {
|
|
642
|
+
if (!request) {
|
|
643
|
+
return undefined;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const headers: Record<string, string> = {};
|
|
647
|
+
if (isRecord(request.headers)) {
|
|
648
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
649
|
+
if (typeof value !== "string") {
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
const headerName = key.trim();
|
|
653
|
+
const headerValue = value.trim();
|
|
654
|
+
if (headerName && headerValue) {
|
|
655
|
+
headers[headerName] = headerValue;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const auth = request.auth;
|
|
661
|
+
if (auth?.mode === "authorization-bearer") {
|
|
662
|
+
const token = auth.token.trim();
|
|
663
|
+
if (token) {
|
|
664
|
+
for (const key of Object.keys(headers)) {
|
|
665
|
+
if (key.toLowerCase() === "authorization") {
|
|
666
|
+
delete headers[key];
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
headers.Authorization = `Bearer ${token}`;
|
|
670
|
+
}
|
|
671
|
+
} else if (auth?.mode === "header") {
|
|
672
|
+
const headerName = auth.headerName.trim();
|
|
673
|
+
const value = auth.value.trim();
|
|
674
|
+
if (headerName && value) {
|
|
675
|
+
const normalizedHeader = headerName.toLowerCase();
|
|
676
|
+
for (const key of Object.keys(headers)) {
|
|
677
|
+
if (
|
|
678
|
+
key.toLowerCase() === normalizedHeader ||
|
|
679
|
+
(normalizedHeader !== "authorization" && key.toLowerCase() === "authorization")
|
|
680
|
+
) {
|
|
681
|
+
delete headers[key];
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
headers[headerName] = `${auth.prefix?.trim() ?? ""}${value}`;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return Object.keys(headers).length > 0 ? headers : undefined;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Attach OpenClaw transport overrides to a model for runtimes that inspect the shared symbol. */
|
|
692
|
+
function attachRuntimeAuthRequestTransport<TModel extends object>(
|
|
693
|
+
model: TModel,
|
|
694
|
+
request: RuntimeModelRequestTransportOverrides | undefined,
|
|
695
|
+
): TModel {
|
|
696
|
+
if (!request) {
|
|
697
|
+
return model;
|
|
698
|
+
}
|
|
699
|
+
const next = { ...model } as TModel & Record<symbol, unknown>;
|
|
700
|
+
next[Symbol.for("openclaw.modelProviderRequestTransport")] = request;
|
|
701
|
+
return next;
|
|
702
|
+
}
|
|
703
|
+
|
|
582
704
|
function buildLegacyAuthFallbackWarning(): string {
|
|
583
705
|
return [
|
|
584
706
|
"[lcm] OpenClaw runtime.modelAuth is unavailable; using legacy auth-profiles fallback.",
|
|
@@ -1094,6 +1216,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1094
1216
|
? api.pluginConfig
|
|
1095
1217
|
: undefined;
|
|
1096
1218
|
const config = resolveLcmConfig(process.env, pluginConfig);
|
|
1219
|
+
const log = createLcmLogger(api);
|
|
1097
1220
|
|
|
1098
1221
|
// Read model overrides from plugin config
|
|
1099
1222
|
if (pluginConfig) {
|
|
@@ -1108,7 +1231,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1108
1231
|
}
|
|
1109
1232
|
|
|
1110
1233
|
if (!modelAuth) {
|
|
1111
|
-
|
|
1234
|
+
log.warn(buildLegacyAuthFallbackWarning());
|
|
1112
1235
|
}
|
|
1113
1236
|
|
|
1114
1237
|
/** Resolve the best config object to hand to runtime.modelAuth for this lookup. */
|
|
@@ -1186,6 +1309,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1186
1309
|
authProfileId,
|
|
1187
1310
|
agentDir,
|
|
1188
1311
|
runtimeConfig,
|
|
1312
|
+
skipModelAuth,
|
|
1189
1313
|
messages,
|
|
1190
1314
|
system,
|
|
1191
1315
|
maxTokens,
|
|
@@ -1205,6 +1329,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1205
1329
|
if (!providerId || !modelId) {
|
|
1206
1330
|
return { content: [] };
|
|
1207
1331
|
}
|
|
1332
|
+
const workspaceDir = agentDir?.trim() || api.resolvePath(".");
|
|
1208
1333
|
|
|
1209
1334
|
// When runtimeConfig is undefined (e.g. resolveLargeFileTextSummarizer
|
|
1210
1335
|
// passes legacyParams without config), fall back to the plugin API so
|
|
@@ -1235,6 +1360,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1235
1360
|
return first.api.trim();
|
|
1236
1361
|
})() ||
|
|
1237
1362
|
inferApiFromProvider(providerId);
|
|
1363
|
+
const modelAuthConfig = resolveModelAuthConfig(effectiveRuntimeConfig);
|
|
1238
1364
|
|
|
1239
1365
|
// Resolve provider-level config (baseUrl, headers, etc.) from runtime config.
|
|
1240
1366
|
// Custom/proxy providers (e.g. bailian, local proxies) store their baseUrl and
|
|
@@ -1251,7 +1377,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1251
1377
|
return isRecord(cfg) ? cfg : {};
|
|
1252
1378
|
})();
|
|
1253
1379
|
|
|
1254
|
-
|
|
1380
|
+
let resolvedModel =
|
|
1255
1381
|
isRecord(knownModel) &&
|
|
1256
1382
|
typeof knownModel.api === "string" &&
|
|
1257
1383
|
typeof knownModel.provider === "string" &&
|
|
@@ -1299,8 +1425,50 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1299
1425
|
: {}),
|
|
1300
1426
|
};
|
|
1301
1427
|
|
|
1428
|
+
let runtimeAuth: RuntimeModelAuthResult | undefined;
|
|
1429
|
+
if (modelAuth && skipModelAuth !== true && typeof modelAuth.getRuntimeAuthForModel === "function") {
|
|
1430
|
+
try {
|
|
1431
|
+
runtimeAuth = await modelAuth.getRuntimeAuthForModel({
|
|
1432
|
+
model: buildModelAuthLookupModel({
|
|
1433
|
+
provider: providerId,
|
|
1434
|
+
model: modelId,
|
|
1435
|
+
api: resolvedModel.api,
|
|
1436
|
+
}),
|
|
1437
|
+
cfg: modelAuthConfig,
|
|
1438
|
+
...(authProfileId ? { profileId: authProfileId } : {}),
|
|
1439
|
+
workspaceDir,
|
|
1440
|
+
});
|
|
1441
|
+
} catch (err) {
|
|
1442
|
+
console.error(
|
|
1443
|
+
`[lcm] modelAuth.getRuntimeAuthForModel FAILED:`,
|
|
1444
|
+
err instanceof Error ? err.message : err,
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const runtimeAuthBaseUrl = resolveBaseUrlFromAuthResult(runtimeAuth);
|
|
1450
|
+
const runtimeAuthHeaders = resolveRuntimeAuthHeaders(runtimeAuth?.request);
|
|
1451
|
+
resolvedModel = attachRuntimeAuthRequestTransport(
|
|
1452
|
+
{
|
|
1453
|
+
...resolvedModel,
|
|
1454
|
+
...(runtimeAuthBaseUrl ? { baseUrl: runtimeAuthBaseUrl } : {}),
|
|
1455
|
+
...(runtimeAuthHeaders
|
|
1456
|
+
? {
|
|
1457
|
+
headers: {
|
|
1458
|
+
...(isRecord(resolvedModel.headers) ? resolvedModel.headers : {}),
|
|
1459
|
+
...runtimeAuthHeaders,
|
|
1460
|
+
},
|
|
1461
|
+
}
|
|
1462
|
+
: {}),
|
|
1463
|
+
},
|
|
1464
|
+
runtimeAuth?.request,
|
|
1465
|
+
);
|
|
1466
|
+
|
|
1302
1467
|
let resolvedApiKey = apiKey?.trim();
|
|
1303
|
-
if (!resolvedApiKey
|
|
1468
|
+
if (!resolvedApiKey) {
|
|
1469
|
+
resolvedApiKey = resolveApiKeyFromAuthResult(runtimeAuth);
|
|
1470
|
+
}
|
|
1471
|
+
if (!resolvedApiKey && modelAuth && skipModelAuth !== true) {
|
|
1304
1472
|
try {
|
|
1305
1473
|
resolvedApiKey = resolveApiKeyFromAuthResult(
|
|
1306
1474
|
await modelAuth.getApiKeyForModel({
|
|
@@ -1309,31 +1477,25 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1309
1477
|
model: modelId,
|
|
1310
1478
|
api: resolvedModel.api,
|
|
1311
1479
|
}),
|
|
1312
|
-
cfg:
|
|
1480
|
+
cfg: modelAuthConfig,
|
|
1313
1481
|
...(authProfileId ? { profileId: authProfileId } : {}),
|
|
1314
1482
|
}),
|
|
1315
1483
|
);
|
|
1316
1484
|
} catch (err) {
|
|
1317
|
-
|
|
1318
|
-
`[lcm] modelAuth.getApiKeyForModel FAILED:`,
|
|
1319
|
-
err instanceof Error ? err.message : err,
|
|
1320
|
-
);
|
|
1485
|
+
log.warn(`[lcm] modelAuth.getApiKeyForModel FAILED: ${describeLogError(err)}`);
|
|
1321
1486
|
}
|
|
1322
1487
|
}
|
|
1323
|
-
if (!resolvedApiKey && modelAuth) {
|
|
1488
|
+
if (!resolvedApiKey && modelAuth && skipModelAuth !== true) {
|
|
1324
1489
|
try {
|
|
1325
1490
|
resolvedApiKey = resolveApiKeyFromAuthResult(
|
|
1326
1491
|
await modelAuth.resolveApiKeyForProvider({
|
|
1327
1492
|
provider: providerId,
|
|
1328
|
-
cfg:
|
|
1493
|
+
cfg: modelAuthConfig,
|
|
1329
1494
|
...(authProfileId ? { profileId: authProfileId } : {}),
|
|
1330
1495
|
}),
|
|
1331
1496
|
);
|
|
1332
1497
|
} catch (err) {
|
|
1333
|
-
|
|
1334
|
-
`[lcm] modelAuth.resolveApiKeyForProvider FAILED:`,
|
|
1335
|
-
err instanceof Error ? err.message : err,
|
|
1336
|
-
);
|
|
1498
|
+
log.warn(`[lcm] modelAuth.resolveApiKeyForProvider FAILED: ${describeLogError(err)}`);
|
|
1337
1499
|
}
|
|
1338
1500
|
}
|
|
1339
1501
|
if (!resolvedApiKey) {
|
|
@@ -1418,7 +1580,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1418
1580
|
...requestMetadata,
|
|
1419
1581
|
};
|
|
1420
1582
|
} catch (err) {
|
|
1421
|
-
|
|
1583
|
+
log.error(`[lcm] completeSimple error: ${describeLogError(err)}`);
|
|
1422
1584
|
const authError = detectProviderAuthError(err);
|
|
1423
1585
|
return {
|
|
1424
1586
|
content: [],
|
|
@@ -1528,15 +1690,66 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1528
1690
|
}
|
|
1529
1691
|
},
|
|
1530
1692
|
agentLaneSubagent: "subagent",
|
|
1531
|
-
log
|
|
1532
|
-
info: (msg) => api.logger.info(msg),
|
|
1533
|
-
warn: (msg) => api.logger.warn(msg),
|
|
1534
|
-
error: (msg) => api.logger.error(msg),
|
|
1535
|
-
debug: (msg) => api.logger.debug?.(msg),
|
|
1536
|
-
},
|
|
1693
|
+
log,
|
|
1537
1694
|
};
|
|
1538
1695
|
}
|
|
1539
1696
|
|
|
1697
|
+
/**
|
|
1698
|
+
* Wire event handlers, context engines, tools, and commands to the
|
|
1699
|
+
* OpenClaw plugin API using shared init closures.
|
|
1700
|
+
*/
|
|
1701
|
+
function wirePluginHandlers(
|
|
1702
|
+
api: OpenClawPluginApi,
|
|
1703
|
+
deps: LcmDependencies,
|
|
1704
|
+
shared: SharedLcmInit,
|
|
1705
|
+
): void {
|
|
1706
|
+
api.on("before_reset", async (event, ctx) => {
|
|
1707
|
+
await (await shared.waitForEngine()).handleBeforeReset({
|
|
1708
|
+
reason: event.reason,
|
|
1709
|
+
sessionId: ctx.sessionId,
|
|
1710
|
+
sessionKey: ctx.sessionKey,
|
|
1711
|
+
});
|
|
1712
|
+
});
|
|
1713
|
+
api.on("before_prompt_build", () => ({
|
|
1714
|
+
prependSystemContext: LOSSLESS_RECALL_POLICY_PROMPT,
|
|
1715
|
+
}));
|
|
1716
|
+
api.on("session_end", async (event) => {
|
|
1717
|
+
const lifecycleEvent = event as SessionEndLifecycleEvent;
|
|
1718
|
+
await (await shared.waitForEngine()).handleSessionEnd({
|
|
1719
|
+
reason: lifecycleEvent.reason,
|
|
1720
|
+
sessionId: lifecycleEvent.sessionId,
|
|
1721
|
+
sessionKey: lifecycleEvent.sessionKey,
|
|
1722
|
+
nextSessionId: lifecycleEvent.nextSessionId,
|
|
1723
|
+
nextSessionKey: lifecycleEvent.nextSessionKey,
|
|
1724
|
+
});
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
api.registerContextEngine("lossless-claw", () => shared.getCachedEngine() ?? shared.waitForEngine());
|
|
1728
|
+
api.registerContextEngine("default", () => shared.getCachedEngine() ?? shared.waitForEngine());
|
|
1729
|
+
|
|
1730
|
+
api.registerTool((ctx) =>
|
|
1731
|
+
createLcmGrepTool({ deps, getLcm: shared.waitForEngine, sessionKey: ctx.sessionKey }),
|
|
1732
|
+
);
|
|
1733
|
+
api.registerTool((ctx) =>
|
|
1734
|
+
createLcmDescribeTool({ deps, getLcm: shared.waitForEngine, sessionKey: ctx.sessionKey }),
|
|
1735
|
+
);
|
|
1736
|
+
api.registerTool((ctx) =>
|
|
1737
|
+
createLcmExpandTool({ deps, getLcm: shared.waitForEngine, sessionKey: ctx.sessionKey }),
|
|
1738
|
+
);
|
|
1739
|
+
api.registerTool((ctx) =>
|
|
1740
|
+
createLcmExpandQueryTool({
|
|
1741
|
+
deps,
|
|
1742
|
+
getLcm: shared.waitForEngine,
|
|
1743
|
+
sessionKey: ctx.sessionKey,
|
|
1744
|
+
requesterSessionKey: ctx.sessionKey,
|
|
1745
|
+
}),
|
|
1746
|
+
);
|
|
1747
|
+
|
|
1748
|
+
api.registerCommand(
|
|
1749
|
+
createLcmCommand({ db: shared.waitForDatabase, config: deps.config, deps }),
|
|
1750
|
+
);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1540
1753
|
const lcmPlugin = {
|
|
1541
1754
|
id: "lossless-claw",
|
|
1542
1755
|
name: "Lossless Context Management",
|
|
@@ -1555,72 +1768,192 @@ const lcmPlugin = {
|
|
|
1555
1768
|
|
|
1556
1769
|
register(api: OpenClawPluginApi) {
|
|
1557
1770
|
const deps = createLcmDependencies(api);
|
|
1558
|
-
const
|
|
1559
|
-
const
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1771
|
+
const dbPath = deps.config.databasePath;
|
|
1772
|
+
const normalizedDbPath = normalizePath(dbPath);
|
|
1773
|
+
|
|
1774
|
+
// ── Singleton check ─────────────────────────────────────────────
|
|
1775
|
+
// OpenClaw v2026.4.5+ calls register() per-agent-context (main,
|
|
1776
|
+
// subagents, cron lanes). Reuse the existing connection and engine
|
|
1777
|
+
// when the same DB path is already initialized.
|
|
1778
|
+
const existingInit = getSharedInit(normalizedDbPath);
|
|
1779
|
+
if (existingInit && !existingInit.stopped) {
|
|
1780
|
+
wirePluginHandlers(api, deps, existingInit);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// ── Eager-first DB init with deferred fallback on lock ──────────
|
|
1785
|
+
let database: DatabaseSync | null = null;
|
|
1786
|
+
let lcm: LcmContextEngine | null = null;
|
|
1787
|
+
let initPromise: Promise<LcmContextEngine> | null = null;
|
|
1788
|
+
let initError: Error | null = null;
|
|
1789
|
+
let resolveDeferredInit: ((engine: LcmContextEngine) => void) | null = null;
|
|
1790
|
+
let rejectDeferredInit: ((error: Error) => void) | null = null;
|
|
1791
|
+
let stopped = false;
|
|
1792
|
+
|
|
1793
|
+
/** Normalize unknown failures into stable Error instances. */
|
|
1794
|
+
function toInitError(error: unknown): Error {
|
|
1795
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/** Build a live DB+engine pair and roll back the DB handle if engine init fails. */
|
|
1799
|
+
function initializeEngine(): LcmContextEngine {
|
|
1800
|
+
const nextDatabase = createLcmDatabaseConnection(dbPath);
|
|
1801
|
+
try {
|
|
1802
|
+
const nextEngine = new LcmContextEngine(deps, nextDatabase);
|
|
1803
|
+
database = nextDatabase;
|
|
1804
|
+
lcm = nextEngine;
|
|
1805
|
+
initError = null;
|
|
1806
|
+
return nextEngine;
|
|
1807
|
+
} catch (error) {
|
|
1808
|
+
closeLcmConnection(nextDatabase);
|
|
1809
|
+
throw error;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/** Keep one shared deferred init promise so early callers all await the same retry. */
|
|
1814
|
+
function ensureDeferredInitPromise(): Promise<LcmContextEngine> {
|
|
1815
|
+
if (initPromise) {
|
|
1816
|
+
return initPromise;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
initPromise = new Promise<LcmContextEngine>((resolve, reject) => {
|
|
1820
|
+
resolveDeferredInit = resolve;
|
|
1821
|
+
rejectDeferredInit = reject;
|
|
1822
|
+
});
|
|
1823
|
+
initPromise.catch(() => {});
|
|
1824
|
+
return initPromise;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/** Resolve the shared deferred init promise exactly once. */
|
|
1828
|
+
function resolveDeferredEngine(nextEngine: LcmContextEngine): void {
|
|
1829
|
+
const resolve = resolveDeferredInit;
|
|
1830
|
+
resolveDeferredInit = null;
|
|
1831
|
+
rejectDeferredInit = null;
|
|
1832
|
+
resolve?.(nextEngine);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
/** Reject the shared deferred init promise exactly once and retain the root cause. */
|
|
1836
|
+
function rejectDeferredEngine(error: Error): void {
|
|
1837
|
+
initError = error;
|
|
1838
|
+
const reject = rejectDeferredInit;
|
|
1839
|
+
resolveDeferredInit = null;
|
|
1840
|
+
rejectDeferredInit = null;
|
|
1841
|
+
reject?.(error);
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/** Return the initialized engine, waiting for deferred startup when the DB is lock-contended. */
|
|
1845
|
+
async function waitForEngine(): Promise<LcmContextEngine> {
|
|
1846
|
+
if (stopped) {
|
|
1847
|
+
throw new Error("[lcm] Database connection closed after gateway_stop");
|
|
1848
|
+
}
|
|
1849
|
+
if (initError) {
|
|
1850
|
+
throw initError;
|
|
1851
|
+
}
|
|
1852
|
+
if (lcm) {
|
|
1853
|
+
return lcm;
|
|
1854
|
+
}
|
|
1855
|
+
if (initPromise) {
|
|
1856
|
+
return initPromise;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
try {
|
|
1860
|
+
const nextEngine = initializeEngine();
|
|
1861
|
+
initPromise = Promise.resolve(nextEngine);
|
|
1862
|
+
return nextEngine;
|
|
1863
|
+
} catch (error) {
|
|
1864
|
+
const normalized = toInitError(error);
|
|
1865
|
+
if (!/database is locked/i.test(normalized.message)) {
|
|
1866
|
+
initError = normalized;
|
|
1867
|
+
throw normalized;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
deps.log.warn("[lcm] DB locked during eager init, deferring to gateway_start");
|
|
1871
|
+
return ensureDeferredInitPromise();
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
/** Return the initialized DB handle, sharing the same wait/error semantics as the engine. */
|
|
1876
|
+
async function waitForDatabase(): Promise<DatabaseSync> {
|
|
1877
|
+
await waitForEngine();
|
|
1878
|
+
if (!database) {
|
|
1879
|
+
throw initError ?? new Error("[lcm] Database initialization finished without a handle");
|
|
1880
|
+
}
|
|
1881
|
+
return database;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
try {
|
|
1885
|
+
const nextEngine = initializeEngine();
|
|
1886
|
+
initPromise = Promise.resolve(nextEngine);
|
|
1887
|
+
} catch (error) {
|
|
1888
|
+
const normalized = toInitError(error);
|
|
1889
|
+
if (!/database is locked/i.test(normalized.message)) {
|
|
1890
|
+
initError = normalized;
|
|
1891
|
+
throw normalized;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
deps.log.warn("[lcm] DB locked during eager init, deferring to gateway_start");
|
|
1895
|
+
ensureDeferredInitPromise();
|
|
1896
|
+
api.on("gateway_start", async () => {
|
|
1897
|
+
if (stopped || lcm || initError) {
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
try {
|
|
1901
|
+
const nextEngine = initializeEngine();
|
|
1902
|
+
initPromise = Promise.resolve(nextEngine);
|
|
1903
|
+
resolveDeferredEngine(nextEngine);
|
|
1904
|
+
} catch (retryError) {
|
|
1905
|
+
const normalizedRetryError = toInitError(retryError);
|
|
1906
|
+
rejectDeferredEngine(normalizedRetryError);
|
|
1907
|
+
deps.log.error(`[lcm] Deferred DB init failed: ${normalizedRetryError.message}`);
|
|
1908
|
+
}
|
|
1566
1909
|
});
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
const shared: SharedLcmInit = {
|
|
1913
|
+
stopped: false,
|
|
1914
|
+
getCachedEngine: () => lcm,
|
|
1915
|
+
waitForEngine,
|
|
1916
|
+
waitForDatabase,
|
|
1917
|
+
};
|
|
1918
|
+
setSharedInit(normalizedDbPath, shared);
|
|
1919
|
+
|
|
1920
|
+
api.on("gateway_stop", async () => {
|
|
1921
|
+
stopped = true;
|
|
1922
|
+
shared.stopped = true;
|
|
1923
|
+
if (!lcm && !database) {
|
|
1924
|
+
rejectDeferredEngine(new Error("[lcm] Database connection closed after gateway_stop"));
|
|
1925
|
+
}
|
|
1926
|
+
if (database) {
|
|
1927
|
+
closeLcmConnection(database);
|
|
1928
|
+
database = null;
|
|
1929
|
+
}
|
|
1930
|
+
lcm = null;
|
|
1931
|
+
removeSharedInit(normalizedDbPath);
|
|
1567
1932
|
});
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
}));
|
|
1571
|
-
api.registerContextEngine("lossless-claw", () => lcm);
|
|
1572
|
-
api.registerContextEngine("default", () => lcm);
|
|
1573
|
-
api.registerTool((ctx) =>
|
|
1574
|
-
createLcmGrepTool({
|
|
1575
|
-
deps,
|
|
1576
|
-
lcm,
|
|
1577
|
-
sessionKey: ctx.sessionKey,
|
|
1578
|
-
}),
|
|
1579
|
-
);
|
|
1580
|
-
api.registerTool((ctx) =>
|
|
1581
|
-
createLcmDescribeTool({
|
|
1582
|
-
deps,
|
|
1583
|
-
lcm,
|
|
1584
|
-
sessionKey: ctx.sessionKey,
|
|
1585
|
-
}),
|
|
1586
|
-
);
|
|
1587
|
-
api.registerTool((ctx) =>
|
|
1588
|
-
createLcmExpandTool({
|
|
1589
|
-
deps,
|
|
1590
|
-
lcm,
|
|
1591
|
-
sessionKey: ctx.sessionKey,
|
|
1592
|
-
}),
|
|
1593
|
-
);
|
|
1594
|
-
api.registerTool((ctx) =>
|
|
1595
|
-
createLcmExpandQueryTool({
|
|
1596
|
-
deps,
|
|
1597
|
-
lcm,
|
|
1598
|
-
sessionKey: ctx.sessionKey,
|
|
1599
|
-
requesterSessionKey: ctx.sessionKey,
|
|
1600
|
-
}),
|
|
1601
|
-
);
|
|
1602
|
-
api.registerCommand(
|
|
1603
|
-
createLcmCommand({
|
|
1604
|
-
db: database,
|
|
1605
|
-
config: deps.config,
|
|
1606
|
-
deps,
|
|
1607
|
-
}),
|
|
1608
|
-
);
|
|
1933
|
+
|
|
1934
|
+
wirePluginHandlers(api, deps, shared);
|
|
1609
1935
|
|
|
1610
1936
|
logStartupBannerOnce({
|
|
1611
1937
|
key: "plugin-loaded",
|
|
1612
|
-
log: (message) =>
|
|
1938
|
+
log: (message) => deps.log.info(message),
|
|
1613
1939
|
message: `[lcm] Plugin loaded (enabled=${deps.config.enabled}, db=${deps.config.databasePath}, threshold=${deps.config.contextThreshold})`,
|
|
1614
1940
|
});
|
|
1615
1941
|
logStartupBannerOnce({
|
|
1616
1942
|
key: "compaction-model",
|
|
1617
|
-
log: (message) =>
|
|
1943
|
+
log: (message) => deps.log.info(message),
|
|
1618
1944
|
message: buildCompactionModelLog({
|
|
1619
1945
|
config: deps.config,
|
|
1620
1946
|
openClawConfig: api.config,
|
|
1621
1947
|
defaultProvider: process.env.OPENCLAW_PROVIDER?.trim() ?? "",
|
|
1622
1948
|
}),
|
|
1623
1949
|
});
|
|
1950
|
+
if (deps.config.fallbackProviders.length > 0) {
|
|
1951
|
+
logStartupBannerOnce({
|
|
1952
|
+
key: "fallback-providers",
|
|
1953
|
+
log: (message) => deps.log.info(message),
|
|
1954
|
+
message: `[lcm] Fallback providers: ${deps.config.fallbackProviders.map((fp) => `${fp.provider}/${fp.model}`).join(", ")}`,
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1624
1957
|
},
|
|
1625
1958
|
};
|
|
1626
1959
|
|