@martian-engineering/lossless-claw 0.5.2 → 0.5.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 +18 -10
- package/docs/configuration.md +21 -0
- package/openclaw.plugin.json +39 -0
- package/package.json +1 -1
- package/src/assembler.ts +194 -3
- package/src/compaction.ts +203 -18
- package/src/db/config.ts +24 -3
- package/src/engine.ts +25 -6
- package/src/plugin/index.ts +111 -73
- package/src/store/summary-store.ts +80 -0
- package/src/summarize.ts +451 -209
- package/src/tools/lcm-expand-query-tool.ts +137 -34
- package/src/types.ts +1 -0
package/src/compaction.ts
CHANGED
|
@@ -38,7 +38,7 @@ export interface CompactionConfig {
|
|
|
38
38
|
condensedMinFanout: number;
|
|
39
39
|
/** Relaxed minimum fanout for hard-trigger sweeps. */
|
|
40
40
|
condensedMinFanoutHard: number;
|
|
41
|
-
/** Incremental depth passes to run after each leaf compaction (default
|
|
41
|
+
/** Incremental depth passes to run after each leaf compaction (default 1). */
|
|
42
42
|
incrementalMaxDepth: number;
|
|
43
43
|
/** Max source tokens to compact per leaf/condensed chunk (default 20000) */
|
|
44
44
|
leafChunkTokens?: number;
|
|
@@ -50,9 +50,11 @@ export interface CompactionConfig {
|
|
|
50
50
|
maxRounds: number;
|
|
51
51
|
/** IANA timezone for timestamps in summaries (default: UTC) */
|
|
52
52
|
timezone?: string;
|
|
53
|
+
/** Maximum allowed overage factor for summaries relative to target tokens (default 3). */
|
|
54
|
+
summaryMaxOverageFactor: number;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
type CompactionLevel = "normal" | "aggressive" | "fallback";
|
|
57
|
+
type CompactionLevel = "normal" | "aggressive" | "fallback" | "capped";
|
|
56
58
|
type CompactionPass = "leaf" | "condensed";
|
|
57
59
|
type CompactionSummarizeOptions = {
|
|
58
60
|
previousSummary?: string;
|
|
@@ -86,6 +88,30 @@ function estimateTokens(content: string): number {
|
|
|
86
88
|
return Math.ceil(content.length / 4);
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
/** Deterministically cap summary text so the persisted output stays within maxTokens. */
|
|
92
|
+
function capSummaryText(
|
|
93
|
+
content: string,
|
|
94
|
+
originalTokens: number,
|
|
95
|
+
maxTokens: number,
|
|
96
|
+
): string {
|
|
97
|
+
const suffixes = [
|
|
98
|
+
`\n[Capped from ${originalTokens} tokens to ~${maxTokens}]`,
|
|
99
|
+
`\n[Capped to ~${maxTokens}]`,
|
|
100
|
+
"\n[Capped]",
|
|
101
|
+
"",
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
for (const suffix of suffixes) {
|
|
105
|
+
const maxChars = Math.max(0, maxTokens * 4 - suffix.length);
|
|
106
|
+
const capped = `${content.slice(0, maxChars)}${suffix}`;
|
|
107
|
+
if (estimateTokens(capped) <= maxTokens) {
|
|
108
|
+
return capped;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return content.slice(0, Math.max(0, maxTokens * 4));
|
|
113
|
+
}
|
|
114
|
+
|
|
89
115
|
/** Format a timestamp as `YYYY-MM-DD HH:mm TZ` for prompt source text. */
|
|
90
116
|
export function formatTimestamp(value: Date, timezone: string = "UTC"): string {
|
|
91
117
|
try {
|
|
@@ -150,6 +176,11 @@ const DEFAULT_LEAF_CHUNK_TOKENS = 20_000;
|
|
|
150
176
|
* with no meaningful text.
|
|
151
177
|
*/
|
|
152
178
|
const MEDIA_PATH_RE = /^MEDIA:\/.+$/;
|
|
179
|
+
const EMBEDDED_DATA_URL_RE = /data:[^;\s"'`]+;base64,[A-Za-z0-9+/=\s]+/gi;
|
|
180
|
+
const MEDIA_ATTACHMENT_PART_TYPES = new Set(["file", "snapshot"]);
|
|
181
|
+
const MEDIA_ATTACHMENT_RAW_TYPES = new Set(["file", "image", "snapshot"]);
|
|
182
|
+
const STRUCTURED_MEDIA_TEXT_KEYS = ["text", "caption", "alt", "title", "summary"] as const;
|
|
183
|
+
const STRUCTURED_MEDIA_NESTED_KEYS = ["content", "parts", "items", "message", "messages"] as const;
|
|
153
184
|
|
|
154
185
|
const CONDENSED_MIN_INPUT_RATIO = 0.1;
|
|
155
186
|
|
|
@@ -165,6 +196,140 @@ function dedupeOrderedIds(ids: Iterable<string>): string[] {
|
|
|
165
196
|
return ordered;
|
|
166
197
|
}
|
|
167
198
|
|
|
199
|
+
/** Parse message-part metadata without throwing on malformed JSON. */
|
|
200
|
+
function parseMessagePartMetadata(part: CreateMessagePartInput | { metadata: string | null }): Record<string, unknown> {
|
|
201
|
+
if (typeof part.metadata !== "string" || !part.metadata.trim()) {
|
|
202
|
+
return {};
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const parsed = JSON.parse(part.metadata) as unknown;
|
|
206
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
207
|
+
? (parsed as Record<string, unknown>)
|
|
208
|
+
: {};
|
|
209
|
+
} catch {
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Detect whether a string is mostly binary/base64 payload and not meaningful prose. */
|
|
215
|
+
function looksLikeBinaryPayload(value: string): boolean {
|
|
216
|
+
const trimmed = value.trim();
|
|
217
|
+
if (!trimmed) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
if (/^data:[^;\s"'`]+;base64,/i.test(trimmed)) {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
const compact = trimmed.replace(/\s+/g, "");
|
|
224
|
+
if (compact.length < 256 || compact.length % 4 !== 0) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
if (!/^[A-Za-z0-9+/=]+$/.test(compact)) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
return !/[ .,:;!?()[\]{}]/.test(trimmed);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Strip attachment payloads from plain strings before they reach the summarizer. */
|
|
234
|
+
function stripEmbeddedMediaPayloads(content: string): string {
|
|
235
|
+
const withoutDataUrls = content.replace(EMBEDDED_DATA_URL_RE, "[embedded media omitted]");
|
|
236
|
+
const sanitizedLines = withoutDataUrls
|
|
237
|
+
.split(/\r?\n/)
|
|
238
|
+
.map((line) => line.trimEnd())
|
|
239
|
+
.filter((line) => {
|
|
240
|
+
const trimmed = line.trim();
|
|
241
|
+
if (!trimmed) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
if (MEDIA_PATH_RE.test(trimmed)) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
if (looksLikeBinaryPayload(trimmed)) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
return true;
|
|
251
|
+
});
|
|
252
|
+
return sanitizedLines.join("\n").trim();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Extract human-readable text from structured content while ignoring attachment payload fields. */
|
|
256
|
+
function extractSanitizedStructuredText(value: unknown, depth = 0): string[] {
|
|
257
|
+
if (depth >= 4 || value == null) {
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
if (typeof value === "string") {
|
|
261
|
+
const sanitized = stripEmbeddedMediaPayloads(value);
|
|
262
|
+
return sanitized ? [sanitized] : [];
|
|
263
|
+
}
|
|
264
|
+
if (Array.isArray(value)) {
|
|
265
|
+
return value.flatMap((entry) => extractSanitizedStructuredText(entry, depth + 1));
|
|
266
|
+
}
|
|
267
|
+
if (typeof value !== "object") {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const record = value as Record<string, unknown>;
|
|
272
|
+
const rawType = typeof record.type === "string" ? record.type.trim().toLowerCase() : "";
|
|
273
|
+
const textFragments: string[] = [];
|
|
274
|
+
|
|
275
|
+
for (const key of STRUCTURED_MEDIA_TEXT_KEYS) {
|
|
276
|
+
const candidate = record[key];
|
|
277
|
+
if (typeof candidate !== "string") {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const sanitized = stripEmbeddedMediaPayloads(candidate);
|
|
281
|
+
if (sanitized) {
|
|
282
|
+
textFragments.push(sanitized);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (MEDIA_ATTACHMENT_RAW_TYPES.has(rawType)) {
|
|
287
|
+
return textFragments;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for (const key of STRUCTURED_MEDIA_NESTED_KEYS) {
|
|
291
|
+
textFragments.push(...extractSanitizedStructuredText(record[key], depth + 1));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return textFragments;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Normalize message content down to human-readable text, excluding binary/media payloads. */
|
|
298
|
+
function extractMeaningfulMessageText(content: string): string {
|
|
299
|
+
const trimmed = content.trim();
|
|
300
|
+
if (!trimmed) {
|
|
301
|
+
return "";
|
|
302
|
+
}
|
|
303
|
+
if ((trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("{") && trimmed.endsWith("}"))) {
|
|
304
|
+
try {
|
|
305
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
306
|
+
const extracted = extractSanitizedStructuredText(parsed)
|
|
307
|
+
.map((fragment) => fragment.trim())
|
|
308
|
+
.filter(Boolean);
|
|
309
|
+
return extracted.join("\n").trim();
|
|
310
|
+
} catch {
|
|
311
|
+
// Fall back to plain-text sanitation below.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return stripEmbeddedMediaPayloads(content);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Identify whether a stored message part represents a media attachment. */
|
|
318
|
+
function isMediaAttachmentPart(part: CreateMessagePartInput | { partType: string; metadata: string | null }): boolean {
|
|
319
|
+
if (MEDIA_ATTACHMENT_PART_TYPES.has(part.partType)) {
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
const metadata = parseMessagePartMetadata(part);
|
|
323
|
+
const rawType =
|
|
324
|
+
typeof metadata.rawType === "string"
|
|
325
|
+
? metadata.rawType.trim().toLowerCase()
|
|
326
|
+
: metadata.raw && typeof metadata.raw === "object" && !Array.isArray(metadata.raw) &&
|
|
327
|
+
typeof (metadata.raw as Record<string, unknown>).type === "string"
|
|
328
|
+
? ((metadata.raw as Record<string, unknown>).type as string).trim().toLowerCase()
|
|
329
|
+
: "";
|
|
330
|
+
return MEDIA_ATTACHMENT_RAW_TYPES.has(rawType);
|
|
331
|
+
}
|
|
332
|
+
|
|
168
333
|
// ── CompactionEngine ─────────────────────────────────────────────────────────
|
|
169
334
|
|
|
170
335
|
export class CompactionEngine {
|
|
@@ -1010,6 +1175,8 @@ export class CompactionEngine {
|
|
|
1010
1175
|
sourceText: string;
|
|
1011
1176
|
summarize: CompactionSummarizeFn;
|
|
1012
1177
|
options?: CompactionSummarizeOptions;
|
|
1178
|
+
/** Target token count for this summary kind (leaf or condensed). Used for hard-cap enforcement. */
|
|
1179
|
+
targetTokens: number;
|
|
1013
1180
|
}): Promise<{ content: string; level: CompactionLevel } | null> {
|
|
1014
1181
|
const sourceText = params.sourceText.trim();
|
|
1015
1182
|
if (!sourceText) {
|
|
@@ -1076,6 +1243,21 @@ export class CompactionEngine {
|
|
|
1076
1243
|
}
|
|
1077
1244
|
}
|
|
1078
1245
|
|
|
1246
|
+
// Hard cap: enforce maximum summary size relative to the kind-appropriate target.
|
|
1247
|
+
const summaryTokens = estimateTokens(summaryText);
|
|
1248
|
+
const maxTokens = Math.ceil(params.targetTokens * this.config.summaryMaxOverageFactor);
|
|
1249
|
+
|
|
1250
|
+
if (summaryTokens > Math.ceil(params.targetTokens * 1.5)) {
|
|
1251
|
+
console.warn(
|
|
1252
|
+
`[lcm] summary exceeds target by ${Math.round((summaryTokens / params.targetTokens - 1) * 100)}%: ${summaryTokens} tokens vs target ${params.targetTokens}`,
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
if (summaryTokens > maxTokens) {
|
|
1257
|
+
summaryText = capSummaryText(summaryText, summaryTokens, maxTokens);
|
|
1258
|
+
level = "capped";
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1079
1261
|
return { content: summaryText, level };
|
|
1080
1262
|
}
|
|
1081
1263
|
|
|
@@ -1086,10 +1268,9 @@ export class CompactionEngine {
|
|
|
1086
1268
|
* attachments. This gives the summarizer enough context to produce a
|
|
1087
1269
|
* meaningful summary instead of trying to compress raw file paths.
|
|
1088
1270
|
*
|
|
1089
|
-
* - Media-only messages
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1092
|
-
* with " [with media attachment]" suffix.
|
|
1271
|
+
* - Media-only messages: content is replaced with "[Media attachment]".
|
|
1272
|
+
* - Media-mostly messages: text is preserved and annotated with
|
|
1273
|
+
* " [with media attachment]".
|
|
1093
1274
|
* - Text-only messages: returned unchanged.
|
|
1094
1275
|
*/
|
|
1095
1276
|
private async annotateMediaContent(
|
|
@@ -1097,27 +1278,29 @@ export class CompactionEngine {
|
|
|
1097
1278
|
content: string,
|
|
1098
1279
|
): Promise<string> {
|
|
1099
1280
|
const parts = await this.conversationStore.getMessageParts(messageId);
|
|
1100
|
-
const hasMediaParts = parts.some(
|
|
1101
|
-
(p) => p.partType === "file" || p.partType === "snapshot",
|
|
1102
|
-
);
|
|
1281
|
+
const hasMediaParts = parts.some((part) => isMediaAttachmentPart(part));
|
|
1103
1282
|
if (!hasMediaParts) {
|
|
1104
1283
|
return content;
|
|
1105
1284
|
}
|
|
1106
1285
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
.
|
|
1110
|
-
.
|
|
1286
|
+
const partText = parts
|
|
1287
|
+
.filter((part) => !isMediaAttachmentPart(part))
|
|
1288
|
+
.map((part) => (typeof part.textContent === "string" ? part.textContent : ""))
|
|
1289
|
+
.map((text) => stripEmbeddedMediaPayloads(text))
|
|
1290
|
+
.map((text) => text.trim())
|
|
1291
|
+
.filter(Boolean)
|
|
1111
1292
|
.join("\n")
|
|
1112
1293
|
.trim();
|
|
1294
|
+
const fallbackText = extractMeaningfulMessageText(content);
|
|
1295
|
+
const meaningfulText = (partText || fallbackText).trim();
|
|
1113
1296
|
|
|
1114
|
-
if (
|
|
1115
|
-
// Media-only: replace with descriptive annotation
|
|
1297
|
+
if (!meaningfulText) {
|
|
1116
1298
|
return "[Media attachment]";
|
|
1117
1299
|
}
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1300
|
+
if (meaningfulText.includes("[with media attachment]")) {
|
|
1301
|
+
return meaningfulText;
|
|
1302
|
+
}
|
|
1303
|
+
return `${meaningfulText} [with media attachment]`;
|
|
1121
1304
|
}
|
|
1122
1305
|
|
|
1123
1306
|
// ── Private: Leaf Pass ───────────────────────────────────────────────────
|
|
@@ -1167,6 +1350,7 @@ export class CompactionEngine {
|
|
|
1167
1350
|
previousSummary: previousSummaryContent,
|
|
1168
1351
|
isCondensed: false,
|
|
1169
1352
|
},
|
|
1353
|
+
targetTokens: this.config.leafTargetTokens,
|
|
1170
1354
|
});
|
|
1171
1355
|
if (!summary) {
|
|
1172
1356
|
console.warn(
|
|
@@ -1274,6 +1458,7 @@ export class CompactionEngine {
|
|
|
1274
1458
|
isCondensed: true,
|
|
1275
1459
|
depth: targetDepth + 1,
|
|
1276
1460
|
},
|
|
1461
|
+
targetTokens: this.config.condensedTargetTokens,
|
|
1277
1462
|
});
|
|
1278
1463
|
if (!condensed) {
|
|
1279
1464
|
console.warn(
|
package/src/db/config.ts
CHANGED
|
@@ -37,11 +37,19 @@ export type LcmConfig = {
|
|
|
37
37
|
expansionProvider: string;
|
|
38
38
|
/** Model override for lcm_expand_query sub-agent. */
|
|
39
39
|
expansionModel: string;
|
|
40
|
+
/** Max time to wait for delegated lcm_expand_query sub-agent completion. */
|
|
41
|
+
delegationTimeoutMs: number;
|
|
40
42
|
autocompactDisabled: boolean;
|
|
41
43
|
/** IANA timezone for timestamps in summaries (from TZ env or system default) */
|
|
42
44
|
timezone: string;
|
|
43
45
|
/** When true, retroactively delete HEARTBEAT_OK turn cycles from LCM storage. */
|
|
44
46
|
pruneHeartbeatOk: boolean;
|
|
47
|
+
/** Hard ceiling for assembly token budget — caps runtime-provided and fallback budgets. */
|
|
48
|
+
maxAssemblyTokenBudget?: number;
|
|
49
|
+
/** Maximum allowed overage factor for summaries relative to target tokens (default 3). */
|
|
50
|
+
summaryMaxOverageFactor: number;
|
|
51
|
+
/** Custom instructions injected into all summarization prompts. */
|
|
52
|
+
customInstructions: string;
|
|
45
53
|
};
|
|
46
54
|
|
|
47
55
|
/** Safely coerce an unknown value to a finite number, or return undefined. */
|
|
@@ -100,6 +108,10 @@ export function resolveLcmConfig(
|
|
|
100
108
|
pluginConfig?: Record<string, unknown>,
|
|
101
109
|
): LcmConfig {
|
|
102
110
|
const pc = pluginConfig ?? {};
|
|
111
|
+
const envDelegationTimeoutMs =
|
|
112
|
+
env.LCM_DELEGATION_TIMEOUT_MS !== undefined
|
|
113
|
+
? toNumber(env.LCM_DELEGATION_TIMEOUT_MS)
|
|
114
|
+
: undefined;
|
|
103
115
|
|
|
104
116
|
return {
|
|
105
117
|
enabled:
|
|
@@ -134,7 +146,7 @@ export function resolveLcmConfig(
|
|
|
134
146
|
?? toNumber(pc.contextThreshold) ?? 0.75,
|
|
135
147
|
freshTailCount:
|
|
136
148
|
(env.LCM_FRESH_TAIL_COUNT !== undefined ? parseInt(env.LCM_FRESH_TAIL_COUNT, 10) : undefined)
|
|
137
|
-
?? toNumber(pc.freshTailCount) ??
|
|
149
|
+
?? toNumber(pc.freshTailCount) ?? 64,
|
|
138
150
|
leafMinFanout:
|
|
139
151
|
(env.LCM_LEAF_MIN_FANOUT !== undefined ? parseInt(env.LCM_LEAF_MIN_FANOUT, 10) : undefined)
|
|
140
152
|
?? toNumber(pc.leafMinFanout) ?? 8,
|
|
@@ -146,13 +158,13 @@ export function resolveLcmConfig(
|
|
|
146
158
|
?? toNumber(pc.condensedMinFanoutHard) ?? 2,
|
|
147
159
|
incrementalMaxDepth:
|
|
148
160
|
(env.LCM_INCREMENTAL_MAX_DEPTH !== undefined ? parseInt(env.LCM_INCREMENTAL_MAX_DEPTH, 10) : undefined)
|
|
149
|
-
?? toNumber(pc.incrementalMaxDepth) ??
|
|
161
|
+
?? toNumber(pc.incrementalMaxDepth) ?? 1,
|
|
150
162
|
leafChunkTokens:
|
|
151
163
|
(env.LCM_LEAF_CHUNK_TOKENS !== undefined ? parseInt(env.LCM_LEAF_CHUNK_TOKENS, 10) : undefined)
|
|
152
164
|
?? toNumber(pc.leafChunkTokens) ?? 20000,
|
|
153
165
|
leafTargetTokens:
|
|
154
166
|
(env.LCM_LEAF_TARGET_TOKENS !== undefined ? parseInt(env.LCM_LEAF_TARGET_TOKENS, 10) : undefined)
|
|
155
|
-
?? toNumber(pc.leafTargetTokens) ??
|
|
167
|
+
?? toNumber(pc.leafTargetTokens) ?? 2400,
|
|
156
168
|
condensedTargetTokens:
|
|
157
169
|
(env.LCM_CONDENSED_TARGET_TOKENS !== undefined ? parseInt(env.LCM_CONDENSED_TARGET_TOKENS, 10) : undefined)
|
|
158
170
|
?? toNumber(pc.condensedTargetTokens) ?? 2000,
|
|
@@ -176,6 +188,7 @@ export function resolveLcmConfig(
|
|
|
176
188
|
env.LCM_EXPANSION_PROVIDER?.trim() ?? toStr(pc.expansionProvider) ?? "",
|
|
177
189
|
expansionModel:
|
|
178
190
|
env.LCM_EXPANSION_MODEL?.trim() ?? toStr(pc.expansionModel) ?? "",
|
|
191
|
+
delegationTimeoutMs: envDelegationTimeoutMs ?? toNumber(pc.delegationTimeoutMs) ?? 120000,
|
|
179
192
|
autocompactDisabled:
|
|
180
193
|
env.LCM_AUTOCOMPACT_DISABLED !== undefined
|
|
181
194
|
? env.LCM_AUTOCOMPACT_DISABLED === "true"
|
|
@@ -185,5 +198,13 @@ export function resolveLcmConfig(
|
|
|
185
198
|
env.LCM_PRUNE_HEARTBEAT_OK !== undefined
|
|
186
199
|
? env.LCM_PRUNE_HEARTBEAT_OK === "true"
|
|
187
200
|
: toBool(pc.pruneHeartbeatOk) ?? false,
|
|
201
|
+
maxAssemblyTokenBudget:
|
|
202
|
+
(env.LCM_MAX_ASSEMBLY_TOKEN_BUDGET !== undefined ? parseInt(env.LCM_MAX_ASSEMBLY_TOKEN_BUDGET, 10) : undefined)
|
|
203
|
+
?? toNumber(pc.maxAssemblyTokenBudget) ?? undefined,
|
|
204
|
+
summaryMaxOverageFactor:
|
|
205
|
+
(env.LCM_SUMMARY_MAX_OVERAGE_FACTOR !== undefined ? parseFloat(env.LCM_SUMMARY_MAX_OVERAGE_FACTOR) : undefined)
|
|
206
|
+
?? toNumber(pc.summaryMaxOverageFactor) ?? 3,
|
|
207
|
+
customInstructions:
|
|
208
|
+
env.LCM_CUSTOM_INSTRUCTIONS?.trim() ?? toStr(pc.customInstructions) ?? "",
|
|
188
209
|
};
|
|
189
210
|
}
|
package/src/engine.ts
CHANGED
|
@@ -1064,6 +1064,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1064
1064
|
condensedTargetTokens: this.config.condensedTargetTokens,
|
|
1065
1065
|
maxRounds: 10,
|
|
1066
1066
|
timezone: this.config.timezone,
|
|
1067
|
+
summaryMaxOverageFactor: this.config.summaryMaxOverageFactor,
|
|
1067
1068
|
};
|
|
1068
1069
|
this.compaction = new CompactionEngine(
|
|
1069
1070
|
this.conversationStore,
|
|
@@ -1189,6 +1190,12 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1189
1190
|
return undefined;
|
|
1190
1191
|
}
|
|
1191
1192
|
|
|
1193
|
+
/** Cap a resolved token budget against the configured maxAssemblyTokenBudget. */
|
|
1194
|
+
private applyAssemblyBudgetCap(budget: number): number {
|
|
1195
|
+
const cap = this.config.maxAssemblyTokenBudget;
|
|
1196
|
+
return cap != null && cap > 0 ? Math.min(budget, cap) : budget;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1192
1199
|
/** Resolve an LCM conversation id from a session key via the session store. */
|
|
1193
1200
|
private async resolveConversationIdForSessionKey(
|
|
1194
1201
|
sessionKey: string,
|
|
@@ -1231,10 +1238,14 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1231
1238
|
};
|
|
1232
1239
|
}
|
|
1233
1240
|
try {
|
|
1241
|
+
const customInstructions =
|
|
1242
|
+
params.customInstructions !== undefined
|
|
1243
|
+
? params.customInstructions
|
|
1244
|
+
: (this.config.customInstructions || undefined);
|
|
1234
1245
|
const runtimeSummarizer = await createLcmSummarizeFromLegacyParams({
|
|
1235
1246
|
deps: this.deps,
|
|
1236
1247
|
legacyParams: lp,
|
|
1237
|
-
customInstructions
|
|
1248
|
+
customInstructions,
|
|
1238
1249
|
});
|
|
1239
1250
|
if (runtimeSummarizer) {
|
|
1240
1251
|
return { summarize: runtimeSummarizer.fn, summaryModel: runtimeSummarizer.model };
|
|
@@ -1271,6 +1282,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
1271
1282
|
const result = await createLcmSummarizeFromLegacyParams({
|
|
1272
1283
|
deps: this.deps,
|
|
1273
1284
|
legacyParams: { provider, model },
|
|
1285
|
+
customInstructions: this.config.customInstructions || undefined,
|
|
1274
1286
|
});
|
|
1275
1287
|
if (!result) {
|
|
1276
1288
|
return undefined;
|
|
@@ -2133,7 +2145,7 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2133
2145
|
runtimeContext: params.runtimeContext,
|
|
2134
2146
|
legacyParams,
|
|
2135
2147
|
});
|
|
2136
|
-
const tokenBudget = resolvedTokenBudget ?? DEFAULT_AFTER_TURN_TOKEN_BUDGET;
|
|
2148
|
+
const tokenBudget = this.applyAssemblyBudgetCap(resolvedTokenBudget ?? DEFAULT_AFTER_TURN_TOKEN_BUDGET);
|
|
2137
2149
|
if (resolvedTokenBudget === undefined) {
|
|
2138
2150
|
console.warn(
|
|
2139
2151
|
`[lcm] afterTurn: tokenBudget not provided; using default ${DEFAULT_AFTER_TURN_TOKEN_BUDGET}`,
|
|
@@ -2220,12 +2232,13 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2220
2232
|
};
|
|
2221
2233
|
}
|
|
2222
2234
|
|
|
2223
|
-
const tokenBudget =
|
|
2235
|
+
const tokenBudget = this.applyAssemblyBudgetCap(
|
|
2224
2236
|
typeof params.tokenBudget === "number" &&
|
|
2225
2237
|
Number.isFinite(params.tokenBudget) &&
|
|
2226
2238
|
params.tokenBudget > 0
|
|
2227
2239
|
? Math.floor(params.tokenBudget)
|
|
2228
|
-
: 128_000
|
|
2240
|
+
: 128_000,
|
|
2241
|
+
);
|
|
2229
2242
|
|
|
2230
2243
|
const assembled = await this.assembler.assemble({
|
|
2231
2244
|
conversationId: conversation.conversationId,
|
|
@@ -2324,11 +2337,14 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2324
2337
|
}
|
|
2325
2338
|
|
|
2326
2339
|
const legacyParams = asRecord(params.runtimeContext) ?? params.legacyParams;
|
|
2327
|
-
const
|
|
2340
|
+
const resolvedTokenBudget = this.resolveTokenBudget({
|
|
2328
2341
|
tokenBudget: params.tokenBudget,
|
|
2329
2342
|
runtimeContext: params.runtimeContext,
|
|
2330
2343
|
legacyParams,
|
|
2331
2344
|
});
|
|
2345
|
+
const tokenBudget = resolvedTokenBudget
|
|
2346
|
+
? this.applyAssemblyBudgetCap(resolvedTokenBudget)
|
|
2347
|
+
: resolvedTokenBudget;
|
|
2332
2348
|
if (!tokenBudget) {
|
|
2333
2349
|
return {
|
|
2334
2350
|
ok: false,
|
|
@@ -2438,11 +2454,14 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
2438
2454
|
}
|
|
2439
2455
|
).manualCompaction === true;
|
|
2440
2456
|
const forceCompaction = force || manualCompactionRequested;
|
|
2441
|
-
const
|
|
2457
|
+
const resolvedTokenBudget = this.resolveTokenBudget({
|
|
2442
2458
|
tokenBudget: params.tokenBudget,
|
|
2443
2459
|
runtimeContext: params.runtimeContext,
|
|
2444
2460
|
legacyParams,
|
|
2445
2461
|
});
|
|
2462
|
+
const tokenBudget = resolvedTokenBudget
|
|
2463
|
+
? this.applyAssemblyBudgetCap(resolvedTokenBudget)
|
|
2464
|
+
: resolvedTokenBudget;
|
|
2446
2465
|
if (!tokenBudget) {
|
|
2447
2466
|
return {
|
|
2448
2467
|
ok: false,
|