@martian-engineering/lossless-claw 0.6.2 → 0.6.3
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 +1 -1
- package/docs/configuration.md +1 -1
- package/package.json +1 -1
- package/src/engine.ts +192 -51
- package/src/plugin/index.ts +23 -5
package/README.md
CHANGED
|
@@ -246,7 +246,7 @@ Lossless-claw distinguishes OpenClaw's two session-reset commands:
|
|
|
246
246
|
- `2`: keep d2+ summaries; recommended default
|
|
247
247
|
- `3+`: keep only deeper, more abstract summaries
|
|
248
248
|
|
|
249
|
-
Lossless-claw
|
|
249
|
+
Lossless-claw applies `/new` pruning through `before_reset` and uses `session_end` to catch transcript rollovers such as `/reset`, idle or daily session rotation, compaction session replacement, and deletions. User-facing confirmation text after `/new` or `/reset` must still be emitted by OpenClaw's command handlers.
|
|
250
250
|
|
|
251
251
|
Use `ignoreSessionPatterns` or `LCM_IGNORE_SESSION_PATTERNS` to keep low-value sessions completely out of LCM. Matching sessions do not create conversations, do not store messages, and do not participate in compaction or delegated expansion grants.
|
|
252
252
|
|
package/docs/configuration.md
CHANGED
|
@@ -159,7 +159,7 @@ Lossless-claw treats the two OpenClaw reset commands differently:
|
|
|
159
159
|
- `/reset` archives the active conversation row and creates a fresh active row for the same stable `sessionKey`.
|
|
160
160
|
|
|
161
161
|
This preserves lossless history while still giving users a real clean-slate command.
|
|
162
|
-
OpenClaw's command handlers still own the user-facing post-command disclosure text
|
|
162
|
+
Lossless-claw applies `/new` through `before_reset`, then uses `session_end` to catch the broader rollover cases OpenClaw can emit: `/reset`, idle or daily session rotation, compaction-driven session replacement, and deletions. OpenClaw's command handlers still own the user-facing post-command disclosure text.
|
|
163
163
|
|
|
164
164
|
Use `ignoreSessionPatterns` or `LCM_IGNORE_SESSION_PATTERNS` to keep low-value sessions completely out of LCM. Matching sessions do not create conversations, do not store messages, and do not participate in compaction or delegated expansion grants.
|
|
165
165
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@martian-engineering/lossless-claw",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Lossless Context Management plugin for OpenClaw — DAG-based conversation summarization with incremental compaction",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
package/src/engine.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { compileSessionPatterns, matchesSessionPattern } from "./session-pattern
|
|
|
48
48
|
import { logStartupBannerOnce } from "./startup-banner-log.js";
|
|
49
49
|
import {
|
|
50
50
|
ConversationStore,
|
|
51
|
+
type ConversationRecord,
|
|
51
52
|
type CreateMessagePartInput,
|
|
52
53
|
type MessagePartRecord,
|
|
53
54
|
type MessagePartType,
|
|
@@ -1017,7 +1018,7 @@ function readFileSegment(sessionFile: string, offset: number): string | null {
|
|
|
1017
1018
|
}
|
|
1018
1019
|
}
|
|
1019
1020
|
|
|
1020
|
-
function readLastJsonlEntryBeforeOffset(sessionFile: string, offset: number): string | null {
|
|
1021
|
+
function readLastJsonlEntryBeforeOffset(sessionFile: string, offset: number, messageOnly = false): string | null {
|
|
1021
1022
|
const chunkSize = 16_384;
|
|
1022
1023
|
let fd: number | null = null;
|
|
1023
1024
|
try {
|
|
@@ -1029,16 +1030,23 @@ function readLastJsonlEntryBeforeOffset(sessionFile: string, offset: number): st
|
|
|
1029
1030
|
fd = openSync(sessionFile, "r");
|
|
1030
1031
|
let cursor = safeOffset;
|
|
1031
1032
|
let carry = "";
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1033
|
+
let reachedStart = false;
|
|
1034
|
+
while (cursor > 0 || (reachedStart && carry.length > 0)) {
|
|
1035
|
+
if (!reachedStart) {
|
|
1036
|
+
const start = Math.max(0, cursor - chunkSize);
|
|
1037
|
+
const length = cursor - start;
|
|
1038
|
+
const buffer = Buffer.alloc(length);
|
|
1039
|
+
readSync(fd, buffer, 0, length, start);
|
|
1040
|
+
carry = buffer.toString("utf8") + carry;
|
|
1041
|
+
cursor = start;
|
|
1042
|
+
if (start === 0) {
|
|
1043
|
+
reachedStart = true;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1038
1046
|
|
|
1039
1047
|
const trimmedEnd = carry.replace(/\s+$/u, "");
|
|
1040
1048
|
if (!trimmedEnd) {
|
|
1041
|
-
|
|
1049
|
+
if (reachedStart) break;
|
|
1042
1050
|
carry = "";
|
|
1043
1051
|
continue;
|
|
1044
1052
|
}
|
|
@@ -1047,17 +1055,36 @@ function readLastJsonlEntryBeforeOffset(sessionFile: string, offset: number): st
|
|
|
1047
1055
|
if (newlineIndex >= 0) {
|
|
1048
1056
|
const candidate = trimmedEnd.slice(newlineIndex + 1).trim();
|
|
1049
1057
|
if (candidate) {
|
|
1058
|
+
if (messageOnly) {
|
|
1059
|
+
let isMessage = false;
|
|
1060
|
+
try {
|
|
1061
|
+
isMessage = extractBootstrapMessageCandidate(JSON.parse(candidate)) != null;
|
|
1062
|
+
} catch { /* not valid JSON, skip */ }
|
|
1063
|
+
if (!isMessage) {
|
|
1064
|
+
carry = trimmedEnd.slice(0, newlineIndex);
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1050
1068
|
return candidate;
|
|
1051
1069
|
}
|
|
1052
1070
|
carry = trimmedEnd.slice(0, newlineIndex);
|
|
1053
|
-
cursor = start;
|
|
1054
1071
|
continue;
|
|
1055
1072
|
}
|
|
1056
1073
|
|
|
1057
|
-
|
|
1058
|
-
|
|
1074
|
+
// No newline found — entire trimmedEnd is one line
|
|
1075
|
+
if (reachedStart) {
|
|
1076
|
+
const firstLine = trimmedEnd.trim() || null;
|
|
1077
|
+
if (firstLine && messageOnly) {
|
|
1078
|
+
let isMessage = false;
|
|
1079
|
+
try {
|
|
1080
|
+
isMessage = extractBootstrapMessageCandidate(JSON.parse(firstLine)) != null;
|
|
1081
|
+
} catch { /* not valid JSON */ }
|
|
1082
|
+
if (!isMessage) return null;
|
|
1083
|
+
}
|
|
1084
|
+
return firstLine;
|
|
1059
1085
|
}
|
|
1060
|
-
|
|
1086
|
+
// Need more data from earlier in the file
|
|
1087
|
+
continue;
|
|
1061
1088
|
}
|
|
1062
1089
|
return null;
|
|
1063
1090
|
} catch {
|
|
@@ -1826,17 +1853,18 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1826
1853
|
conversationId: number;
|
|
1827
1854
|
historicalMessages: AgentMessage[];
|
|
1828
1855
|
}): Promise<{
|
|
1856
|
+
blockedByImportCap: boolean;
|
|
1829
1857
|
importedMessages: number;
|
|
1830
1858
|
hasOverlap: boolean;
|
|
1831
1859
|
}> {
|
|
1832
1860
|
const { sessionId, conversationId, historicalMessages } = params;
|
|
1833
1861
|
if (historicalMessages.length === 0) {
|
|
1834
|
-
return { importedMessages: 0, hasOverlap: false };
|
|
1862
|
+
return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
|
|
1835
1863
|
}
|
|
1836
1864
|
|
|
1837
1865
|
const latestDbMessage = await this.conversationStore.getLastMessage(conversationId);
|
|
1838
1866
|
if (!latestDbMessage) {
|
|
1839
|
-
return { importedMessages: 0, hasOverlap: false };
|
|
1867
|
+
return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
|
|
1840
1868
|
}
|
|
1841
1869
|
|
|
1842
1870
|
const storedHistoricalMessages = historicalMessages.map((message) => toStoredMessage(message));
|
|
@@ -1857,7 +1885,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1857
1885
|
}
|
|
1858
1886
|
}
|
|
1859
1887
|
if (dbOccurrences === historicalOccurrences) {
|
|
1860
|
-
return { importedMessages: 0, hasOverlap: true };
|
|
1888
|
+
return { blockedByImportCap: false, importedMessages: 0, hasOverlap: true };
|
|
1861
1889
|
}
|
|
1862
1890
|
}
|
|
1863
1891
|
|
|
@@ -1909,13 +1937,20 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1909
1937
|
}
|
|
1910
1938
|
|
|
1911
1939
|
if (anchorIndex < 0) {
|
|
1912
|
-
return { importedMessages: 0, hasOverlap: false };
|
|
1940
|
+
return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
|
|
1913
1941
|
}
|
|
1914
1942
|
if (anchorIndex >= historicalMessages.length - 1) {
|
|
1915
|
-
return { importedMessages: 0, hasOverlap: true };
|
|
1943
|
+
return { blockedByImportCap: false, importedMessages: 0, hasOverlap: true };
|
|
1916
1944
|
}
|
|
1917
1945
|
|
|
1918
1946
|
const missingTail = historicalMessages.slice(anchorIndex + 1);
|
|
1947
|
+
|
|
1948
|
+
const existingDbCount = await this.conversationStore.getMessageCount(conversationId);
|
|
1949
|
+
if (existingDbCount > 0 && missingTail.length > Math.max(existingDbCount * 0.2, 50)) {
|
|
1950
|
+
console.error(`[lcm] reconcileSessionTail: import cap exceeded — would import ${missingTail.length} messages (existing: ${existingDbCount}). Aborting to prevent flood.`);
|
|
1951
|
+
return { blockedByImportCap: true, importedMessages: 0, hasOverlap: true };
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1919
1954
|
let importedMessages = 0;
|
|
1920
1955
|
for (const message of missingTail) {
|
|
1921
1956
|
const result = await this.ingestSingle({ sessionId, sessionKey: params.sessionKey, message });
|
|
@@ -1924,7 +1959,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1924
1959
|
}
|
|
1925
1960
|
}
|
|
1926
1961
|
|
|
1927
|
-
return { importedMessages, hasOverlap: true };
|
|
1962
|
+
return { blockedByImportCap: false, importedMessages, hasOverlap: true };
|
|
1928
1963
|
}
|
|
1929
1964
|
|
|
1930
1965
|
async bootstrap(params: {
|
|
@@ -2019,6 +2054,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2019
2054
|
const tailEntryRaw = readLastJsonlEntryBeforeOffset(
|
|
2020
2055
|
params.sessionFile,
|
|
2021
2056
|
bootstrapState.lastProcessedOffset,
|
|
2057
|
+
true,
|
|
2022
2058
|
);
|
|
2023
2059
|
const tailEntryMessage = readBootstrapMessageFromJsonLine(tailEntryRaw);
|
|
2024
2060
|
const tailEntryHash = tailEntryMessage
|
|
@@ -2143,6 +2179,14 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2143
2179
|
historicalMessages,
|
|
2144
2180
|
});
|
|
2145
2181
|
|
|
2182
|
+
if (reconcile.blockedByImportCap) {
|
|
2183
|
+
return {
|
|
2184
|
+
bootstrapped: false,
|
|
2185
|
+
importedMessages: 0,
|
|
2186
|
+
reason: "reconcile import capped",
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2146
2190
|
if (!conversation.bootstrappedAt) {
|
|
2147
2191
|
await this.conversationStore.markConversationBootstrapped(conversationId);
|
|
2148
2192
|
}
|
|
@@ -2405,9 +2449,34 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2405
2449
|
};
|
|
2406
2450
|
}
|
|
2407
2451
|
|
|
2408
|
-
|
|
2452
|
+
const result = await params.runtimeContext.rewriteTranscriptEntries({
|
|
2409
2453
|
replacements,
|
|
2410
2454
|
});
|
|
2455
|
+
|
|
2456
|
+
if (result.changed) {
|
|
2457
|
+
try {
|
|
2458
|
+
const fileStat = statSync(params.sessionFile);
|
|
2459
|
+
const newSize = fileStat.size;
|
|
2460
|
+
const newMtimeMs = Math.trunc(fileStat.mtimeMs);
|
|
2461
|
+
const lastEntryRaw = readLastJsonlEntryBeforeOffset(params.sessionFile, newSize, true);
|
|
2462
|
+
const lastEntryMsg = readBootstrapMessageFromJsonLine(lastEntryRaw);
|
|
2463
|
+
const lastEntryHash = lastEntryMsg ? createBootstrapEntryHash(toStoredMessage(lastEntryMsg)) : null;
|
|
2464
|
+
if (lastEntryHash) {
|
|
2465
|
+
await this.summaryStore.upsertConversationBootstrapState({
|
|
2466
|
+
conversationId: conversation.conversationId,
|
|
2467
|
+
sessionFilePath: params.sessionFile,
|
|
2468
|
+
lastSeenSize: newSize,
|
|
2469
|
+
lastSeenMtimeMs: newMtimeMs,
|
|
2470
|
+
lastProcessedOffset: newSize,
|
|
2471
|
+
lastProcessedEntryHash: lastEntryHash,
|
|
2472
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
} catch (e) {
|
|
2475
|
+
console.error("[lcm] Failed to update bootstrap checkpoint after maintain:", e);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
return result;
|
|
2411
2480
|
},
|
|
2412
2481
|
);
|
|
2413
2482
|
}
|
|
@@ -3219,6 +3288,69 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
3219
3288
|
// The shared connection is managed for the lifetime of the plugin process.
|
|
3220
3289
|
}
|
|
3221
3290
|
|
|
3291
|
+
/** Detect the empty replacement row created during a prior lifecycle rollover. */
|
|
3292
|
+
private async isFreshLifecycleConversation(conversation: ConversationRecord): Promise<boolean> {
|
|
3293
|
+
const currentMessageCount = await this.conversationStore.getMessageCount(conversation.conversationId);
|
|
3294
|
+
if (currentMessageCount !== 0) {
|
|
3295
|
+
return false;
|
|
3296
|
+
}
|
|
3297
|
+
const currentContextItems = await this.summaryStore.getContextItems(conversation.conversationId);
|
|
3298
|
+
return currentContextItems.length === 0 && !conversation.bootstrappedAt;
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
/**
|
|
3302
|
+
* Archive the current active conversation and optionally create the replacement
|
|
3303
|
+
* row that bootstrap should attach to for the next session transcript.
|
|
3304
|
+
*/
|
|
3305
|
+
private async applySessionReplacement(params: {
|
|
3306
|
+
reason: string;
|
|
3307
|
+
sessionId?: string;
|
|
3308
|
+
sessionKey?: string;
|
|
3309
|
+
nextSessionId?: string;
|
|
3310
|
+
nextSessionKey?: string;
|
|
3311
|
+
createReplacement: boolean;
|
|
3312
|
+
createReplacementWhenMissing?: boolean;
|
|
3313
|
+
}): Promise<void> {
|
|
3314
|
+
const current = await this.conversationStore.getConversationForSession({
|
|
3315
|
+
sessionId: params.sessionId,
|
|
3316
|
+
sessionKey: params.sessionKey,
|
|
3317
|
+
});
|
|
3318
|
+
if (!current && !params.createReplacementWhenMissing) {
|
|
3319
|
+
return;
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
if (current?.active) {
|
|
3323
|
+
if (params.createReplacement && await this.isFreshLifecycleConversation(current)) {
|
|
3324
|
+
this.deps.log.info(
|
|
3325
|
+
`[lcm] ${params.reason} lifecycle no-op for already fresh conversation ${current.conversationId}`,
|
|
3326
|
+
);
|
|
3327
|
+
return;
|
|
3328
|
+
}
|
|
3329
|
+
await this.conversationStore.archiveConversation(current.conversationId);
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
if (!params.createReplacement) {
|
|
3333
|
+
this.deps.log.info(
|
|
3334
|
+
`[lcm] ${params.reason} lifecycle archived conversation ${current?.conversationId ?? "(none)"}`,
|
|
3335
|
+
);
|
|
3336
|
+
return;
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
const nextSessionId = params.nextSessionId?.trim() || params.sessionId?.trim() || current?.sessionId;
|
|
3340
|
+
if (!nextSessionId) {
|
|
3341
|
+
this.deps.log.warn(`[lcm] ${params.reason} lifecycle skipped: no session identity available`);
|
|
3342
|
+
return;
|
|
3343
|
+
}
|
|
3344
|
+
const nextSessionKey = params.nextSessionKey?.trim() || params.sessionKey?.trim() || current?.sessionKey;
|
|
3345
|
+
const freshConversation = await this.conversationStore.createConversation({
|
|
3346
|
+
sessionId: nextSessionId,
|
|
3347
|
+
sessionKey: nextSessionKey,
|
|
3348
|
+
});
|
|
3349
|
+
this.deps.log.info(
|
|
3350
|
+
`[lcm] ${params.reason} lifecycle archived prior conversation and created ${freshConversation.conversationId}`,
|
|
3351
|
+
);
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3222
3354
|
/** Apply LCM lifecycle semantics for OpenClaw's /new and /reset commands. */
|
|
3223
3355
|
async handleBeforeReset(params: {
|
|
3224
3356
|
reason?: string;
|
|
@@ -3261,44 +3393,50 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
3261
3393
|
);
|
|
3262
3394
|
return;
|
|
3263
3395
|
}
|
|
3264
|
-
|
|
3265
|
-
|
|
3396
|
+
await this.applySessionReplacement({
|
|
3397
|
+
reason: "/reset",
|
|
3266
3398
|
sessionId: params.sessionId,
|
|
3267
3399
|
sessionKey: params.sessionKey,
|
|
3400
|
+
createReplacement: true,
|
|
3401
|
+
createReplacementWhenMissing: true,
|
|
3268
3402
|
});
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
);
|
|
3273
|
-
const currentContextItems = await this.summaryStore.getContextItems(
|
|
3274
|
-
current.conversationId,
|
|
3275
|
-
);
|
|
3276
|
-
if (
|
|
3277
|
-
currentMessageCount === 0
|
|
3278
|
-
&& currentContextItems.length === 0
|
|
3279
|
-
&& !current.bootstrappedAt
|
|
3280
|
-
) {
|
|
3281
|
-
this.deps.log.info(
|
|
3282
|
-
`[lcm] /reset no-op for already fresh conversation ${current.conversationId}`,
|
|
3283
|
-
);
|
|
3284
|
-
return;
|
|
3285
|
-
}
|
|
3286
|
-
await this.conversationStore.archiveConversation(current.conversationId);
|
|
3287
|
-
}
|
|
3403
|
+
}),
|
|
3404
|
+
);
|
|
3405
|
+
}
|
|
3288
3406
|
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3407
|
+
/** Apply generic lifecycle semantics for session rollover and deletion hooks. */
|
|
3408
|
+
async handleSessionEnd(params: {
|
|
3409
|
+
reason?: string;
|
|
3410
|
+
sessionId?: string;
|
|
3411
|
+
sessionKey?: string;
|
|
3412
|
+
nextSessionId?: string;
|
|
3413
|
+
nextSessionKey?: string;
|
|
3414
|
+
}): Promise<void> {
|
|
3415
|
+
const reason = params.reason?.trim();
|
|
3416
|
+
if (!reason || reason === "new" || reason === "unknown") {
|
|
3417
|
+
return;
|
|
3418
|
+
}
|
|
3419
|
+
if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
|
|
3420
|
+
return;
|
|
3421
|
+
}
|
|
3422
|
+
if (this.isStatelessSession(params.sessionKey ?? params.nextSessionKey)) {
|
|
3423
|
+
return;
|
|
3424
|
+
}
|
|
3294
3425
|
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3426
|
+
const createReplacement = reason !== "deleted";
|
|
3427
|
+
this.ensureMigrated();
|
|
3428
|
+
await this.withSessionQueue(
|
|
3429
|
+
this.resolveSessionQueueKey(params.nextSessionId ?? params.sessionId, params.sessionKey ?? params.nextSessionKey),
|
|
3430
|
+
async () =>
|
|
3431
|
+
this.conversationStore.withTransaction(async () => {
|
|
3432
|
+
await this.applySessionReplacement({
|
|
3433
|
+
reason: `session_end:${reason}`,
|
|
3434
|
+
sessionId: params.sessionId,
|
|
3435
|
+
sessionKey: params.sessionKey ?? params.nextSessionKey,
|
|
3436
|
+
nextSessionId: params.nextSessionId,
|
|
3437
|
+
nextSessionKey: params.nextSessionKey,
|
|
3438
|
+
createReplacement,
|
|
3298
3439
|
});
|
|
3299
|
-
this.deps.log.info(
|
|
3300
|
-
`[lcm] /reset archived prior conversation and created ${freshConversation.conversationId}`,
|
|
3301
|
-
);
|
|
3302
3440
|
}),
|
|
3303
3441
|
);
|
|
3304
3442
|
}
|
|
@@ -3446,3 +3584,6 @@ function createEmergencyFallbackSummarize(): (
|
|
|
3446
3584
|
return text.slice(0, maxChars) + "\n[Truncated for context management]";
|
|
3447
3585
|
};
|
|
3448
3586
|
}
|
|
3587
|
+
|
|
3588
|
+
/** @internal Exposed for unit tests only. */
|
|
3589
|
+
export const __testing = { readLastJsonlEntryBeforeOffset };
|
package/src/plugin/index.ts
CHANGED
|
@@ -66,6 +66,14 @@ type RuntimeModelAuthResult = {
|
|
|
66
66
|
apiKey?: string;
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
+
type SessionEndLifecycleEvent = {
|
|
70
|
+
sessionId?: string;
|
|
71
|
+
sessionKey?: string;
|
|
72
|
+
reason?: string;
|
|
73
|
+
nextSessionId?: string;
|
|
74
|
+
nextSessionKey?: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
69
77
|
type RuntimeModelAuthModel = {
|
|
70
78
|
id: string;
|
|
71
79
|
provider: string;
|
|
@@ -1529,9 +1537,9 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
1529
1537
|
},
|
|
1530
1538
|
agentLaneSubagent: "subagent",
|
|
1531
1539
|
log: {
|
|
1532
|
-
info: (msg) =>
|
|
1533
|
-
warn: (msg) =>
|
|
1534
|
-
error: (msg) =>
|
|
1540
|
+
info: (msg) => console.error(msg),
|
|
1541
|
+
warn: (msg) => console.error(msg),
|
|
1542
|
+
error: (msg) => console.error(msg),
|
|
1535
1543
|
debug: (msg) => api.logger.debug?.(msg),
|
|
1536
1544
|
},
|
|
1537
1545
|
};
|
|
@@ -1568,6 +1576,16 @@ const lcmPlugin = {
|
|
|
1568
1576
|
api.on("before_prompt_build", () => ({
|
|
1569
1577
|
prependSystemContext: LOSSLESS_RECALL_POLICY_PROMPT,
|
|
1570
1578
|
}));
|
|
1579
|
+
api.on("session_end", async (event) => {
|
|
1580
|
+
const lifecycleEvent = event as SessionEndLifecycleEvent;
|
|
1581
|
+
await lcm.handleSessionEnd({
|
|
1582
|
+
reason: lifecycleEvent.reason,
|
|
1583
|
+
sessionId: lifecycleEvent.sessionId,
|
|
1584
|
+
sessionKey: lifecycleEvent.sessionKey,
|
|
1585
|
+
nextSessionId: lifecycleEvent.nextSessionId,
|
|
1586
|
+
nextSessionKey: lifecycleEvent.nextSessionKey,
|
|
1587
|
+
});
|
|
1588
|
+
});
|
|
1571
1589
|
api.registerContextEngine("lossless-claw", () => lcm);
|
|
1572
1590
|
api.registerContextEngine("default", () => lcm);
|
|
1573
1591
|
api.registerTool((ctx) =>
|
|
@@ -1609,12 +1627,12 @@ const lcmPlugin = {
|
|
|
1609
1627
|
|
|
1610
1628
|
logStartupBannerOnce({
|
|
1611
1629
|
key: "plugin-loaded",
|
|
1612
|
-
log: (message) =>
|
|
1630
|
+
log: (message) => console.error(message),
|
|
1613
1631
|
message: `[lcm] Plugin loaded (enabled=${deps.config.enabled}, db=${deps.config.databasePath}, threshold=${deps.config.contextThreshold})`,
|
|
1614
1632
|
});
|
|
1615
1633
|
logStartupBannerOnce({
|
|
1616
1634
|
key: "compaction-model",
|
|
1617
|
-
log: (message) =>
|
|
1635
|
+
log: (message) => console.error(message),
|
|
1618
1636
|
message: buildCompactionModelLog({
|
|
1619
1637
|
config: deps.config,
|
|
1620
1638
|
openClawConfig: api.config,
|