@martian-engineering/lossless-claw 0.6.3 → 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 +15 -3
- 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 +814 -97
- package/src/lcm-log.ts +37 -0
- package/src/plugin/index.ts +398 -83
- 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,29 @@ 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;
|
|
67
94
|
};
|
|
68
95
|
|
|
69
96
|
type SessionEndLifecycleEvent = {
|
|
@@ -104,6 +131,13 @@ type RuntimeModelAuth = {
|
|
|
104
131
|
profileId?: string;
|
|
105
132
|
preferredProfile?: string;
|
|
106
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>;
|
|
107
141
|
};
|
|
108
142
|
|
|
109
143
|
const MODEL_AUTH_PR_URL = "https://github.com/openclaw/openclaw/pull/41090";
|
|
@@ -136,12 +170,20 @@ const LOSSLESS_RECALL_POLICY_PROMPT = [
|
|
|
136
170
|
"Recall order for compacted conversation history:",
|
|
137
171
|
"1. `lcm_grep` — search by regex or full-text across messages and summaries",
|
|
138
172
|
"2. `lcm_describe` — inspect a specific summary (cheap, no sub-agent)",
|
|
139
|
-
"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.',
|
|
140
181
|
"",
|
|
141
182
|
"**`lcm_expand_query` usage** — two patterns (always requires `prompt`):",
|
|
142
183
|
"- With IDs: `lcm_expand_query(summaryIds: [\"sum_xxx\"], prompt: \"What config changes were discussed?\")`",
|
|
143
184
|
"- With search: `lcm_expand_query(query: \"database migration\", prompt: \"What strategy was decided?\")`",
|
|
144
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.",
|
|
145
187
|
"",
|
|
146
188
|
"These precedence rules apply only to compacted conversation history. Lossless-claw does not supersede memory tools globally.",
|
|
147
189
|
"",
|
|
@@ -587,6 +629,78 @@ function resolveApiKeyFromAuthResult(auth: RuntimeModelAuthResult | undefined):
|
|
|
587
629
|
return apiKey ? apiKey : undefined;
|
|
588
630
|
}
|
|
589
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
|
+
|
|
590
704
|
function buildLegacyAuthFallbackWarning(): string {
|
|
591
705
|
return [
|
|
592
706
|
"[lcm] OpenClaw runtime.modelAuth is unavailable; using legacy auth-profiles fallback.",
|
|
@@ -1102,6 +1216,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1102
1216
|
? api.pluginConfig
|
|
1103
1217
|
: undefined;
|
|
1104
1218
|
const config = resolveLcmConfig(process.env, pluginConfig);
|
|
1219
|
+
const log = createLcmLogger(api);
|
|
1105
1220
|
|
|
1106
1221
|
// Read model overrides from plugin config
|
|
1107
1222
|
if (pluginConfig) {
|
|
@@ -1116,7 +1231,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1116
1231
|
}
|
|
1117
1232
|
|
|
1118
1233
|
if (!modelAuth) {
|
|
1119
|
-
|
|
1234
|
+
log.warn(buildLegacyAuthFallbackWarning());
|
|
1120
1235
|
}
|
|
1121
1236
|
|
|
1122
1237
|
/** Resolve the best config object to hand to runtime.modelAuth for this lookup. */
|
|
@@ -1194,6 +1309,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1194
1309
|
authProfileId,
|
|
1195
1310
|
agentDir,
|
|
1196
1311
|
runtimeConfig,
|
|
1312
|
+
skipModelAuth,
|
|
1197
1313
|
messages,
|
|
1198
1314
|
system,
|
|
1199
1315
|
maxTokens,
|
|
@@ -1213,6 +1329,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1213
1329
|
if (!providerId || !modelId) {
|
|
1214
1330
|
return { content: [] };
|
|
1215
1331
|
}
|
|
1332
|
+
const workspaceDir = agentDir?.trim() || api.resolvePath(".");
|
|
1216
1333
|
|
|
1217
1334
|
// When runtimeConfig is undefined (e.g. resolveLargeFileTextSummarizer
|
|
1218
1335
|
// passes legacyParams without config), fall back to the plugin API so
|
|
@@ -1243,6 +1360,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1243
1360
|
return first.api.trim();
|
|
1244
1361
|
})() ||
|
|
1245
1362
|
inferApiFromProvider(providerId);
|
|
1363
|
+
const modelAuthConfig = resolveModelAuthConfig(effectiveRuntimeConfig);
|
|
1246
1364
|
|
|
1247
1365
|
// Resolve provider-level config (baseUrl, headers, etc.) from runtime config.
|
|
1248
1366
|
// Custom/proxy providers (e.g. bailian, local proxies) store their baseUrl and
|
|
@@ -1259,7 +1377,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1259
1377
|
return isRecord(cfg) ? cfg : {};
|
|
1260
1378
|
})();
|
|
1261
1379
|
|
|
1262
|
-
|
|
1380
|
+
let resolvedModel =
|
|
1263
1381
|
isRecord(knownModel) &&
|
|
1264
1382
|
typeof knownModel.api === "string" &&
|
|
1265
1383
|
typeof knownModel.provider === "string" &&
|
|
@@ -1307,8 +1425,50 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1307
1425
|
: {}),
|
|
1308
1426
|
};
|
|
1309
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
|
+
|
|
1310
1467
|
let resolvedApiKey = apiKey?.trim();
|
|
1311
|
-
if (!resolvedApiKey
|
|
1468
|
+
if (!resolvedApiKey) {
|
|
1469
|
+
resolvedApiKey = resolveApiKeyFromAuthResult(runtimeAuth);
|
|
1470
|
+
}
|
|
1471
|
+
if (!resolvedApiKey && modelAuth && skipModelAuth !== true) {
|
|
1312
1472
|
try {
|
|
1313
1473
|
resolvedApiKey = resolveApiKeyFromAuthResult(
|
|
1314
1474
|
await modelAuth.getApiKeyForModel({
|
|
@@ -1317,31 +1477,25 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1317
1477
|
model: modelId,
|
|
1318
1478
|
api: resolvedModel.api,
|
|
1319
1479
|
}),
|
|
1320
|
-
cfg:
|
|
1480
|
+
cfg: modelAuthConfig,
|
|
1321
1481
|
...(authProfileId ? { profileId: authProfileId } : {}),
|
|
1322
1482
|
}),
|
|
1323
1483
|
);
|
|
1324
1484
|
} catch (err) {
|
|
1325
|
-
|
|
1326
|
-
`[lcm] modelAuth.getApiKeyForModel FAILED:`,
|
|
1327
|
-
err instanceof Error ? err.message : err,
|
|
1328
|
-
);
|
|
1485
|
+
log.warn(`[lcm] modelAuth.getApiKeyForModel FAILED: ${describeLogError(err)}`);
|
|
1329
1486
|
}
|
|
1330
1487
|
}
|
|
1331
|
-
if (!resolvedApiKey && modelAuth) {
|
|
1488
|
+
if (!resolvedApiKey && modelAuth && skipModelAuth !== true) {
|
|
1332
1489
|
try {
|
|
1333
1490
|
resolvedApiKey = resolveApiKeyFromAuthResult(
|
|
1334
1491
|
await modelAuth.resolveApiKeyForProvider({
|
|
1335
1492
|
provider: providerId,
|
|
1336
|
-
cfg:
|
|
1493
|
+
cfg: modelAuthConfig,
|
|
1337
1494
|
...(authProfileId ? { profileId: authProfileId } : {}),
|
|
1338
1495
|
}),
|
|
1339
1496
|
);
|
|
1340
1497
|
} catch (err) {
|
|
1341
|
-
|
|
1342
|
-
`[lcm] modelAuth.resolveApiKeyForProvider FAILED:`,
|
|
1343
|
-
err instanceof Error ? err.message : err,
|
|
1344
|
-
);
|
|
1498
|
+
log.warn(`[lcm] modelAuth.resolveApiKeyForProvider FAILED: ${describeLogError(err)}`);
|
|
1345
1499
|
}
|
|
1346
1500
|
}
|
|
1347
1501
|
if (!resolvedApiKey) {
|
|
@@ -1426,7 +1580,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1426
1580
|
...requestMetadata,
|
|
1427
1581
|
};
|
|
1428
1582
|
} catch (err) {
|
|
1429
|
-
|
|
1583
|
+
log.error(`[lcm] completeSimple error: ${describeLogError(err)}`);
|
|
1430
1584
|
const authError = detectProviderAuthError(err);
|
|
1431
1585
|
return {
|
|
1432
1586
|
content: [],
|
|
@@ -1536,15 +1690,66 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1536
1690
|
}
|
|
1537
1691
|
},
|
|
1538
1692
|
agentLaneSubagent: "subagent",
|
|
1539
|
-
log
|
|
1540
|
-
info: (msg) => console.error(msg),
|
|
1541
|
-
warn: (msg) => console.error(msg),
|
|
1542
|
-
error: (msg) => console.error(msg),
|
|
1543
|
-
debug: (msg) => api.logger.debug?.(msg),
|
|
1544
|
-
},
|
|
1693
|
+
log,
|
|
1545
1694
|
};
|
|
1546
1695
|
}
|
|
1547
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
|
+
|
|
1548
1753
|
const lcmPlugin = {
|
|
1549
1754
|
id: "lossless-claw",
|
|
1550
1755
|
name: "Lossless Context Management",
|
|
@@ -1563,82 +1768,192 @@ const lcmPlugin = {
|
|
|
1563
1768
|
|
|
1564
1769
|
register(api: OpenClawPluginApi) {
|
|
1565
1770
|
const deps = createLcmDependencies(api);
|
|
1566
|
-
const
|
|
1567
|
-
const
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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;
|
|
1574
1822
|
});
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
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
|
+
}
|
|
1587
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);
|
|
1588
1932
|
});
|
|
1589
|
-
|
|
1590
|
-
api
|
|
1591
|
-
api.registerTool((ctx) =>
|
|
1592
|
-
createLcmGrepTool({
|
|
1593
|
-
deps,
|
|
1594
|
-
lcm,
|
|
1595
|
-
sessionKey: ctx.sessionKey,
|
|
1596
|
-
}),
|
|
1597
|
-
);
|
|
1598
|
-
api.registerTool((ctx) =>
|
|
1599
|
-
createLcmDescribeTool({
|
|
1600
|
-
deps,
|
|
1601
|
-
lcm,
|
|
1602
|
-
sessionKey: ctx.sessionKey,
|
|
1603
|
-
}),
|
|
1604
|
-
);
|
|
1605
|
-
api.registerTool((ctx) =>
|
|
1606
|
-
createLcmExpandTool({
|
|
1607
|
-
deps,
|
|
1608
|
-
lcm,
|
|
1609
|
-
sessionKey: ctx.sessionKey,
|
|
1610
|
-
}),
|
|
1611
|
-
);
|
|
1612
|
-
api.registerTool((ctx) =>
|
|
1613
|
-
createLcmExpandQueryTool({
|
|
1614
|
-
deps,
|
|
1615
|
-
lcm,
|
|
1616
|
-
sessionKey: ctx.sessionKey,
|
|
1617
|
-
requesterSessionKey: ctx.sessionKey,
|
|
1618
|
-
}),
|
|
1619
|
-
);
|
|
1620
|
-
api.registerCommand(
|
|
1621
|
-
createLcmCommand({
|
|
1622
|
-
db: database,
|
|
1623
|
-
config: deps.config,
|
|
1624
|
-
deps,
|
|
1625
|
-
}),
|
|
1626
|
-
);
|
|
1933
|
+
|
|
1934
|
+
wirePluginHandlers(api, deps, shared);
|
|
1627
1935
|
|
|
1628
1936
|
logStartupBannerOnce({
|
|
1629
1937
|
key: "plugin-loaded",
|
|
1630
|
-
log: (message) =>
|
|
1938
|
+
log: (message) => deps.log.info(message),
|
|
1631
1939
|
message: `[lcm] Plugin loaded (enabled=${deps.config.enabled}, db=${deps.config.databasePath}, threshold=${deps.config.contextThreshold})`,
|
|
1632
1940
|
});
|
|
1633
1941
|
logStartupBannerOnce({
|
|
1634
1942
|
key: "compaction-model",
|
|
1635
|
-
log: (message) =>
|
|
1943
|
+
log: (message) => deps.log.info(message),
|
|
1636
1944
|
message: buildCompactionModelLog({
|
|
1637
1945
|
config: deps.config,
|
|
1638
1946
|
openClawConfig: api.config,
|
|
1639
1947
|
defaultProvider: process.env.OPENCLAW_PROVIDER?.trim() ?? "",
|
|
1640
1948
|
}),
|
|
1641
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
|
+
}
|
|
1642
1957
|
},
|
|
1643
1958
|
};
|
|
1644
1959
|
|